Merge remote-tracking branch 'origin/master' into tmp4876
This commit is contained in:
commit
1a21e958df
726 files changed
+9142
-3315
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md
|
||||
2026-07-08-tool-output-spill-files.md: fa6697aad63c97b0ef580fe1ca46e50541e3d682
|
||||
2026-07-08-tool-output-spill-files.zh.md: a995eafc7878b342a2164c5116da1f43ada791a5
|
||||
2026-07-08-tool-output-spill-files.md: e32aec72f423dff33a29d9012c0f71c9cfbd24fb
|
||||
2026-07-08-tool-output-spill-files.zh.md: 2941fc516723e72b5d5670e674ce612f3cf581cc
|
||||
@@ -66,36 +66,7 @@ interface SpillRef {
|
||||
|
||||
### Spill policy
|
||||
|
||||
`dsh-spill-policy` is a `tools/post-execute` result transformer with one configuration knob:
|
||||
|
||||
```ts ignore-check
|
||||
interface Config {
|
||||
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
|
||||
maxInlineBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
When `maxInlineBytes` is omitted the plugin registers nothing (a true no-op). When set, it applies a default policy to final plain-text tool results:
|
||||
|
||||
1. Let the tool run normally, delegating via `next()` so a downstream listener settles the result first.
|
||||
2. Flatten the accepted final `ContentBlock[]` only when it is entirely plain text; a result with any non-text block is left untouched.
|
||||
3. If its UTF-8 byte size is at or below `maxInlineBytes`, leave it unchanged.
|
||||
4. If it is larger, call `ctx.spillStore.saveText()` with the full final text.
|
||||
5. Replace the model-facing result with a retained head/tail preview plus the spill reference.
|
||||
|
||||
The preview is an implementation default owned by the policy: a head/tail split of `maxInlineBytes` via the retention library's `TextRetainer`. Future config can expose preview sizing only after a second deployment needs it.
|
||||
|
||||
The replacement text is intentionally generic because the policy only knows the final formatted tool result, not the tool's internal resource:
|
||||
|
||||
```text
|
||||
<retained preview>
|
||||
|
||||
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
If `ctx.spillStore.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result.
|
||||
|
||||
The policy skips `read` to avoid a circular `read -> spill file -> read again` loop. Additional opt-out configuration is deferred until a real second tool needs it.
|
||||
`dsh-spill-policy` retains ordered text/image ends under `maxInlineTokens` after post-execute policy accepts the result. `saveText()` stores the complete result with readable image paths; image bytes remain in attachment storage. The [multimodal retention decision](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md) owns image projection order, atomic image omission, and route pricing. An omitted budget installs no listeners, recovery failures keep the original result, and model-facing `read` results skip retention to avoid a read/spill loop.
|
||||
|
||||
## Showcase: web_fetch
|
||||
|
||||
@@ -129,7 +100,7 @@ With `dsh-spill-policy` configured, a large formatted fetch result is automatica
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
maxInlineTokens: 12500
|
||||
```
|
||||
|
||||
This separation is important. `web-fetch-http` still owns resource caps (`maxResponseBytes`, `maxBodyChars`) to protect network, memory, and decoding work. `spill-policy` owns only the model-facing context cap after the result already exists. If the provider already returned `truncated: true`, the spill file contains the full formatted result the tool returned, not the full original webpage; the policy does not claim otherwise.
|
||||
@@ -186,11 +157,11 @@ The local-backend value proposition depends on the existing `read`/`grep` tools
|
||||
|
||||
**Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario.
|
||||
|
||||
The policy can become too large if it starts owning tool-specific semantics. It stays narrow: plain-text final results only. Tool-owned early spill remains future work.
|
||||
The policy can become too large if it starts owning tool-specific semantics. It handles accepted text/image sequences without interpreting tool-specific text. Tool-owned early spill remains future work.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape.
|
||||
**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineTokens` deployment knob is enough to prove the shape.
|
||||
|
||||
**Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint.
|
||||
|
||||
|
||||
@@ -66,36 +66,7 @@ interface SpillRef {
|
||||
|
||||
### spill 策略
|
||||
|
||||
`dsh-spill-policy` 是一个 `tools/post-execute` 结果转换器,只提供一个配置项:
|
||||
|
||||
```ts ignore-check
|
||||
interface Config {
|
||||
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
|
||||
maxInlineBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
省略 `maxInlineBytes` 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略:
|
||||
|
||||
1. 让工具正常运行,通过 `next()` 委托,使下游监听器先结算结果。
|
||||
2. 仅当已接受的最终 `ContentBlock[]` 全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。
|
||||
3. 如果 UTF-8 字节大小不超过 `maxInlineBytes`,保持不变。
|
||||
4. 如果超出上限,使用完整的最终文本调用 `ctx.spillStore.saveText()`。
|
||||
5. 把模型可见结果替换为保留的首尾预览和 spill 引用。
|
||||
|
||||
预览属于策略所有的实现默认值:以 `maxInlineBytes` 为上限,使用保留库的 `TextRetainer` 进行首尾分割。只有第二个部署证明有此需求后,未来配置才会公开预览大小。
|
||||
|
||||
替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源:
|
||||
|
||||
```text
|
||||
<retained preview>
|
||||
|
||||
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
如果 `ctx.spillStore.saveText()` 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。spill 失败绝不会把成功的工具调用变为 `isError` 结果,也不会隐藏内联结果。
|
||||
|
||||
策略跳过 `read`,以避免形成 `read -> spill file -> read again` 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。
|
||||
`dsh-spill-policy` 在执行后策略接受结果之后,按 `maxInlineTokens` 对文字和图片共同计量并保留首尾。完整结果仍通过 `saveText()` 保存,图片本体留在附件存储,结果文件记录可读取路径。图片投影顺序、整图省略和模型计量由[图文结果保留决策](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.zh.md)负责。省略配置时不安装监听器,存储失败时保留原结果;`read` 的模型可见结果跳过自动省略,避免重复读取产生循环。
|
||||
|
||||
## 示例:web_fetch
|
||||
|
||||
@@ -129,7 +100,7 @@ ctx.tools.register(defineTool({
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
maxInlineTokens: 12500
|
||||
```
|
||||
|
||||
这项分离很重要。`web-fetch-http` 仍负责资源上限(`maxResponseBytes`、`maxBodyChars`),用来保护网络、内存和解码工作。`spill-policy` 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 `truncated: true`,spill 文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。
|
||||
@@ -186,11 +157,11 @@ ctx.tools.register(defineTool({
|
||||
|
||||
**快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` spill 提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` spill 需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。
|
||||
|
||||
如果策略开始负责工具专用语义,就会膨胀得过大。它的范围保持狭窄:只处理纯文本最终结果。由工具负责的提前 spill 仍留作未来工作。
|
||||
如果策略开始负责工具专用语义,就会膨胀得过大。它处理已接受的图文序列,不解释工具专用的文字语义。由工具负责的提前 spill 仍留作未来工作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。
|
||||
**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineTokens` 部署配置项即可验证该形态。
|
||||
|
||||
**把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前 spill 合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
|
||||
2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1
|
||||
2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720
|
||||
2026-08-31-released-session-format-migrations.md: 00672af920cf8369022b4274c1c50552f5407a7e
|
||||
2026-08-31-released-session-format-migrations.zh.md: 9404d8f454b7c17be69ae064c405c4dcc0794f88
|
||||
+2
@@ -89,6 +89,8 @@ Each released codec creates a row decoder with explicit `strict` or `recoverable
|
||||
|
||||
The v0-to-v1 edge preserves logical headers, sequence numbers, references, timestamps, and payloads except for bounded released-v0 normalizations. It translates the retired `steering/message` and `compact/*` event names, accepts a released `llm/retry` after its matching `step/end`, deterministically supplies a missing `llm/retry.retryId` per turn/step/provider/policy chain, and supplies one deterministic `compactionId` across a legacy compaction group that omitted it. The v1-to-v2 edge owns attempt folding and reference remapping, and emits only settled v2 events. It splits a legacy goal-sourced user message into `goal/change` plus the original model-visible message. It also inserts an interrupted `turn/end` for the bounded released restart in which an open turn with no open step is followed by a non-empty `next-turn` inbox splice and the next numbered `turn/start`.
|
||||
|
||||
The V3-to-V4 edge applies the same bounded restart repair to logs written directly as V3. Keeping this repair in the incoming edge preserves [native V4 lifecycle validation](2026-09-17-native-v4-read-validation.md); accepting repeated starts there would admit producer corruption in new logs. Insertions require local-reference and inherited-cut remapping, while captured generations retain their original coordinates. The [format specification](../../../../packages/session/session-format-v3-to-v4/README.md#sequence-references) owns the exact evidence and fields.
|
||||
|
||||
The catalog exposes one `createRestore()` operation for production, Worker, fixture, and replay callers. Recovery policy and final validation policy are chosen once at restore creation. Historical production uses recoverable source parsing with transformed-current validation; this validates the released current result after migration, while input that is already current receives only codec validation. Worker and fixture verification use strict parsing with full installed current restoration. A migration-stage or transformed-current validation refusal remains `SessionFormatUnsupportedMigrationError`; physical decoding failures remain corruption. Test support keeps only fixture-specific token and envelope materialization.
|
||||
|
||||
### JSONL integration
|
||||
|
||||
+2
@@ -89,6 +89,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
|
||||
|
||||
v0-to-v1 除了有限的 released-v0 归一化外,会保留逻辑 header、seq、引用、时间戳与 payload。它转换已移除的 `steering/message` 与 `compact/*` 事件名称,接受出现在对应 `step/end` 之后的已发布 `llm/retry`,按 turn/step/provider/policy chain 为缺失的 `llm/retry.retryId` 确定性补值,并为省略 id 的旧 compaction group 确定性补充同一个 `compactionId`。v1-to-v2 负责 attempt folding 与引用重写,并且只 emit 已结算的 v2 event。它会把旧的 goal 来源 user message 拆成 `goal/change` 与原本的模型可见 message。它还会为一种有限的已发布 restart 插入 interrupted `turn/end`:一个没有 open step 的 open turn 后出现非空 `next-turn` inbox splice,随后直接开始编号连续的下一轮。
|
||||
|
||||
V3-to-V4 边对直接写成 V3 的日志应用同样的有限 restart 修复。将修复保留在迁入边,可以维持[原生 V4 生命周期校验](2026-09-17-native-v4-read-validation.zh.md);若在那里接受重复 start,新日志也会允许生产者损坏。插入事件要求重映射本地引用与继承截点,而捕获代际保留原坐标。[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#sequence-references)负责定义具体证据和字段。
|
||||
|
||||
Catalog 为 production、Worker、fixture 与 replay 暴露同一个 `createRestore()`。Recovery policy 与最终 validation policy 在 restore 创建时一次确定。Historical production 使用 recoverable source parsing 与 transformed-current validation;这种策略会在迁移后校验已发布 current 结果,而已经是 current 的输入只接受 codec 校验。Worker 与 fixture verification 使用 strict parsing 与已安装 current 格式的完整 restoration。Migration stage 或 transformed-current validation 的拒绝会保持为 `SessionFormatUnsupportedMigrationError`;物理解码失败仍是 corruption。Test support 只保留 fixture 自身需要的 token 和 envelope materialization。
|
||||
|
||||
### JSONL 串联
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
|
||||
2026-09-14-deepseek-account-login.md: 65405b293cdea14a231d8d700589f9c3e7ef6db7
|
||||
2026-09-14-deepseek-account-login.zh.md: 0aca011826006a0fb8f0557464187b73da377238
|
||||
2026-09-14-deepseek-account-login.md: 9d9853617a3b7e288084c93eac4e150f3c180999
|
||||
2026-09-14-deepseek-account-login.zh.md: d9cdbed25b0ae79caa4c61b310383cc52d8b586a
|
||||
@@ -46,7 +46,7 @@ Profile and recharge-wallet queries use independent getProfile and getBalance op
|
||||
|
||||
Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider.
|
||||
|
||||
The Host sends login_source (desktop or web) in auth_init so Platform can choose the completion experience. Web failures close the authorization tab while the original tab receives Host state; no Web UI return URL is used. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
|
||||
The bundled account UI is Desktop-only: its preload bridge enables the account launcher, settings and sign-in onboarding. Plain Web retains API-key onboarding and the standard Settings launcher without account subscription or login. The Host protocol accepts login_source (desktop or web) in auth_init; the bundled UI sends desktop. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
|
||||
|
||||
The macOS development launcher registers an isolated, ad-hoc-signed application bundle for `dsh://open`. The bundle retains the workspace entry and development paths for Launch Services cold starts without copying credentials or changing the package-manager-owned Electron application. Protocol registration targets the latest launched development or packaged application.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请
|
||||
|
||||
开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。
|
||||
|
||||
Host 在 auth_init 中发送 login_source(desktop 或 web),供 Platform 选择完成页交互。Web 失败时关闭授权标签页,原标签页接收 Host 状态,不使用 Web UI 返回地址。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
|
||||
内置账号界面仅用于 Desktop:preload 桥接启用账号入口、设置和登录引导。普通 Web 保留 API Key 引导及标准设置入口,不订阅账号状态或显示登录。Host 协议在 auth_init 中接受 login_source(desktop 或 web);内置界面发送 desktop。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
|
||||
|
||||
macOS 开发启动器为 `dsh://open` 注册独立、经临时签名的应用包。该应用包保留工作区入口和开发路径,使 Launch Services 能够冷启动;凭证不会被复制,也不修改包管理器安装的 Electron 应用。协议注册指向最近启动的开发版或打包版应用。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md
|
||||
2026-09-21-conversation-build-groups.md: 6591ebea61d8edd06edbaf5f54f2bebd1bd00602
|
||||
2026-09-21-conversation-build-groups.zh.md: 70657ded8e04f0badb987ada965aef1184cfc7b0
|
||||
2026-09-21-conversation-build-groups.md: 7c02de8dd0aaf842690806ea67ddaf5d4561fc4e
|
||||
2026-09-21-conversation-build-groups.zh.md: 4f2b331596cacdd63d902c9db73931d65ac589ad
|
||||
@@ -12,9 +12,11 @@ A Chat Builder aggregates one target's Nodes and indexes. Hardcoding a Chat proc
|
||||
|
||||
Process groups are orthogonal to Steps: one Assistant Node can contribute reasoning inside a group and a reply outside it; tools after that reply enter a later group. Step-number ranges cannot describe membership. Existing event Definitions interpret one event at a time, whereas grouping needs the Nodes those Definitions have already interpreted.
|
||||
|
||||
Broad benchmark budgets do not establish that grouping updates are local. Keyed notifications, component identity, browser layout, and retained memory are distinct properties; passing one check does not establish the others.
|
||||
|
||||
## Decision
|
||||
|
||||
The grouping foundation provides a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Production Chat segmentation and PR #4565 presentation are not registered by this foundation; the [business migration proposal](../../proposed/feature/2026-09-20-chat-work-details-migration.md) retains their requirements. The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details.
|
||||
The grouping foundation provides a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Chat owns its registered segmentation and presentation in the [business-rule reference](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md). The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
@@ -22,21 +24,19 @@ The grouping foundation provides a separate, Node-input `ConversationGroupDefini
|
||||
|---|---|
|
||||
| Business grouping belongs to a registered Definition | Membership, segmentation, replies, steering, retries, summaries, and cache invalidation stay together. |
|
||||
| Builder and assembler do generic work | Supply inputs, dispatch Definitions, validate references, reuse identities, and publish; never construct a concrete grouping class. |
|
||||
| PR #4565 defines product behavior | Preserve its behavior without copying its invasive implementation. |
|
||||
| PR #4565 supplies the product reference | The Chat business-rule reference owns the current behavior and its agreed adjustments. |
|
||||
| React has two root kinds | The ordered root contains NodeReference and GroupReference, not another layout object model. |
|
||||
| All modes share grouping | Mode is absent from Definition input, keys, and parent selection. |
|
||||
| No buildLayout | ConversationViewDefinition keeps its existing responsibilities. |
|
||||
| Foundation precedes business | No production Chat grouping, summaries, notifications, or footer changes in the foundation PR. |
|
||||
| Business remains target-owned | Chat owns process grouping, summaries, notifications, and footer behavior; generic assembly does not interpret them. |
|
||||
|
||||
### Related decisions
|
||||
|
||||
| Record | Relationship |
|
||||
|---|---|
|
||||
| [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. |
|
||||
| [Presentation policy and grouping](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) | Retains Definition ownership, local subscriptions, and mode independence; replaces event folding and Step-range membership as the grouping mechanism. |
|
||||
| [Work-details migration](../../proposed/feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. |
|
||||
|
||||
These records retain independent rationale and remain active. Cross-View navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced.
|
||||
Grouping preserves the independent Node-assembly decisions. Cross-View navigation and `toolCallFocus` remain separate responsibilities; Group references carry no View handles or resource-navigation policy.
|
||||
|
||||
## Definition input, state, and registration
|
||||
|
||||
@@ -116,7 +116,9 @@ The root selects `views.grouped('chat')?.entries` through the existing useConver
|
||||
|
||||
React keys derive from reference kind, NodeKey/groupPart or GroupKey, using unambiguous tuples. No separate RenderKey type or renderer dispatch field is needed. Compact, Detailed, and Expanded keep the same group container and member parents; presentation changes do not rerun the Group Definition or select a different root branch.
|
||||
|
||||
The stable Group parent is a `div` with `display: contents`, without its own layout box. CSS inheritance remains available; business styles adapt child/sibling selectors and own measurable body containers. Neither CSS variables nor geometry enter the Definition. Reading-position capture measures member Nodes and retains part-specific anchors; existing Turn navigation resolves the original NodeKey to its first visible part.
|
||||
The stable Group parent is a measurable `div`; its body owns capped inner scrolling and its content box reports growth. CSS inheritance remains available, and business styles adapt child/sibling selectors. Neither CSS variables nor geometry enter the Definition. Reading-position capture measures member Nodes and retains part-specific anchors; existing Turn navigation resolves the original NodeKey to its first visible part.
|
||||
|
||||
The presentation channel maps each stored mode to a stable policy object. Seats and renderers select the fields they consume instead of receiving a mode prop through every renderer. Mode changes do not register Definitions or replay Contexts. Whole-Turn eligibility uses that Turn's loaded lifecycle facts rather than Session-wide pagination completion; the business-rule reference defines partial-history folding. Local group disclosure remains component state, while explicit whole-Turn closing resets participating disclosures through the injected Hook without replacing keys.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -138,6 +140,12 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
|
||||
|
||||
**Remove the group wrapper in Expanded.** Rejected: changing the parent remounts members even when their keys survive.
|
||||
|
||||
**Attach a header to the first member or to a per-Step Context.** A first-member header couples group UI to an arbitrary business row. Per-Step identity cannot distinguish multiple groups in one Step; sharing aggregate Turn data also refreshes unrelated headers or leaves later Step reads stale.
|
||||
|
||||
**Emit multiple Nodes from one event Context or derive child Contexts.** Most event Contexts own one Node; arrays add indirection without solving cross-Node grouping. Parent-derived Contexts require forwarding, replay, and removal lifecycles. A separate framework decision is warranted only if derived entities need those lifecycles independently of grouping.
|
||||
|
||||
**Keep all Markdown mounted or reset renderers by remounting.** Retaining lightweight seats is not a reason to retain every full Markdown body. Full bodies remain disclosure-owned, and explicit resets avoid destroying unrelated renderer state. Importing all of #4565 at once would also mix independent visual changes with grouping costs and obscure which behavior caused a regression.
|
||||
|
||||
## Verification
|
||||
|
||||
- [Group store tests](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts) cover atomic reference validation, root/member identity reuse, local publication, and removal without deleting source Nodes.
|
||||
@@ -145,6 +153,15 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
|
||||
- [Node source tests](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts) cover projected grouping inputs, indexed readers, and empty change batches.
|
||||
- [Chat rendering tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) retain component state across modes, rebind replacement Node stores, pass independent parts, and omit unreferenced Nodes. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) cover grouped reading anchors, history prepend, and part-aware Turn navigation.
|
||||
|
||||
Business behavior is verified through keyed-update regressions and recorded Web replay.
|
||||
|
||||
| Evidence | Required observation |
|
||||
|---|---|
|
||||
| Keyed notifications and React updates | Content growth affects the owning Node and affected Group; unchanged historical rows and unrelated Turns receive no extra notifications. Mode changes preserve keys and member parents. |
|
||||
| Recorded Web replay | Current accessible titles, group disclosure, hidden ordinary Context, trigger notices, and footer placement match the committed expected output. |
|
||||
|
||||
[Recorded Web scenarios](../../../../apps/web/tests/steering.e2e.ts) cover online steering, reconnect handoff, and grouped presentation through the shipped Web profile. These behavior checks do not establish a quantified latency or memory improvement.
|
||||
|
||||
## Consequences
|
||||
|
||||
- This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback.
|
||||
@@ -152,4 +169,5 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
|
||||
- Node order and visibility have one owner: the Builder. Grouping must not independently sort raw events or infer membership again in infrastructure.
|
||||
- Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made.
|
||||
- Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes.
|
||||
- Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups.
|
||||
- Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction belong to Chat's business adaptation; generic reference tests alone do not establish those behaviors.
|
||||
- Turn status/duration replaces the old tool/message-count title, so status, accessible names, and empty-process Turns require explicit presentation coverage. Hiding ordinary Context retains non-human waking notices and original Session/Trajectory inspection; footer placement depends on the real Turn end rather than group membership.
|
||||
@@ -12,9 +12,11 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
|
||||
|
||||
过程组与 Step 正交:同一个 Assistant Node 可以把推理放在组内、回复放在组外,回复后的工具进入后面的组。Step 编号范围无法描述这种成员归属。现有事件 Definition 逐个解释事件,分组则需要这些 Definition 已经解释完成的节点。
|
||||
|
||||
宽松的基准预算不能证明分组更新具有局部性。按键通知、组件身份、浏览器布局与常驻内存是不同的性质,通过其中一项检查不能证明其他性质。
|
||||
|
||||
## Decision
|
||||
|
||||
分组基础机制提供独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。基础机制不注册真实 Chat 分段及 PR #4565 展示,[业务迁移提案](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md)保留这些需求。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。
|
||||
分组基础机制提供独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。Chat 在[业务规则参考](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md)中拥有其已注册的分段与展示规则。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。
|
||||
|
||||
### 职责
|
||||
|
||||
@@ -22,21 +24,19 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
|
||||
|---|---|
|
||||
| 分组业务属于已注册 Definition | 成员、分段、回复、steering、重试、摘要和缓存失效放在一起。 |
|
||||
| Builder 与 assembler 做统一工作 | 提供输入、调度 Definition、校验引用、复用身份并发布,不创建具体分组类。 |
|
||||
| 产品行为以 PR #4565 为准 | 保留行为,不复制其侵入性实现。 |
|
||||
| PR #4565 提供产品参考 | Chat 业务规则参考拥有当前行为及已确认的调整。 |
|
||||
| React 根循环只有两种 kind | 根顺序包含 NodeReference 和 GroupReference,不引入另一套布局对象模型。 |
|
||||
| 所有模式共用分组 | 模式不进入 Definition 输入、key 或父级选择。 |
|
||||
| 不采用 buildLayout | ConversationViewDefinition 保留现有职责。 |
|
||||
| 先基础、后业务 | 基础 PR 不包含真实 Chat 分组、摘要、通知或页脚变化。 |
|
||||
| 业务属于目标包 | Chat 拥有过程分组、摘要、通知和页脚行为,通用组装不解释这些业务。 |
|
||||
|
||||
### 与已有记录的关系
|
||||
|
||||
| 记录 | 关系 |
|
||||
|---|---|
|
||||
| [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 |
|
||||
| [展示策略与过程分组](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 |
|
||||
| [工作过程展示迁移](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 |
|
||||
|
||||
这些记录仍有独立理由,保留有效状态。跨 View 导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。
|
||||
分组保留独立的节点组装决策。跨 View 导航与 `toolCallFocus` 仍是独立职责,Group 引用不携带 View 句柄或资源导航策略。
|
||||
|
||||
## Definition 输入、状态与注册
|
||||
|
||||
@@ -116,7 +116,9 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价
|
||||
|
||||
React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,展示变化不重新运行 Group Definition,也不选择不同的根分支。
|
||||
|
||||
稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。
|
||||
稳定 Group 父级使用可测量的 `div`,正文拥有组内限高滚动,内容盒子报告尺寸增长。CSS 继承仍然可用,业务样式适配子级/兄弟选择器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。
|
||||
|
||||
展示通道将每个存储模式映射为稳定策略对象。Seat 和 renderer 选择自己使用的字段,不通过所有 renderer 透传模式 prop。模式变化不注册 Definition,也不重放 Context。整轮折叠资格使用本轮已加载的生命周期事实,不依赖整个 Session 的分页完成状态;半截历史的折叠规则由业务参考定义。组开合保留在组件本地状态中,显式收起整轮通过注入 Hook 重置参与折叠的内部开合,不更换 key。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -138,6 +140,12 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
|
||||
|
||||
**Expanded 移除组容器。** 不采用,即使 key 不变,父级改变仍会重挂载成员。
|
||||
|
||||
**让首成员或每 Step 的 Context 承载组头。** 首成员组头把组 UI 耦合到任意业务行。每 Step 的身份无法区分同一步内的多个组,共享整轮聚合数据还会刷新无关组头,或让后续 Step 的读取过期。
|
||||
|
||||
**一个事件 Context 输出多个 Node,或派生子 Context。** 多数事件 Context 只拥有一个 Node,数组增加间接层却不能解决跨 Node 分组。父级派生 Context 需要转发、重放及移除生命周期,只有派生实体需要独立于分组的这些生命周期时,才值得另作框架决策。
|
||||
|
||||
**常驻全部 Markdown,或靠重挂载重置 renderer。** 保留轻量 Seat 不意味着需要常驻所有完整 Markdown 正文。完整正文仍由开合状态控制,显式重置避免销毁无关 renderer 状态。一次性引入 #4565 的全部改动还会混合独立视觉变化与分组成本,难以判断导致回退的具体行为。
|
||||
|
||||
## Verification
|
||||
|
||||
- [Group 存储测试](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts)覆盖原子引用校验、根与成员身份复用、局部发布,以及删除组但保留原 Node。
|
||||
@@ -145,6 +153,15 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
|
||||
- [Node 来源测试](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts)覆盖投影后的分组输入、索引读取器和空变化批次。
|
||||
- [Chat 渲染测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)保留模式切换时的组件状态,重绑定替换后的 Node 存储,传递独立部分,并省略未引用 Node。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。
|
||||
|
||||
业务行为通过按键更新回归与已录制的 Web 回放验证。
|
||||
|
||||
| 证据 | 必须观察到的结果 |
|
||||
|---|---|
|
||||
| 按键通知与 React 更新 | 正文增长影响所属 Node 和相关 Group;未变化历史行及其他 Turn 没有额外通知。模式变化保留 key 与成员父级。 |
|
||||
| 已录制的 Web 回放 | 当前可访问标题、组开合、普通 Context 隐藏、触发通知及页脚位置与提交的预期输出一致。 |
|
||||
|
||||
[已录制的 Web 场景](../../../../apps/web/tests/steering.e2e.ts)通过正式 Web profile 覆盖在线 steering、重连接续与分组展示。这些行为检查不构成量化延迟或内存改善的证明。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。
|
||||
@@ -152,4 +169,5 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
|
||||
- Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。
|
||||
- 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。
|
||||
- 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。
|
||||
- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。
|
||||
- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动属于 Chat 业务适配,仅凭通用引用测试不能确认这些行为。
|
||||
- 整轮状态与用时替代旧工具/消息计数标题,因此状态、可访问名称和空过程轮次需要明确的展示覆盖。隐藏普通 Context 时保留非人工唤醒通知及原始 Session/Trajectory 查看能力;页脚定位依赖真实 Turn 结束,不依赖分组成员。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md
|
||||
2026-09-21-multimodal-tool-result-retention.md: 41470724911d3086878d206f82c76ad9a4993146
|
||||
2026-09-21-multimodal-tool-result-retention.zh.md: 8e6d4953f7380d45235ac13f9f245e2e005f1abf
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Multimodal tool-result retention
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-21-multimodal-tool-result-retention.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
MCP prepares durable images asynchronously while its pure renderer emits text placeholders. Installing prepared content after post-execute policy makes text retention invalidate image replacement. A byte-only limit also cannot compare text with visual input costs.
|
||||
|
||||
## Decision
|
||||
|
||||
`ToolDefinition.projectContent` installs execution-prepared content before post-execute policy. The final callback retains its separate role for terminal and job limits. Policy blocks and replacements remain authoritative.
|
||||
|
||||
The spill policy prices ordered text/image blocks in estimated tokens, reusing the text estimator and the active route image calculator, including descriptor text. It reserves omission notices, divides remaining tokens between contiguous ends, and omits the middle. Text can split between code points; images remain indivisible at their original positions.
|
||||
|
||||
The complete spill file contains accepted text and readable attachment paths in order. Attachments own image bytes. Missing image pricing or a readable recovery path preserves the original result with a diagnostic. PTC program values remain complete; forwarding and logs retain a recovery notice even when every image is omitted.
|
||||
|
||||
The [spill storage decision](../architecture/2026-07-08-tool-output-spill-files.md) still owns backend separation and secure local files. The [MCP client decision](../feature/2026-07-07-mcp-client-plugin.md) still owns canonical values and image admission.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Restore images after policy edits.** This can resurrect blocked images or discarded text. Preparing content before policy lets transformations inspect the actual images.
|
||||
|
||||
**Keep images outside the budget or charge a fixed maximum.** Images must share the result budget with text. The existing route calculator already prices projected dimensions and descriptor text, so it owns that cost.
|
||||
|
||||
**Embed image bytes in spill text.** Base64 inflates the artifact and duplicates attachment storage. Readable attachment paths preserve retrieval and ordering without copying bytes.
|
||||
|
||||
## Consequences
|
||||
|
||||
The shipped result budget is 12,500 estimated tokens, replacing the former 50,000-byte setting. Text estimation remains heuristic; provider usage is authoritative. Unused space at an image boundary stays unused to preserve contiguous ends. Other block types pass through.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 工具结果的图文保留
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-21-multimodal-tool-result-retention.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
MCP 异步准备持久化图片,而纯渲染器输出文字占位。在执行后策略完成后才安装已准备内容,会让文字保留使图片替换失效。按字节限制也无法比较文字与视觉输入的成本。
|
||||
|
||||
## Decision
|
||||
|
||||
`ToolDefinition.projectContent` 在执行后策略之前安装执行期间准备的内容。最终回调保留其对终端和任务结果设定上限的独立职责。策略阻止和替换仍然生效。
|
||||
|
||||
spill 策略以估算 token 对有序图文块计量,复用文字估算和当前模型图片计算器,并计入说明文字。它预留省略提示,将剩余 token 平分给连续两端,省略中间。文字可在码点之间切分;图片不可切分,并保持原位置。
|
||||
|
||||
完整结果文件按顺序保存已接受文字和可读取的附件路径。附件拥有图片字节。缺少图片计量或可读取的恢复路径时,保留原结果并记录诊断。PTC 程序返回值保持完整;全部图片被省略时,转发内容和日志仍保留读取提示。
|
||||
|
||||
[spill 存储决策](../architecture/2026-07-08-tool-output-spill-files.zh.md)继续负责后端拆分和安全的本地文件。[MCP 客户端决策](../feature/2026-07-07-mcp-client-plugin.zh.md)继续负责规范值和图片准入。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在策略修改后恢复图片。** 这可能恢复已阻止图片或已丢弃文字。在策略之前准备内容,让转换能够检查真实图片。
|
||||
|
||||
**图片不计入预算或按固定上限计费。** 图片必须与文字共享结果预算。现有模型计算器已经衡量请求尺寸和说明文字,因此由它负责成本。
|
||||
|
||||
**把图片字节嵌入 spill 文本。** Base64 会增大产物并重复附件存储。可读取的附件路径在不复制字节的情况下保留读取方式和顺序。
|
||||
|
||||
## Consequences
|
||||
|
||||
随产品提供的结果预算为 12,500 个估算 token,替代此前的 50,000 字节配置。文字计量仍为估算,提供方用量具有权威性。图片分界处剩余空间保持空闲,以维持连续的首尾内容。其他内容块类型原样通过。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md
|
||||
2026-07-07-mcp-client-plugin.md: a4aa2cf216d9f28afedebf82f0659db70606bbf1
|
||||
2026-07-07-mcp-client-plugin.zh.md: 5903e4ef976317602527d9f335d640bc86939f72
|
||||
2026-07-07-mcp-client-plugin.md: dd793b67bf2043fa88377d50837df40faadf3fbf
|
||||
2026-07-07-mcp-client-plugin.zh.md: 5056118df0185f1e7ec2b1ea06818ed920d2af26
|
||||
@@ -145,7 +145,7 @@ A unified `execute` handler for all tools from one MCP server:
|
||||
1. Call the SDK's `callTool` with the closed-over `rawName`, model arguments, `exec.signal`, the configured timeout, and the complete discovered `toolDefinition` — the public name is never sent to the server.
|
||||
2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/PTC mode value. `isError: true` throws before any image persistence so the registry owns the failure path.
|
||||
3. Prepare a separate ordered Native projection. Text runs join with `'\n'`; resource links preserve name and URI as text; audio and embedded resources become explicit diagnostics. The SDK rejects malformed wire results. If any image exists, the bridge strictly decodes the complete batch, resolves the calling agent's latest exact route, requires an attachment store plus explicit model image input, and delegates all-member validation and ordered persistence to `AttachmentStore.saveImages()`. Any decode, capability, or storage refusal renders every image as diagnostic text and returns no partial references.
|
||||
4. Keep `output.render` synchronous and pure. The executor stages its richer projection in a generation-local `WeakMap` keyed by the exact execution; `finalizeContent` installs it only when the registry's post-execute result still has the original canonical value and fallback content. A policy block, value replacement, or content replacement remains authoritative, and a re-sync cannot let an older generation consume new execution state.
|
||||
4. The executor preserves the complete canonical MCP value and prepares ordered text/image content. `projectContent` installs that content before `tools/post-execute`, so retention sees real images. Subsequent content replacement, value replacement, and blocking remain authoritative.
|
||||
5. PTC mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case.
|
||||
6. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, exact-model lookup, and the pre-storage gate.
|
||||
|
||||
@@ -157,6 +157,8 @@ Build the child environment from the subprocess seam's shared `scrubbedParentEnv
|
||||
|
||||
A per-instance connection supervisor reconnects automatically after a lost connection with bounded exponential backoff and a per-outage attempt budget, re-running discovery on success; exhaustion unregisters the server's tools and stops until reload. The [auto-reconnect Agent Note](../../archived/feature/2026-08-06-mcp-client-auto-reconnect.md) owns that decision, including the `reconnect` config block and the `reconnect.enabled: false` opt-out that restores manual HMR/restart recovery.
|
||||
|
||||
The [multimodal retention decision](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md) explains why prepared images enter content before result policies.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### MCP Server side (expose harness tools to external MCP clients)
|
||||
|
||||
@@ -145,7 +145,7 @@ SDK 接受符合协议的工具,并执行现代 HTTP header 声明检查。注
|
||||
1. 调用 SDK 的 `callTool`,传入闭包持有的 `rawName`、模型参数、`exec.signal`、配置的超时时间与完整的已发现 `toolDefinition`——公开名称永远不发送给服务器。
|
||||
2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/PTC mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。
|
||||
3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频和嵌入资源成为明确诊断。SDK 拒绝格式错误的协议结果。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用。
|
||||
4. 保持 `output.render` 同步且纯净。执行器把更丰富的投影暂存在按同步世代创建、以确切执行为键的 `WeakMap` 中;只有注册表的 post-execute 结果仍保留原规范值和兜底内容时,`finalizeContent` 才安装该投影。策略阻止、值替换或内容替换仍具有权威性,重新同步也无法让旧世代消费新执行状态。
|
||||
4. 执行器保留完整的规范 MCP 返回值,并准备有序图文内容。`projectContent` 在 `tools/post-execute` 之前安装已准备的内容,让省略策略看到真实图片块。后续策略的内容替换、值替换和阻止均保持生效。
|
||||
5. PTC mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。
|
||||
6. 取消:`exec.signal`(来自 agent loop 的取消)透传给 MCP SDK 的 `callTool`、确切模型查询和存储前门禁。
|
||||
|
||||
@@ -157,6 +157,8 @@ SDK 接受符合协议的工具,并执行现代 HTTP header 声明检查。注
|
||||
|
||||
每个实例的连接监督器在连接丢失后以有界指数退避和单次故障尝试预算自动重连,成功后重新执行发现流程;尝试耗尽则注销该服务器的工具并停止,直到重新加载。[自动重连 Agent Note](../../archived/feature/2026-08-06-mcp-client-auto-reconnect.md) 拥有该决策,包括 `reconnect` 配置块和恢复手动 HMR/重启恢复的 `reconnect.enabled: false` opt-out。
|
||||
|
||||
图片在结果策略之前进入内容的原因见[图文结果保留决策](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.zh.md)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### MCP Server 端(将 harness 工具暴露给外部 MCP 客户端)
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md
|
||||
2026-09-20-chat-presentation-policy-and-step-groups.md: 65fbb313d57a34a792ed2ec08d0adfb2fdd5f03d
|
||||
2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 7ac3442476ea34d5127376cfc40d42892403ce47
|
||||
-288
@@ -1,288 +0,0 @@
|
||||
# Agent Note: Chat presentation policy channel and process-group architecture
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-09-20-chat-presentation-policy-and-step-groups.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
At the start of this proposal, Chat's display setting had two modes: `normal` did not fold content, while `compact` folded process rows behind a whole-Turn control after completion. `TranscriptViewPolicy.mode` was injected into ChatView as an observable. ChatView read it through `useTranscriptView`, converted it to the boolean `compactTranscript`, and passed that prop through `ChatNodeList` to every `ChatNodeSeat`. The global `hasMore` boolean followed the same path as `historyIncomplete`. Either change re-rendered every seat, even when only a few foldable rows changed their output.
|
||||
|
||||
The product requires three modes—Compact, Detailed, and Expanded—with consecutive process rows between replies collected into collapsible groups in Compact and Detailed. PR #4565 implemented the full feature, but was reverted eight minutes after merging because of performance regressions. The recorded local before/after comparison found long-session pagination +27%, DOM nodes +23%, heap +14%, and Trajectory switching +10%, all within CI budgets. None of the six existing repository benchmarks covered the new hot path: reasoning or tool chunks entering an active group during streaming, group reprojection, and seat re-rendering.
|
||||
|
||||
The review identified implementation choices, not the product requirements, as the problem:
|
||||
|
||||
- The mode became an input to list structure. `ChatNodeList` subscribed to the entire `layout` and generated wrapper components from ranges; Expanded removed those wrappers. Switching modes remounted the list, and regrouping rebuilt wrapper keys.
|
||||
- The mode entered `ChatNodeOwnerProps`. Every seat's owner object changed with the mode, re-rendering all business renderers, including unrelated user messages, tool rows, and compaction markers.
|
||||
- A seat split one Assistant node into derived reasoning and response objects solely to wrap the former in the DOM. Master's `AssistantMarkdown` already had `reasoningHidden` and `revealProcess`, which could hide reasoning independently.
|
||||
- Consumers repeated infrastructure-row filtering in four places, although nodes already had a Definition-owned `visibility` field.
|
||||
|
||||
The deeper difficulty is information ownership: a group header must exist before its members as soon as the group opens, but its content requires a view of the entire Turn. Each event-driven Context receives only its own events. Only a turn-level Context can observe the whole Turn, and it can produce only one node.
|
||||
|
||||
This proposal supplies an architecture for three modes and grouping that satisfies the constraints below and confines later product changes to a small set of nodes.
|
||||
|
||||
The three settings, presentation policy channel, and per-Turn folding eligibility are implemented; step-group remains proposed. The [migration progress table](../feature/2026-09-20-chat-work-details-migration.md) owns current implementation, committed scope, and incomplete items. The grouping state machine and change inventory below do not describe delivered work.
|
||||
|
||||
## Goals and constraints
|
||||
|
||||
The [implemented Definition-owned Build Group foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) supersedes this note's event fold, Builder-materialized group headers, Step-range membership, and changedLocationData proposal. This note retains the presentation-policy and per-Turn eligibility rationale; the grouping implementation below records the superseded route, not the current implementation plan.
|
||||
|
||||
The following decisions were confirmed during design review and are inputs to this proposal.
|
||||
|
||||
| Number | Decision | Meaning |
|
||||
|---|---|---|
|
||||
| 1 | Definitions own group structure | A Definition matching multiple events in the Turn folds group opening, closing, member ranges, and summaries; renderers do not derive them by scanning nodes |
|
||||
| 2 | Limit seat re-rendering | Mode changes re-render only seats whose presentation changes; streaming chunks re-render only their seat and group header; user messages, replies, and turn-tail do not re-render for either change |
|
||||
| 3 | One node per Context | Do not introduce multiple nodes per Context; most Contexts have one element, and arrays would fragment memory further |
|
||||
| 4 | Modes do not toggle Definitions | Registry changes rebuild and replay all Contexts, refreshing every seat; Definitions must be mode-independent |
|
||||
| 5 | Decide folding per Turn | A Turn can fold when its own `turn/start` is loaded; the oldest partial Turn stays open and is not optimized here |
|
||||
| 6 | ui-conversation may change | Engine changes are allowed only where needed for this design to work correctly |
|
||||
| 7 | Initial implementation scope | Initially change only code and this note; add tests and other documentation during CI repair |
|
||||
|
||||
Accepting a full-page browser layout on mode changes does not mean accepting every seat re-rendering. Layout is a geometric result; a seat re-render executes a React component function. This proposal constrains the latter.
|
||||
|
||||
## Viable approaches
|
||||
|
||||
There are five approaches to group-header identity and content ownership.
|
||||
|
||||
**Approach A: the first member's seat renders the header.** Keep the list unchanged. The builder derives membership and tells the first member's seat to render a title above itself; other member seats use the existing `hidden` mechanism. This adds no new concept, but creates a third seat state—visible with only a title—and attaches the header to a business node's seat.
|
||||
|
||||
**Approach α: one Context per step supplies a header.** Add a Definition identified by `turn:step`. During construction, read the preceding `assistant-step` data to decide whether this is a new group start and emit a header node if so. Another turn-level Definition publishes the group's content as Turn data for the renderer.
|
||||
|
||||
**Approach β: one Context emits multiple nodes.** A turn-level Definition emits the whole-Turn control and N group headers.
|
||||
|
||||
**Approach γ: a parent Context derives child Contexts.** When a group opens, the turn-level fold creates a `step-group` child Context and forwards later events to it. Each child owns one node. The engine needs forwarding and cascaded replay primitives.
|
||||
|
||||
**Approach δ: Definitions own structure; the builder materializes rows.** A turn-level Definition folds the group list and publishes Turn data. The Chat builder materializes header nodes and assigns membership. Seats use combined selectors for visibility. `ViewBuilder.apply` receives the Location data whose references changed in the flush.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One Context per group cannot satisfy the matching rules.** The engine requires `match(event)` to derive an id from one event. A group number depends on whether a reply preceded the event; one event cannot determine it. This is incompatible with the matching contract, not merely difficult to implement.
|
||||
|
||||
**Approach α conflicts with decision 2.** Header identity is derivable, but every header reads the same Turn data, so one group's change refreshes all headers in that Turn. Reading later steps through `turn.steps` instead leaves stale references because a header node is not rebuilt when later steps arrive.
|
||||
|
||||
**Decision 3 rules out approach β.** See the table above.
|
||||
|
||||
**Approach γ is not justified yet.** It has the clearest ownership, but forwarding and cascaded replay would add hundreds of lines to the assembler core. It remains an alternative under the conditions at the end of this note.
|
||||
|
||||
**Approach A remains the fallback.** If builder-owned materialization is rejected in practice, use A. Both share the same structural source and differ only in what carries the header row.
|
||||
|
||||
Choose approach δ.
|
||||
|
||||
## Proposal
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
事件流
|
||||
──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件)
|
||||
──▶ Turn 数据 step-groups:组列表,每组一个对象
|
||||
──▶ Chat builder
|
||||
├─ 物化组头节点:一组一个 key,data 即组对象
|
||||
└─ 成员归属:按 step 映射到 key,写进 per-key presentation
|
||||
──▶ seat:hidden = f(策略字段, 角色, 展开状态)
|
||||
──▶ 组头渲染器:读自己节点的 data
|
||||
展示策略
|
||||
──▶ 渲染器:按 kind 注入的 hooks,选单个字段
|
||||
──▶ seat:组合 selector,选出的是自己的结论
|
||||
```
|
||||
|
||||
The only engine change passes the Location data whose references changed during a flush to the builder through `apply`.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
| Layer | Owns | Explicitly does not own |
|
||||
|---|---|---|
|
||||
| `step-groups` Definition | Opening and closing groups, `headStep`, `closeStep`, tool counts and names, running tools; publishes Turn data | View nodes or identification of member nodes |
|
||||
| Chat builder | Header-node identity and lifecycle, membership, `turnStarted`, identity comparison and publication of per-key presentation | Reading events or deriving business structure |
|
||||
| Presentation policy object | Mapping the enum to capability fields | Node or seat state |
|
||||
| Seat | Combining policy fields, role, and store state into hidden state; supplying group open/close actions to the header renderer | Determining group boundaries |
|
||||
| Header renderer | Title, chevron, click-to-toggle interaction | The source of group content |
|
||||
| assistant-step renderer | Settled reasoning first-line preview; hiding reasoning when closing a group | Group boundaries |
|
||||
|
||||
### Header-node identity and lifecycle
|
||||
|
||||
A header node's key is `step-group:${turn}:${headStep}`. `headStep` is fixed when a group opens, and later events cannot change its first member. The header exists and is reused from that point onward; its key does not change during streaming. Closing the group only changes `closed` in its data from false to true.
|
||||
|
||||
The header anchor is the first member's anchor minus a synthetic offset, placing it immediately before that member. A streaming Assistant is anchored at its first visible chunk, then at its message event after persistence. Its anchor moves once; the header follows in the same structural update without adding another update.
|
||||
|
||||
A header is part of the whole-Turn process and hides with its members when the Turn folds. It is not in `TURN_PROCESS_INDEPENDENT_KINDS`.
|
||||
|
||||
Header data is the group object published by the fold. Closed group objects never change references, so their header sources never notify. Each event that changes a running group replaces only that group's object and re-renders only its header.
|
||||
|
||||
### Fold state machine
|
||||
|
||||
The `step-groups` Definition uses the Turn number as its id, starts on `turn/start`, and matches the same event set as `turn-process`. Its State is `{ turn, groups, open }`: `groups` is an array of group objects, and `open` is the active group's index or empty.
|
||||
|
||||
| Event | Transition |
|
||||
|---|---|
|
||||
| `tool/call` | If no group is open, open one at the event's step; increment the count, record distinct tool names, and add callId to the running set |
|
||||
| Appended `tool/result` | Remove callId from the running set |
|
||||
| `assistant/live-chunk` reasoning delta with visible characters | Open a group at the chunk's step if none is open |
|
||||
| `assistant/live-chunk` text delta or text-block end with visible characters | Close an open group at the chunk's step |
|
||||
| Appended `assistant/message` | Apply the reasoning rule for visible reasoning and the text rule for a visible reply, in reasoning-then-text order |
|
||||
| `turn/end` | Mark an open group as closed by the Turn end |
|
||||
| `step/start`, `step/end`, `llm/retry` | No state change; retry rows belong to no group |
|
||||
|
||||
Three invariants apply:
|
||||
|
||||
- Idempotence: opening an already-open group and closing an already-closed group do nothing. Live chunks and persisted messages describing the same content do not open or close twice; replay with only persisted messages derives the same group list.
|
||||
- Stable references: `update` returns the original object when State is unchanged; the `groups` array changes reference only when the group list changes; closed group objects are never replaced.
|
||||
- Forward-only folding: do not read neighboring Contexts, nodes, or later history.
|
||||
|
||||
Turn-data publication: `buildLocationData` returns a value only for the turn phase and reuses its previous value when the `groups` reference is unchanged, preventing republication. A history window without `turn/start` uses the same fallback as `turn-process`: fold State from the available matches.
|
||||
|
||||
Publication timing: batch live chunks at animation frames; publish other changes immediately.
|
||||
|
||||
### Membership rules
|
||||
|
||||
The builder assigns membership by step, not by seq ranges, because live-chunk seqs and persisted-event seqs use different scales.
|
||||
|
||||
| Node | Rule |
|
||||
|---|---|
|
||||
| `tool-call` at step s | Belongs to the last group with `headStep ≤ s`. If text closes the preceding group in the same step, later tool calls open a new group at that step, so the last group is the applicable one |
|
||||
| `assistant-step` at step s where a group has `closeStep === s` | Closer role: the node remains visible; folding the group hides only its reasoning through the existing `reasoningHidden` mechanism |
|
||||
| Other `assistant-step` at step s | Member role: belongs to the group with `headStep ≤ s` whose `closeStep` has not been passed |
|
||||
| `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail`, `user`, `steering` | Belong to no group |
|
||||
|
||||
Recompute membership only when the group list changes, with cost proportional to that Turn's node count. Streaming chunks that leave the group list unchanged do not publish member presentation.
|
||||
|
||||
The header anchors before the earliest member. If a group contains only its closer's reasoning and no members, such as reasoning followed by a reply in one step, anchor the header before the closer so the hidden reasoning still has a control that reveals it.
|
||||
|
||||
### Delivering group changes to the builder
|
||||
|
||||
The fold publishes Turn data, not view nodes. When Location data changes, the engine's `flush` sets `timelineDirty` and calls `apply` on all active targets, but the builder cannot identify the changed Turn and key. Inferring them from an unrelated node update in the same Turn and flush is not a reliable guarantee.
|
||||
|
||||
Add optional `changedLocationData` to `ConversationViewBuilder.apply`: the Location values whose references changed in this flush, each carrying `kind`, `turn`, optional `step`, and `key`. The assembler already compares references in `applyDirtyLocationData`; it collects those results for the builder. Chat reprojects only Turns with `key === 'step-groups'`. When the optional input is absent, the builder falls back to Turns affected by the current upserts.
|
||||
|
||||
### Combining hidden state in a seat
|
||||
|
||||
Seats stop receiving `compactTranscript` and `historyIncomplete`. A seat reads policy through `usePresentation`, selecting its own result rather than the mode:
|
||||
|
||||
```ts
|
||||
declare function usePresentation<T>(select: (policy: {
|
||||
stepGrouping: 'collapsed' | 'none'
|
||||
foldCompletedTurns: boolean
|
||||
}) => T): T
|
||||
declare const group: { readonly headStep: number } | undefined
|
||||
|
||||
const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping)
|
||||
const foldCompleted = usePresentation(p => p.foldCompletedTurns)
|
||||
```
|
||||
|
||||
For a seat outside every group, `grouping` is `'none'` in all modes, so it does not re-render. `foldCompletedTurns` likewise stays true in all modes. The selector hook uses `useSyncExternalStoreWithSelector` and re-renders only when the selected value changes.
|
||||
|
||||
Three sources combine into `hidden`:
|
||||
|
||||
| Source | Condition | Reveal action |
|
||||
|---|---|---|
|
||||
| Whole-Turn folding | Existing rule: `turnClosed && turnStarted && has reply`, without manual expansion | Open the Turn |
|
||||
| Group folding | `grouping === 'collapsed'`, member role, group not manually expanded | Open the group |
|
||||
| Group header in Expanded | Header role and `grouping === 'none'` | None |
|
||||
|
||||
The closer's reasoning is hidden independently: the seat passes `stepGroup.reasoningHidden` through owner props to the assistant-step renderer.
|
||||
|
||||
The chat store keeps group expansion under a key combining `turn` and `headStep`, alongside whole-Turn manual expansion.
|
||||
|
||||
### Re-render scope
|
||||
|
||||
The following guarantees also define the next round of render-count assertions. Each row names an exact set, not an upper bound.
|
||||
|
||||
| External change | May re-render |
|
||||
|---|---|
|
||||
| A running group receives a reasoning chunk | That assistant seat |
|
||||
| A running group receives `tool/call` | That tool seat and the group's header |
|
||||
| A reply appears and closes a group | That assistant seat and group header; membership recomputation occurs only when opening or closing groups |
|
||||
| A new group opens | Insert the new header into `order` with one structural layout; no other seat refreshes |
|
||||
| Switch Compact ↔ Detailed | Only assistant-step renderers with settled reasoning |
|
||||
| Switch to or from Expanded | Only member and header seats |
|
||||
| Change `performanceUsage` | Only turn-tail |
|
||||
| Load an earlier page | New seats and historical seats whose presentation changes |
|
||||
| Change locale | All seats, as a control case |
|
||||
|
||||
### Presentation policy object
|
||||
|
||||
Separate the persisted enum from runtime capabilities. `TranscriptViewMode` contains `compact`, `detailed`, and `expanded`; reading the legacy saved value `normal` maps it to `detailed` without writing it back. Runtime capabilities are the derived `ChatPresentationPolicy`:
|
||||
|
||||
| Field | compact | detailed | expanded |
|
||||
|---|---|---|---|
|
||||
| `foldCompletedTurns` | true | true | true |
|
||||
| `stepGrouping` | collapsed | collapsed | none |
|
||||
| `settledReasoningPreview` | false | true | true |
|
||||
|
||||
The mapping has one owner. Each mode maps to a constant object with a stable reference, so the derived observable needs no subscription bookkeeping: `getSnapshot` performs a lookup and `subscribe` forwards to the mode observable.
|
||||
|
||||
Renderers select needed fields instead of comparing enum values. Policy is injected by kind: the assistant-step registration receives it through `inject.hooks.presentation`, just as turn-tail receives `performanceUsage`. Adding a fourth mode changes the table, not the renderers.
|
||||
|
||||
### Per-Turn folding
|
||||
|
||||
Add `turnStarted` to per-key presentation, derived from whether the Turn's `turn/start` is in the loaded window. Folding eligibility uses it instead of global `!historyIncomplete`. Completing pagination changes no global seat prop; only new nodes and presentation in their Turns change.
|
||||
|
||||
### Complexity requirements
|
||||
|
||||
| Scenario | Fold | Builder | Publication |
|
||||
|---|---|---|---|
|
||||
| Content chunk without a group-boundary change | O(1) transition, original object returned | No reprojection | None |
|
||||
| Event that changes a group boundary | O(1) | Reproject only the owning Turn, proportional to its node count | Keys whose facts changed in that Turn |
|
||||
| Structural changes and pagination | Proportional to matched events | One full pass | Keys whose facts changed |
|
||||
| Mode change | None | None | None; seat and renderer selectors determine re-renders |
|
||||
|
||||
## Change inventory
|
||||
|
||||
| Package | File | Change |
|
||||
|---|---|---|
|
||||
| ui-conversation | `contract/conversation.ts` | Add `ConversationLocationDataUpdate` and optional `changedLocationData` to `ConversationViewBuilder.apply` |
|
||||
| ui-conversation | `conversation/assembler.ts` | Collect changed Location references in `applyDirtyLocationData` and pass them through `apply` |
|
||||
| ui-conversation | `index.ts` | Export the new type |
|
||||
| ui-chat | `chat-settings.ts` | Three-mode enum; schema accepts legacy `normal` |
|
||||
| ui-chat | `transcript-view.ts` | Map `normal` to `detailed` on read |
|
||||
| ui-chat | New `presentation-policy.ts` | Policy objects, mapping table, derived observable |
|
||||
| ui-chat | `settings/TranscriptViewRow.tsx` | Three options |
|
||||
| ui-chat | `locale.ts` | Mode labels and group-header copy |
|
||||
| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`, `PresentationInjected`, `ChatNodeOwnerProps.stepGroup`, `StepGroupOwnerProps` |
|
||||
| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`, `ChatNodeGroupPresentation`; combine `processSource` values per key |
|
||||
| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` |
|
||||
| ui-chat | `contract/store.ts`, `stores.ts` | Manual group-expansion state and actions |
|
||||
| ui-chat | `conversation-nodes/common.ts` | Header anchor offset |
|
||||
| ui-chat | New `conversation-nodes/step-groups.ts` | Fold Definition and Turn-data declaration merging |
|
||||
| ui-chat | New `conversation-nodes/step-group-nodes.ts` | Materialize headers and project membership |
|
||||
| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted` and combined per-key presentation |
|
||||
| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | Integrate materialization, consume `changedLocationData`, support key deletion in the store |
|
||||
| ui-chat | `conversation-nodes/register.ts` | Register the fold Definition |
|
||||
| ui-chat | `chat/ChatView.tsx` | Stop passing `compactTranscript` and `historyIncomplete`; pass `usePresentation` |
|
||||
| ui-chat | `chat/ChatNodeSeat.tsx` | Combined selector, three hiding sources, `stepGroup` owner props |
|
||||
| ui-chat | `chat/AssistantNodeView.tsx`, `chat/AssistantMarkdown.tsx`, `chat/ReasoningRow.tsx` | Inject policy, toggle settled reasoning previews, hide closer reasoning |
|
||||
| ui-chat | New `chat/StepGroupNodeView.tsx` | Header renderer |
|
||||
| ui-chat | `chat/register-node-renderers.ts` | Register header renderer and inject policy into assistant-step |
|
||||
| ui-chat | `apply.ts`, `index.ts` | Create and inject policy observable; export types |
|
||||
|
||||
This inventory retains the complete grouping proposal. Independent features have updated READMEs, i18n pairing files, and the slot catalog. The migration record tracks tests and snapshots; those updates do not complete the outstanding grouping work.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Grouping validation remains unimplemented; the migration record tracks independent presentation-feature evidence.
|
||||
|
||||
**Render-count isolation.** Add counters to the existing `chat-view.client.spec` mounting approach: React Profiler `onRender` counts per seat, and test renderers registered in `conversation.chat.node` count by kind. Assert the exact sets in the re-render-scope table for each stimulus.
|
||||
|
||||
**Publication counts.** Count notifications from each key's source under the same stimuli and assert the exact set.
|
||||
|
||||
**Fold state-machine tests.** Feed event sequences and assert opening, closing, a new group after text then tools in one step, Turn-end closure, idempotence, unchanged closed-group references, and equivalent replay using only persisted messages.
|
||||
|
||||
**Membership tests.** Given the group list and Turn nodes, assert each key's role.
|
||||
|
||||
**Benchmark coverage.** Add incremental apply with reasoning and tools to `conversation-fold`, and a streaming fixture with tools and reasoning to `long-session-browser`. These provide wall-clock evidence, not a gate.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Builder-owned node creation.** This is the first proposed node creation by a view builder. A builder may only materialize rows from Turn or Step data; it must not read events or derive business structure. Synthetic keys use `step-group:`, while Context keys begin with digits, preventing collisions. Fall back to approach A if this ownership is rejected in practice.
|
||||
- **Header anchor moves once with its first member.** Persisting a streaming Assistant moves its anchor from the first visible chunk to the message event. The header follows in the Assistant's structural update.
|
||||
- **Actual mode differences in this phase.** Compact and Detailed currently differ only in settled reasoning first-line previews. Running detail titles and category summaries are outside this phase; the policy table leaves room for them.
|
||||
- **Pagination and partial Turns.** The oldest partial Turn neither folds nor groups. Membership uses steps and is independent of pagination.
|
||||
- **Header copy.** This phase proposes only processing/completed labels and tool-call counts; category summaries remain product work.
|
||||
- **Streaming chunk step.** `assistant/live-chunk` carries `step`, as used by `turn-process`; a chunk without a step does not open a group.
|
||||
|
||||
## Retained alternative: parent-derived child Contexts
|
||||
|
||||
If builder materialization exposes more ownership problems than expected, add an engine primitive: a Context's `update` may derive a child Context with an explicit kind and id, then forward later matches. Each child owns its node, publication timing, and Location. Parent replay aligns children by id; children no longer derived become hidden under the existing withdrawal rules.
|
||||
|
||||
This returns row identity and content to Definitions with one Context per group and no array fragmentation. It requires assembler forwarding and cascaded replay, and introduces a Context creation path outside `match`. Reconsider it if more than two business features exceed the builder's materialization limits, or if membership requires cases that steps cannot express.
|
||||
-288
@@ -1,288 +0,0 @@
|
||||
# Agent Note: Chat 展示策略通道与过程分组的基础架构
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-09-20-chat-presentation-policy-and-step-groups.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不折叠、`compact` 在轮次结束后把过程行折进一个整轮控件。设置值的流向是:`TranscriptViewPolicy.mode` 观察量注入 ChatView,ChatView 用 `useTranscriptView` 读成一个布尔值 `compactTranscript`,再作为 prop 传给 `ChatNodeList`,再传给每一个 `ChatNodeSeat`。另一个全局布尔值 `hasMore` 走同一条路,以 `historyIncomplete` 的名字进每个 seat。两个值一变,全部 seat 重渲染一遍,哪怕只有可折叠的那几个输出真的变了。
|
||||
|
||||
产品要把这个设置改成三档:简洁、详细、完全展开;并在简洁和详细两档里把回复之间的连续过程行归成可折叠的组。PR #4565 做过一次完整实现,合并八分钟后因性能回退被整体撤回。合并前后在本机的对照实测:长会话分页耗时 +27%,DOM 节点 +23%,堆内存 +14%,Trajectory 切换 +10%,全部仍在 CI 预算内;仓库现有六个基准没有任何一个覆盖「流式期间 reasoning 或工具 chunk 进入活动分组、分组重投影、seat 重渲染」这条它新增的热路径。
|
||||
|
||||
审阅那次实现,问题不在业务需求,在选型:
|
||||
|
||||
- 模式被当成列表结构的输入。`ChatNodeList` 订阅整表 `layout`,按 range 生成 wrapper 组件;完全展开模式下 wrapper 消失。模式切换等于整棵列表重挂载,任何一次重新分组都重建 wrapper 的 key。
|
||||
- 模式进了 `ChatNodeOwnerProps`。每个 seat 的 owner 对象随模式变,所有业务渲染器跟着重渲染,包括与模式无关的用户消息、工具行、压缩标记。
|
||||
- 同一个 Assistant 节点在 seat 里被拆成 reasoning 与 response 两份派生对象,只为了让 wrapper 在 DOM 上包住前者不包住后者。master 的 `AssistantMarkdown` 早有 `reasoningHidden` 与 `revealProcess`,靠隐藏状态就能只藏 reasoning。
|
||||
- 基础设施行的 Chat 可见性由消费方过滤函数在四处各判一遍,而节点本身有 `visibility` 字段由 Definition 决定。
|
||||
|
||||
再往下一层,是一个信息流上的两难:分组的行要出现在成员之前,从组打开那一刻就该存在;但它要显示的内容,只有看到整轮所有事件的人才知道。事件驱动的 Context 模型里每个 Context 只收自己的事件,能看到整轮的只有 turn 级 Context,而它只能产出一个节点。
|
||||
|
||||
本记录给出一套基础架构,让三档模式与分组落地时满足下面的约束,并让后续产品变化只落在少数节点上。
|
||||
|
||||
三档设置、展示策略通道与按轮折叠资格已实现;step-group 仍是提案。当前实现、已提交范围和未完成项以[迁移进度表](../feature/2026-09-20-chat-work-details-migration.zh.md)为准,下面的分组状态机与改动清单不表示已经交付。
|
||||
|
||||
## 目标与约束
|
||||
|
||||
[已实现的 Definition 分组基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。
|
||||
|
||||
下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。
|
||||
|
||||
| 编号 | 裁决 | 含义 |
|
||||
|---|---|---|
|
||||
| 1 | 分组结构在 Definition 层定义 | 组的开合、成员范围、摘要由一个多重匹配本轮事件的 Definition 折叠出来,不由渲染层扫描节点得到 |
|
||||
| 2 | 有限刷 seat | 切模式只重渲染 presentation 真正变化的 seat;流式 chunk 只重渲染所属 seat 和它所在组的组头;用户消息、回复、turn-tail 在这两种变化下零重渲染 |
|
||||
| 3 | 一个 Context 一个节点 | 不引入「一个 Context 产出多个节点」,理由是绝大多数 Context 只会有一个元素,数组化让内存更零散 |
|
||||
| 4 | 模式不开关 Definition | 注册变化会触发 registry 重建与全量重放,所有 seat 必刷;Definition 必须与模式无关 |
|
||||
| 5 | 折叠按轮判定 | 一轮只要自己的 `turn/start` 已加载就可以折叠;被分页切开的最老一轮保持展开,本轮不优化 |
|
||||
| 6 | 允许改 ui-conversation | 引擎可以改,但只做让方案正确成立所必需的小改动 |
|
||||
| 7 | 初始实现范围 | 初始阶段只写代码与本记录;测试与其他文档在 CI 修复阶段补齐 |
|
||||
|
||||
有一条前置澄清:接受「切模式全屏重排版」不等于接受「所有 seat 重渲染」。浏览器重排是几何结果,seat 重渲染是 React 组件函数重跑,后者才是这里要限制的量。
|
||||
|
||||
## 可行的路线
|
||||
|
||||
围绕「组头这一行的身份从哪来、内容从哪来」,可行的路线有五条。
|
||||
|
||||
**路线 A,头成员的 seat 画组头。** 列表不变,builder 算出分组后告诉第一个成员的 seat「你是组头」,该 seat 在自己上方画标题,其余成员 seat 用现有 `hidden` 机制藏起来。零新概念。缺点是 seat 里出现「自身可见但只显示标题」的第三种状态,且标题寄生在一个业务节点的 seat 里。
|
||||
|
||||
**路线 α,每步一个 Context 当组头。** 增加一个 id 为 `turn:step` 的 Definition,在构建时读上一步的 `assistant-step` 数据判断自己是否新组开头,是则产出组头节点。组的内容由另一个 turn 级 Definition 发布成 Turn 数据供渲染器读取。
|
||||
|
||||
**路线 β,一个 Context 产出多个节点。** turn 级 Definition 一个人产出整轮控件加 N 个组头。
|
||||
|
||||
**路线 γ,父 Context 派生子 Context。** turn 级 fold 在开组时生成一个 `step-group` 子 Context 并把后续事件转发给它,子 Context 有自己的单个节点。引擎需要新增转发原语与重放级联。
|
||||
|
||||
**路线 δ,Definition 拥有结构,builder 物化行。** turn 级 Definition 折叠出组列表并发布为 Turn 数据;Chat builder 据此物化组头节点并给成员写归属;seat 用组合 selector 决定隐藏;引擎给 `ViewBuilder.apply` 的入参加一份「本次 flush 哪些 Location 数据换了引用」。
|
||||
|
||||
## 备选方案与取舍
|
||||
|
||||
**每组一个 Context 无法成立。** 引擎要求 `match(event)` 只看单个事件算出 id,「第几组」取决于前面有没有回复,单个事件算不出。这不是实现难度,是契约上的不可能。
|
||||
|
||||
**路线 α 与裁决 2 冲突。** 组头身份能算出,但内容要读整轮一份 Turn 数据,任何一组变化全轮组头都重渲染。改成去读 `turn.steps` 里后面几步的数据,又碰上组头节点不会因为后面的步到来而重建,读到的是陈旧引用。
|
||||
|
||||
**路线 β 被裁决 3 否决。** 见上表。
|
||||
|
||||
**路线 γ 现在不值。** 它在所有权上最纯,但要给 assembler 加转发与重放级联,是几百行的引擎核心改动。本记录把它保留为备选,条件写在末尾。
|
||||
|
||||
**路线 A 作为退路保留。** 如果 builder 物化节点在实践中被否决,退到 A;两者的结构来源相同,只差组头这一行的承载。
|
||||
|
||||
选择路线 δ。
|
||||
|
||||
## 方案
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
事件流
|
||||
──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件)
|
||||
──▶ Turn 数据 step-groups:组列表,每组一个对象
|
||||
──▶ Chat builder
|
||||
├─ 物化组头节点:一组一个 key,data 即组对象
|
||||
└─ 成员归属:按 step 映射到 key,写进 per-key presentation
|
||||
──▶ seat:hidden = f(策略字段, 角色, 展开状态)
|
||||
──▶ 组头渲染器:读自己节点的 data
|
||||
展示策略
|
||||
──▶ 渲染器:按 kind 注入的 hooks,选单个字段
|
||||
──▶ seat:组合 selector,选出的是自己的结论
|
||||
```
|
||||
|
||||
引擎改动只有一处:assembler 在 flush 时把「哪些 Location 数据换了引用」随 `apply` 入参交给 builder。
|
||||
|
||||
### 各层职责
|
||||
|
||||
| 层 | 拥有 | 明确不拥有 |
|
||||
|---|---|---|
|
||||
| `step-groups` Definition | 组的开合、`headStep`、`closeStep`、工具计数与名称、运行中的工具;作为 Turn 数据发布 | 任何视图节点;成员是哪些节点 |
|
||||
| Chat builder | 组头节点的身份与生命周期;成员归属;`turnStarted`;per-key presentation 的身份比较发布 | 读事件;自己算业务结构 |
|
||||
| 展示策略对象 | 枚举到能力字段的映射 | 任何节点或 seat 状态 |
|
||||
| seat | 用策略字段、角色、store 状态合成 hidden;把组的开合能力交给组头渲染器 | 判断组的边界 |
|
||||
| 组头渲染器 | 标题、箭头、点击开合 | 组的内容来源 |
|
||||
| assistant-step 渲染器 | 是否显示已完成推理的首行预览;作为组的关闭者时隐藏推理部分 | 组的边界 |
|
||||
|
||||
### 组头节点的身份与生命周期
|
||||
|
||||
组头节点的 key 是 `step-group:${turn}:${headStep}`。`headStep` 在组打开那一刻就确定,之后任何事件都改不了「谁是第一个」,所以组头节点从打开起就存在并被复用,运行中不会换 key。组关闭只是它 data 里的 `closed` 从 false 变 true。
|
||||
|
||||
组头节点的锚点是该组头成员节点锚点减去一个合成偏移,位置紧贴在第一个成员之前。头成员的 Assistant 节点在流式期间锚在第一个可见 chunk,落盘后锚在消息事件,锚点会移动一次;组头随之移动一次,这是一次结构性更新,与 Assistant 节点自己的结构性更新同时发生,不额外增加次数。
|
||||
|
||||
组头节点属于整轮过程的一部分:整轮折叠时它随成员一起隐藏。它不在 `TURN_PROCESS_INDEPENDENT_KINDS` 里。
|
||||
|
||||
组头节点的 data 就是 fold 发布的组对象。已关闭的组对象引用永不改变,因此已关闭组头的 source 永不通知;正在运行的组每来一个改变它的事件换一个对象,只有这一组的组头重渲染。
|
||||
|
||||
### fold 状态机
|
||||
|
||||
`step-groups` Definition 的 id 是 turn 号,在 `turn/start` 上启动,匹配与 `turn-process` 同一批事件。State 是 `{ turn, groups, open }`,`groups` 是组对象数组,`open` 是当前打开组的下标或空。
|
||||
|
||||
| 事件 | 转移 |
|
||||
|---|---|
|
||||
| `tool/call` | 没有打开的组则以该事件的 step 开新组;计数加一;记录 distinct 工具名;把 callId 加入运行集合 |
|
||||
| `tool/result` 追加 | 从运行集合移除 callId |
|
||||
| `assistant/live-chunk` 推理 delta 含可见字符 | 没有打开的组则以该 chunk 的 step 开新组 |
|
||||
| `assistant/live-chunk` 文本 delta 或文本块结束含可见字符 | 有打开的组则以该 chunk 的 step 关闭它 |
|
||||
| `assistant/message` 追加 | 消息含可见推理则按推理规则处理,含可见回复则按文本规则处理;顺序是先推理后文本 |
|
||||
| `turn/end` | 有打开的组则标记为被轮次结束关闭 |
|
||||
| `step/start`、`step/end`、`llm/retry` | 不改变状态;重试行不属于任何组 |
|
||||
|
||||
三条不变量:
|
||||
|
||||
- 幂等。开组时已有打开的组则无操作;关组时组已关闭则无操作。live chunk 与落盘消息重复描述同一内容不会开两次或关两次,历史重放只有落盘消息也能得到同样的组列表。
|
||||
- 引用稳定。State 没变时 `update` 返回原对象;`groups` 数组只在组列表变化时换引用;已关闭的组对象永不替换。
|
||||
- 只向前折叠。不读邻居 Context,不读节点,不向后看。
|
||||
|
||||
Turn 数据发布:`buildLocationData` 只在 turn 阶段返回值,`groups` 引用不变时返回上一次发布的值,引擎据此不重新发布。没有 `turn/start` 的历史窗口用与 `turn-process` 相同的兜底:从已有匹配折叠出 State。
|
||||
|
||||
发布节奏:live chunk 走动画帧合并,其余立即。
|
||||
|
||||
### 成员归属规则
|
||||
|
||||
归属由 builder 按 step 判定,不用 seq 区间,因为 live chunk 的 seq 与落盘事件的 seq 不在一个尺度上。
|
||||
|
||||
| 节点 | 规则 |
|
||||
|---|---|
|
||||
| `tool-call`,step 为 s | 属于 `headStep ≤ s` 的最后一组。文本在同一 step 关闭了上一组之后的工具调用,会由 fold 以该 step 开出新组,所以「最后一组」总是正确的那一组 |
|
||||
| `assistant-step`,step 为 s,且某组 `closeStep === s` | 角色为关闭者。节点保持可见;组折叠时只隐藏它的推理部分,用现有 `reasoningHidden` 机制 |
|
||||
| `assistant-step`,step 为 s,其余情况 | 属于 `headStep ≤ s` 且 `s` 未越过 `closeStep` 的组,角色为成员 |
|
||||
| `model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail`、`user`、`steering` | 不属于任何组 |
|
||||
|
||||
归属只在组列表变化时重算,成本是该轮节点数。流式 chunk 不改变组列表时,成员的 presentation 不发布。
|
||||
|
||||
组头行锚在最早的成员之前。一个组如果只有关闭者的推理而没有任何成员,比如一步里先推理后回复,组头行就锚在关闭者之前,这样被隐藏的推理仍然有一行可以点开。
|
||||
|
||||
### 分组变化怎样到达 builder
|
||||
|
||||
fold 只发布 Turn 数据,没有视图节点。engine 的 `flush` 在 Location 数据有变化时会把 `timelineDirty` 置真并对所有活跃目标调用 `apply`,但 builder 不知道是哪一轮的哪个 key 变了。依赖「同一轮总有别的节点也在这次 flush 里变了」去推断,是巧合不是契约。
|
||||
|
||||
所以给 `ConversationViewBuilder.apply` 的入参增加可选的 `changedLocationData`:本次 flush 里换了引用的 Location 数据清单,每项含 `kind`、`turn`、可选 `step`、`key`。assembler 在 `applyDirtyLocationData` 里本来就逐条比较了前后引用,只是把比较结果收集起来交给 builder。Chat builder 只取 `key === 'step-groups'` 的轮次做重投影。入参可选,不传时 builder 退化为按本次 upsert 涉及的轮次重投影。
|
||||
|
||||
### seat 的 hidden 合成
|
||||
|
||||
seat 不再接收 `compactTranscript` 与 `historyIncomplete` 两个 prop。它通过 `usePresentation` 读策略,但选出的不是模式,而是自己的结论:
|
||||
|
||||
```ts
|
||||
declare function usePresentation<T>(select: (policy: {
|
||||
stepGrouping: 'collapsed' | 'none'
|
||||
foldCompletedTurns: boolean
|
||||
}) => T): T
|
||||
declare const group: { readonly headStep: number } | undefined
|
||||
|
||||
const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping)
|
||||
const foldCompleted = usePresentation(p => p.foldCompletedTurns)
|
||||
```
|
||||
|
||||
不在任何组里的 seat,`grouping` 在三档下都是 `'none'`,值没变就不重渲染。`foldCompletedTurns` 在三档下都是 true,同理。selector hook 的底层是 `useSyncExternalStoreWithSelector`,只有选出的值变化才重渲染。
|
||||
|
||||
三个隐藏来源合成一个 `hidden`:
|
||||
|
||||
| 来源 | 条件 | 揭示动作 |
|
||||
|---|---|---|
|
||||
| 整轮折叠 | 现有规则,`turnClosed && turnStarted && 有回复` 且未手动展开 | 打开整轮 |
|
||||
| 组折叠 | `grouping === 'collapsed'`、角色为成员、组未手动展开 | 打开该组 |
|
||||
| 组头在完全展开档 | 角色为组头且 `grouping === 'none'` | 无 |
|
||||
|
||||
关闭者节点的推理部分单独隐藏:seat 通过 owner props 里的 `stepGroup.reasoningHidden` 告诉 assistant-step 渲染器。
|
||||
|
||||
组的展开状态进 chat store,key 是 `turn` 加 `headStep`,与整轮的手动展开状态并列。
|
||||
|
||||
### 刷新范围
|
||||
|
||||
下表是这套结构给出的保证,也是下一轮渲染次数测试的断言表。每一行断言的是精确集合,不是上限。
|
||||
|
||||
| 外部变化 | 允许重渲染的 |
|
||||
|---|---|
|
||||
| 运行中的组来一个推理 chunk | 该 assistant seat |
|
||||
| 运行中的组来一个 `tool/call` | 该 tool seat,加这一组的组头 |
|
||||
| 回复出现,组关闭 | 该 assistant seat,加这一组的组头;成员归属重算只发生在开关组时 |
|
||||
| 新组打开 | 新组头进 `order`,一次结构重排;其他 seat 不刷 |
|
||||
| 模式在简洁与详细之间切换 | 仅有已完成推理的 assistant-step 渲染器 |
|
||||
| 模式切到完全展开或切回 | 仅成员与组头 seat |
|
||||
| `performanceUsage` 切换 | 仅 turn-tail |
|
||||
| 加载更早一页 | 新增 seat,加 presentation 变了的历史 seat |
|
||||
| 语言切换 | 全部,作为对照 |
|
||||
|
||||
### 展示策略对象
|
||||
|
||||
持久化的枚举与运行时词汇分开。枚举 `TranscriptViewMode` 是 `compact`、`detailed`、`expanded`,存档里的旧值 `normal` 读取时映射为 `detailed`,不回写。运行时词汇是派生的 `ChatPresentationPolicy`:
|
||||
|
||||
| 字段 | compact | detailed | expanded |
|
||||
|---|---|---|---|
|
||||
| `foldCompletedTurns` | true | true | true |
|
||||
| `stepGrouping` | collapsed | collapsed | none |
|
||||
| `settledReasoningPreview` | false | true | true |
|
||||
|
||||
映射只存在于一处。每档对应一个常量对象,同一档永远同一引用,所以派生观察量不需要自己的订阅簿记:`getSnapshot` 是查表,`subscribe` 直接转给模式观察量。
|
||||
|
||||
渲染器不比较枚举,只 select 自己需要的字段。策略观察量按 kind 注入:assistant-step 的注册通过 `inject.hooks.presentation` 拿到它,与 turn-tail 注入 `performanceUsage` 是同一写法。以后加第四档只改这张表,渲染器一个不动。
|
||||
|
||||
### 按轮折叠
|
||||
|
||||
per-key presentation 增加 `turnStarted`,来源是该轮 `turn/start` 是否在加载窗口内。seat 的折叠资格由 `turnStarted` 替代原来的全局 `!historyIncomplete`。分页加载完成时不再有任何全局 prop 变化,只有新增节点与它们所在轮的 presentation 变化。
|
||||
|
||||
### 复杂度契约
|
||||
|
||||
| 场景 | fold | builder | 发布 |
|
||||
|---|---|---|---|
|
||||
| 内容型 chunk,不改变组边界 | O(1) 状态转移,返回原对象 | 无重投影 | 零 |
|
||||
| 改变组边界的事件 | O(1) | 只重投影所属轮,成本为该轮节点数 | 该轮内事实变化的 key |
|
||||
| 结构变化与分页 | 按匹配的事件数 | 全量一次 | 事实变化的 key |
|
||||
| 模式切换 | 无 | 无 | 无;重渲染由 seat 与渲染器的 selector 决定 |
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 包 | 文件 | 改动 |
|
||||
|---|---|---|
|
||||
| ui-conversation | `contract/conversation.ts` | 新增 `ConversationLocationDataUpdate`;`ConversationViewBuilder.apply` 入参增加可选 `changedLocationData` |
|
||||
| ui-conversation | `conversation/assembler.ts` | `applyDirtyLocationData` 收集换了引用的 Location 数据并随 `apply` 传出 |
|
||||
| ui-conversation | `index.ts` | 导出新类型 |
|
||||
| ui-chat | `chat-settings.ts` | 三档枚举;schema 接受旧值 `normal` |
|
||||
| ui-chat | `transcript-view.ts` | 读取时 `normal` 映射为 `detailed` |
|
||||
| ui-chat | `presentation-policy.ts` 新增 | 策略对象、映射表、派生观察量 |
|
||||
| ui-chat | `settings/TranscriptViewRow.tsx` | 三个选项 |
|
||||
| ui-chat | `locale.ts` | 三档文案;组头文案 |
|
||||
| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`;`PresentationInjected`;`ChatNodeOwnerProps.stepGroup`;`StepGroupOwnerProps` |
|
||||
| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`;`ChatNodeGroupPresentation`;`processSource` 的值改为 per-key 组合对象 |
|
||||
| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` |
|
||||
| ui-chat | `contract/store.ts`、`stores.ts` | 组的手动展开状态与动作 |
|
||||
| ui-chat | `conversation-nodes/common.ts` | 组头锚点偏移 |
|
||||
| ui-chat | `conversation-nodes/step-groups.ts` 新增 | fold Definition 与 Turn 数据声明合并 |
|
||||
| ui-chat | `conversation-nodes/step-group-nodes.ts` 新增 | 组头节点物化与成员归属投影 |
|
||||
| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted`;组合 per-key presentation |
|
||||
| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | 接入物化投影;消费 `changedLocationData`;store 支持删除 key |
|
||||
| ui-chat | `conversation-nodes/register.ts` | 注册 fold Definition |
|
||||
| ui-chat | `chat/ChatView.tsx` | 删除 `compactTranscript` 与 `historyIncomplete` 的传递;传 `usePresentation` |
|
||||
| ui-chat | `chat/ChatNodeSeat.tsx` | 组合 selector;三个隐藏来源;`stepGroup` owner props |
|
||||
| ui-chat | `chat/AssistantNodeView.tsx`、`chat/AssistantMarkdown.tsx`、`chat/ReasoningRow.tsx` | 注入策略;已完成推理的预览开关;关闭者的推理隐藏 |
|
||||
| ui-chat | `chat/StepGroupNodeView.tsx` 新增 | 组头渲染器 |
|
||||
| ui-chat | `chat/register-node-renderers.ts` | 注册组头渲染器;给 assistant-step 注入策略 |
|
||||
| ui-chat | `apply.ts`、`index.ts` | 创建策略观察量并注入;导出类型 |
|
||||
|
||||
上述清单保留完整分组方案;独立功能已经更新 README、i18n 配对文件及 slot catalog。测试与快照进度见迁移记录,未完成的分组改动不随这些更新自动完成。
|
||||
|
||||
## 验收标准
|
||||
|
||||
分组相关验收仍待实现;独立展示功能的验证进度见迁移记录。
|
||||
|
||||
**渲染次数隔离测试。** 在现有 `chat-view.client.spec` 的挂载方式上加计数:React Profiler 的 `onRender` 按 seat 计数,测试专用的计数渲染器注册进 `conversation.chat.node` 按 kind 计数。刺激与期望按「刷新范围」一节的表逐行断言精确集合。
|
||||
|
||||
**发布次数测试。** 同样的刺激下统计每个 key 的 source 通知次数,断言精确集合。
|
||||
|
||||
**fold 状态机单测。** 喂事件序列,断言组列表:开组、关组、同 step 先文本后工具开新组、轮次结束关组、幂等性、已关闭组对象引用不变、只有落盘消息的历史重放得到相同组列表。
|
||||
|
||||
**成员归属单测。** 给定组列表与该轮节点,断言每个 key 的角色。
|
||||
|
||||
**基准补位。** `conversation-fold` 增加带推理与工具的增量 apply 场景;`long-session-browser` 增加带工具与推理的流式 fixture。这是墙钟兜底,不是门禁。
|
||||
|
||||
## 风险
|
||||
|
||||
- **builder 产出节点的边界。** 这是这套架构里第一次由视图 builder 产出节点。边界写死为:builder 只能从 Turn 或 Step 数据物化行,不能读事件,不能自己算业务结构。合成 key 用 `step-group:` 前缀,Context key 以数字开头,不会冲突。如果实践中被否决,退到路线 A。
|
||||
- **组头锚点随头成员移动一次。** 头成员是流式 Assistant 时,落盘让锚点从首个可见 chunk 移到消息事件,组头跟着移动一次。与 Assistant 自己的结构性更新同时发生。
|
||||
- **三档在本轮的实际差异。** 简洁与详细目前只差已完成推理的首行预览;运行中的详情标题、类别摘要等产品特性不在本轮。策略表已为它们留了位置。
|
||||
- **分页与半截轮。** 被分页切开的最老一轮不折叠、不分组;归属只按 step 计算,不受分页影响。
|
||||
- **组头文案。** 本轮只有「正在处理」与「已完成处理」加工具调用次数,类别摘要留给产品层。
|
||||
- **流式 chunk 的 step 字段。** `assistant/live-chunk` 事件携带 `step`,与 `turn-process` 的用法相同;若 chunk 缺少 step 则不开组。
|
||||
|
||||
## 备选保留:父 Context 派生子 Context
|
||||
|
||||
如果 builder 物化节点在使用中暴露出比预期更多的边界问题,替代方案是给引擎加一个原语:Context 在 `update` 中可以派生一个指定 kind 与 id 的子 Context,并把后续匹配转发给它;子 Context 有自己的单个节点、自己的发布节奏与 Location。父 Context 重放时子 Context 按 id 对齐,未再派生的子 Context 按现有撤回规则转为隐藏。
|
||||
|
||||
它的收益是行的身份与内容都回到 Definition 层,一组一个 Context,没有数组碎片。代价是 assembler 要新增转发与重放级联,且 `match` 之外多出一条 Context 创建路径。切换条件:builder 物化节点的边界在两个以上业务里被突破,或成员归属的按 step 规则出现无法用 step 表达的用例。
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md
|
||||
2026-09-20-chat-work-details-migration.md: 126b61d32d4a4dbf27cb5997149abc9820d7f881
|
||||
2026-09-20-chat-work-details-migration.zh.md: 20727c299307881b67d0662092d269afa9af176c
|
||||
@@ -1,176 +0,0 @@
|
||||
# Agent Note: Chat work-details migration progress
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-09-20-chat-work-details-migration.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
PR #4565 combines three display modes, process grouping, whole-Turn status, trigger notifications, tool-row visuals, and infrastructure-row hiding. Migrating it as one batch mixes independent features with list restructuring and makes individual performance changes difficult to assess. A feature-based progress table allows independent visuals to be reviewed first and Turn-related behavior to be considered with step-group later, without losing product requirements.
|
||||
|
||||
## Proposal
|
||||
|
||||
Completed independent features stay in `feat/chat-presentation-channel`. The remaining unfinished code continues in `.artifacts/worktrees/pr-4565-turnstep` under the repository root, based on the current channel and this progress record, with drafts kept unstaged for combined turn/step and grouping review. The `pr-4565` worktree remains the original PR's behavior and implementation reference; existing `feat/chat-step-groups` experiments are outside this transfer.
|
||||
|
||||
This record tracks feature scope, current implementation, and acceptance progress. The [presentation policy channel and process-group architecture](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) retains the architecture and alternatives. This record neither replaces it nor treats its proposals as implemented. Because it does not fully supersede that decision, retain the architecture note without archival or deletion.
|
||||
|
||||
This record maintains an English/Chinese pair. Completion, implementation, staging or commit state, and acceptance are separate dimensions. Incomplete items remain marked incomplete even when drafts exist, and stay in the inventory. Tests and documentation are added during CI repair; browser snapshots and performance acceptance are tracked separately. Written code, staging, commits, and successful builds do not establish complete functional or performance acceptance.
|
||||
|
||||
### Status definitions
|
||||
|
||||
| Dimension | State | Meaning |
|
||||
|---|---|---|
|
||||
| Completion | Complete | Migration code has been reviewed feature by feature and committed to this channel; behavior and performance evidence are separate |
|
||||
| Completion | Incomplete | Outside the channel's completed migration scope; may have an unstaged draft or no implementation |
|
||||
| Implementation | Existing capability | Already present in this channel |
|
||||
| Implementation | Code written | Implementation exists in the relevant worktree; does not imply completion, staging, commit, or acceptance |
|
||||
| Implementation | Awaiting migration | Included in non-grouping scope but not yet implemented |
|
||||
| Implementation | Grouping phase | Requirement retained; depends on grouping structure or interactions and is not connected to this channel yet |
|
||||
| Staging or commit | Unstaged | Changes remain in the worktree, including untracked files |
|
||||
| Staging or commit | Partially staged | Only some feature changes are staged; the exact scope must be stated |
|
||||
| Staging or commit | Staged | All pending code changes for the feature are staged but not committed |
|
||||
| Staging or commit | Committed | Feature code is included in current HEAD |
|
||||
| Acceptance | Pending | Requires behavior, node identity, update-scope, or browser performance evidence; Git state and compilation cannot substitute |
|
||||
|
||||
Verify staging against the feature's diff hunks, not whole files. Inspect the worktree, index, and HEAD when updating status. Staging some fields in a shared file does not stage every feature depending on that file.
|
||||
|
||||
### Capabilities already in this channel
|
||||
|
||||
| Capability | compact | detailed | expanded | Completion | Implementation | Staging or commit | Acceptance |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Three settings and legacy-value reading | Compact | Detailed; legacy `normal` reads as this mode without write-back | Expanded | Complete | Existing capability | Committed | Pending |
|
||||
| Per-Turn folding eligibility | A closed Turn with its start loaded and a final reply can fold process content; cancellation/failure extensions are item 6 | Same | Same | Complete | Existing capability | Committed | Pending |
|
||||
| Collapsed settled-reasoning summary | Think title only | First line | First line | Complete | Existing capability | Committed | Pending |
|
||||
| Retain summary DOM across mode changes | CSS hides summary and separator | Show existing summary and separator | Same | Complete | Existing capability | Committed | Pending |
|
||||
| Local reasoning-preview subscription | `ReasoningRow` selects its own visibility | Same | Same | Complete | Existing capability | Committed | Pending |
|
||||
| Stable disclosure-row references | Callbacks, icons, and unchanged summaries retain references; full Markdown mounts only on expansion | Same | Same | Complete | Existing capability | Committed | Pending |
|
||||
|
||||
All three modes retain whole-Turn folding. `expanded` removes group-level folding, headers, and height limits while retaining group identity and member parents; it does not disable whole-Turn folding or automatically expand reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically.
|
||||
|
||||
### Migration inventory without grouping dependencies
|
||||
|
||||
“All three modes” means the behavior applies in every mode, not that switching modes changes it. “Without grouping dependencies” means the draft introduces no step-group, not that every item must be accepted before grouping. Numbers follow the feature review inventory; item 0's compilation wiring is complete and committed, so it is not listed as a product feature. Incomplete drafts remain in `pr-4565-turnstep`, outside this channel's completed features. Behavior and performance acceptance remain pending for the features below.
|
||||
|
||||
| Number | Feature | compact | detailed | expanded | Completion | Implementation | Staging or commit | Destination and considerations |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | Chinese tool titles | All three modes | All three modes | All three modes | Complete | Code written | Committed | Bash/Pwsh: “运行命令”; Grep: “搜索文件内容”; Glob: “查找文件”; Skill: “加载技能”. Only UI dictionaries change; model tool names do not |
|
||||
| 2 | Tool-row interaction visuals | All three modes | All three modes | All three modes | Complete | Code written | Committed | Hover darkens titles and summaries in tool, Bash, and command rows; shared `DisclosureRow` and independent Bash rows point the expanded chevron upward |
|
||||
| 3 | Bash stopped-state screen-reader text | All three modes | All three modes | All three modes | Complete | Code written | Committed | Complete the stopped-state accessible name without changing execution or cancellation semantics |
|
||||
| 4 | Running tool/command text shimmer | All three modes | All three modes | All three modes | Complete | Code written | Both 4A shimmer and 4B accompanying optimizations committed | 4A includes a fixed `TextShimmer` span, CSS activation, tool/command integration, replacement of old sweep styles, and reduced-motion support, excluding step/turn. 4B adds business-row computation caches, stable callbacks, and JSX references |
|
||||
| 5 | Streaming reasoning summary | Update after a paragraph's first line completes | Same | Same | Complete | Code written | Committed | `ReasoningRow` retains the original PR's `latestCompletedParagraphFirstLine(text)` and computes from full text. Retain summary DOM, stable props, and right-edge fading; add no Definition parsing state or cross-layer summary fields |
|
||||
| 6 | Keep process expanded after cancellation/failure | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft uses `keepOpen` in Turn process projection; folding eligibility and stopped/failed behavior await Turn/grouping review |
|
||||
| 7 | Whole-Turn status and duration control | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors the control to loaded `turn/start` after opening inputs, isolates timing in `LiveTurnProcessLabel`, and replaces the bottom activity bar; positioning and status await review |
|
||||
| 8 | Non-human trigger notifications | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft identifies waking inputs from next-turn and first-step next-step claims and projects `turn-trigger`; Turn starts, notification ownership, and expanded content await review |
|
||||
| 9 (permission/system) | Hide permission-command and system-prompt rows | All three modes | All three modes | All three modes | Complete | Code written | Committed | `isVisibleChatNode` centralizes filtering for the existing Chat list, navigation, and process presentation. Command and request-prompt Definitions do not own these filters; persistence and Trajectory are unchanged |
|
||||
| 9 (context) | Hide ordinary Context rows | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Paired with item 8's waking notifications to retain source explanations; final filter ownership awaits Turn/grouping review |
|
||||
| 10 | Turn action-bar positioning | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors `turn-tail` after `turn/end`; reply ownership for feedback/copy and the real Turn-end seq for branching await combined review |
|
||||
| 11 | Action-bar persistent visibility | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft projects `footer` per Turn in the Chat builder: persistently visible when the latest Turn ends in a reply, otherwise on hover/focus; tail detection will be considered with grouping |
|
||||
|
||||
The current base already enables developer tools by default; do not migrate that again. Retain the protection that waits for Host acceptance of settings instead of applying the original PR's missing-value fallback. Its `Switch` change only affects a comment and is not a migrated feature.
|
||||
|
||||
### Subsequent review batches
|
||||
|
||||
Review batches are independent of implementation and staging status. Review remaining drafts in `pr-4565-turnstep`, kept unstaged; a draft does not complete a migration. Transfer only this channel's remaining code, without automatically incorporating `feat/chat-step-groups` experiments or installing dependencies and compiling in the destination worktree.
|
||||
|
||||
| Batch | Features | Review scope |
|
||||
|---|---|---|
|
||||
| Independent feature, committed | 4A: text shimmer | `TextShimmer`, row integration, and browser painting cost; performance acceptance pending |
|
||||
| Independent optimization, committed | 4B: accompanying memoization and reference stability | `ToolRow`, `BashRow`, and `GenericCommandCard` computation caches, callbacks, and JSX references; not required for shimmer itself; performance acceptance pending |
|
||||
| Independent feature, committed | Permission-command and system-prompt hiding from item 9 | Retain the original PR's centralized visibility decision; without step-group, the main list connects it in `orderedVisibleChatNodes`, independent of waking-message classification |
|
||||
| Incomplete, transferred to turnstep worktree | 6, 7, 10, 11 | Whole-Turn folding eligibility, status/clock, action-bar position, and tail detection, considered with Turn/group relationships |
|
||||
| Incomplete, transferred to turnstep worktree | 8 and ordinary Context hiding from item 9 | Trigger notifications depend on Turn starts and Inbox claims; Context hiding accompanies notifications so waking reasons remain visible |
|
||||
|
||||
### Requirements retained for the grouping phase
|
||||
|
||||
All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. The [Definition-owned grouping foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) is implemented; that does not establish completion of these product behaviors or performance requirements.
|
||||
|
||||
| Feature | compact | detailed | expanded | Current state |
|
||||
|---|---|---|---|---|
|
||||
| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows without group-level folding; retain member parents | Incomplete (grouping phase) |
|
||||
| Independent reasoning/reply visibility within one Assistant | Reasoning may join the preceding group; reply stays visible | Same | No secondary group hiding | Incomplete; do not directly copy the original split into two render nodes |
|
||||
| Header category summary | Current category while running; aggregate main categories after completion | Same | No header | Incomplete (grouping phase) |
|
||||
| Running header details | Hidden | Show command, path, query, etc.; reasoning summary when no tool is running | No header | Incomplete (grouping phase) |
|
||||
| Header update cadence | Preserve category title's minimum display duration | Also handle detail updates | Not applicable | Incomplete (grouping phase) |
|
||||
| Height-limited scrolling within a group | Limit expanded height, scroll independently, fade edges | Same | Not applicable | Incomplete (grouping phase) |
|
||||
| Group and whole-Turn folding interaction | Hide groups when the Turn folds | Same | Whole-Turn folding only | Incomplete; retain node identity and separately decide whether internal expansion resets |
|
||||
| Pagination, streaming settlement, and group identity | Stable existing headers and members; handle partial Turns separately | Same | Show the same business nodes directly | Incomplete (grouping phase) |
|
||||
|
||||
Grouping details to verify: completed summaries aggregate at most three categories and use “etc.” beyond three; call counts determine category ordering rather than appearing as header numbers. Detailed-mode live details collapse whitespace and are capped at 160 characters. Titles remain for at least 150ms; retain only the latest update without replaying queued updates. Expanded height is at most the smaller of 400px and half the viewport. Scrollable edges use a 24px fade and preserve interaction with outer scrolling. Retry rows are outside secondary groups. These values describe the original PR's reference behavior, not completed grouping experiments.
|
||||
|
||||
### Definition-owned segmentation and presentation
|
||||
|
||||
Business State accepts each input, clears pending changes, and returns repeatable output. Replace builds the loaded window; apply separates structural changes from content updates and maintains affected segments or Turn-local indexes. It owns group opening/closing, stable keys, summaries, and invalidation; generic infrastructure never corrects those decisions by Node kind or Step number.
|
||||
|
||||
| Ordered visible input | Business action |
|
||||
|---|---|
|
||||
| Owning Turn changes | Close the pending group. |
|
||||
| No Turn ownership | Close the group and emit an independent Node; never merge across it. |
|
||||
| turn-process control | Emit independently without closing the pending process. |
|
||||
| User, steering, trigger, retry, error, max-tokens, or tail | Close the group and emit independently. |
|
||||
| Assistant reasoning | Append the reasoning part to the pending group. |
|
||||
| Assistant visible reply | Close the group and emit the response part independently. Later tools enter a later group. |
|
||||
| Other process Nodes | Append the Node to the pending group; an ordinary Step boundary does not close it. |
|
||||
| Turn end | Close that Turn's pending groups even without Node upserts. |
|
||||
|
||||
Content growth with unchanged classification does not resegment history; it may update only the current summary. A first reply, new tool, steering, or retry can change structure. Visibility/location repair handles both old and new ownership. Real structural changes may linearly merge root references; the interface does not promise all operations are constant-time.
|
||||
|
||||
Group headers select data independently from member arrays. All modes retain the same Group container and member parents:
|
||||
|
||||
| Mode | Group header | Body |
|
||||
|---|---|---|
|
||||
| Compact | Short activity summary | Group open/closed state controls visibility. |
|
||||
| Detailed | Summary plus live command/path/query details | Same group open/closed state. |
|
||||
| Expanded | Hidden | Visible without group-level collapse or height cap; wrapper remains. |
|
||||
|
||||
This assumes the outer Turn process is open. Expanded retains whole-Turn folding and individual tool/reasoning disclosure; it must not eagerly mount all full Markdown. Turn-close resets of internal state are explicit business actions, not key changes. Group headers, scrolling/fades, and Turn interaction belong to this business integration.
|
||||
|
||||
| Business implementation | Responsibility |
|
||||
|---|---|
|
||||
| process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. |
|
||||
| AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. |
|
||||
|
||||
## Performance constraints
|
||||
|
||||
| Scenario | Property to preserve | Required evidence |
|
||||
|---|---|---|
|
||||
| Switching among modes | Business-node keys do not change; modes do not re-register Definitions; only relevant visibility/presentation fields update | Node identity and update-scope checks |
|
||||
| Incremental reasoning/tool content | Update only the owning node without scanning full history; compute reasoning summary from current full text during rendering | Update scope and long-text cost; the current summary algorithm does not reduce `ReasoningRow` render count |
|
||||
| Whole-Turn running clock | Only live clock content updates each second, without refreshing members, historical rows, or list | Update scope and timer disposal |
|
||||
| Summary visibility | Keep lightweight summaries mounted; do not mount every full Markdown body for mode switching | DOM identity, initial loading, memory observations |
|
||||
| Pagination | Update new nodes and nodes with changed facts; completed Turns do not refresh together because of global pagination state | Multi-page interaction and update-scope checks |
|
||||
| State transitions | Stable text/icon/callback references when content is unchanged; do not clear internal state through remounting | Interaction state and component lifecycle checks |
|
||||
|
||||
Retaining DOM reduces mounting and unmounting cost, not style calculation, layout, or painting, and can increase retained memory. Changing display properties alone is not a guarantee against jank. The aim is lower switching peaks with acceptable initial-load, pagination, and streaming costs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Replay the original PR as one batch.** Rejected: grouping, node splitting, whole-Turn status, and independent visuals would mix, obscuring each feature's performance effects and overwriting local subscriptions and node reuse already completed in this channel.
|
||||
|
||||
**Unmount and recreate summaries or process nodes on mode changes.** Rejected for summary mode changes. Stable mounts allow repeated switching to change only display state; initial costs for new nodes still require separate validation.
|
||||
|
||||
**Keep every Markdown body mounted for smooth switching.** Rejected: lightweight summaries and full bodies differ in memory, DOM, and rendering cost. Bodies remain mounted according to manual expansion.
|
||||
|
||||
**Remount renderers when folding a Turn to reset tool cards.** Do not migrate directly. It conflicts with stable nodes and preserved interaction state. If reset behavior remains necessary, define its state owner and trigger explicitly.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Update implementation, staging, and commit status separately as work progresses. Completion follows feature review and commits; acceptance follows actual validation. Retain incomplete items across branch splits and worktree transfers. Before grouping work starts, check every product requirement and mode mapping above.
|
||||
|
||||
| Acceptance item | Current state |
|
||||
|---|---|
|
||||
| Complete behavior validation of existing channel capabilities | Relevant unit and browser regressions pass; this excludes unmigrated grouping features and does not establish performance acceptance |
|
||||
| Non-grouping feature migration | Completed scope is in the inventory; incomplete drafts await review in `pr-4565-turnstep`; functional and performance validation remain incomplete |
|
||||
| DOM identity and update scope during mode changes | Unit tests verify summary/expanded-body DOM identity and policy-field subscriptions; browsers verify persistence of all modes and whole-Turn folding; whole-page update scope and performance remain pending |
|
||||
| Long-session initial loading, pagination, streaming, and memory | Pending; the original PR's historical measurements do not validate this implementation |
|
||||
| Unit, browser, and recorded-snapshot coverage | 785 relevant unit tests pass; read-only replay of 35 browser files yields 178 passing tests and 4 conditional skips; UI expectations are updated and Session JSONL is unchanged |
|
||||
| Grouping and the Detailed/Expanded distinction | Incomplete (grouping phase) |
|
||||
|
||||
Validation evidence: after rebasing onto master, this channel passed full `pnpm run build`, lint, duplication checks, and all 41 documentation checks. New presentation policy, shimmer, and visibility filtering, plus the settings and Skill rows reported by CI, pass 100% coverage checks over their selected source scope. Test repairs, UI snapshots, and bilingual pairing are committed together as CI fixes. No install, build, or tests ran in `pr-4565-turnstep`; this channel's results do not establish acceptance for that worktree. A real-model GUI demo and performance measurements remain outstanding.
|
||||
|
||||
## Risks
|
||||
|
||||
- `expanded` and `detailed` currently present identically; the original PR's distinction exists only after grouping is implemented.
|
||||
- Whole-Turn duration replaces the existing tool/message-count title, changing product information. Migration must preserve status, accessible names, and Turns with no process content, not merely restyle the control.
|
||||
- Hiding ordinary Context before adding trigger notifications removes background waking reasons; connect both items in the inventory's order.
|
||||
- The original footer decision depends on grouping indexes. Rework ownership per feature rather than introducing the whole grouping infrastructure for a footer.
|
||||
- Group identity, membership, and builder materialization in the experiments still require evaluation; this record does not treat the experiment as final architecture.
|
||||
@@ -1,176 +0,0 @@
|
||||
# Agent Note: Chat 工作过程展示迁移进度
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-09-20-chat-work-details-migration.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先评审独立视觉功能,再结合 step-group 统一考虑轮次相关行为,避免遗漏原有产品需求。
|
||||
|
||||
## 方案
|
||||
|
||||
已完成的独立功能保留在 `feat/chat-presentation-channel`。剩余未完成代码的后续工作区是仓库根目录下的 `.artifacts/worktrees/pr-4565-turnstep`,以当前通道及本进度记录为基线,草稿保持未暂存;后续结合 turn/step 与分组整体评审。`pr-4565` worktree 继续作为原 PR 的行为和实现参考;`feat/chat-step-groups` 的既有分组实验不在本次搬迁范围内。
|
||||
|
||||
本记录管理功能范围、当前实现和验收进度。[展示策略通道与过程分组的基础架构](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md)保留架构路线及备选方案;本记录不替代它,也不把其中的方案视为已经实现。新记录没有完整取代既有架构决策,因此保留原记录,不做归档或删除。
|
||||
|
||||
本记录维护中英文配对。完成状态、实现状态、暂存或提交状态、验收状态分别记录。未完成项即使已有代码草稿,也继续标为未完成,不从清单移除。测试和文档随 CI 修复补齐,浏览器快照与性能验收单独记录;代码写好、进入暂存区、提交或构建通过,都不等同于完整功能或性能验收。
|
||||
|
||||
### 状态定义
|
||||
|
||||
| 维度 | 状态 | 含义 |
|
||||
|---|---|---|
|
||||
| 完成 | 已完成 | 功能迁移代码已逐项审阅并提交到当前通道;行为与性能证据另行记录 |
|
||||
| 完成 | 未完成 | 尚未纳入当前通道的已完成迁移范围;可以已有未暂存草稿,也可以尚未编写 |
|
||||
| 实现 | 当前代码已有 | 当前通道的既有基础能力 |
|
||||
| 实现 | 代码已写好 | 对应工作区中已有实现;不代表已完成、已暂存、已提交或通过验收 |
|
||||
| 实现 | 待迁移 | 已纳入非分组迁移范围,当前尚未实现 |
|
||||
| 实现 | 分组阶段 | 产品需求保留,依赖分组结构或分组交互,暂不接入当前通道 |
|
||||
| 暂存或提交 | 未暂存 | 该功能的改动仍在工作区,包括尚未跟踪的新文件 |
|
||||
| 暂存或提交 | 部分暂存 | 该功能只有部分改动进入暂存区,必须注明具体范围 |
|
||||
| 暂存或提交 | 已暂存 | 该功能当前待提交的代码改动均已进入暂存区,尚未提交 |
|
||||
| 暂存或提交 | 已提交 | 该功能代码已包含在当前 HEAD 中 |
|
||||
| 验收 | 待验收 | 需要行为、节点身份、刷新范围或浏览器性能证据;不能用 Git 状态或编译结果代替 |
|
||||
|
||||
暂存状态按产品功能对应的差异片段核对,不按整个文件推断。更新状态时同时查看工作区、暂存区和 HEAD;共享文件只暂存部分字段,不能将依赖该文件的所有功能都标为已暂存。
|
||||
|
||||
### 当前通道已有的能力
|
||||
|
||||
| 能力 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 验收状态 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 三档设置与旧值读取 | 简洁 | 详细;旧值 `normal` 读作本档,不回写 | 完全展开 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 按轮判断整轮折叠资格 | 已加载起点且存在最终回复的结束轮次,可收起过程内容;停止和失败的扩展行为见迁移项 6 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 已完成推理的收起摘要 | 只显示「思考」 | 显示首行 | 显示首行 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 模式切换时保留摘要 DOM | 用 CSS 隐藏摘要及分隔点 | 显示原有摘要及分隔点 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
|
||||
三档都保留整轮折叠。`expanded` 取消组级折叠、组头和限高,保留组身份与成员父级;并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。
|
||||
|
||||
### 不依赖分组的迁移清单
|
||||
|
||||
下表的“三档共用”表示三档都应用该行为,不表示切换档位时会改变它。“不依赖分组”指实现草稿未引入 step-group,不代表都要先于分组接受。编号沿用逐项评审清单;编号 0 的编译接线修正已完成并提交,不作为产品功能另列。未完成项的草稿留在 `pr-4565-turnstep`,不是当前通道已完成的功能。下列功能的行为与性能均待验收。
|
||||
|
||||
| 编号 | 功能 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 迁移落点与注意事项 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | 工具标题中文化 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | Bash/Pwsh 为「运行命令」、Grep 为「搜索文件内容」、Glob 为「查找文件」、Skill 为「加载技能」;只改 UI 字典,不改模型工具名 |
|
||||
| 2 | 工具行交互视觉 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 工具行、Bash 行和命令行悬停时标题及摘要加深;共享 `DisclosureRow` 和独立 Bash 行展开箭头朝上 |
|
||||
| 3 | Bash 停止状态的读屏提示 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 补齐停止状态的可访问名称,不改变执行或取消语义 |
|
||||
| 4 | 工具及命令运行时文字扫光 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 4A 扫光与 4B 附带优化均已提交 | 4A 包含 `TextShimmer` 固定 span 与 CSS 启停、工具及命令行接入、旧扫光样式替换和减少动态效果支持,不含 step/turn;4B 为业务行额外的计算缓存、稳定回调及 JSX 引用 |
|
||||
| 5 | 流式推理摘要 | 段落首行写完后才更新 | 相同 | 相同 | 已完成 | 代码已写好 | 已提交 | `ReasoningRow` 沿用原 PR 的 `latestCompletedParagraphFirstLine(text)`,直接从完整文本计算;摘要 DOM、稳定 props 和右侧渐隐保留,不增加 Definition 解析状态或跨层摘要字段 |
|
||||
| 6 | 取消、失败后保持过程展开 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿在 Turn 过程投影中使用 `keepOpen`;轮次与分组阶段再评审折叠资格和停止、失败行为 |
|
||||
| 7 | 整轮状态与用时控件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将控件锚定到已加载的 `turn/start`、排在开场输入之后,以 `LiveTurnProcessLabel` 单独计时,并替换底部运行条;整体定位与状态待评审 |
|
||||
| 8 | 非人工触发通知 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿使用 next-turn 及首步 next-step 领取事实识别唤醒输入,投影 `turn-trigger`;轮次起点、通知归属和展开内容待评审 |
|
||||
| 9(permission/系统) | 隐藏 permission 命令与系统提示词行 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | `isVisibleChatNode` 集中判断,现有 Chat 列表、导航和过程展示复用;命令与请求提示词 Definition 不承载这些过滤规则,持久化数据与 Trajectory 不变 |
|
||||
| 9(context) | 隐藏普通上下文行 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 与 8 的唤醒通知配套,保留来源解释;过滤规则的最终归属留到轮次与分组阶段 |
|
||||
| 10 | 轮次操作栏位置 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将 `turn-tail` 锚定到 `turn/end` 之后;反馈、复制的回复归属和分支的真实轮次结束序号待整体评审 |
|
||||
| 11 | 操作栏常显条件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿由 Chat builder 按 Turn 投影 `footer`;最新轮以回复结尾时常显,其余悬停或聚焦显示,尾部判定与分组一起考虑 |
|
||||
|
||||
开发者工具默认开启已由当前基线提供,不重复迁移。当前实现保留等待 Host 接受设置后才启用相关能力的保护,不以原 PR 的缺值回退覆盖它。原 PR 的 `Switch` 改动只有注释,不作为功能迁移项。
|
||||
|
||||
### 后续评审批次
|
||||
|
||||
评审批次独立于实现和暂存状态。未完成草稿的后续评审统一在 `pr-4565-turnstep`,保持未暂存;已有草稿不代表迁移已完成。仅搬迁当前通道的剩余代码,不自动合入 `feat/chat-step-groups` 的实验,也不在目标工作区安装依赖或编译。
|
||||
|
||||
| 批次 | 功能 | 评审范围 |
|
||||
|---|---|---|
|
||||
| 独立功能,已提交 | 4A:文字扫光 | `TextShimmer` 本身、各行的调用方式和浏览器绘制成本;性能待验收 |
|
||||
| 独立优化,已提交 | 4B:附带的 memo 与引用稳定化 | `ToolRow`、`BashRow` 和 `GenericCommandCard` 的计算缓存、回调和 JSX 引用;不是扫光功能的必要条件,性能待验收 |
|
||||
| 独立功能,已提交 | 9 中的 permission 命令与系统提示词隐藏 | 沿用原 PR 的集中可见性判断;当前无 step-group,主列表改在 `orderedVisibleChatNodes` 接入,不依赖唤醒消息分类 |
|
||||
| 未完成,转入 turnstep 工作区 | 6、7、10、11 | 整轮折叠资格、状态与时钟、操作栏位置和尾部判定,统一考虑轮次与分组关系 |
|
||||
| 未完成,转入 turnstep 工作区 | 8、9 中的普通 context 隐藏 | 触发通知依赖轮次起点和 Inbox 领取判定;隐藏普通上下文与通知配套,避免先隐藏唤醒原因 |
|
||||
|
||||
### 分组阶段保留的需求
|
||||
|
||||
这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。[Definition 拥有分组的基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)已经实现,但不代表这些产品行为或性能要求已经完成。
|
||||
|
||||
| 功能 | compact | detailed | expanded | 当前状态 |
|
||||
|---|---|---|---|---|
|
||||
| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行不做组级折叠,保留成员父级 | 未完成(分组阶段) |
|
||||
| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 |
|
||||
| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) |
|
||||
| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) |
|
||||
| 组头内容更新节奏 | 保留类别标题的最短展示时间 | 同时处理详情更新 | 不适用 | 未完成(分组阶段) |
|
||||
| 组内限高滚动 | 展开内容限高、独立滚动、边缘渐隐 | 相同 | 不适用 | 未完成(分组阶段) |
|
||||
| 分组与整轮折叠联动 | 整轮收起时隐藏分组 | 相同 | 仅整轮折叠 | 未完成;保留节点身份,内部开合状态是否重置单独确定 |
|
||||
| 分页、流式结算与组身份 | 既有组头和成员稳定,半截轮单独处理 | 相同 | 同一批业务节点直接展示 | 未完成(分组阶段) |
|
||||
|
||||
分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。
|
||||
|
||||
### Definition 拥有的分段与展示
|
||||
|
||||
业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。
|
||||
|
||||
| 有序可见输入 | 业务动作 |
|
||||
|---|---|
|
||||
| 所属 Turn 改变 | 关闭待完成组。 |
|
||||
| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 |
|
||||
| turn-process 控件 | 独立输出,不关闭待完成过程。 |
|
||||
| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 |
|
||||
| Assistant 推理 | 把 reasoning 部分加入待完成组。 |
|
||||
| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 |
|
||||
| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 |
|
||||
| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 |
|
||||
|
||||
分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。
|
||||
|
||||
组头独立于成员数组选择 data,所有模式保留同一 Group 容器及成员父级:
|
||||
|
||||
| 模式 | 组头 | 正文 |
|
||||
|---|---|---|
|
||||
| Compact | 简短活动摘要 | 按组开合状态显隐。 |
|
||||
| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 |
|
||||
| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 |
|
||||
|
||||
以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于本业务接入。
|
||||
|
||||
| 业务实现 | 职责 |
|
||||
|---|---|
|
||||
| process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 |
|
||||
| AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 |
|
||||
|
||||
## 性能约束
|
||||
|
||||
| 场景 | 要保留的性质 | 所需证据 |
|
||||
|---|---|---|
|
||||
| 切换三档模式 | 不改变业务节点 key,不因模式切换重新注册 Definition;仅相关可见性或展示字段更新 | 节点身份和刷新范围检查 |
|
||||
| 推理或工具内容增量更新 | 只更新所属节点,不扫描完整历史;推理摘要在渲染时从当前完整文本计算 | 更新范围与长内容计算开销;当前摘要算法不减少 `ReasoningRow` 的渲染次数 |
|
||||
| 整轮运行时钟 | 只有运行中的时钟内容按秒更新,不带动整轮成员、历史行和列表 | 更新范围及计时器释放检查 |
|
||||
| 摘要显隐 | 轻量摘要常驻;完整 Markdown 正文不为模式切换而全部常驻 | DOM 身份、首次加载和内存观察 |
|
||||
| 分页 | 新增节点与事实变化的节点更新,已完成轮次不因全局分页状态一起刷新 | 多页历史交互与刷新范围检查 |
|
||||
| 状态切换 | 文字、图标、回调引用在内容未变时稳定;不通过重挂载清空内部状态 | 交互状态与组件生命周期检查 |
|
||||
|
||||
保留 DOM 只减少挂载和卸载成本,不消除样式计算、布局与绘制,也会增加常驻内存。不能将“只改显示属性”写成“不会卡顿”的保证。性能目标是降低切换峰值,并保持首次加载、分页和流式更新的成本可接受。
|
||||
|
||||
## 备选方案与取舍
|
||||
|
||||
**整批重放原 PR。** 不采用。分组、节点拆分、整轮状态和独立视觉功能混在一起,难以把性能变化对应到具体功能,也会覆盖当前通道已完成的局部订阅与节点复用。
|
||||
|
||||
**切换模式时卸载、重新创建摘要或过程节点。** 摘要模式切换不采用该方式。稳定挂载让来回切换只改变显示状态;新增节点的初始成本仍须单独检查。
|
||||
|
||||
**为了切换平滑,把所有 Markdown 正文常驻。** 不采用。轻量摘要与完整正文的内存、DOM 和渲染成本不同;正文继续按手动展开状态挂载。
|
||||
|
||||
**收起整轮时重挂载 renderer,以重置工具卡。** 不直接迁移。该实现与稳定节点及保留交互状态的方向冲突;若后续仍需要重置行为,应明确状态所有者和触发规则。
|
||||
|
||||
## 验收标准
|
||||
|
||||
每项代码写好、暂存或提交后,分别更新对应状态;完成状态按逐项评审和提交记录更新,验收只按实际验证记录更新。未完成项保留,不因拆分分支或搬迁工作区而删除。分组阶段开始前,逐行复核上面的产品需求和模式对应关系。
|
||||
|
||||
| 验收项 | 当前状态 |
|
||||
|---|---|
|
||||
| 当前通道已有能力的完整行为验证 | 已通过本次相关单元和浏览器回归;不包含未迁移的分组功能,也不代表性能验收 |
|
||||
| 非分组功能迁移 | 完成范围见迁移清单;未完成草稿留到 `pr-4565-turnstep` 继续评审,功能与性能验证仍未完成 |
|
||||
| 模式切换时的 DOM 身份和刷新范围 | 单元测试已验证摘要、手动展开正文的 DOM 身份和策略字段订阅;浏览器已验证三档持久化及整轮折叠,整页刷新范围与性能仍待验收 |
|
||||
| 长会话首次打开、分页、流式输入与内存 | 待验收;原 PR 的历史测量不代表本实现 |
|
||||
| 新行为的单元、浏览器与录制快照覆盖 | 相关单元测试 785 项通过;35 个浏览器文件只读回放,178 项通过、4 项按条件跳过;UI 预期输出已更新,Session JSONL 未改动 |
|
||||
| 分组功能与两档差异 | 未完成(分组阶段) |
|
||||
|
||||
验证记录:当前通道在 rebase master 后通过完整 `pnpm run build`、lint、重复代码检查和完整文档检查(41 项)。新增展示策略与扫光、可见性过滤,以及 CI 报告的设置行和 Skill 行覆盖率已通过对应源码范围的 100% 检查。测试修复、UI 快照和中英配对随 CI 修复一并提交;`pr-4565-turnstep` 未执行安装、构建或测试,当前通道的结果不代表该工作区已验收。真实模型 GUI 演示和性能测量仍待补。
|
||||
|
||||
## 风险
|
||||
|
||||
- 当前 `expanded` 与 `detailed` 的展示相同;只有分组阶段完成后,两档才具备原 PR 设计的区别。
|
||||
- 整轮用时控件替换现有工具和消息计数标题,属于产品信息变化;迁移时不能只换样式而遗漏状态、可访问名称或空过程轮次。
|
||||
- 普通上下文隐藏若先于触发通知,会让后台唤醒原因消失;两项需按清单顺序衔接。
|
||||
- 原 PR 的页脚判定与分组索引耦合,需要按功能重写归属,不能为页脚单独引入整套分组基础设施。
|
||||
- 分组实验中的组身份、成员归属和 builder 物化路线仍需继续评估;本记录不将实验实现列为最终架构。
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
|
||||
"version": "0.1.6-alpha.2",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop-host",
|
||||
"description": "Private Node-mode host process for the Electron desktop application",
|
||||
"version": "0.1.6-alpha.2",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop",
|
||||
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
|
||||
"version": "0.1.6-alpha.2",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-frontend",
|
||||
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
|
||||
"version": "0.1.6-alpha.2",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/agent-preset-authoring', import.meta.url))
|
||||
const mode = webSnapshotMode()
|
||||
@@ -21,7 +21,7 @@ describe('web e2e: preset roster guidance', () => {
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await page.getByRole('heading', { name: 'Agent 预设' }).waitFor()
|
||||
}, 120_000)
|
||||
@@ -61,7 +61,7 @@ describe('web e2e: preset roster guidance', () => {
|
||||
// so the flow needs a connected workspace to enter.
|
||||
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭', exact: true }).last().click()
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await settings.getByRole('button', { name: '让 Agent 帮我创建预设模式', exact: true }).click()
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu,
|
||||
import { openSettings,
|
||||
connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft,
|
||||
} from './support.ts'
|
||||
|
||||
@@ -254,7 +254,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Agent presets' }).click()
|
||||
const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' })
|
||||
@@ -347,7 +347,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled'))
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Agent presets' }).click()
|
||||
await dialog.getByRole('button', { name: 'Set as new task default: Minimal mode' }).click()
|
||||
@@ -364,7 +364,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
|
||||
// The switch controls availability only: re-enabling restores the saved
|
||||
// default and aligns this same still-blank task with it.
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const reopened = page.getByRole('dialog', { name: 'Settings' })
|
||||
await reopened.getByRole('button', { name: 'Agent presets' }).click()
|
||||
const reopenedToggle = reopened.getByRole('switch', { name: 'Choose a mode for new tasks' })
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.waitFor()
|
||||
const outside = page.getByRole('button', { name: 'Account menu', exact: true })
|
||||
const outside = page.getByRole('button', { name: 'Settings', exact: true })
|
||||
await outside.focus()
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
expect(await outside.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
import { AUTO_REVIEW_FIXTURE, captureAutoReviewState } from './auto-review-fixture.ts'
|
||||
|
||||
@@ -52,9 +52,10 @@ describe.skipIf(MODE === 'record')('web e2e: cold Auto-review denial', () => {
|
||||
expect(result?.data).not.toHaveProperty('callId')
|
||||
|
||||
scaffold = await launchWebScaffold(AUTO_REVIEW_FIXTURE)
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { expandTurnProcesses, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/acp/cancel-tool-calls/session.v3.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/bash-abort-row', import.meta.url))
|
||||
@@ -30,9 +30,10 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -43,6 +44,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expandTurnProcesses(page)
|
||||
await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
@@ -67,9 +69,6 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
|
||||
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
// The borrowed fixture's UTC date is still the previous day in PDT;
|
||||
// the disclosure golden must not depend on the runner timezone.
|
||||
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url))
|
||||
const FIXTURE = join(DIR, 'session.v3.jsonl')
|
||||
@@ -136,7 +136,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
expect(await header.getByRole('tablist').count()).toBe(0)
|
||||
const compactHeight = await header.evaluate(element => element.getBoundingClientRect().height)
|
||||
expect(compactHeight).toBeLessThan(60)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('switch', { name: '开发者工具' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: '开发者工具' }).getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
@@ -712,6 +712,7 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
|
||||
const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
|
||||
await expandOwningTurnProcess(world.page, liveRow)
|
||||
await liveRow.waitFor({ timeout: 15_000 })
|
||||
expect(await liveRow.getAttribute('data-state')).toBe('running')
|
||||
await expectBottom(world.page)
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/clickable-links-gallery', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui.expected.md', import.meta.url))
|
||||
@@ -373,11 +373,9 @@ describe('web e2e: clickable links gallery', () => {
|
||||
// no openable path even though its create still joins the produced chips.
|
||||
expect(await page.locator('button[class*="fileLink"]').count()).toBe(7)
|
||||
|
||||
// Expanded cards. The turn-process group collapses a multi-call turn, so
|
||||
// it opens first. Rows expand via a right-edge click: the row center can
|
||||
// land on the nested fileLink button, which would hand the path to the
|
||||
// Host's opener.
|
||||
await page.getByRole('button', { name: `${String(CALLS.length)} tool calls` }).click()
|
||||
// A row-center click can hit the nested fileLink button and invoke the
|
||||
// Host opener; right-edge clicks expand the card itself.
|
||||
await expandTurnProcesses(page)
|
||||
for (const row of [
|
||||
/^Search clickable link styles/,
|
||||
/^Fetch /,
|
||||
@@ -455,7 +453,7 @@ describe('web e2e: clickable links gallery', () => {
|
||||
await markdown.locator(`a[href="${HTTP_URL}"]`).click()
|
||||
await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL)
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await page.getByRole('button', { name: 'Built-in browser', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'New browser tab', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ linkOpening: 'new-tab' })
|
||||
@@ -472,7 +470,7 @@ describe('web e2e: clickable links gallery', () => {
|
||||
}
|
||||
|
||||
await page.reload()
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await page.getByRole('button', { name: 'New browser tab', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Built-in browser', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ linkOpening: 'sidebar' })
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold,
|
||||
seedSession, watchConsole, webSnapshotMode,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-live-client', import.meta.url))
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/client-plugin-live', import.meta.url))
|
||||
@@ -19,7 +19,7 @@ const SESSION_TITLE = 'Session action extension'
|
||||
|
||||
async function openInventory(page: Page, url: string) {
|
||||
await page.goto(url, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '内置插件', exact: true }).click()
|
||||
await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
|
||||
|
||||
@@ -39,13 +39,11 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett
|
||||
expect(scaffold.ctx.llm.listProviders()).toContainEqual({ id: 'deepseek-official', name: 'DeepSeek' })
|
||||
expect(scaffold.ctx.llm.listProviders().filter(provider => provider.id === 'deepseek-official')).toHaveLength(1)
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' })
|
||||
await page.getByRole('dialog', { name: '开始你的创作' })
|
||||
.getByRole('button', { name: '添加 API Key', exact: true }).click()
|
||||
const onboarding = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await onboarding.getByLabel('API 密钥', { exact: true }).fill('sk-messages-onboarding')
|
||||
await onboarding.getByRole('button', { name: '保存并继续' }).click()
|
||||
await onboarding.waitFor({ state: 'detached' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
await dialog.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await dialog.getByText('DeepSeek', { exact: true }).waitFor()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, saveFailureShot } from './support.ts'
|
||||
import { openSettings, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/desktop-locale', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -53,7 +53,7 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
|
||||
it('uses OS languages without saving them, then shares an explicit choice across reloads', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-desktop-locale'))
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const zhDialog = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
await zhDialog.getByRole('button', { name: '中文', exact: true }).waitFor()
|
||||
await expect.poll(() => reported).toEqual(['zh'])
|
||||
@@ -73,7 +73,7 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
|
||||
reported.length = 0
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await enDialog.getByRole('button', { name: 'English', exact: true }).waitFor()
|
||||
await expect.poll(() => reported).toEqual(['en'])
|
||||
expect(await page.evaluate(() => document.documentElement.lang)).toBe('en')
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { launchWebScaffold } from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, newEnglishPage } from './support.ts'
|
||||
import { openSettings, newEnglishPage } from './support.ts'
|
||||
|
||||
it('persists developer tools in the Host settings document and restores the accepted choice', async () => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
@@ -13,7 +13,7 @@ it('persists developer tools in the Host settings document and restores the acce
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await newEnglishPage(browser)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const toggle = page.getByRole('switch', { name: 'Developer tools' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).not.toMatch(/id: ui-settings(?:\r?\n|$)/)
|
||||
@@ -21,7 +21,7 @@ it('persists developer tools in the Host settings document and restores the acce
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).toContain('enabled: false')
|
||||
await page.reload()
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await page.getByRole('button', { name: 'Agent presets', exact: true }).click()
|
||||
|
||||
@@ -14,7 +14,7 @@ import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documen
|
||||
import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts'
|
||||
import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
|
||||
@@ -425,7 +425,7 @@ else process.exit(1);
|
||||
`- Local script: ${await basicHtml.locator('#local-result').innerText()}`,
|
||||
`- Network requests: ${previewNetworkRequests}`,
|
||||
].join('\n'))
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const settings = page.getByRole('dialog', { name: 'Settings' })
|
||||
await settings.getByRole('switch', { name: 'Developer tools' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: 'Developer tools' }).getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
- tab "Trajectory"
|
||||
- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}"
|
||||
- button "Copy"
|
||||
- button "16 tool calls" [expanded]
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Edited files, called tools, searched code, etc." [expanded]
|
||||
- button "Write site/report.html +1 -0":
|
||||
- text: Write
|
||||
- button "site/report.html"
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
- button "Jump to turn 3"
|
||||
- text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}"
|
||||
- button "Copy"
|
||||
- button "3 tool calls" [expanded]
|
||||
- button "Context injection runtime-context"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Called tools" [expanded]
|
||||
- button "Think"
|
||||
- button "Inspect plugins 0 dynamic plugins"
|
||||
- button "Think"
|
||||
@@ -37,7 +38,11 @@
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- text: 9/1 {{clock}}
|
||||
- button "Thought for a while"
|
||||
- button "Plugin status updated 9/1 {{clock}}":
|
||||
- text: Plugin status updated
|
||||
- time: 9/1 {{clock}}
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: The Cordis Plugin is running.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -45,7 +50,9 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}}
|
||||
- button "Copy"
|
||||
- button "1 tool call" [expanded]
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Called tools" [expanded]
|
||||
- text: Stop Cordis Plugin snap-1
|
||||
- button "Inspect"
|
||||
- text: Dynamic Plugin snap-1 is stopped; its definition and versions remain.
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
|
||||
- button "Copy"
|
||||
- button "Think"
|
||||
- text: Failed
|
||||
- 'button "Read The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."'
|
||||
- text: Failed
|
||||
- 'button "Tool call The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."'
|
||||
- status: Worked
|
||||
- button "Took {{duration}}"
|
||||
- text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}}
|
||||
- button "Copy"
|
||||
- button "Thought for a while"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: FORK_BRANCH_FIRST this branch has no read results, so I would check what the parent completed before retrying. FORK_BRANCH_DONE.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- button "Thought for a while" [expanded]
|
||||
- button "Context injection webhook github webhook handled by review-pr-when-ready"
|
||||
- button "Context injection runtime-context"
|
||||
- button "GitHub event received {{clock}}":
|
||||
- text: GitHub event received
|
||||
- time: {{clock}}
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: "Review complete: no actionable findings."
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- button "Thought for a while"
|
||||
- button "GitHub event received {{clock}}":
|
||||
- text: GitHub event received
|
||||
- time: {{clock}}
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: "Review complete: no actionable findings."
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Render adjacent CJK strong emphasis. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "CJK strong emphasis" [level=2]
|
||||
- paragraph:
|
||||
- strong: 注意:
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Show the Markdown image policy. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "Markdown images" [level=2]
|
||||
- paragraph:
|
||||
- img "Remote test image"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Show the local preview URL. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "Inline code links" [level=2]
|
||||
- paragraph:
|
||||
- text: "Preview:"
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Render this mathematical proof. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "Math rendering" [level=2]
|
||||
- paragraph:
|
||||
- text: Inline dollar
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
- dialog "开始你的创作":
|
||||
- heading "开始你的创作" [level=2]
|
||||
- button "关闭"
|
||||
- paragraph: 登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。
|
||||
- button "添加 API Key"
|
||||
- button "登录"
|
||||
@@ -7,7 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Research notes what changed? Referenced session · Research notes {{clock}}
|
||||
- button "Copy"
|
||||
- button "Session recall Research notes"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
- button "meeting notes.md"
|
||||
- text: and confirm the fixture wiring {{clock}}
|
||||
- button "Copy"
|
||||
- button "Thought for a while" [expanded]
|
||||
- button "Context injection runtime-context"
|
||||
- button "Context injection user-invoke-demo"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
- button "meeting notes.md"
|
||||
- text: and confirm the fixture wiring {{clock}}
|
||||
- button "Copy"
|
||||
- button "Thought for a while"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
- button "Jump to turn 28"
|
||||
- text: m1 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r1
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -43,6 +45,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m2 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r2
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -50,6 +54,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m3 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r3
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -57,6 +63,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m4 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r4
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -64,6 +72,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m5 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r5
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -71,6 +81,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m6 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r6
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -78,6 +90,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m7 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r7
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -85,6 +99,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m8 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r8
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -92,6 +108,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m9 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r9
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -99,6 +117,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m10 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r10
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -106,6 +126,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m11 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r11
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -113,6 +135,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m12 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r12
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -120,6 +144,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m13 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r13
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -127,6 +153,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m14 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r14
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -134,6 +162,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m15 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r15
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -141,6 +171,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m16 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r16
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -148,6 +180,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m17 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r17
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -155,6 +189,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m18 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r18
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -162,6 +198,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m19 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r19
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -169,6 +207,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m20 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r20
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -176,6 +216,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m21 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r21
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -183,6 +225,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m22 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r22
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -190,6 +234,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m23 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r23
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -197,6 +243,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m24 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r24
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -204,6 +252,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m25 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r25
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -211,6 +261,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m26 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r26
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -218,6 +270,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m27 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r27
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -225,6 +279,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m28 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r28
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
- tab "Trajectory"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "Copy"
|
||||
- button "Context injection runtime-context"
|
||||
- button "Think"
|
||||
- status: Deep diving...
|
||||
- button "Deep diving for {{duration}}" [disabled] [expanded]
|
||||
- button "Analyzing the request"
|
||||
- text: "Interjection: include the word BANANA in your final reply."
|
||||
- button "Copy"
|
||||
- text: "Interjection: include the word ORANGE in your final reply."
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
- tab "Trajectory"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "Copy"
|
||||
- button "1 tool call" [expanded]
|
||||
- button "Context injection runtime-context"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- button "Asked questions" [expanded]
|
||||
- button "Think"
|
||||
- button "Ask question 1/1 answered"
|
||||
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
- tab "Trajectory"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "Copy"
|
||||
- button "1 tool call"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- button "Asked questions"
|
||||
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
- button "Copy"
|
||||
- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- alert:
|
||||
- paragraph: "Cannot download model.int8.onnx: the download service returned HTTP 503."
|
||||
- paragraph: Confirm that the download address is available and the proxy can reach the download service, or retry later.
|
||||
- text: "Download source: https://model-mirror.example"
|
||||
@@ -339,6 +339,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await input.press('Enter')
|
||||
if (MODE !== 'record') {
|
||||
const thinking = page.locator('[data-variant="think"][data-state="running"]')
|
||||
await expandOwningTurnProcess(page, thinking)
|
||||
await expect.poll(() => thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
await reasoningComplete.promise
|
||||
expect(await thinking.getAttribute('data-preview')).toBeNull()
|
||||
|
||||
@@ -50,6 +50,7 @@ const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-se
|
||||
// model call.
|
||||
const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
|
||||
const RUNNING_DRAFT = 'Queue this follow-up while the current turn is running.'
|
||||
const RETRY_PARTIAL = 'This partial reply must disappear when the attempt fails.'
|
||||
|
||||
/** turn/end reasons observed, in order. */
|
||||
function turnEndReasons(events: SessionEvent[]): string[] {
|
||||
@@ -270,21 +271,44 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
expect(derived).toHaveLength(1)
|
||||
await launch(() => ({
|
||||
patches: [
|
||||
{ at: 0, entry: { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' } },
|
||||
{ at: 0, entry: {
|
||||
kind: 'throw', message: 'upstream 503', code: 'SERVER',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: RETRY_PARTIAL },
|
||||
],
|
||||
} },
|
||||
// Append the fixture's own success as the retry attempt — single-
|
||||
// sourced from the recording, never copied into a committed sidecar.
|
||||
{ at: 1, entry: derived[0]! },
|
||||
],
|
||||
}))
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-retry'))
|
||||
// llm-retry backs off ~500ms before the second attempt.
|
||||
const { settled } = await sendPrompt(60_000)
|
||||
await settled
|
||||
const releaseFailure = Promise.withResolvers<undefined>()
|
||||
// Retirement must follow a visible partial, not race the first browser paint.
|
||||
const stopHolding = scaffold!.ctx.on('llm/stream', async function* (_request, next) {
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (chunk.type === 'text-delta' && chunk.text === RETRY_PARTIAL) await releaseFailure.promise
|
||||
}
|
||||
})
|
||||
try {
|
||||
const { settled } = await sendPrompt(60_000)
|
||||
await page.getByText(RETRY_PARTIAL, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
releaseFailure.resolve(undefined)
|
||||
await settled
|
||||
} finally {
|
||||
releaseFailure.resolve(undefined)
|
||||
stopHolding()
|
||||
}
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed')
|
||||
// The durable retry record proves the second attempt (request/header logs
|
||||
// only on change, so attempt count is invisible there).
|
||||
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
await expect.poll(() => page.getByText(RETRY_PARTIAL, { exact: true }).count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
// Golden of the recovered end-state: the discarded partial stays absent,
|
||||
// while the settled retry row remains as durable recovery context.
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-cjk-strong', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-cjk-strong/ui.expected.md', import.meta.url))
|
||||
@@ -36,7 +36,6 @@ const CASES = [
|
||||
/** Build one settled assistant reply covering CJK-adjacent strong punctuation boundaries. */
|
||||
function markdownFixture(): string {
|
||||
const session = Session.create(SessionId('markdown-cjk-strong-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Render adjacent CJK strong emphasis.' }],
|
||||
@@ -81,7 +80,7 @@ function markdownFixture(): string {
|
||||
}),
|
||||
...session.snapshotEvents().map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
time: WEB_FIXTURE_TIME + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -95,9 +94,10 @@ describe('web e2e: CJK-adjacent Markdown strong emphasis', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, markdownFixture(), SEED_ID)
|
||||
await seedSession(scaffold, markdownFixture(), SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-inline-code-links', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-inline-code-links/ui.expected.md', import.meta.url))
|
||||
@@ -27,7 +27,6 @@ const LINK_URL = 'http://127.0.0.1:3199/?demo=1'
|
||||
/** Build a settled assistant reply with linkable URL code and inert code controls. */
|
||||
function markdownFixture(linkUrl: string): string {
|
||||
const session = Session.create(SessionId('markdown-inline-code-links-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show the local preview URL.' }],
|
||||
@@ -79,7 +78,7 @@ function markdownFixture(linkUrl: string): string {
|
||||
}),
|
||||
...session.snapshotEvents().map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
time: WEB_FIXTURE_TIME + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -95,9 +94,10 @@ describe('web e2e: Markdown inline-code links', () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)),
|
||||
})
|
||||
await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID)
|
||||
await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
await page.route('http://127.0.0.1:3199/**', async route => route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: '<h1>Inline-code preview</h1>',
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/math-rendering', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/math-rendering/ui.expected.md', import.meta.url))
|
||||
@@ -26,7 +26,6 @@ const DONE = 'MATH_RENDERING_DONE'
|
||||
/** Build a settled assistant reply that exercises every supported math delimiter. */
|
||||
function mathFixture(): string {
|
||||
const session = Session.create(SessionId('math-rendering-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
@@ -82,7 +81,7 @@ function mathFixture(): string {
|
||||
}),
|
||||
...session.snapshotEvents().map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
time: WEB_FIXTURE_TIME + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -96,9 +95,10 @@ describe('web e2e: settled Markdown math rendering', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, mathFixture(), SEED_ID)
|
||||
await seedSession(scaffold, mathFixture(), SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actions', import.meta.url))
|
||||
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
|
||||
@@ -321,7 +321,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
it.skipIf(MODE === 'record')('persists performance detail and hides statistics in Compact', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-performance-usage'))
|
||||
const stats = page.locator('[data-composer-stats]')
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
const row = dialog.getByText('Performance & usage', { exact: true }).locator('../..')
|
||||
await row.getByRole('button', { name: 'Detailed', exact: true }).click()
|
||||
@@ -336,7 +336,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'compact.expected.md'), await captureStableAria(page, '[data-composer-stats]', scaffold.workspaceCwd), MODE)
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload()
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await row.getByRole('button', { name: 'Compact', exact: true }).waitFor()
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await row.getByRole('button', { name: 'Compact', exact: true }).click()
|
||||
|
||||
@@ -152,7 +152,14 @@ describe('minimal agent preset', () => {
|
||||
await process.click()
|
||||
await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
const row = page.locator('[data-sample="bash"]').first()
|
||||
const group = page.locator('[data-chat-group-key]').filter({ has: page.locator('[data-sample="bash"]') }).first()
|
||||
const groupControl = group.locator('[data-process-activity]')
|
||||
await groupControl.waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => groupControl.getAttribute('aria-expanded')).toBe('false')
|
||||
const row = group.locator('[data-sample="bash"]').first()
|
||||
expect(await row.isVisible()).toBe(false)
|
||||
await groupControl.click()
|
||||
await expect.poll(() => groupControl.getAttribute('aria-expanded')).toBe('true')
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
|
||||
await row.click()
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/models-settings-recovery/stored-error.expected.md', import.meta.url))
|
||||
const FAILURE = 'llm-pi-ai: provider "openrouter" model "111" needs an api; '
|
||||
@@ -37,7 +37,7 @@ describe('web e2e: repairs a stored provider after catalog drift', () => {
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await dialog.getByText(FAILURE, { exact: true }).waitFor()
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
@@ -72,7 +72,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
|
||||
it('opens the add card over the dormant directory vocabulary', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '模型', exact: true }).click()
|
||||
|
||||
@@ -16,11 +16,10 @@ import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-deepseek-config', import.meta.url))
|
||||
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
|
||||
const SIGN_IN_EXPECTED = join(SNAPSHOT_DIR, 'sign-in.expected.md')
|
||||
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
||||
const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
|
||||
const DEFAULT_MODELS_EXPECTED = join(SNAPSHOT_DIR, 'default-models.expected.md')
|
||||
@@ -73,13 +72,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const signIn = page.getByRole('dialog', { name: '开始你的创作' })
|
||||
await signIn.waitFor({ timeout: 15_000 })
|
||||
await compareOrRefreshGolden(SIGN_IN_EXPECTED,
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
|
||||
await signIn.getByRole('button', { name: '添加 API Key', exact: true }).click()
|
||||
const credentialStep = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await credentialStep.waitFor({ timeout: 15_000 })
|
||||
expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0)
|
||||
expect(await page.getByRole('button', { name: '账号菜单', exact: true }).count()).toBe(0)
|
||||
const keyInput = credentialStep.getByLabel('API 密钥', { exact: true })
|
||||
await keyInput.waitFor({ timeout: 10_000 })
|
||||
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
@@ -102,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
|
||||
// The ordinary Models surface reuses the refreshed join and exposes the
|
||||
// configured write-only placeholder without a reload.
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
@@ -203,7 +199,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
|
||||
// Opened here rather than inherited: the credential test reloads the page
|
||||
// after configuring the key, so nothing carries an open dialog across.
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
@@ -292,7 +288,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(
|
||||
SNAPSHOT_DIR,
|
||||
['welcome.expected.md', 'sign-in.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md'],
|
||||
['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md'],
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, WELCOME_NOTICE_COPY, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-native', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -52,7 +52,14 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
|
||||
}
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
const accountMenu = page.getByRole('button', { name: '账号菜单', exact: true })
|
||||
if (desktop) await accountMenu.waitFor()
|
||||
else {
|
||||
await page.getByRole('button', { name: '设置', exact: true }).waitFor()
|
||||
expect(await accountMenu.count()).toBe(0)
|
||||
expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0)
|
||||
}
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await settings.getByLabel('API 密钥', { exact: true }).waitFor()
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-usable-provider', import.meta.url))
|
||||
const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md')
|
||||
@@ -44,15 +44,14 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
|
||||
|
||||
it('closes the setup card without discarding the add card beside it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
|
||||
const signIn = page.getByRole('dialog', { name: '开始你的创作' })
|
||||
await signIn.waitFor({ timeout: 15_000 })
|
||||
await signIn.getByRole('button', { name: '添加 API Key', exact: true }).click()
|
||||
const credentialStep = page.getByRole('dialog', { name: CREDENTIAL_STEP })
|
||||
await credentialStep.waitFor({ timeout: 15_000 })
|
||||
expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0)
|
||||
expect(await page.getByRole('button', { name: '账号菜单', exact: true }).count()).toBe(0)
|
||||
await credentialStep.getByRole('button', { name: '稍后配置' }).click()
|
||||
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
// Dismissing the onboarding step leaves Settings closed, so enter the
|
||||
@@ -116,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
|
||||
|
||||
// The Models page agrees: DeepSeek stays a row rather than reopening its
|
||||
// setup card over a user who already has somewhere to send a request.
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SCAFFOLD_DEFAULTS_BUNDLE, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, openSettingsFromAccountMenu, saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, openSettings, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
|
||||
const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
|
||||
@@ -64,7 +64,7 @@ describe('web e2e: plugin manager', () => {
|
||||
const source = language === 'en' ? '中文' : 'English'
|
||||
const target = language === 'en' ? 'English' : '中文'
|
||||
if (await page.getByRole('dialog', { name: settings }).count() === 0) {
|
||||
await openSettingsFromAccountMenu(page, language === 'en' ? 'zh' : 'en')
|
||||
await openSettings(page, language === 'en' ? 'zh' : 'en')
|
||||
}
|
||||
await page.getByRole('dialog', { name: settings }).getByRole('button', { name: source }).click()
|
||||
await page.getByRole('menuitem', { name: target }).click()
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
|
||||
import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage } from './support.ts'
|
||||
|
||||
const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url))
|
||||
const FIXTURE = join(DIR, 'session.v3.jsonl')
|
||||
@@ -195,7 +195,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
await expect.poll(() => page.locator('[data-presented-file] [role="status"]').count(), { timeout: 10_000 }).toBe(0)
|
||||
const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
|
||||
await page.locator('[data-turn-process]').click()
|
||||
await expandTurnProcesses(page)
|
||||
const failed = page.locator('[data-tool="present"][data-state="error"]')
|
||||
const delivered = page.locator('[data-tool="present"][data-state="ok"]')
|
||||
expect(await failed.count()).toBe(1)
|
||||
|
||||
@@ -316,7 +316,6 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
|
||||
const continueButton = page.getByRole('button', { name: 'Continue' })
|
||||
await continueButton.waitFor({ timeout: HERO_TIMEOUT_MS })
|
||||
await continueButton.click()
|
||||
await page.getByRole('button', { name: 'Add API Key', exact: true }).click()
|
||||
const configureLater = page.getByRole('button', { name: 'Configure later' })
|
||||
await configureLater.waitFor({ timeout: 30_000 })
|
||||
await configureLater.click()
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
fixtureUserPrompts, launchWebScaffold, seedSession, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/pwsh-terminal', import.meta.url))
|
||||
const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
@@ -81,6 +81,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
|
||||
// The tool row is expand-gated: the settled row uses the bash layout and carries the
|
||||
// shell-family variant, and the terminal card lives in the expanded body.
|
||||
const row = page.locator('[data-tool="pwsh"]').first()
|
||||
await expandOwningTurnProcess(page, row)
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
if (await row.getAttribute('aria-expanded') !== 'true') await row.click()
|
||||
const card = page.locator('[data-terminal]').first()
|
||||
|
||||
@@ -391,6 +391,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', ()
|
||||
|
||||
it('expands to the cancellation verdict and original questions', async () => {
|
||||
onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row'))
|
||||
await expandTurnProcesses(cancelledPage)
|
||||
const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true })
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
await row.click()
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, writeComposerDraft } from './support.ts'
|
||||
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeComposerDraft } from './support.ts'
|
||||
|
||||
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
|
||||
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
|
||||
@@ -62,6 +62,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
if (first === undefined || second === undefined || third === undefined) throw new Error('preview stages are incomplete')
|
||||
await first.arrived.promise
|
||||
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
|
||||
await expandOwningTurnProcess(page, reasoning)
|
||||
await reasoning.waitFor()
|
||||
expect(await reasoning.getAttribute('data-preview')).toBeNull()
|
||||
|
||||
|
||||
@@ -390,7 +390,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('renders the durable direct-message then recall order', async () => {
|
||||
it('renders the direct message while retaining the following recall only in the log', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
|
||||
const group = page.getByRole('treeitem', { name: /Ungrouped/ })
|
||||
await group.waitFor({ timeout: 15_000 })
|
||||
@@ -402,12 +402,20 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
const target = groupSection.locator('[role="treeitem"]').nth(1)
|
||||
await target.waitFor({ timeout: 15_000 })
|
||||
await target.click()
|
||||
await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 })
|
||||
await page.locator('[data-chat-flow-kind="user"]').filter({ hasText: 'Research notes' }).waitFor({ timeout: 15_000 })
|
||||
const session = scaffold.ctx.sessions.get(SessionId(TARGET_SESSION_ID))
|
||||
if (session === undefined) throw new Error('reference target session is unavailable')
|
||||
const inputs = session.snapshotEvents().filter(event => event.type === 'user/message')
|
||||
expect(inputs.map(event => event.data.source.kind)).toEqual(['user', 'session-reference'])
|
||||
expect(inputs[0]?.seq).toBeLessThan(inputs[1]!.seq)
|
||||
expect(JSON.stringify(inputs[1]?.data)).toContain('<referenced-sessions>snapshot</referenced-sessions>')
|
||||
expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(TARGET_SESSION_ID).join('{{targetId}}')
|
||||
await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes'))
|
||||
expect(snapshot).toContain('Research notes what changed?')
|
||||
expect(snapshot).not.toContain('Session recall Research notes')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md'])
|
||||
|
||||
@@ -1591,7 +1591,7 @@ export async function captureStableAria(
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a stable aria snapshot with every eligible Turn process expanded,
|
||||
* Capture stable aria with every eligible Turn process and secondary group expanded,
|
||||
* then restore the controls that were closed before the capture.
|
||||
* @param page - the page under test.
|
||||
* @param selector - the region locator selector.
|
||||
@@ -1605,13 +1605,13 @@ export async function captureExpandedTurnProcessAria(
|
||||
workspaceCwd: string,
|
||||
options: { scrollToBottom?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const controls = page.locator('[data-turn-process]')
|
||||
const controls = page.locator('[data-turn-process], [data-process-activity]')
|
||||
const count = await controls.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
const opened: number[] = []
|
||||
for (let index = 0; index < count; index++) {
|
||||
const control = controls.nth(index)
|
||||
if (!await control.isVisible() || await control.getAttribute('aria-expanded') === 'true') continue
|
||||
if (!await control.isVisible() || await control.getAttribute('aria-expanded') !== 'false') continue
|
||||
await control.click()
|
||||
opened.push(index)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@ const SEED_ID = 'seeded-history-web-e2e'
|
||||
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
|
||||
/** Trajectory visibility confirms Client receipt; Host persistence alone does not. */
|
||||
async function waitForContextInClient(page: Page, seq: number): Promise<void> {
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
try {
|
||||
await page.locator(`[data-trajectory-scroll] tr[data-trajectory-row-key="context%00seq%00${seq}"]`)
|
||||
.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
} finally {
|
||||
await page.getByRole('tab', { name: 'Chat', exact: true }).click()
|
||||
}
|
||||
await page.locator('[data-conversation-scroll]').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a complete manual `/compact` lifecycle and valid compaction transaction
|
||||
* over the recorded turn's own surface. The recording stays model-authentic and
|
||||
@@ -346,7 +358,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
).toBe(true)
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
const injected = agent.session.append('user/message', createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<system-reminder>\n'
|
||||
@@ -367,10 +379,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
}],
|
||||
},
|
||||
}), { surfaceOp: 'append' })
|
||||
// The header names the producer the durable source records, so the
|
||||
// reconciled instruction file is readable without expanding the row.
|
||||
await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
await scaffold.ctx.sessions.flush(agent.session)
|
||||
expect(agent.session.snapshotEvents()).toContainEqual(injected)
|
||||
await waitForContextInClient(page, injected.seq)
|
||||
expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
|
||||
@@ -397,6 +409,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
const wasExpanded = await turnProcess.getAttribute('aria-expanded') === 'true'
|
||||
if (!wasExpanded) await turnProcess.click()
|
||||
const thinking = page.locator('[data-variant="think"]').first()
|
||||
await expandOwningTurnProcess(page, thinking)
|
||||
const toggle = thinking.getByRole('button').first()
|
||||
const secondarySize = await thinking.locator('[class*="summaryText"]').evaluate(element => getComputedStyle(element).fontSize)
|
||||
await toggle.click()
|
||||
@@ -425,60 +438,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
|
||||
const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
|
||||
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
const collapsedIcon = disclosure.locator('svg').first()
|
||||
const collapsedIconBox = await collapsedIcon.boundingBox()
|
||||
expect(collapsedIconBox?.width).toBe(14)
|
||||
expect(collapsedIconBox?.height).toBe(14)
|
||||
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
const body = page.locator('[data-context-injection-body]')
|
||||
await body.waitFor({ timeout: 5_000 })
|
||||
// The instructions form names the file it reconciled above the text, and
|
||||
// the text keeps the framing the model read rather than a cleaned excerpt.
|
||||
expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded'])
|
||||
expect(await body.locator('[data-context-text]').innerText()).toContain('<system-reminder>')
|
||||
const headerBox = await disclosure.boundingBox()
|
||||
const bodyBox = await body.boundingBox()
|
||||
if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable')
|
||||
expect(headerBox.height).toBe(24)
|
||||
expect(bodyBox.x - headerBox.x).toBe(22)
|
||||
expect(bodyBox.y - headerBox.y - headerBox.height).toBe(4)
|
||||
expect(bodyBox.height).toBe(141)
|
||||
|
||||
const style = await body.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return {
|
||||
backgroundColor: computed.backgroundColor,
|
||||
borderRadius: computed.borderRadius,
|
||||
color: computed.color,
|
||||
fontSize: computed.fontSize,
|
||||
lineHeight: computed.lineHeight,
|
||||
padding: [
|
||||
computed.paddingTop,
|
||||
computed.paddingRight,
|
||||
computed.paddingBottom,
|
||||
computed.paddingLeft,
|
||||
],
|
||||
scrolls: element.scrollHeight > element.clientHeight,
|
||||
}
|
||||
})
|
||||
expect(style).toEqual({
|
||||
backgroundColor: 'rgb(249, 250, 251)',
|
||||
borderRadius: '8px',
|
||||
color: 'rgb(129, 133, 140)',
|
||||
fontSize: '11px',
|
||||
lineHeight: '16px',
|
||||
padding: ['10px', '16px', '12px', '12px'],
|
||||
scrolls: true,
|
||||
})
|
||||
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
it.skipIf(MODE === 'record')('omits ordinary instructions from Chat while retaining their logged content', async () => {
|
||||
const session = scaffold.ctx.sessions.get(SessionId(SEED_ID))
|
||||
if (session === undefined) throw new Error('seeded session is unavailable')
|
||||
const instructions = session.snapshotEvents().filter(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'agent-instructions')
|
||||
expect(instructions).toHaveLength(1)
|
||||
expect(instructions[0]?.data).toMatchObject({ source: { form: 'instructions', changes: [{ path: 'AGENTS.md' }] } })
|
||||
expect(JSON.stringify(instructions[0]?.data)).toContain('Instruction 24: preserve the logged context contract.')
|
||||
expect(await page.locator('[data-chat-flow-kind="context"], [data-context-injection-body]').count()).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('restores the active turn rail mark across Chat and Trajectory', async () => {
|
||||
@@ -788,26 +756,19 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => {
|
||||
it.skipIf(MODE === 'record')('keeps short injected context out of Chat without dropping the event', async () => {
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
const injected = agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Short injected context.' }],
|
||||
source: { kind: 'fixture' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true })
|
||||
await disclosure.waitFor({ timeout: 10_000 })
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
// The instructions row above stays expanded from the geometry case; the
|
||||
// opaque body is the one without a declared form.
|
||||
const body = page.locator('[data-context-injection-body]:not([data-context-form])')
|
||||
const bodyBox = await body.boundingBox()
|
||||
if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable')
|
||||
expect(bodyBox.height).toBeLessThan(141)
|
||||
expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false)
|
||||
await scaffold.ctx.sessions.flush(agent.session)
|
||||
expect(agent.session.snapshotEvents()).toContainEqual(injected)
|
||||
await waitForContextInClient(page, injected.seq)
|
||||
expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0)
|
||||
expect(await page.getByText('Short injected context.', { exact: true }).count()).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('restores the recorded file preview in its original tab after page reload', async () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/settings-chrome', import.meta.url))
|
||||
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
||||
@@ -59,15 +59,13 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
it('opens the settings dialog, switches sections, and closes by every path', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell'))
|
||||
const trigger = page.getByRole('button', { name: '账号菜单', exact: true })
|
||||
expect(await trigger.getAttribute('aria-haspopup')).toBe('menu')
|
||||
const trigger = page.getByRole('button', { name: '设置', exact: true })
|
||||
expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog')
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
await trigger.click()
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
await page.getByRole('menuitem', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
// General is active by default; Permission, Language and Appearance are functional.
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
|
||||
await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 })
|
||||
@@ -186,7 +184,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
// Close path 2: the header close button (focus lands there on open).
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click()
|
||||
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
@@ -198,7 +196,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
onTestFailed(() => saveFailureShot(overflowPage, 'web-e2e-settings-navigation'))
|
||||
const console = watchConsole(overflowPage)
|
||||
await overflowPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(overflowPage, 'zh')
|
||||
await openSettings(overflowPage, 'zh')
|
||||
const dialog = overflowPage.getByRole('dialog', { name: '设置', exact: true })
|
||||
const navigation = dialog.getByRole('navigation')
|
||||
const list = navigation.locator('[class*="_navList"]')
|
||||
@@ -242,7 +240,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const selector = dialog.getByRole('button', { name: '工作区内修改' })
|
||||
@@ -309,7 +307,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
it('uses the persisted dark preference while plugins are still loading', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-boot-theme'))
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const initialDialog = page.getByRole('dialog', { name: '设置' })
|
||||
const darkCube = initialDialog.getByRole('button', { name: '深色' })
|
||||
await selectTheme(darkCube, 'dark')
|
||||
@@ -355,7 +353,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const restoredDialog = page.getByRole('dialog', { name: '设置' })
|
||||
const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' })
|
||||
// The boot palette precedes the settings mirror's saved preference.
|
||||
@@ -409,7 +407,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(light.faviconPaths).toEqual(['/favicon.svg'])
|
||||
expectThemeColorSynchronized(light)
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const darkCube = dialog.getByRole('button', { name: '深色' })
|
||||
@@ -463,7 +461,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
}
|
||||
|
||||
// `system` follows the emulated OS scheme (dark stays dark, light clears).
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' })
|
||||
await selectTheme(systemCube, 'system')
|
||||
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
|
||||
@@ -525,7 +523,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
}
|
||||
expect(await readFontSize()).toBe('14px')
|
||||
expect(await readSecondaryFontSize()).toBe('13px')
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
// The stepper reveals its arrows on hover; the up arrow steps 14 → 15 → 16.
|
||||
@@ -550,7 +548,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(await readSecondaryFontSize()).toBe('14px')
|
||||
|
||||
// Restore the default for the specs that follow (and the dialog golden).
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const restored = page.getByRole('dialog', { name: '设置' })
|
||||
await restored.waitFor({ timeout: 10_000 })
|
||||
await restored.getByText('16', { exact: true }).hover()
|
||||
@@ -565,7 +563,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
['detailed', '详细'], ['expanded', '完全展开'],
|
||||
] as const)('persists the %s work-details mode across reload', async (mode, label) => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('工作过程展示', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
@@ -581,7 +579,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
const restoredDetails = reloaded.getByText('工作过程展示', { exact: true }).locator('../..')
|
||||
await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
|
||||
@@ -597,7 +595,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
it('persists the busy-state Enter behavior across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '排队发送' }).click()
|
||||
@@ -612,7 +610,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -623,7 +621,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openSettingsFromAccountMenu(secondPage, 'zh')
|
||||
await openSettings(secondPage, 'zh')
|
||||
await secondPage.getByRole('dialog', { name: '设置' })
|
||||
.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
|
||||
@@ -646,7 +644,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
it('persists the settings language across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
|
||||
await openSettingsFromAccountMenu(page, 'zh')
|
||||
await openSettings(page, 'zh')
|
||||
const zhDialog = page.getByRole('dialog', { name: '设置' })
|
||||
await zhDialog.waitFor({ timeout: 10_000 })
|
||||
// The document language follows the active locale in the assembled app, not
|
||||
@@ -678,7 +676,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
const enTrigger = page.getByRole('button', { name: 'Account menu' })
|
||||
const enTrigger = page.getByRole('button', { name: 'Settings' })
|
||||
await enTrigger.waitFor({ timeout: 10_000 })
|
||||
|
||||
// A Chinese browser on another port still receives the explicit English
|
||||
@@ -690,7 +688,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openSettingsFromAccountMenu(secondPage, 'en')
|
||||
await openSettings(secondPage, 'en')
|
||||
await secondPage.getByRole('dialog', { name: 'Settings' })
|
||||
.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
@@ -701,7 +699,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await second.close()
|
||||
}
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
|
||||
await page.getByRole('menuitem', { name: '中文' }).click()
|
||||
await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 })
|
||||
@@ -725,7 +723,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await enPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
|
||||
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await openSettingsFromAccountMenu(enPage, 'en')
|
||||
await openSettings(enPage, 'en')
|
||||
const dialog = enPage.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
@@ -764,7 +762,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await frPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
|
||||
await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await openSettingsFromAccountMenu(frPage, 'en')
|
||||
await openSettings(frPage, 'en')
|
||||
const dialog = frPage.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
@@ -798,7 +796,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
onTestFinished(() => withoutBrowser.close())
|
||||
const browserConsole = watchConsole(withoutBrowser)
|
||||
await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettingsFromAccountMenu(withoutBrowser, 'en')
|
||||
await openSettings(withoutBrowser, 'en')
|
||||
const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor()
|
||||
expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0)
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-api-terminal-controller'
|
||||
import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||||
import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url))
|
||||
const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url))
|
||||
@@ -47,7 +47,7 @@ async function controlTransport(page: Page) {
|
||||
}
|
||||
|
||||
async function selectTerminalTheme(page: Page, name: string): Promise<void> {
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(candidate => new URL(candidate.url()).pathname === '/api/settings/mutate' && candidate.request().method() === 'POST'),
|
||||
@@ -510,7 +510,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
||||
expect(await page.getByRole('alert').count()).toBe(0)
|
||||
expect(alive(original)).toBe(true)
|
||||
const disconnected = fileURLToPath(new URL('./expected/sidebar-terminal/disconnected.expected.md', import.meta.url))
|
||||
await compareOrRefreshGolden(disconnected, await page.getByRole('status').ariaSnapshot(), webSnapshotMode())
|
||||
await compareOrRefreshGolden(disconnected, await page.locator('[data-sidebar-terminal]').getByRole('status').ariaSnapshot(), webSnapshotMode())
|
||||
await page.screenshot({ path: `${shots}/disconnected.png`, fullPage: true })
|
||||
await reconnect.click()
|
||||
transport.reconnect()
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// the composer (issue #1470). The entered `/name args` line claims into
|
||||
// skill.invoke: the real host forwards the gesture as an ordinary user
|
||||
// prompt, injects the rendered body as instructions context named after the
|
||||
// skill, and starts a turn answered by the replay adapter. The transcript shows
|
||||
// the gesture bubble, the collapsed context-injection row, and the reply.
|
||||
// skill, and starts a turn answered by the replay adapter. Chat shows the
|
||||
// gesture bubble and reply; the Session retains the injected instructions.
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
@@ -99,7 +99,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed')
|
||||
})
|
||||
|
||||
it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => {
|
||||
it('claims /name args into a gesture bubble, logged instructions, and a replayed answer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke'))
|
||||
const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
|
||||
await composer.waitFor({ timeout: 15_000 })
|
||||
@@ -116,37 +116,28 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
|
||||
await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
|
||||
await composer.press('Enter')
|
||||
|
||||
// The gesture stays an ordinary user bubble (decorated /name token plus
|
||||
// the trailing text), ahead of the injected context.
|
||||
// The gesture stays an ordinary user bubble with the skill chip and trailing text.
|
||||
const bubble = page.locator('[data-ref-chip="skill"]').first()
|
||||
await bubble.waitFor({ timeout: 15_000 })
|
||||
expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
|
||||
|
||||
// The rendered body arrives as a context-injection row named after the
|
||||
// skill. Context plus the final answer contributes no summary count, so
|
||||
// the Turn uses the fallback title while the row's own disclosure remains usable.
|
||||
const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME })
|
||||
await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 })
|
||||
await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
|
||||
await settled
|
||||
const process = page.getByRole('button', { name: 'Thought for a while', exact: true })
|
||||
const sessionId = await settled
|
||||
const process = page.locator('[data-turn-process]')
|
||||
await process.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
// The chip derives from the step's logged injection, so it must survive
|
||||
// every later Node rebuild of the Turn (process publication, turn close).
|
||||
expect(await bubble.count()).toBe(1)
|
||||
expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
|
||||
await expandOwningTurnProcess(page, injectionFlow)
|
||||
const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
|
||||
await injectionRow.click()
|
||||
const injectionBody = page
|
||||
.locator('[data-context-injection-body]')
|
||||
.filter({ hasText: `<skill_content name="${SKILL_NAME}">` })
|
||||
await injectionBody.waitFor({ timeout: 10_000 })
|
||||
const injected = await injectionBody.textContent()
|
||||
expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0)
|
||||
const session = scaffold.ctx.sessions.get(sessionId)
|
||||
if (session === undefined) throw new Error('skill invocation session is unavailable')
|
||||
const injected = session.snapshotEvents().flatMap(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'skill-invocation'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []).join('')
|
||||
expect(injected).toContain(`<skill_content name="${SKILL_NAME}">`)
|
||||
expect(injected).toContain('Reply with the fixture acknowledgement line.')
|
||||
expect(injected).not.toContain(ARGS_TEXT)
|
||||
await injectionRow.click()
|
||||
await process.click()
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
- tab "Trajectory"
|
||||
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
|
||||
- button "Copy"
|
||||
- button "Context injection runtime-context"
|
||||
- status: Deep diving...
|
||||
- button "Deep diving for {{duration}}" [disabled] [expanded]
|
||||
- text: ts
|
||||
- button "Copy"
|
||||
- code: "const first: number = 1 const second = \"two\" let tail"
|
||||
- status: Deep diving...
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Page, WebSocketRoute } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { expandAssistantStream } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
|
||||
compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/steering', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
@@ -196,6 +196,7 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let remoteSocket: WebSocketRoute | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -203,6 +204,10 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.routeWebSocket('**/api/remote.mux', (route) => {
|
||||
remoteSocket = route
|
||||
route.connectToServer()
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -224,6 +229,7 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => sessionEvents.some(event => event.type === 'request/context'), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
|
||||
await input.fill(STEER)
|
||||
@@ -235,9 +241,37 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
|
||||
await pendingSteering.waitFor({ timeout: 10_000 })
|
||||
await composer.getByRole('radio', { name: 'Yes' }).click()
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await settled
|
||||
const admission = Promise.withResolvers<undefined>()
|
||||
let claimed = false
|
||||
const stopHolding = scaffold.ctx.on('agent/pre-step', async (payload, next) => {
|
||||
if (payload.messages.some(message => message.content.some(block => block.type === 'text' && block.text === STEER))) {
|
||||
claimed = true
|
||||
await admission.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
try {
|
||||
await composer.getByRole('radio', { name: 'Yes' }).click()
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await expect.poll(() => claimed, { timeout: 10_000 }).toBe(true)
|
||||
await page.evaluate(() => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { resolve() }))
|
||||
}))
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(1)
|
||||
expect(await page.locator('[data-submission-echo]').filter({ hasText: STEER }).count()).toBe(1)
|
||||
const socket = remoteSocket
|
||||
if (socket === undefined) throw new Error('steering page has no Remote connection')
|
||||
const warningStart = tripwire.warnings.length
|
||||
await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' })
|
||||
await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true)
|
||||
await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
} finally {
|
||||
admission.resolve(undefined)
|
||||
stopHolding()
|
||||
await settled
|
||||
}
|
||||
|
||||
const steerEvents = claimedMessages(sessionEvents, STEER)
|
||||
expect(steerEvents).toHaveLength(1)
|
||||
@@ -275,7 +309,7 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('queues Cmd+Enter when plain Enter is configured to Steer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-swapped-shortcut'))
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Queue' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Steer' }).click()
|
||||
@@ -397,7 +431,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
|
||||
// The reasoning row streams independently of the steering handoff. Wait
|
||||
// for the block to settle so the mid snapshot does not race its transient
|
||||
// visually-hidden Running label while the question keeps the turn open.
|
||||
await page.locator('[data-variant="think"][data-state="ok"]').first().waitFor({ timeout: 10_000 })
|
||||
await page.locator('[data-variant="think"][data-state="ok"]').first().waitFor({ state: 'attached', timeout: 10_000 })
|
||||
const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE)
|
||||
|
||||
|
||||
+26
-13
@@ -37,6 +37,9 @@ export function requireBuilt(name: string): unknown {
|
||||
*/
|
||||
export const ZH_BROWSER_LOCALE = 'zh-CN'
|
||||
|
||||
/** Same-day anchor for seeded event times and the Asia/Shanghai browser clock. */
|
||||
export const WEB_FIXTURE_TIME = Date.parse('2026-01-15T12:00:00+08:00')
|
||||
|
||||
/**
|
||||
* Open the standard browser-test page advertising English before client boot.
|
||||
* This keeps role locators and goldens deterministic while leaving the Host
|
||||
@@ -53,32 +56,37 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise<P
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand every currently eligible Turn-process group so a Tool-focused
|
||||
* Expand every eligible Turn process and secondary group so a Tool-focused
|
||||
* scenario can exercise the original row contract beneath product-default
|
||||
* compact Chat presentation.
|
||||
* @param page - page containing the Chat view.
|
||||
*/
|
||||
export async function expandTurnProcesses(page: Page): Promise<void> {
|
||||
const controls = page.locator('[data-turn-process]')
|
||||
const controls = page.locator('[data-turn-process], [data-process-activity]')
|
||||
await controls.first().waitFor({ state: 'visible', timeout: 10_000 })
|
||||
const count = await controls.count()
|
||||
for (let index = 0; index < count; index++) {
|
||||
const control = controls.nth(index)
|
||||
if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
|
||||
if (await control.isVisible() && await control.getAttribute('aria-expanded') === 'false') await control.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the Turn-process group containing one possibly hidden descendant.
|
||||
* Expand the Turn process and secondary group containing a hidden descendant.
|
||||
* @param page - page containing the Chat view.
|
||||
* @param target - descendant whose owning Turn process should open.
|
||||
* @param target - descendant whose outer process disclosures should open.
|
||||
*/
|
||||
export async function expandOwningTurnProcess(page: Page, target: Locator): Promise<void> {
|
||||
if (await target.isVisible()) return
|
||||
const turn = await target.evaluate(element => element.closest<HTMLElement>('[data-chat-turn]')?.dataset.chatTurn)
|
||||
if (turn === undefined || await target.isVisible()) return
|
||||
const control = page.locator(`[data-turn-process="${turn}"]`)
|
||||
await control.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
|
||||
if (turn !== undefined) {
|
||||
const control = page.locator(`[data-turn-process="${turn}"]`)
|
||||
await control.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
if (await control.getAttribute('aria-expanded') === 'false') await control.click()
|
||||
}
|
||||
const group = target.locator('xpath=ancestor::*[@data-chat-group-key][1]')
|
||||
const header = group.locator('[data-process-activity]').first()
|
||||
if (await header.isVisible() && await header.getAttribute('aria-expanded') === 'false') await header.click()
|
||||
}
|
||||
|
||||
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
|
||||
@@ -218,11 +226,16 @@ export function conversationContextKey(kind: string, id: string): string {
|
||||
return `${kind.length}:${kind}${id}`
|
||||
}
|
||||
|
||||
/** Open Settings through the sidebar account menu.
|
||||
/** Open Settings through the Web gear or Desktop account menu.
|
||||
* @param page - browser page with the mounted sidebar.
|
||||
* @param locale - current UI language.
|
||||
*/
|
||||
export async function openSettingsFromAccountMenu(page: Page, locale: 'en' | 'zh'): Promise<void> {
|
||||
await page.getByRole('button', { name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: locale === 'zh' ? '设置' : 'Settings', exact: true }).click()
|
||||
export async function openSettings(page: Page, locale: 'en' | 'zh'): Promise<void> {
|
||||
const label = locale === 'zh' ? '设置' : 'Settings'
|
||||
if (await page.evaluate(() => 'dshDesktop' in globalThis)) {
|
||||
await page.getByRole('button', { name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click()
|
||||
} else {
|
||||
await page.getByRole('button', { name: label, exact: true }).click()
|
||||
}
|
||||
}
|
||||
@@ -226,13 +226,15 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize))
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
await page.locator('[data-conversation-scroll]').evaluate((host) => {
|
||||
const groupScroll = thinking.locator('xpath=ancestor::*[@data-step-process-body][1]')
|
||||
await groupScroll.scrollIntoViewIfNeeded()
|
||||
await groupScroll.evaluate((host) => {
|
||||
const row = host.querySelector('[data-variant="think"] tbody tr:nth-child(12)')
|
||||
if (row === null) throw new Error('tall Thinking table row missing')
|
||||
host.scrollTop += row.getBoundingClientRect().top - host.getBoundingClientRect().top
|
||||
})
|
||||
await expect.poll(() => toggle.evaluate((button) => {
|
||||
const host = button.closest('[data-conversation-scroll]')
|
||||
const host = button.closest('[data-step-process-body]')
|
||||
const table = button.closest('[data-variant="think"]')?.querySelector('table')
|
||||
if (host === null || table === null || table === undefined) throw new Error('Thinking scroll context missing')
|
||||
const buttonRect = button.getBoundingClientRect()
|
||||
|
||||
@@ -13,14 +13,14 @@ import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
@@ -57,6 +57,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
await closing?.close().catch((error: unknown) => failures.push(error))
|
||||
if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
sidecarDir = undefined
|
||||
vi.restoreAllMocks()
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'turn-tail-actions teardown failed')
|
||||
})
|
||||
@@ -68,6 +69,15 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
paceMs = 1,
|
||||
): Promise<void> {
|
||||
sessionEvents = []
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
if (MODE !== 'record') {
|
||||
// Playwright records real installation time; align Host time before booting it.
|
||||
await page.clock.install({ time: WEB_FIXTURE_TIME })
|
||||
const now = await page.evaluate(() => Date.now())
|
||||
const startedAt = performance.now()
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now + Math.floor(performance.now() - startedAt))
|
||||
}
|
||||
let overridePath: string | undefined
|
||||
if (buildOverride !== undefined) {
|
||||
sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-'))
|
||||
@@ -85,8 +95,6 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
},
|
||||
)
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -135,7 +143,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
// The marker IS the synchronization: the second call is provably parked,
|
||||
// so the first step's message and tool result are already durable.
|
||||
await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
|
||||
expect(await page.locator('[data-turn-process]').count()).toBe(0)
|
||||
const runningProcess = page.locator('[data-turn-process]')
|
||||
expect(await runningProcess.count()).toBe(1)
|
||||
expect(await runningProcess.isDisabled()).toBe(true)
|
||||
expect(await runningProcess.getAttribute('aria-expanded')).toBe('true')
|
||||
await expect.poll(
|
||||
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
|
||||
{ timeout: 10_000 },
|
||||
@@ -245,7 +256,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
expect(await process.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await tool.isVisible()).toBe(false)
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByText('Work details', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: 'Compact', exact: true }).click()
|
||||
@@ -258,7 +269,17 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
|
||||
.toContain(`transcriptView: ${mode}`)
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
await process.click()
|
||||
const group = page.locator('[data-sample="bash"]').first().locator('xpath=ancestor::*[@data-chat-group-key]')
|
||||
const groupControl = group.locator('[data-process-activity]')
|
||||
expect(await groupControl.isVisible()).toBe(true)
|
||||
expect(await groupControl.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await tool.isVisible()).toBe(false)
|
||||
await groupControl.click()
|
||||
expect(await tool.isVisible()).toBe(true)
|
||||
await process.click()
|
||||
|
||||
await openSettings(page, 'en')
|
||||
const restored = page.getByRole('dialog', { name: 'Settings' })
|
||||
await restored.getByText('Work details', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: label, exact: true }).click()
|
||||
@@ -276,6 +297,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-focused'))
|
||||
const { settled } = await sendPrompt()
|
||||
const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' })
|
||||
await expandOwningTurnProcess(page, page.locator('[data-sample="bash"]').first())
|
||||
await tool.waitFor({ timeout: 30_000 })
|
||||
await tool.focus()
|
||||
expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/** The real local provider publishes actionable download failures through the speech Remote to bundle details. */
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser } from 'playwright'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage } from './support.ts'
|
||||
|
||||
const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url))
|
||||
const expected = fileURLToPath(new URL('./expected/voice-download.expected.md', import.meta.url))
|
||||
|
||||
it('shows the failed asset, actual download source and recovery advice, then retries on request', async () => {
|
||||
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-voice-download-'))
|
||||
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
let status = 503
|
||||
const requests: string[] = []
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url!)
|
||||
response.writeHead(status); response.end()
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
try { await resources.browser?.close() } finally {
|
||||
try { await resources.scaffold?.close() } finally {
|
||||
const closed = new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
|
||||
server.closeAllConnections(); await closed
|
||||
}
|
||||
}
|
||||
})
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening')
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Expected a loopback listener')
|
||||
const origin = `http://127.0.0.1:${address.port}`, overlay = join(root, 'voice.patch.yml')
|
||||
await writeFile(overlay, `- id: speech-to-text-sensevoice\n config:\n dataRoot: ${JSON.stringify(join(root, 'models'))}\n modelOrigin: ${origin}\n`)
|
||||
const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled: true }] }, extraOverlayPath: overlay })
|
||||
resources.scaffold = scaffold
|
||||
const browser = await chromium.launch()
|
||||
resources.browser = browser
|
||||
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').getByRole('button').click()
|
||||
await page.getByRole('button', { name: 'Download and prepare', exact: true }).click()
|
||||
const alert = page.getByRole('alert').filter({ hasText: 'Cannot download model.int8.onnx' })
|
||||
await alert.waitFor()
|
||||
expect(await alert.textContent()).toContain('HTTP 503')
|
||||
expect(await alert.textContent()).toContain(`Download source: ${origin}`)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]).toContain('/resolve/2365baeacb507f821a0c8120fcee3d484dba7a07/model.int8.onnx')
|
||||
await compareOrRefreshGolden(expected,
|
||||
(await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd)).replaceAll(origin, 'https://model-mirror.example'), webSnapshotMode())
|
||||
status = 404
|
||||
await page.getByRole('button', { name: 'Retry preparation', exact: true }).click()
|
||||
await expect.poll(() => alert.textContent()).toContain('HTTP 404')
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import {
|
||||
connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
|
||||
connectFreshWorkspace, expandOwningTurnProcess, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
|
||||
} from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -90,6 +90,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
await input.press('Enter')
|
||||
|
||||
const workflow = page.locator('[data-workflow-run][data-run-status="running"]')
|
||||
await expandOwningTurnProcess(page, workflow)
|
||||
await workflow.waitFor({ timeout: 30_000 })
|
||||
const disclosures = workflow.locator('[data-disclosure-row]')
|
||||
await disclosures.nth(1).waitFor({ timeout: 15_000 })
|
||||
|
||||
@@ -556,7 +556,7 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
|
||||
expect(await copied.isVisible()).toBe(true)
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle)
|
||||
// Leaving anchor and card together closes it after the grace.
|
||||
await page.getByRole('button', { name: 'Account menu' }).hover()
|
||||
await page.getByRole('button', { name: 'Settings' }).hover()
|
||||
await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
@@ -586,7 +586,7 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
|
||||
await page.waitForTimeout(POINTER_HOLD_MS)
|
||||
expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
|
||||
// Pointer-leave dismissal still applies once the pointer genuinely leaves.
|
||||
await page.getByRole('button', { name: 'Account menu' }).hover()
|
||||
await page.getByRole('button', { name: 'Settings' }).hover()
|
||||
await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"tests/scaffold.ts",
|
||||
"tests/voice-input.e2e.ts",
|
||||
"tests/voice-setup.e2e.ts",
|
||||
"tests/voice-download.e2e.ts",
|
||||
"tests/prefix-proxy.ts",
|
||||
"tests/auto-review-fixture.ts",
|
||||
"tests/scaffold-generation.spec.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 573d16f37b9ce3c123c55c366d8ee6424feb3d64
|
||||
config-catalog.zh.md: 9d827a4c65ec198239d7d1ef29735747a39838b9
|
||||
config-catalog.md: 4d2fe53661495ecaa2010dc466ae4a5e3df8508a
|
||||
config-catalog.zh.md: 3f38d368e2bcfe5ac2c7d407e1a82c0972d1af48
|
||||
@@ -2781,18 +2781,14 @@ Source: [`packages/spill/spill-local/src/index.ts:31`](../packages/spill/spill-l
|
||||
Requires: `tools`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. */
|
||||
/** Optional result-retention budget. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The model-facing context cap for a plain-text tool result, in UTF-8 bytes.
|
||||
* Omitted disables the policy entirely (no-op). When set, a result larger than
|
||||
* this is spilled and replaced with a preview derived from this same budget.
|
||||
*/
|
||||
maxInlineBytes?: number
|
||||
/** Maximum estimated tokens in a retained result, including image descriptors and omission notices. Omitted disables retention. */
|
||||
maxInlineTokens?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
|
||||
Source: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-ssh"></a>
|
||||
|
||||
@@ -3808,7 +3804,7 @@ export interface Config {
|
||||
export type ToolPresentationMode = 'native' | 'ptc' | 'both'
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:663`](../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:673`](../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-typert-loader"></a>
|
||||
|
||||
|
||||
@@ -2783,18 +2783,14 @@ export interface Config {
|
||||
需要: `tools`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. */
|
||||
/** Optional result-retention budget. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The model-facing context cap for a plain-text tool result, in UTF-8 bytes.
|
||||
* Omitted disables the policy entirely (no-op). When set, a result larger than
|
||||
* this is spilled and replaced with a preview derived from this same budget.
|
||||
*/
|
||||
maxInlineBytes?: number
|
||||
/** Maximum estimated tokens in a retained result, including image descriptors and omission notices. Omitted disables retention. */
|
||||
maxInlineTokens?: number
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
|
||||
来源: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-ssh"></a>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: fc5c06727e58705ac6d9b44470b1b37010dc3223
|
||||
module-graph.zh.md: 6911d9820932569c7a666b47a8bd53696fdb6393
|
||||
module-graph.md: bfe3e5faa7168b03848ac76d2dd2584d4aa31f5e
|
||||
module-graph.zh.md: bfa0f83e14cbcf89599fa207ec919195128db49e
|
||||
@@ -806,11 +806,6 @@ flowchart TD
|
||||
pkg_tool_web --> pkg_system_prompt
|
||||
pkg_tool_web --> pkg_tools
|
||||
pkg_tool_web --> pkg_web
|
||||
pkg_spill_policy --> pkg_llm
|
||||
pkg_spill_policy --> pkg_output_retention
|
||||
pkg_spill_policy --> pkg_session
|
||||
pkg_spill_policy --> pkg_spill
|
||||
pkg_spill_policy --> pkg_tools
|
||||
pkg_tool_todo --> pkg_agent
|
||||
pkg_tool_todo --> pkg_invariants
|
||||
pkg_tool_todo --> pkg_session
|
||||
@@ -1101,6 +1096,14 @@ flowchart TD
|
||||
pkg_subagent --> pkg_user_approval
|
||||
pkg_subagent --> pkg_util_time
|
||||
pkg_subagent --> pkg_workspace
|
||||
pkg_spill_policy --> pkg_attachment
|
||||
pkg_spill_policy --> pkg_fs
|
||||
pkg_spill_policy --> pkg_llm
|
||||
pkg_spill_policy --> pkg_output_retention
|
||||
pkg_spill_policy --> pkg_session
|
||||
pkg_spill_policy --> pkg_spill
|
||||
pkg_spill_policy --> pkg_token_meter
|
||||
pkg_spill_policy --> pkg_tools
|
||||
pkg_session_query_sqlite --> pkg_session
|
||||
pkg_session_query_sqlite --> pkg_session_persistence
|
||||
pkg_session_query_sqlite --> pkg_session_query
|
||||
@@ -1566,7 +1569,6 @@ flowchart TD
|
||||
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
|
||||
| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) |
|
||||
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
|
||||
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
|
||||
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
|
||||
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
|
||||
@@ -1619,6 +1621,7 @@ flowchart TD
|
||||
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
|
||||
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
|
||||
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
|
||||
|
||||
@@ -808,11 +808,6 @@ flowchart TD
|
||||
pkg_tool_web --> pkg_system_prompt
|
||||
pkg_tool_web --> pkg_tools
|
||||
pkg_tool_web --> pkg_web
|
||||
pkg_spill_policy --> pkg_llm
|
||||
pkg_spill_policy --> pkg_output_retention
|
||||
pkg_spill_policy --> pkg_session
|
||||
pkg_spill_policy --> pkg_spill
|
||||
pkg_spill_policy --> pkg_tools
|
||||
pkg_tool_todo --> pkg_agent
|
||||
pkg_tool_todo --> pkg_invariants
|
||||
pkg_tool_todo --> pkg_session
|
||||
@@ -1103,6 +1098,14 @@ flowchart TD
|
||||
pkg_subagent --> pkg_user_approval
|
||||
pkg_subagent --> pkg_util_time
|
||||
pkg_subagent --> pkg_workspace
|
||||
pkg_spill_policy --> pkg_attachment
|
||||
pkg_spill_policy --> pkg_fs
|
||||
pkg_spill_policy --> pkg_llm
|
||||
pkg_spill_policy --> pkg_output_retention
|
||||
pkg_spill_policy --> pkg_session
|
||||
pkg_spill_policy --> pkg_spill
|
||||
pkg_spill_policy --> pkg_token_meter
|
||||
pkg_spill_policy --> pkg_tools
|
||||
pkg_session_query_sqlite --> pkg_session
|
||||
pkg_session_query_sqlite --> pkg_session_persistence
|
||||
pkg_session_query_sqlite --> pkg_session_query
|
||||
@@ -1568,7 +1571,6 @@ flowchart TD
|
||||
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
|
||||
| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) |
|
||||
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
|
||||
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
|
||||
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
|
||||
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
|
||||
@@ -1621,6 +1623,7 @@ flowchart TD
|
||||
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
|
||||
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
|
||||
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
|
||||
persistence-catalog.md: bb3ee2c998189533ed87e149422eae8123fb07b1
|
||||
persistence-catalog.zh.md: 3dabca40d2975e090935d471cd187def826ea4df
|
||||
persistence-catalog.md: 022944bea076f49e054e7194e6b475cf6dfca2bc
|
||||
persistence-catalog.zh.md: 096bf746085d329427dfb5516db4c6387f0974fe
|
||||
@@ -4929,7 +4929,7 @@ Sources: [`packages/llm/llm/src/types.ts:127`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f`
|
||||
|
||||
Sources: [`packages/core/tools/src/index.ts:689`](../packages/core/tools/src/index.ts)
|
||||
Sources: [`packages/core/tools/src/index.ts:699`](../packages/core/tools/src/index.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
Loaded 100 of 726 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user