Merge remote-tracking branch 'origin/master' into fix/pty-bundled-openconsole
# Conflicts: # .agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml # .agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md # .agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md
This commit is contained in:
commit
f290488fbf
869 files changed
+31978
-6086
No files matched your search
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: f9721b730c7e70c7
|
||||
zh: 367e7839912cb726
|
||||
/agent-note-default-deepseek-session-log-upload/decision:
|
||||
en: ec3b05fb70ae490f
|
||||
zh: 59b0691e327855b4
|
||||
en: b72ef475a41b1a23
|
||||
zh: b17a68367679730b
|
||||
/agent-note-default-deepseek-session-log-upload/alternatives-considered:
|
||||
en: 4cc4b61d76f84ff3
|
||||
zh: bdd29ea66037f744
|
||||
|
||||
@@ -9,7 +9,9 @@ Ordinary DeepSeek requests do not contain the complete canonical Session traject
|
||||
|
||||
## Decision
|
||||
|
||||
`session-log-deepseek.Config.enabled` defaults to `true` in every process. An explicit `enabled: false` disables the contribution. The plugin does not inspect test-runner or snapshot environment variables.
|
||||
`session-log-deepseek.Config.enabled` defaults to `true` in every process. A resolved `enabled: false` disables the contribution; configuration precedence determines which value takes effect. The plugin does not inspect test-runner or snapshot environment variables.
|
||||
|
||||
The `enabled` value is read for each request through a volatile reference so a saved preference takes effect without restarting the plugin. Profile writes can override bundle defaults under the [profile-owned configuration rules](2026-09-19-profile-owned-live-configuration.md); home patches and command-line overlays still reject conflicting writes. Disabling upload leaves the acceptance watermark unchanged because it records provider acceptance, not user eligibility. Re-enabling therefore sends the unaccepted suffix, including events recorded while disabled. Already prepared requests retain their payload.
|
||||
|
||||
This supersedes only the opt-in default in the [request-extension decision](2026-08-21-deepseek-llm-api-request-extensions.md); that note still owns field serialization, destinations, acceptance, and retry semantics. The headless and ACP corpus base patches and Web scaffold explicitly disable upload. Later scenario patches can enable it. The SDK text-turn recording omits the setting and exercises the shipped default, including durable acceptance events.
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`session-log-deepseek.Config.enabled` 在所有进程中均默认为 `true`。显式设置 `enabled: false` 可关闭贡献。插件不读取测试运行器或快照环境变量。
|
||||
`session-log-deepseek.Config.enabled` 在所有进程中均默认为 `true`。最终生效的 `enabled: false` 会关闭贡献;配置优先级决定采用哪个值。插件不读取测试运行器或快照环境变量。
|
||||
|
||||
每次请求通过 volatile 引用读取 `enabled`,因此保存的偏好无需重启插件即可生效。按照 [profile 所有的配置规则](2026-09-19-profile-owned-live-configuration.zh.md),profile 写入可覆盖组合包默认值;home patch 和命令行 overlay 仍会拒绝冲突写入。关闭上传不改变接受水位,因为它记录提供方的接受结果,而不是用户是否允许上传。重新开启后会发送尚未接受的后缀,包括关闭期间记录的事件。已准备的请求保留原有载荷。
|
||||
|
||||
本决策仅取代[请求扩展决策](2026-08-21-deepseek-llm-api-request-extensions.zh.md)中的主动启用默认策略;该记录仍负责字段序列化、目的地址、接受与重试语义。headless 和 ACP 语料的基础 patch 以及 Web scaffold 显式关闭上传。后续场景 patch 可以启用上传。SDK text-turn 录制省略该设置,验证产品默认值及持久接受事件。
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Bilingual-pair consistency record for 2026-09-19-timed-user-question-two-settlements.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md
|
||||
/agent-note-timed-user-questions-as-two-settlements:
|
||||
en: 5b6fa73158a11213
|
||||
zh: b55c32342d6e6692
|
||||
/agent-note-timed-user-questions-as-two-settlements/problem:
|
||||
en: f665127a9b5b8225
|
||||
zh: b19845f62310f3a2
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision:
|
||||
en: ce869bf2eed04e81
|
||||
zh: 5b1fcf44c44c6137
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/modes-and-scope:
|
||||
en: adccc5dc758ad8f6
|
||||
zh: 50304d68db42e8b1
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/ownership:
|
||||
en: 0543678ad9d26fc5
|
||||
zh: aa6ba207e661c567
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/foreground-wait-and-claim-lifetime:
|
||||
en: 447713bbdf1ab4d8
|
||||
zh: 2757678ae47822aa
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/durable-state-and-late-answers:
|
||||
en: 93f3def953d10a2e
|
||||
zh: e1106340e0043254
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/card-state-and-delivery-races:
|
||||
en: 3dd7321bd37c51bf
|
||||
zh: 2d1f058732bd6d0f
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/local-countdown-and-panel-actions:
|
||||
en: 3a33f2583e30e32a
|
||||
zh: 0ad7e8d1e47dc510
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/reply-node-and-grouping:
|
||||
en: 10323b0e0340a40b
|
||||
zh: a17fc3a4d4d820a7
|
||||
/agent-note-timed-user-questions-as-two-settlements/alternatives-considered:
|
||||
en: bf61d0ec80c63bb4
|
||||
zh: d4ce4d4438ffb013
|
||||
/agent-note-timed-user-questions-as-two-settlements/verification:
|
||||
en: d912a73a5ff409eb
|
||||
zh: 7b626794773e2301
|
||||
/agent-note-timed-user-questions-as-two-settlements/consequences:
|
||||
en: 2bc99c99be8ca9da
|
||||
zh: 8f262ec8b9b0945e
|
||||
+25
-21
@@ -1,6 +1,6 @@
|
||||
# Agent Note: Timed user questions as two settlements
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-timed-user-question-two-settlements.zh.md)
|
||||
|
||||
@@ -10,14 +10,14 @@ A question can block an agent even when useful work does not depend on the answe
|
||||
|
||||
The foreground tool result and the eventual answer have different lifetimes. A `user-questions/request` waterfall settles once; keeping it alive after returning pending creates a second answer path that outlives its caller. Countdown, focus, and editing also have different owners from durable question state.
|
||||
|
||||
## Proposal
|
||||
## Decision
|
||||
|
||||
Keep foreground answers on the existing Remote Event waterfall. A timeout ends that request and returns pending; a later answer uses a business RPC that steers a durable user message. The claim stream controls the foreground wait only. It neither transports answers nor replaces the Remote Event.
|
||||
Foreground answers use the existing Remote Event waterfall. A timeout ends that request and returns pending; a later answer uses a business RPC that steers a durable user message. The claim stream controls the foreground wait only. It neither transports answers nor replaces the Remote Event.
|
||||
|
||||
### Modes and scope
|
||||
|
||||
- The default `mode: legacy` retains the blocking schema and `ask()` path. Explicit `mode: timed` adds `timeout`, using the configured default of 120 seconds when omitted.
|
||||
- A positive integer timeout selects `askTimed()`; `-1` selects blocking `ask()`. A blocking call made with the timed schema still participates in timed-question projection and review; it is not identical to a legacy call.
|
||||
- A positive integer timeout selects `askTimed()`; `-1` selects blocking `ask()`. A native blocking call made with the timed schema still participates in timed-question projection and review; it is not identical to a legacy call.
|
||||
- Plan review retains `ask()`, its `plan-review` intent, and `BAD_INTENT` validation. This decision does not move plan review to another package.
|
||||
- Human interaction requires the exact live runtime root when an Agent is supplied. A stale instance fails with `CALLER_NOT_LIVE`; an owned child fails with `DELEGATED_CALLER`. Durable Session lineage does not prevent a resumed runtime root from asking.
|
||||
|
||||
@@ -46,27 +46,27 @@ Client countdown expiry rejects the waterfall with `ASK_TIMED_OUT`. Unattended H
|
||||
|
||||
### Durable state and late answers
|
||||
|
||||
The `userQuestions` projection reconstructs question state from existing Session events. It recognises the timed schema by the `timeout` parameter recorded in `request/header`; legacy calls are not tracked. A valid `tool/call` opens a question. Its `tool/result` makes it continued for pending or `TOOL_OUTCOME_UNKNOWN`, settles it with a valid answer batch, or removes it for another result or failure.
|
||||
The `userQuestions` projection reconstructs question state from existing Session events. For native calls, it recognises the timed schema by the `timeout` parameter recorded in `request/header`; legacy calls are not tracked. A valid `tool/call` opens a question. Its `tool/result` makes it continued for pending or `TOOL_OUTCOME_UNKNOWN`, settles it with a valid answer batch, or removes it for another result or failure. A PTC sub-call enters the projection when its `tool/ptc-dispatch` result is pending, since only `run_code` appears in the model request header.
|
||||
|
||||
A continued question accepts `answer(agent, callId, answer)`. The method returns `false` for an unknown or non-continued call and throws `BAD_ANSWER` unless the batch names each question exactly once. Accepted answers use `agent.steer(createUserMessage(...))`, source `{ kind: 'user-question-reply', callId, outcome: 'answered' }`, and JSON text containing `answer_to_pending_question`, the call id, original questions, and answers. Remote Agent resolution from the Session id resumes a root when needed; the `answer()` body does not own that resume operation.
|
||||
A continued question accepts `answer(agent, callId, answer)`. The method returns `false` for an unknown or non-continued call, throws `REPLY_QUEUED` while another reply awaits admission, and throws `BAD_ANSWER` unless the batch names each question exactly once. Accepted answers use `agent.steer(createUserMessage(...))`, source `{ kind: 'user-question-reply', callId, outcome: 'answered' }`, and JSON text containing `answer_to_pending_question`, the call id, original questions, and answers. Remote Agent resolution from the Session id resumes a root when needed; the `answer()` body does not own that resume operation.
|
||||
|
||||
The inbox message is the durable reply. `agent/inbox/spliced` settles the question when the message is queued; the later admitted `user/message` is idempotent for that settlement. Settled answer batches remain in the projection so the original tool row can show late answers even though its own result remains pending. Unusable recorded reply text settles with an empty batch.
|
||||
The inbox message is the durable queued reply. A per-Session reservation covers the interval from queuing through claim and admission; claiming a restored inbox reply creates the reservation again. Admission as `user/message` settles the question, while discard or a Turn that ends without admission releases the reservation for another answer. Settled answer batches remain in the projection so the original tool row can show late answers even though its own result remains pending. Unusable recorded reply text settles with an empty batch.
|
||||
|
||||
No new Session event type or wait-state log is needed. The message-source extension requires the repository's persistence-type acknowledgement. `dismissed` remains readable for historical records but has no producer: hiding a panel does not manufacture a reply.
|
||||
No new Session event type or wait-state log is needed. The message-source extension requires the repository's persistence-type acknowledgement. Hiding a panel does not manufacture a reply.
|
||||
|
||||
### Card state and delivery races
|
||||
|
||||
`QuestionCards` shares one `PendingQuestion` between the live request and the continued projection, keyed by Session and `callId`. A blocking request without a call id has its own per-request card. An open card submits through its waterfall; without that channel it disables submission. A continued card uses the late-answer RPC.
|
||||
`QuestionCards` shares one `PendingQuestion` between the live request and the continued projection, keyed by Session and `callId`. A forwarded request keeps its card through the claim's first frame even before the projection lists it. A blocking request without a call id has its own per-request card with a reload-unique key, so an old persisted draft cannot attach to another request. An open card submits through its waterfall; without that channel it disables submission. A continued card uses the late-answer RPC.
|
||||
|
||||
A waterfall submission has no delivery acknowledgement. The Gateway can discard an outcome that loses a settlement race. The card therefore retains its draft and stays busy until projection reconciliation. If the question becomes continued during submission, the controls re-arm with a resubmit hint; the user can submit the retained draft through RPC. This is not automatic retry or a guarantee that every local submission reaches the model.
|
||||
A waterfall submission has no delivery acknowledgement. The Gateway can discard an outcome that loses a settlement race. The card therefore retains its draft and stays busy until projection reconciliation. If the question becomes continued during submission, the controls re-arm with a resubmit hint; the user can submit the retained draft through RPC. An accepted RPC submission steers a reply toward the nearest step without settling the question, and the panel closes. A current tool wait must finish before that step can read the reply; if the steer is discarded, the question can be reopened and answered again. This is not automatic retry or a guarantee that every local submission reaches the model.
|
||||
|
||||
A tool-call-keyed card is removed when its call is absent from the active projection and no waterfall remains. Removal closes the card and clears its draft. Neither a local submission nor a transport cancel frame alone decides that the question is answered. An unkeyed blocking card ends with its waterfall; closing that blocking panel rejects with `ASK_CANCELLED`.
|
||||
A tool-call-keyed card is removed when its call is absent from the active projection and no forwarded request remains. Removal closes the card and clears its draft. Neither a local submission nor a transport cancel frame alone decides that the question is answered. An unkeyed blocking card ends with its waterfall; closing that blocking panel rejects with `ASK_CANCELLED`.
|
||||
|
||||
### Local countdown and panel actions
|
||||
|
||||
The Client computes `Date.now() + remainingMs`. Manual focus on a pristine answer surface pauses its countdown, and blur resumes the preserved remainder. Focus before the claim handshake remains effective when the deadline arrives; blur before the handshake does not create a timer. First edit or Take time freezes the countdown, with that disposition saved beside the browser-local draft.
|
||||
|
||||
Autofocus applies only when the answer channel is ready, no countdown is active, and the card is not locked. Hiding a timed-call panel leaves its claim and countdown running; its tool row can reopen it. A settled call opens a read-only panel from recorded questions and answers, not another answer channel.
|
||||
Autofocus applies only when the answer channel is ready, no countdown is active, and the card is not locked. Hiding a timed-call panel leaves its claim and countdown running; its tool row can reopen it. Once a late reply enters the Inbox, the tool row displays its submitted answers and “View answers” expands the read-only transcript while admission is pending. A settled call opens a read-only panel with recorded answers. Discarding the queued reply restores the answer action.
|
||||
|
||||
### Reply node and grouping
|
||||
|
||||
@@ -86,7 +86,7 @@ This decision adds no process-role field, independent-layout flag, user-message
|
||||
|
||||
**Delegate every timed request immediately.** Dispatching to an intentionally empty answerer set wastes the existing blocking-answer path and requires special handling of `NO_PROVIDER`. Delegation is for an unavailable answerer, not the timed mode itself.
|
||||
|
||||
**Persist a whole-value wait/focus/edit stream or use Host focus leases.** Durable question status is derivable from tool and inbox events. Focus and editing are local UI state; logging them or synchronising focus leases adds Host state without making multiple Clients share one editing session.
|
||||
**Persist a whole-value wait/focus/edit stream or use Host focus leases.** Durable question status is derivable from tool and admitted user events. Focus and editing are local UI state; logging them or synchronising focus leases adds Host state without making multiple Clients share one editing session.
|
||||
|
||||
**Reclassify JSON inside generic `MessageItem`, borrow the relay form, or use the default context row.** Parsing text to identify the business message confuses its source with its content; the relay form describes another agent's message, and a generic context row exposes JSON. A dedicated source, Definition, and renderer own the question presentation.
|
||||
|
||||
@@ -94,20 +94,24 @@ This decision adds no process-role field, independent-layout flag, user-message
|
||||
|
||||
**A log-only close event or a manufactured dismissal message on panel close.** A log-only close hides the user's decision from the model and adds another durable mechanism. Manufacturing a reply treats hiding the panel as an answer. Panel close therefore leaves a timed question answerable.
|
||||
|
||||
**A dedicated late-reply Session event beside the inbox message.** The inbox splice and admitted user message already record the reply. A third event duplicates the same fact and adds another persistence type.
|
||||
**A dedicated late-reply Session event beside the inbox message.** The inbox splice records the queued reply and the admitted user message records its delivery. A third event duplicates those facts and adds another persistence type.
|
||||
|
||||
## Acceptance criteria
|
||||
## Verification
|
||||
|
||||
- Service tests cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation.
|
||||
- Projection tests distinguish legacy and timed schemas and cover pending, answered, other failures, resume repair, and duplicate inbox/message settlement.
|
||||
- Client tests cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel hiding/reopening.
|
||||
- Grouping and browser replay cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
|
||||
- Legacy question round trips, cancellation, plan-review intent, and Session remounts retain their existing behavior. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests.
|
||||
- [Service tests](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts) and [wait tests](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts) cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation.
|
||||
- [Projection tests](../../../../packages/interaction/user-questions/tests/projection.spec.ts) distinguish legacy and timed schemas and cover native and PTC pending calls, other failures, resume repair, queued reply cancellation, and admitted settlement.
|
||||
- [Client listener tests](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts) and [composer tests](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx) cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel actions.
|
||||
- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
|
||||
- Legacy round-trip, cancellation, plan-review intent, and Session-remount regressions remain covered by their owning tests. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests.
|
||||
|
||||
## Risks
|
||||
## Consequences
|
||||
|
||||
The model can continue independent work while the question remains answerable. Business-owned timing and reply rendering reuse the existing transport and conversation APIs, at the cost of local rather than globally coordinated waiting and ordinary folded visibility.
|
||||
|
||||
- Take time affects only one Client. Another Client can answer or expire the shared waterfall; local focus does not establish a global hold.
|
||||
- A terminal claim-stream failure can reject the foreground question rather than produce pending. No unattended-wait fallback is provided for that failure.
|
||||
- Host and Client clocks need not align, but Client system-clock changes can move expiry and transport latency can delay the local countdown's start.
|
||||
- The duplicate projections remain stored. Aggregation adds linear scans during structural grouping rebuilds; ordinary text-only streaming updates retain their incremental path. This is an algorithmic bound, not a measured latency claim.
|
||||
- Unanswered continued questions can remain after reopening a Session. Panel close records nothing, and visibility follows normal folding rather than an always-visible reply guarantee.
|
||||
- A queued late reply can be canceled before the agent admits it. The question stays answerable until an admitted `user/message` records the answer.
|
||||
- A PTC sub-call interrupted before its `tool/ptc-dispatch` result is recorded cannot be reconstructed from the parent `run_code` failure, so it cannot take a late answer after recovery.
|
||||
+24
-20
@@ -1,6 +1,6 @@
|
||||
# Agent Note: 计时用户提问的两次结算
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-timed-user-question-two-settlements.md) | 中文
|
||||
|
||||
@@ -10,14 +10,14 @@ Status: proposed
|
||||
|
||||
前台工具结果与最终回答的生命周期不同。`user-questions/request` waterfall(瀑布式事件)只结算一次;返回 pending 后仍保留它,会产生一条活过调用方的第二回答路径。倒计时、聚焦和编辑的归属也不同于持久问题状态。
|
||||
|
||||
## Proposal
|
||||
## Decision
|
||||
|
||||
前台回答继续使用现有 Remote Event waterfall。超时结束该请求并返回 pending;迟到回答通过业务 RPC steer 一条持久用户消息。接手 stream 只控制前台等待,既不传递回答,也不替代 Remote Event。
|
||||
|
||||
### 模式与范围
|
||||
|
||||
- 默认 `mode: legacy` 保留阻塞式 schema 和 `ask()` 路径。显式 `mode: timed` 增加 `timeout`,省略时使用配置默认值 120 秒。
|
||||
- 正整数超时选择 `askTimed()`;`-1` 选择阻塞式 `ask()`。timed schema 发出的阻塞调用仍参与计时问题投影和答案查看,并不等同于 legacy 调用。
|
||||
- 正整数超时选择 `askTimed()`;`-1` 选择阻塞式 `ask()`。timed schema 发出的原生阻塞调用仍参与计时问题投影和答案查看,并不等同于 legacy 调用。
|
||||
- 计划评审保留 `ask()`、`plan-review` 意图及 `BAD_INTENT` 校验。本决策不把计划评审迁移到其他包。
|
||||
- 提供 Agent 时,人机交互要求它是运行时中确切的存活根实例。陈旧实例以 `CALLER_NOT_LIVE` 失败;被其他 agent 拥有的子实例以 `DELEGATED_CALLER` 失败。持久 Session 的血缘关系不妨碍恢复后的运行时根实例提问。
|
||||
|
||||
@@ -46,27 +46,27 @@ Client 倒计时到期以 `ASK_TIMED_OUT` 拒绝 waterfall。无人接手时,H
|
||||
|
||||
### 持久状态与迟到回答
|
||||
|
||||
`userQuestions` 投影从现有 Session 事件重建问题状态。它通过 `request/header` 记录的 `timeout` 参数识别 timed schema,不跟踪 legacy 调用。有效的 `tool/call` 打开问题。其 `tool/result` 在 pending 或 `TOOL_OUTCOME_UNKNOWN` 时将问题标为已继续,在有效回答批次时结算并保存答案,其他结果或失败则移除问题。
|
||||
`userQuestions` 投影从现有 Session 事件重建问题状态。对原生调用,它通过 `request/header` 记录的 `timeout` 参数识别 timed schema,不跟踪 legacy 调用。有效的 `tool/call` 打开问题。其 `tool/result` 在 pending 或 `TOOL_OUTCOME_UNKNOWN` 时将问题标为已继续,在有效回答批次时结算并保存答案,其他结果或失败则移除问题。PTC 子调用在 `tool/ptc-dispatch` 结果为 pending 时进入投影,因为模型请求头只列出 `run_code`。
|
||||
|
||||
已继续的问题接受 `answer(agent, callId, answer)`。未知或非已继续调用返回 `false`;回答批次没有恰好包含每个问题一次时抛出 `BAD_ANSWER`。接受的回答通过 `agent.steer(createUserMessage(...))` 送入 agent,来源为 `{ kind: 'user-question-reply', callId, outcome: 'answered' }`,JSON 文本包含 `answer_to_pending_question`、调用 id、原问题及答案。Remote 根据 Session id 解析 Agent 时按需恢复根实例;恢复操作不属于 `answer()` 方法体。
|
||||
已继续的问题接受 `answer(agent, callId, answer)`。未知或非已继续调用返回 `false`;另一条回复等待准入时抛出 `REPLY_QUEUED`;回答批次没有恰好包含每个问题一次时抛出 `BAD_ANSWER`。接受的回答通过 `agent.steer(createUserMessage(...))` 送入 agent,来源为 `{ kind: 'user-question-reply', callId, outcome: 'answered' }`,JSON 文本包含 `answer_to_pending_question`、调用 id、原问题及答案。Remote 根据 Session id 解析 Agent 时按需恢复根实例;恢复操作不属于 `answer()` 方法体。
|
||||
|
||||
inbox 消息就是持久回复。`agent/inbox/spliced` 在消息入队时结算问题;随后接纳的 `user/message` 对该结算是幂等的。已结算答案批次保留在投影中,让原工具行即使自身结果仍是 pending,也能显示迟到回答。无法使用的已记录回复文本以空答案批次结算。
|
||||
inbox 消息是持久的排队回复。逐 Session 预约覆盖从排队、领取到准入的期间;领取恢复后的 inbox 回复时会重建预约。回复作为 `user/message` 准入时问题结算;丢弃回复或 Turn 结束而未准入时释放预约,允许再次回答。已结算答案批次保留在投影中,让原工具行即使自身结果仍是 pending,也能显示迟到回答。无法使用的已记录回复文本以空答案批次结算。
|
||||
|
||||
无需新增 Session 事件类型或等待状态日志。消息来源扩展需要仓库的持久化类型变更确认。`dismissed` 为历史记录保留可读性,但没有生产者:隐藏面板不会伪造回复。
|
||||
无需新增 Session 事件类型或等待状态日志。消息来源扩展需要仓库的持久化类型变更确认。隐藏面板不会伪造回复。
|
||||
|
||||
### 卡片状态与投递竞争
|
||||
|
||||
`QuestionCards` 按 Session 和 `callId`,让实时请求与已继续投影共享一个 `PendingQuestion`。没有调用 id 的阻塞请求拥有独立的逐请求卡片。开放卡片通过 waterfall 提交;缺少该通道时禁用提交。已继续卡片使用迟到回答 RPC。
|
||||
`QuestionCards` 按 Session 和 `callId`,让实时请求与已继续投影共享一个 `PendingQuestion`。转发请求在投影列出问题之前,仍会让卡片存活至接手 stream 的首帧。没有调用 id 的阻塞请求拥有独立的逐请求卡片,并使用跨重载唯一的键,避免旧草稿附着到另一请求。开放卡片通过 waterfall 提交;缺少该通道时禁用提交。已继续卡片使用迟到回答 RPC。
|
||||
|
||||
waterfall 提交没有送达确认。Gateway 可能丢弃输掉结算竞争的结果,因此卡片保留草稿并保持忙碌,等待投影同步。若提交期间问题变为已继续,控件带重提提示重新可用,用户可以通过 RPC 提交保留的草稿。这不是自动重试,也不保证每次本地提交都能到达模型。
|
||||
waterfall 提交没有送达确认。Gateway 可能丢弃输掉结算竞争的结果,因此卡片保留草稿并保持忙碌,等待投影同步。若提交期间问题变为已继续,控件带重提提示重新可用,用户可以通过 RPC 提交保留的草稿。RPC 接受提交后会将回复作为 steer 送往最近一步,但不会立即结算问题,同时关闭面板。当前工具等待须先结束,该步才能读取回复;若 steer 被丢弃,可以重新打开问题并再次回答。这不是自动重试,也不保证每次本地提交都能到达模型。
|
||||
|
||||
按工具调用标识的卡片在调用不属于活动投影且没有 waterfall 时移除。移除会关闭卡片并清理草稿。本地提交和传输取消帧都不能单独决定问题已经回答。无调用标识的阻塞卡片随 waterfall 结束;关闭这种阻塞面板以 `ASK_CANCELLED` 拒绝请求。
|
||||
按工具调用标识的卡片在调用不属于活动投影且没有转发中的请求时移除。移除会关闭卡片并清理草稿。本地提交和传输取消帧都不能单独决定问题已经回答。无调用标识的阻塞卡片随 waterfall 结束;关闭这种阻塞面板以 `ASK_CANCELLED` 拒绝请求。
|
||||
|
||||
### 本地倒计时与面板操作
|
||||
|
||||
Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会暂停倒计时,失焦后从保留的剩余时间继续。接手握手前的聚焦在 deadline 到达时仍生效;握手前失焦不会制造计时器。首次编辑或「慢慢回答」冻结倒计时,该状态与浏览器本地草稿一起保存。
|
||||
|
||||
只有回答通道就绪、没有活动倒计时且卡片未锁定时才自动聚焦。隐藏计时调用的面板会保留接手和倒计时,工具行可以重新打开面板。已结算调用依据记录的问题与答案打开只读面板,不产生另一条回答通道。
|
||||
只有回答通道就绪、没有活动倒计时且卡片未锁定时才自动聚焦。隐藏计时调用的面板会保留接手和倒计时,工具行可以重新打开面板。迟到回复进入 Inbox 后,工具行显示已提交的答案;等待准入期间,「查看回答」展开只读的行内记录。已结算调用打开只读面板,显示记录的答案。排队回复被丢弃后,回答操作恢复可用。
|
||||
|
||||
### 回复节点与分组
|
||||
|
||||
@@ -86,7 +86,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
|
||||
**立即委托所有计时请求。** 向故意留空的回答方集合派发请求,浪费现有阻塞回答路径,还要求特殊处理 `NO_PROVIDER`。委托表示回答方不可用,不代表计时模式本身。
|
||||
|
||||
**持久化整值等待/聚焦/编辑流,或使用 Host 聚焦租约。** 持久问题状态可从工具与 inbox 事件推导。聚焦和编辑属于本地 UI 状态;记录它们或同步聚焦租约会增加 Host 状态,却不能让多个 Client 共享同一编辑会话。
|
||||
**持久化整值等待/聚焦/编辑流,或使用 Host 聚焦租约。** 持久问题状态可从工具事件与已准入的用户事件推导。聚焦和编辑属于本地 UI 状态;记录它们或同步聚焦租约会增加 Host 状态,却不能让多个 Client 共享同一编辑会话。
|
||||
|
||||
**在通用 `MessageItem` 内重新分类 JSON、借用 relay form,或使用默认 context 行。** 解析文本来识别业务消息混淆了来源与内容;relay form 表示其他 agent 的消息,通用 context 行则会暴露 JSON。专用来源、Definition 和渲染器拥有问题呈现。
|
||||
|
||||
@@ -94,20 +94,24 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
|
||||
**面板关闭时写仅入日志的关闭事件,或伪造放弃消息。** 仅入日志会让模型看不到用户决定,还增加另一套持久机制。伪造回复则把隐藏面板当成回答。因此关闭面板仍让计时问题保持可回答。
|
||||
|
||||
**在 inbox 消息之外增加专用迟到回复 Session 事件。** inbox 插入与接纳的用户消息已经记录回复。第三个事件重复同一事实,并增加另一种持久化类型。
|
||||
**在 inbox 消息之外增加专用迟到回复 Session 事件。** inbox 插入记录排队回复,准入的用户消息记录其交付。第三个事件重复这些事实,并增加另一种持久化类型。
|
||||
|
||||
## Acceptance criteria
|
||||
## 验证
|
||||
|
||||
- 服务测试覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。
|
||||
- 投影测试区分 legacy 与 timed schema,并覆盖 pending、已回答、其他失败、恢复修复及 inbox/消息重复结算。
|
||||
- Client 测试覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板隐藏/重开。
|
||||
- 分组与浏览器回放覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
|
||||
- legacy 问答往返、取消、计划评审意图及 Session 重新挂载保留既有行为。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。
|
||||
- [服务测试](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts)与[等待测试](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts)覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。
|
||||
- [投影测试](../../../../packages/interaction/user-questions/tests/projection.spec.ts)区分 legacy 与 timed schema,并覆盖原生及 PTC pending 调用、其他失败、恢复修复、排队回复取消和准入后结算。
|
||||
- [Client 监听器测试](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts)与[composer 测试](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx)覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板操作。
|
||||
- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
|
||||
- legacy 问答往返、取消、计划评审意图及 Session 重新挂载的回归仍由各自测试覆盖。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。
|
||||
|
||||
## Risks
|
||||
## Consequences
|
||||
|
||||
模型可以继续独立工作,问题同时保持可回答。业务层拥有的计时与回复渲染复用现有传输和对话 API,代价是等待由本地而非全局协调,可见性遵循普通折叠。
|
||||
|
||||
- 「慢慢回答」只影响一个 Client。其他 Client 可以回答或让共享 waterfall 到期,本地聚焦不构成全局暂停。
|
||||
- 接手 stream 的终止性失败可能让前台问题失败,而不是产生 pending。不为该失败提供无人接手等待的 fallback。
|
||||
- Host 与 Client 时钟不必对齐,但 Client 系统时钟变化可能移动到期时间,传输延迟可能推迟本地倒计时的开始。
|
||||
- 重复投影仍保留在存储中。聚合在结构性分组重建时增加线性扫描;普通纯文本 stream 更新保留增量路径。这是算法复杂度界限,不是实测延迟结论。
|
||||
- 无人回答的已继续问题可以在重新打开 Session 后继续存在。关闭面板不写记录,可见性遵循普通折叠,而不是保证回复始终可见。
|
||||
- 排队的迟到回复可能在 agent 准入前被取消。只有已准入的 `user/message` 记录答案,问题在此之前保持可回答。
|
||||
- PTC 子调用若在记录 `tool/ptc-dispatch` 结果前中断,无法从父 `run_code` 的失败中重建,因此恢复后不能接受迟到回答。
|
||||
@@ -6,11 +6,11 @@
|
||||
en: 5e8c5673a0669d9c
|
||||
zh: c8442a6186288a2a
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/problem:
|
||||
en: 83a81a7a2e1917fd
|
||||
zh: 05a74c8d80cd1823
|
||||
en: 2f5e9297c5fd4853
|
||||
zh: 8168c90bc6fa13b5
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/decision:
|
||||
en: 653c74442decb65a
|
||||
zh: 46f01c45d48b846b
|
||||
en: a998f5c6abdd68eb
|
||||
zh: a7cdd13e1e4de6c6
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/alternatives-considered:
|
||||
en: 21a9a19b76f42758
|
||||
zh: 30a8f5425b59900d
|
||||
@@ -18,5 +18,5 @@
|
||||
en: b58904921286a1db
|
||||
zh: a696c2954d86c04f
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/testing:
|
||||
en: 78253191eea198fa
|
||||
zh: 32bdd6807ddc13a0
|
||||
en: c741ea002d239ae6
|
||||
zh: d68846526e63ce51
|
||||
@@ -6,11 +6,11 @@ English | [中文](2026-09-27-pwsh-prompt-tail-grace.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds. The self-hosted pool's render is a different case: the [bundled console host note](2026-09-28-pty-bundled-console-host.md) measures its tail arriving already malformed (`\ndsh>\x1b[1C`), which no bound on a well-formed tail can cover; this field still owns genuinely delayed tails.
|
||||
Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders under Windows PowerShell 5.1, the same two-chunk render under pwsh 7.6.6), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds. The lane's own failures are not that state: the first master run carrying this field (run 36326153388, 2026-09-27 14:30Z) took the same 20.8 s as the run before it (36309006133), so its degraded sends settled at the plain bound with the tolerance inert; the [Windows-lane observation note](../testing/2026-09-27-observation-waits-on-observed-state.md) records that investigation, and the [bundled console host note](2026-09-28-pty-bundled-console-host.md) measures that pool's tail arriving already malformed (`\ndsh>\x1b[1C`), which no bound on a well-formed tail can cover. This field still owns genuinely delayed tails.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case sets 5000 ms, because the hosts whose stalls the field is for are exactly the ones that run it, and its assertion (at least six `stdin_read` settlements, no `inferred_idle`) then measures the controlled-prompt path instead of the host's delivery timing. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
|
||||
`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case keeps the default: the self-hosted lane's degraded sends settle at the plain bound with the tolerance present or absent, so a nonzero value there would only lengthen a never-arriving-tail fallback from 3.3 s to 8.3 s per send while reporting a genuinely late tail as a slow pass instead of a named failure. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,4 +28,4 @@ A deployment whose console renderer stalls keeps those sends on the exact path b
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` carries the end-to-end evidence on native Windows: with every session's prompt tail withheld for four seconds the case passes with the configured tolerance — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and fails with the tolerance patched to zero, where two sends settle `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms.
|
||||
`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. The end-to-end evidence on native Windows came from a guest-patched copy of `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` (Windows PowerShell 5.1, 2026-09-27): with every session's prompt tail withheld for four seconds and the tolerance at 5000 ms the case passed — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and with the tolerance at zero two sends settled `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms. The committed case runs with the default and prints a per-send readiness timeline on failure, so that state is named when it occurs rather than inferred from wall time.
|
||||
@@ -6,11 +6,11 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。自托管池上的渲染是另一种情况:[自带控制台宿主说明](2026-09-28-pty-bundled-console-host.zh.md)实测其尾部以已经畸形的形态到达(`\ndsh>\x1b[1C`),任何针对良构尾部的上界都覆盖不了;本字段仍然负责真正迟到的尾部。
|
||||
自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(Windows PowerShell 5.1 下 56 次渲染,pwsh 7.6.6 下同样是两分块渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。该 lane 自身的失败不是这种状态:首个带着该字段的 master run(run 36326153388,2026-09-27 14:30Z)与其前一个 run(36309006133)同样耗时 20.8 秒,因此其退化的 send 是在宽限不起作用的情况下按普通上界结算的;[Windows lane 观测笔记](../testing/2026-09-27-observation-waits-on-observed-state.zh.md)记录了该调查,而[自带控制台宿主说明](2026-09-28-pty-bundled-console-host.zh.md)实测该池的尾部以已经畸形的形态到达(`\ndsh>\x1b[1C`),任何针对良构尾部的上界都覆盖不了。本字段仍然负责真正迟到的尾部。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例设置 5000 毫秒,因为需要该字段的宿主机正是运行该用例的那些,而其断言(至少六次 `stdin_read` 结算、不含 `inferred_idle`)由此度量受控提示符路径,而不是宿主机的投递时序。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
|
||||
`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例保持默认值:自托管 lane 上退化的 send 无论有没有该宽限都按普通上界结算,因此在那里设置非零值只会把尾部永不到达时的回退从每次 send 3.3 秒拉长到 8.3 秒,并把真正迟到的尾部报告成一次缓慢的通过,而不是一次点名状态的失败。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,4 +28,4 @@ Status: implemented
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 承载原生 Windows 上的端到端证据:把每个 session 的提示符尾部扣留四秒时,配置了该宽限后用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;把该宽限改为零后同一停摆失败,其中两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。
|
||||
`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。原生 Windows 上的端到端证据来自 guest 内打过补丁的 `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 副本(Windows PowerShell 5.1,2026-09-27):把每个 session 的提示符尾部扣留四秒且宽限设为 5000 毫秒时用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;宽限为零时两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。提交的用例以默认值运行,并在失败时打印逐 send 的就绪时间线,因此该状态一旦出现就会被点名,而不是从墙钟时间推断。
|
||||
+4
-1
@@ -10,7 +10,10 @@
|
||||
zh: 91844972d41dd6f9
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
|
||||
en: 125dea7427047966
|
||||
zh: 5d0bfd3921b38714
|
||||
zh: 1e459b1106597695
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
|
||||
en: 8ea2f91a914d540b
|
||||
zh: cafada631044e43a
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity:
|
||||
en: ac9b3d800889ea39
|
||||
zh: 9db1065b6027f65f
|
||||
|
||||
@@ -12,6 +12,14 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win
|
||||
|
||||
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the Line truncated
|
||||
|
||||
## ACL diagnosis and recovery
|
||||
|
||||
The bundled diagnosis skill runs its preserving ACL repairs outside the confined token because that token cannot inspect the conflicting objects. External PowerShell requires physical script files, so registration extracts resources from ASAR or SEA instead of exposing archive paths. The fiber owns that temporary copy. Recovery files live in the caller's persistent output directory so disposal of the skill does not remove the means to restore a DACL.
|
||||
|
||||
A repair backs up before mutation and verifies effective access or the resulting ACEs. Failed invocations restore attempted changes in reverse order and remain failures: continuing after a failed grant would remove access without establishing the prerequisite. Inherited package entries are repaired at their explicit sources, preserving inheritance; incomplete or out-of-scope sources prevent mutation. Labels and denies remain untouched because removing them could defeat the backend's confinement or an intentional user policy.
|
||||
|
||||
Both extracted and recovery scripts are user-writable, so this workflow never recommends elevating them. Missing unconfined WRITE_DAC stops the workflow for permission-policy review. Automatic recovery can itself fail and cannot survive process termination; reports retain pending recovery commands rather than promising an atomic transaction. The operator keeps those artifacts until verification of the original confined operation succeeds.
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
|
||||
|
||||
+9
@@ -12,6 +12,15 @@ Status: implemented
|
||||
|
||||
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
|
||||
|
||||
<a id="acl-diagnosis-and-recovery"></a>
|
||||
## ACL 诊断与恢复
|
||||
|
||||
内置诊断技能在受限令牌之外执行保留原有权限的 ACL 修复,因为受限令牌无法检查发生冲突的对象。外部 PowerShell 需要实际脚本文件,因此注册时从 ASAR 或 SEA 提取资源,而不提供归档内路径。fiber 拥有该临时副本。恢复文件保存在调用者指定的持久输出目录中,确保技能释放后仍能恢复 DACL。
|
||||
|
||||
修复在修改前备份,并验证有效权限或生成的 ACE。失败调用按相反顺序恢复尝试修改的内容,且仍视为失败:授权失败后继续操作,会在前置条件尚未满足时移除访问权限。继承的包条目从显式来源处修复,保持继承不变;来源信息不完整或超出范围时不做修改。标签与拒绝条目保持不变,因为移除它们可能破坏后端隔离或用户有意设置的策略。
|
||||
|
||||
提取的脚本和恢复脚本均可由用户写入,因此此流程绝不建议提权运行它们。不受限调用者缺少 WRITE_DAC 时,流程停止并交由权限策略审查处理。自动恢复本身也可能失败,且无法跨进程终止继续执行;报告保留待执行的恢复命令,不承诺原子事务。操作者应保留这些文件,直到原始受限操作验证成功。
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 4fb1c27a631b32d3
|
||||
zh: 9dbe9a4b9aadfca9
|
||||
/agent-note-serial-cross-platform-ci-reference/decision:
|
||||
en: 7c58d88d4c002f19
|
||||
zh: a3b961be49f2a96b
|
||||
en: 6c682d0b7b2a03ca
|
||||
zh: b75950853f45cccf
|
||||
/agent-note-serial-cross-platform-ci-reference/alternatives-considered:
|
||||
en: 3610152c5c4e93af
|
||||
zh: f4c09d327716f750
|
||||
|
||||
@@ -24,7 +24,7 @@ Platform ownership remains explicit inside that complete aggregate. `terminal-ba
|
||||
|
||||
The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
|
||||
|
||||
The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
|
||||
The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. That job grants `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`, the coverage and serial lanes' key and value, and runs the unit inventory through the `ci-unit` aggregate that consumes it, so cases that inherit Vitest's defaults keep those lanes' budget on the shared macOS runner. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
|
||||
|
||||
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ Status: implemented
|
||||
|
||||
macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也涵盖从交互式子进程继承而来的标记。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
|
||||
|
||||
独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
|
||||
独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。该作业授予与覆盖率、串行通道相同键值的 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`,并通过消费该变量的 `ci-unit` 聚合运行单元测试清单,因此继承 Vitest 默认值的用例在共享的 macOS 运行器上保有这些通道的预算。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
|
||||
|
||||
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-28-dual-release-install-layout-work-budget.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md
|
||||
/agent-note-dual-release-install-layout-work-budget:
|
||||
en: 808f7c1d11ce15b5
|
||||
zh: 861c63dd2de66452
|
||||
/agent-note-dual-release-install-layout-work-budget/problem:
|
||||
en: 46916488210ba493
|
||||
zh: 98107c3805a22b2c
|
||||
/agent-note-dual-release-install-layout-work-budget/decision:
|
||||
en: c432b68c4ad1b3c1
|
||||
zh: a11e958fa68dc2f3
|
||||
/agent-note-dual-release-install-layout-work-budget/what-the-resolution-costs:
|
||||
en: 36d3e3ef39cbba2f
|
||||
zh: 2480acf6260817b3
|
||||
/agent-note-dual-release-install-layout-work-budget/alternatives-considered:
|
||||
en: 29987b7634c84d28
|
||||
zh: a1469e4b6aaa46e2
|
||||
/agent-note-dual-release-install-layout-work-budget/consequences:
|
||||
en: 8dffc0c3fa699980
|
||||
zh: 99ec25001e5aa716
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Agent Note: Dual-release install layout work budget
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-28-dual-release-install-layout-work-budget.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `Dependency layout` lane packs two incompatible synthetic DSH releases into a temporary consumer, runs one real npm resolution over them, and asserts the physical placement npm chose. It decided pass or fail with `TIMEOUT_MS = 300_000` on the npm child process. That deadline measures the runner, not the graph: the same graph resolves in 137-205 s on an idle developer host and in 288.59 s on the CI runner, 4% below the deadline, while the placement assertion reported no error. The diagnostic named only elapsed time, so a slow runner and a graph that had grown were indistinguishable.
|
||||
|
||||
## Decision
|
||||
|
||||
`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 875_000`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant.
|
||||
|
||||
The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (910 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart.
|
||||
|
||||
This restores the [published dependency faces](2026-08-26-published-dependency-faces.md) decision that `verify-npm-install-layout` does not enforce resolver duration; the 300 s deadline had contradicted it.
|
||||
|
||||
## What the resolution costs
|
||||
|
||||
Measured 2026-09-28 on an idle M-series host, against `origin/master` (277 DSH packages per release, 2524 internal edges), by running the lane's own npm invocation with `--timing` and `--cpu-prof`:
|
||||
|
||||
| Phase | Cost |
|
||||
|---|---|
|
||||
| Registry synthesis (`buildRegistryIndex`, 3952 installed manifests plus 319 DSH workspace manifests into 1544 names) | 1.67 s |
|
||||
| npm child process | 180.66 s of 185.07 s end to end |
|
||||
| `idealTree:buildDeps` inside that child | 153.30 s, and nearly all of it lands in 14 of 877 per-node timers |
|
||||
| `checkCanPlace` / `canPlacePeers` peer-conflict analysis | 117.3 s, of which `new URL()` 65.9 s and `SemVer` 27.9 s |
|
||||
| Registry traffic | 682 requests, 383 KB of packument bodies |
|
||||
| Layout assertion and process startup | 4.4 s |
|
||||
|
||||
The same graph resolved in 1.12 s with `--legacy-peer-deps`, so peer placement is 152.7 s of the 153.8 s. `canPlacePeers` re-checks each internal edge against the incoming edges of its peer target, which makes the cost grow with placements times edge count; the nested release's copy of `@deepseek-ai/dsh-base` alone takes 48.8 s against 1.3 s for the identical package at the root of the same tree. About three quarters of the verified internal edges are peer edges (1874 of 2524), so this work is the assertion, not overhead around it.
|
||||
|
||||
Repeated runs of the same graph on one host span 137.8 s to 204.8 s of npm time, a factor of 1.5 with no input change, which is why a wall-clock threshold cannot separate a slow host from a larger graph.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Reuse one npm cache directory across runs.** The lane's `npm_config_cache` lives inside the temporary consumer, and the registry listens on an ephemeral port, so `make-fetch-happen` keys every cache entry as `http://127.0.0.1:<port>/<name>` and a persisted cache cannot hit. Re-running the full graph warm with `--prefer-offline` over the cold run's 4.4 MB cache produced a byte-identical package lock in 189.8 s, against 153.8 s cold: correct, and slower. The profile's 10.2 s of idle time is the ceiling on any network saving, because npm spends the rest of the run in its own placement code.
|
||||
|
||||
**Merge or parallelize the two releases.** The lane already resolves both releases in one npm invocation, which is what makes the co-existence claim checkable, and one release cannot reuse the other's peer analysis because `canPlacePeers` examines the whole tree. Splitting the graph into two independent resolutions would remove the shared-Cordis and cross-release placement facts the assertion exists to check.
|
||||
|
||||
**Raise `TIMEOUT_MS`.** A larger deadline leaves the failure mode in place: the verdict still depends on runner speed, and a graph that grows until it is slow on every machine still fails with no measurement of what grew.
|
||||
|
||||
**`--prefer-dedupe`.** It resolves the same graph in 126.8 s but chooses a different placement (809 lock entries against 815, eight differing entries), so the lane would assert a non-default npm placement.
|
||||
|
||||
**`--legacy-peer-deps`.** It resolves in 1.12 s and drops the 52 peer-installed packages, along with the peer edges that make up most of the assertion's coverage.
|
||||
|
||||
## Consequences
|
||||
|
||||
The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to 910 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Agent Note: 双发布安装布局的工作量预算
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-28-dual-release-install-layout-work-budget.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`Dependency layout` lane 把两个互不兼容的合成 DSH 发布打进临时消费者目录,对它们跑一次真实 npm 解析,并断言 npm 选定的物理摆放。它用 npm 子进程上的 `TIMEOUT_MS = 300_000` 判定通过或失败。这个期限度量的是 runner,而不是图:同一张图在空闲开发机上用 137–205 秒解析完成,在 CI runner 上用 288.59 秒,比期限低 4%,而摆放断言没有报告任何错误。诊断信息只给出耗时,因此 runner 慢和依赖图变大无法区分。
|
||||
|
||||
## Decision
|
||||
|
||||
`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 875_000` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。
|
||||
|
||||
npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(910 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。
|
||||
|
||||
这恢复了[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)中「`verify-npm-install-layout` 不限制 resolver 耗时」的决定;300 秒期限与该决定相矛盾。
|
||||
|
||||
## What the resolution costs
|
||||
|
||||
2026-09-28 在空闲的 M 系列主机上,针对 `origin/master`(每个发布 277 个 DSH 包、2524 条内部边),用 `--timing` 和 `--cpu-prof` 运行 lane 自身的 npm 调用测得:
|
||||
|
||||
| 阶段 | 开销 |
|
||||
|---|---|
|
||||
| Registry 合成(`buildRegistryIndex`,3952 个已安装 manifest 加 319 个 DSH workspace manifest,合成 1544 个包名) | 1.67 s |
|
||||
| npm 子进程 | 端到端 185.07 s 中的 180.66 s |
|
||||
| 该子进程内的 `idealTree:buildDeps` | 153.30 s,且几乎全部落在 877 个按节点计时器中的 14 个上 |
|
||||
| `checkCanPlace` / `canPlacePeers` peer 冲突分析 | 117.3 s,其中 `new URL()` 65.9 s、`SemVer` 27.9 s |
|
||||
| Registry 流量 | 682 次请求、383 KB packument 响应体 |
|
||||
| 布局断言与进程启动 | 4.4 s |
|
||||
|
||||
同一个依赖图加上 `--legacy-peer-deps` 后 1.12 s 解析完成,因此 peer 摆放占 153.8 s 中的 152.7 s。`canPlacePeers` 会把每条内部边与其 peer 目标的入边重新核对一遍,这使开销随「摆放次数 × 边数」增长;嵌套发布的 `@deepseek-ai/dsh-base` 副本单独耗时 48.8 s,而同一棵树上根位置的同一包只耗时 1.3 s。被校验的内部边约有四分之三是 peer 边(2524 条中的 1874 条),所以这部分工作是断言本身,而不是断言之外的开销。
|
||||
|
||||
同一台主机上对同一张图的重复运行,npm 耗时在 137.8 s 到 204.8 s 之间波动,输入没有任何变化却有 1.5 倍差距,这正是墙钟阈值无法区分「主机慢」和「图更大」的原因。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**跨次复用同一个 npm cache 目录。** lane 的 `npm_config_cache` 位于临时消费者目录内,registry 监听临时端口,因此 `make-fetch-happen` 把每个 cache 条目键为 `http://127.0.0.1:<port>/<name>`,持久化的 cache 无法命中。用冷启动那次留下的 4.4 MB cache 加 `--prefer-offline` 重跑整张图,产出的 package lock 逐字节相同,耗时 189.8 s,而冷启动为 153.8 s:结果正确,但更慢。profile 中 10.2 s 的空闲时间是任何网络节省的上限,因为 npm 把其余时间都花在自己的摆放代码里。
|
||||
|
||||
**合并或并行两个发布。** lane 已经在一次 npm 调用里解析两个发布,这正是共存声明可被校验的原因;而 `canPlacePeers` 会检查整棵树,一个发布无法复用另一个的 peer 分析。把图拆成两次相互独立的解析会丢掉断言存在的理由:共享 Cordis 与跨发布摆放。
|
||||
|
||||
**调大 `TIMEOUT_MS`。** 更大的期限保留了原有失效模式:判定仍取决于 runner 速度,而一个增长到在任何机器上都慢的图仍会失败,且不给出任何关于「什么变大了」的测量。
|
||||
|
||||
**`--prefer-dedupe`。** 它用 126.8 s 解析同一张图,但选择了不同的摆放(809 条 lock 条目对 815 条,8 条条目不同),因此 lane 断言的将是非默认的 npm 摆放。
|
||||
|
||||
**`--legacy-peer-deps`。** 它 1.12 s 完成解析,但丢掉了 52 个由 peer 安装的包,也丢掉了构成断言大部分覆盖面的 peer 边。
|
||||
|
||||
## Consequences
|
||||
|
||||
lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多 910 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。
|
||||
+6
-6
@@ -9,14 +9,14 @@
|
||||
en: 270b015afc8de8ac
|
||||
zh: 65aeb1e270d19a27
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/decision:
|
||||
en: 479cb25421102304
|
||||
zh: 9e23396728001e2c
|
||||
en: 55a0bcab15f38620
|
||||
zh: 677545442a19c7dc
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/alternatives-considered:
|
||||
en: 00e59c561085e081
|
||||
zh: 4f6ac4dfc3259f58
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/consequences:
|
||||
en: 13db32f9da19d87b
|
||||
zh: 48081fc7035dea45
|
||||
en: 46517270dd3b22b1
|
||||
zh: 94e300066237cece
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/deferred:
|
||||
en: 18dc81f98375fb04
|
||||
zh: e30413cd3d201b80
|
||||
en: 8e182739fdf90baf
|
||||
zh: 5f93f7b7d9518764
|
||||
@@ -16,7 +16,7 @@ Two self-hosted Windows lane cases observed state through a wall-clock window in
|
||||
|
||||
`observingChild` stays in flight until the test has read the run record, so the read cannot race the run's end, and its bounded wait starts at the mocked launcher's hand-over instead of at test start: the recorded run is one atomic write that follows the launch, so the bound only has to cover that write. `RECORD_WAIT_MS` (2 s) is deliberately below the case budget, so a record that never appears reports as the assertion that names it rather than as a runner timeout. Both the repair-install case and the single-run case hand the child over through the same `spawned` hook.
|
||||
|
||||
The loader-composition settle-reason assertion carries `JSON.stringify(settleReasons)` as its failure message. The threshold and the assertions are unchanged.
|
||||
The loader-composition settle-reason assertions carry the recorded reasons and a per-send readiness timeline in their failure message: every decoded pty chunk with the sanitizer's marker verdict and prompt tail, every foreground poll result, every input write, each send's settle reason and elapsed time, the session's prompt evidence replayed from those chunks, and the host, shell, PSReadLine, and console-host versions. `onTestFailed` prints the same timeline when the runner's budget ends the case before the assertions. The threshold and the assertions are unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,6 +30,10 @@ The loader-composition settle-reason assertion carries `JSON.stringify(settleRea
|
||||
|
||||
Both plugin-manager record cases now observe the record while the run is in flight on a loaded runner, and a genuinely missing record still fails the same assertion. The bounded wait costs 2 s only when the operation never writes the record.
|
||||
|
||||
[The task-detail close note](2026-09-28-detail-close-waits-on-observed-state.md) applies the same rule to a web client case, where the sampled state lands in a passive effect one commit after the list the case already awaited.
|
||||
|
||||
## Deferred
|
||||
|
||||
The loader-composition failure of 2026-09-25/26 is an environment-sensitive readiness window, not a product logic error, and the product now owns a documented tolerance for it: the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md). On Windows `isStdinWaiting` returns false and `foregroundPgid` returns the shell pid, so `stdin_read` can only come from the controlled-prompt tail. A native Windows probe (Windows 11 ARM64 guest, 8 vCPU, with the readiness probe enabled) shows the fast path is deterministic when delivery is normal: every prompt render is the OSC marker alone in one pty chunk followed by a five-byte `dsh> ` chunk 2-28 ms later (56 renders), nothing printable follows any prompt, the output carries no terminal queries, and 21 runs (baseline, six CPU burners, emulated x64, four concurrent pty-heavy instances, and Windows PowerShell 5.1 as the shell) settled all seven sends on the prompt, six `stdin_read` plus the `exit` command's `session_exit`. Withholding each session's prompt tail for four seconds reproduces the reported failure exactly: `expected 4 to be greater than or equal to 6` with `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`, the degraded settles carrying `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3301/3313 ms (exactly the configured bound), 22.1/21.9 s against 17.5/20.9 s in CI, and every output assertion still passing. The CI trigger itself was not reproduced locally, so this case keeps its assertion and supplies `promptTailGraceMs` in its composition: it measures the controlled-prompt path rather than the host's delivery timing, and it still fails when no prompt marker appears at all.
|
||||
The loader-composition failure on the self-hosted Windows lane (2026-09-25..27, issue #2487) remains open, and its mechanism is not a stalled prompt tail. The master run before the [prompt tail grace](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md) landed (run 36309006133, 2026-09-27 09:19Z) and the first run carrying it with `promptTailGraceMs: 5000` in this composition (run 36326153388, 14:30Z, reasons `["stdin_read","inferred_idle","inferred_idle","inferred_idle","inferred_idle","session_exit","inferred_idle"]`) both took 20.8 s, so the degraded sends settled at the plain `idleSilenceMs + handoffGraceMs` bound: either no marker was seen, or a marker was seen and its tail was invalidated by later printable text — the two states the tolerance does not cover. The foreground comparison is not a candidate on Windows: `WindowsProcessInspector.foregroundPgid` returns the shell pid unconditionally and `isStdinWaiting` returns false, so `shellPgid` always matches and `stdin_read` can only come from the controlled-prompt tail. The composition therefore keeps the field at its product default of `0`, and the failure message's timeline names the state directly the next time the lane fails; the 2026-09-27 12:51Z run (36320394583) instead hit the 120 s case budget before any assertion, which the same print covers.
|
||||
|
||||
Native evidence with pwsh 7.6.6 and PSReadLine 2.4.5 on Windows 11 25H2 (conhost 10.0.26100.1; the probes the tail-grace note cites ran under Windows PowerShell 5.1, the guest's only preinstalled shell): the case settles every send on the prompt at baseline, under eight CPU burners plus disk churn, under emulated x64 Node, under `vitest run --coverage --maxWorkers=1`, and with a 12-row viewport that scrolls from the second command, each prompt arriving as one in-order marker chunk followed by the five-byte tail. With an 8-row, 60-column viewport ConPTY re-emits the stored `133;D` mark inside repaint frames — five markers in one send, one followed by repainted row text (`\x07\e[Hdsh> Write-Output …`) — which leaves a tail that is not a prefix of the controlled prompt; on that console host the real prompt's marker still arrived last and the send settled `stdin_read`. That repaint path is the leading hypothesis for the lane, whose Windows build and console-host version are unknown (the job logs carry neither and the pool has no dispatchable job); the failure message records `os.release()`, `os.version()`, the pwsh and PSReadLine versions, and `conhost.exe`'s file version, and `dsh-subprocess-local` spawns node-pty without `useConptyDll`, so the system console host is the one in play. The pid `0` those probes also exposed on Windows is a separate defect, tracked by issue #5297.
|
||||
+6
-2
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
`observingChild` 会保持该 run 在进行中,直到测试读到 run 记录,因此读取不可能与 run 结束竞争;其有界等待从被 mock 的启动器交接处开始,而不是从用例开始处开始:被记录的 run 只是紧随启动之后的一次原子写入,因此等待上界只需覆盖这次写入。`RECORD_WAIT_MS`(2 秒)刻意低于用例预算,于是一条始终没有出现的记录会以指名它的断言报告,而不是以 runner 超时报告。修复安装用例与单 run 用例都通过同一个 `spawned` 钩子交接子进程。
|
||||
|
||||
loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作为失败信息。阈值与断言本身不变。
|
||||
loader-composition 的结算层级断言把记录到的结算原因与一份逐 send 的就绪时间线放进失败信息:每个解码后的 pty 分块及 sanitizer 对它的标记判定与提示符尾部、每次前台轮询的结果、每次输入写入、每次 send 的结算原因与耗时、由这些分块回放出的会话提示符证据,以及宿主机、shell、PSReadLine 与控制台宿主的版本。当 runner 预算在断言之前结束用例时,`onTestFailed` 打印同一份时间线。阈值与断言本身不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,6 +30,10 @@ loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作
|
||||
|
||||
在负载较高的 runner 上,两个插件管理器记录用例现在都能在该 run 仍在进行时观测到记录,而真正缺失的记录仍会以同一条断言失败。只有在操作始终不写记录时,这段有界等待才会付出 2 秒。
|
||||
|
||||
[任务详情关闭断言](2026-09-28-detail-close-waits-on-observed-state.zh.md) 把同一条规则用在 web 客户端用例上:被采样的状态落在该用例已经等待过的那个列表之后一个 commit 的被动 effect 里。
|
||||
|
||||
## Deferred
|
||||
|
||||
loader-composition 于 2026-09-25/26 的失败是一个对投递时序敏感的就绪窗口,而不是 product 逻辑错误;product 现在为它拥有一个文档化的容忍度:[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)。在 Windows 上 `isStdinWaiting` 返回 false、`foregroundPgid` 返回 shell 自身的 pid,因此 `stdin_read` 只能来自受控提示符尾部。原生 Windows 探测(Windows 11 ARM64 guest,8 vCPU,启用就绪探测)表明只要投递正常,快路径就是确定的:每次提示符渲染都是 OSC 标记独占一个 pty 分块,紧随其后的下一个分块是 5 字节的 `dsh> `,间隔 2–28 毫秒(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询;21 次运行(基线、6 个 CPU burner、模拟 x64、4 个并发 pty 实例、以及以 Windows PowerShell 5.1 作为 shell)全部七次 send 都按提示符结算——六次 `stdin_read` 加 `exit` 命令的 `session_exit`。把每个 session 的提示符尾部扣留 4 秒可以精确复现上报的失败:`expected 4 to be greater than or equal to 6`,`settleReasons` 为 `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`,退化的结算带着 `promptSeen` 为 true、`promptTextSeen` 为 false、空尾部与 `idleFor` 3301/3313 毫秒(正是配置的上界),耗时 22.1/21.9 秒对照 CI 的 17.5/20.9 秒,且所有输出断言仍然通过。CI 的触发条件本身未能在本地复现,因此该用例保留其断言并在组合中提供 `promptTailGraceMs`:它度量受控提示符路径而不是宿主机的投递时序,且在完全没有提示符标记时仍然失败。
|
||||
自托管 Windows lane 上 loader-composition 的失败(2026-09-25..27,issue #2487)仍未关闭,其机制不是提示符尾部停摆。[提示符尾部宽限](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)合入前的 master run(run 36309006133,2026-09-27 09:19Z)与首个带着该字段、且本组合设置了 `promptTailGraceMs: 5000` 的 run(run 36326153388,14:30Z,结算原因为 `["stdin_read","inferred_idle","inferred_idle","inferred_idle","inferred_idle","session_exit","inferred_idle"]`)都耗时 20.8 秒,因此退化的 send 是按普通的 `idleSilenceMs + handoffGraceMs` 上界结算的:要么根本没看到标记,要么看到了标记但其尾部被随后的可打印文本失效——这正是该宽限不覆盖的两种状态。前台比较在 Windows 上不是候选:`WindowsProcessInspector.foregroundPgid` 无条件返回 shell pid、`isStdinWaiting` 返回 false,因此 `shellPgid` 总是匹配,`stdin_read` 只能来自受控提示符尾部。于是该组合把字段保持在 product 默认值 `0`,下一次该 lane 失败时,失败信息里的时间线会直接点名所处的状态;2026-09-27 12:51Z 的 run(36320394583)则是在任何断言之前就撞上 120 秒的用例预算,同一份打印也覆盖这条路径。
|
||||
|
||||
在 Windows 11 25H2(conhost 10.0.26100.1)上以 pwsh 7.6.6 与 PSReadLine 2.4.5 取得的原生证据(尾部宽限笔记所引用的探测运行在 Windows PowerShell 5.1 之下,那是 guest 唯一预装的 shell):基线、8 个 CPU burner 加磁盘反复读写、模拟 x64 Node、`vitest run --coverage --maxWorkers=1`、以及从第二条命令起就滚动的 12 行视口下,用例的每次 send 都按提示符结算,每个提示符都是一个按序到达的标记分块紧跟 5 字节尾部。在 8 行 60 列的视口下,ConPTY 会在重绘帧内重新发出已存储的 `133;D` 标记——一次 send 内出现五个标记,其中一个后面紧跟重绘的行文本(`\x07\e[Hdsh> Write-Output …`)——这使尾部不再是受控提示符的前缀;在该控制台宿主上,真正提示符的标记仍然最后到达,因此该 send 仍按 `stdin_read` 结算。这条重绘路径是该 lane 的首要假设,而该 lane 的 Windows 版本与控制台宿主版本未知(job 日志两者都没有,该 pool 也没有可手动触发的 job);失败信息会记录 `os.release()`、`os.version()`、pwsh 与 PSReadLine 版本以及 `conhost.exe` 的文件版本,而 `dsh-subprocess-local` 启动 node-pty 时未设置 `useConptyDll`,因此起作用的是系统控制台宿主。这些探测同时暴露的 Windows 上 pid 为 `0` 的问题是另一个缺陷,由 issue #5297 跟踪。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-oxlint-contract-spawn-case-budgets.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md
|
||||
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling:
|
||||
en: 152f1e58efbf506e
|
||||
zh: 889504ebfe5783ca
|
||||
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/problem:
|
||||
en: 08d6430c0b4e868f
|
||||
zh: b98c15541bcea4ee
|
||||
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/decision:
|
||||
en: 34bd113b8c9228fb
|
||||
zh: 99843bf33b703b5e
|
||||
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/alternatives-considered:
|
||||
en: 2b317170890802f7
|
||||
zh: 2a5221dcfa8b9116
|
||||
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/consequences:
|
||||
en: e484a10680ca20b8
|
||||
zh: 8fd9fcf13e3dbcd7
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Oxlint contract cases that spawn a process carry a 90 s ceiling
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-oxlint-contract-spawn-case-budgets.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Twelve of the fourteen cases in [`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) spawn a real child process: `runOxlint` starts the oxlint CLI, and `runRepositoryOxlint` boots [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) under tsx, which runs oxlint and, after `--fix`, runs it once more. The root `vitest.config.ts` sets no `testTimeout`, so a case without its own budget inherits Vitest's 5000 ms default; the coverage lanes raise it through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` and the Windows native lane passes `--testTimeout 90000`, while the `unit tests (darwin parity, macos-latest)` job in `sandbox.yml` and a developer's `pnpm run test` run plain. Seven cases (five `it` declarations, one of them a three-way `it.each`) carried the explicit `90_000` that #3115 aligned the contended Windows spawn budgets to; the other seven ran on the default, and five of those spawn. Two runner classes exposed it: the self-hosted Windows pool, where process creation spikes to several seconds (#2581), and the hosted macos-latest runner of Sandbox run 36332053320, which ran the whole file 2.2× slower than the passing run 36320394290 and timed out `prints only the final diagnostics when a fix retry still fails` at 6309 ms, a case that took 1122 ms on the passing run. `Test timed out in 5000ms` names neither a contract violation nor the spawn that overran.
|
||||
|
||||
## Decision
|
||||
|
||||
Every case in the file that spawns oxlint or the repository lint entrypoint carries the same explicit per-case `90_000` ceiling. The two configuration-only cases, `keeps the complete stylistic contract in Oxlint` and `keeps repository lint workflows Oxlint-only`, spawn nothing and keep the default. The value is a completion ceiling for a hang, not a latency target: the slowest case on the slow darwin runner took 9507 ms, and the file under six-fold CPU oversubscription on a 12-core host peaked at 20767 ms, both under a quarter of the ceiling. Assertions, the absence of retries, and Vitest's global `testTimeout` are unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A `describe`-level `{ timeout: 90_000 }`.** Rejected: case-level values already bound seven cases and take precedence over a `describe` value, so a suite-level number would govern only the remaining seven, including the two configuration-only cases that spawn nothing, and the file would carry two budget styles. Per-case values keep one style, one place to read each case's ceiling, and the default on the cases that spawn nothing. Either form overrides the lane's `--testTimeout` rather than yielding to it (#2677). The archived [translation-pairing-merge](../../archived/testing/2026-08-27-translation-pairing-merge-budget.md) and [Lefthook budget](../../archived/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md) notes chose the `describe` form for files whose cases carried no constants of their own, because a case added later without an allowance inherits the default; this file already carried per-case constants, so that risk is accepted here and named under Consequences.
|
||||
|
||||
**Passing `--testTimeout 90000` to the darwin parity job and letting the cases inherit the lane budget**, the form the [subagent teardown budgets](2026-09-07-subagent-teardown-test-budgets.md) note prefers for suites bound by process creation. Rejected: the cost is the file's own tsx boot plus two oxlint runs, not a darwin property, so a developer's plain `pnpm run test` would keep the default; the parity job exists to run the same command developers run; and seven cases already carry constants, so a lane flag would split the file's budget between two sources.
|
||||
|
||||
**Raising Vitest's global `testTimeout`.** Rejected: the whole unit inventory runs on the default; widening it to absorb one file's process spawns removes the hang detection the default provides everywhere else.
|
||||
|
||||
**A tighter value sized to the observed 6.3 s.** Rejected: the Windows contention fires as multi-second spikes that rotated across cases under 15–30 s budgets before the 90 s alignment; a smaller number moves the flake rather than removing it.
|
||||
|
||||
## Consequences
|
||||
|
||||
A contract violation still fails through its own assertion well inside the ceiling; the five cases bounded here settle in 0.5–1.7 s on an idle host and no spawning case in the file exceeded 3.8 s there, so only a genuine hang waits the full 90 s. The Windows self-hosted lane, the Linux coverage lanes, the darwin parity job, and a local `pnpm run test` read the same ceiling for this file regardless of `DSH_COVERAGE_TEST_TIMEOUT_MS`. A spawning case added later without its own `90_000` runs on the default until the file's inventory is checked. Under 72 busy-loop processes on a 12-core host the unbounded fix-retry case reproduced `Test timed out in 5000ms` at 7084 ms before the change and passes with the ceiling after it.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: 会 spawn 进程的 Oxlint 契约用例携带 90 s 上限
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-oxlint-contract-spawn-case-budgets.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) 的 14 个用例中有 12 个会启动真实子进程:`runOxlint` 启动 oxlint CLI,`runRepositoryOxlint` 在 tsx 下引导 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts),后者运行 oxlint,并在 `--fix` 之后再运行一次。根 `vitest.config.ts` 没有设置 `testTimeout`,因此没有自带预算的用例继承 Vitest 的 5000 ms 默认值;coverage 通道通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 抬高它,Windows 原生通道传入 `--testTimeout 90000`,而 `sandbox.yml` 中的 `unit tests (darwin parity, macos-latest)` job 与开发者本机的 `pnpm run test` 都直接运行。7 个用例(5 个 `it` 声明,其中一个是三路 `it.each`)携带 #3115 把有争抢的 Windows spawn 预算对齐到的显式 `90_000`;另外 7 个跑在默认值上,其中 5 个会 spawn。两类 runner 暴露了这一点:自托管 Windows 池,其进程创建会出现数秒尖峰(#2581);以及 Sandbox run 36332053320 的托管 macos-latest runner,它跑整个文件比通过的 run 36320394290 慢 2.2 倍,并让 `prints only the final diagnostics when a fix retry still fails` 在 6309 ms 时超时,而该用例在通过的 run 中耗时 1122 ms。`Test timed out in 5000ms` 既不指出契约违规,也不指出超时的那次 spawn。
|
||||
|
||||
## 决策
|
||||
|
||||
文件中每个会 spawn oxlint 或仓库 lint 入口的用例都携带同一个显式的用例级 `90_000` 上限。两个只检查配置的用例 `keeps the complete stylistic contract in Oxlint` 与 `keeps repository lint workflows Oxlint-only` 不 spawn 任何进程,保持默认值。该值是针对挂起的完成上限,不是延迟目标:慢 darwin runner 上最慢的用例耗时 9507 ms,12 核宿主机上六倍 CPU 超订下整个文件的峰值为 20767 ms,两者都不到上限的四分之一。断言、不加重试、以及 Vitest 全局 `testTimeout` 均不变。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**`describe` 级 `{ timeout: 90_000 }`。** 拒绝:用例级取值已经约束 7 个用例并且优先于 `describe` 级取值,因此套件级数字只会作用于其余 7 个用例,包括两个不 spawn 的纯配置用例,而且文件会同时带两种预算风格。用例级取值保持一种风格,每个用例的上限只在一处可读,不 spawn 的用例保持默认值。两种形式都覆盖通道的 `--testTimeout` 而不是让位于它(#2677)。已归档的 [translation-pairing-merge](../../archived/testing/2026-08-27-translation-pairing-merge-budget.md) 与 [Lefthook 预算](../../archived/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md)两篇 Note 为用例本身不带常量的文件选择了 `describe` 形式,理由是之后新增而未带余量的用例会继承默认值;本文件已经带有用例级常量,因此这里接受该风险并在「后果」中点名。
|
||||
|
||||
**给 darwin parity job 传入 `--testTimeout 90000`,让用例继承通道预算**,这是[子代理清理预算](2026-09-07-subagent-teardown-test-budgets.zh.md)一篇对进程创建受限套件偏好的形式。拒绝:成本来自本文件自身的 tsx 引导加两次 oxlint 运行,不是 darwin 的属性,因此开发者直接运行的 `pnpm run test` 仍会停留在默认值;parity job 的存在意义就是运行与开发者相同的命令;而且 7 个用例已带常量,通道旗标会把本文件的预算拆到两个来源。
|
||||
|
||||
**抬高 Vitest 全局 `testTimeout`。** 拒绝:整个单元测试清单都跑在默认值上;为吸收一个文件的进程 spawn 而放宽它,会移除默认值在其他所有地方提供的挂起检测。
|
||||
|
||||
**按观测到的 6.3 s 收紧取值。** 拒绝:Windows 争抢表现为数秒尖峰,在对齐到 90 s 之前曾在 15–30 s 预算下于各用例间轮转出现;更小的数字只是移动 flake 而不是消除它。
|
||||
|
||||
## 后果
|
||||
|
||||
契约违规仍通过其自身断言在上限之内早早失败;本次加边界的 5 个用例在空载宿主机上 0.5–1.7 s 内结算,文件中任何会 spawn 的用例在那里都不超过 3.8 s,因此只有真正的挂起才会等满 90 s。Windows 自托管通道、Linux coverage 通道、darwin parity job 与本机 `pnpm run test` 对本文件读到同一个上限,与 `DSH_COVERAGE_TEST_TIMEOUT_MS` 无关。之后新增而未自带 `90_000` 的 spawn 用例会跑在默认值上,直到有人核对本文件的清单。在 12 核宿主机上以 72 个忙循环进程加载时,原本无边界的 fix-retry 用例在改动前于 7084 ms 复现 `Test timed out in 5000ms`,改动后在上限内通过。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-plugin-install-interceptor-lifetime.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md
|
||||
/agent-note-keep-plugin-install-interception-for-the-page-lifetime:
|
||||
en: 2f732d10bfaf9f5b
|
||||
zh: fff9f6cf8bcd86a7
|
||||
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/problem:
|
||||
en: 71b72bb5b13530bb
|
||||
zh: b78170e3ac09cccf
|
||||
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/decision:
|
||||
en: 45d689dced9dad9f
|
||||
zh: cb89fbbb7937ca8e
|
||||
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/alternatives-considered:
|
||||
en: 1027b59d76cd3ff7
|
||||
zh: c4c60fb406acb1db
|
||||
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/verification:
|
||||
en: 0e7fbf7fbb47b07c
|
||||
zh: 9b52991ad8b355ca
|
||||
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/consequences:
|
||||
en: 3583e26c8ec33e03
|
||||
zh: 6080722751cf15e6
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Keep plugin-install interception for the page lifetime
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-plugin-install-interceptor-lifetime.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [plugin-install scenario](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) can leave a directory read pending after cancelling an installation. The Host emits invalidations that trigger background reads while the scenario removes its Playwright routes between installation phases. A stalled read keeps the installed package out of the list, so enabling it closes the dialog but never exposes the highlighted card.
|
||||
|
||||
## Decision
|
||||
|
||||
The scenario registers its `installBundle` and `cancelInstall` interceptors before navigation and retains them until the browser closes. Each interceptor calls a typed handler that the scenario replaces when it changes phases. The delivery and cancellation barriers retain their ordering, and `activeReplySettled` joins the lost-response handler before the next installation starts. Ordinary requests still reach the real Host.
|
||||
|
||||
The [input-state decision](2026-09-27-web-lane-assertions-name-their-input-state.md) owns the separate wait for the highlighted card's status to become `running`. Keeping interception active lets the directory read complete; polling its result still establishes the state the golden records.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Remove routes with `unrouteAll({ behavior: 'wait' })`.** Waiting for matched handlers does not settle unrelated browser requests. A captured Chromium protocol trace shows `Fetch.disable` immediately followed by a `pluginManager/listPlugins` request with no response or failure before teardown. The Host's inventory and plugin methods still answer direct calls.
|
||||
|
||||
**Advance the browser clock or extend the locator timeout.** Advancing the paused clock does not release the stalled request. The 2.4-second highlight expiry is not responsible for a package that never reaches the directory.
|
||||
|
||||
**Add a general route-switching helper.** Two endpoint handlers in one scenario do not need another fixture API. Their local delegates preserve the existing cancellation and lost-response cases without changing the browser's interception configuration.
|
||||
|
||||
## Verification
|
||||
|
||||
The unchanged scenario passes alone and fails at the same highlighted-card locator in one of four concurrent independent processes. Instrumented failures place the unanswered directory request inside the second `unrouteAll` interval; advancing the page clock leaves it unanswered, while direct Host reads complete. The protocol trace identifies the interception transition independently of the DOM assertion.
|
||||
|
||||
`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/plugin-install-cancel.e2e.ts` passes in four concurrent independent processes (1/1 each). One protocol capture observes one `Fetch.enable`, no `Fetch.disable`, and responses for all 33 directory requests. The existing goldens cover cancellation before delivery, cancellation of the running child, manifest and lockfile restoration, retry, enabled-card status, both themes and motion preferences, highlight expiry, and lost-result recovery.
|
||||
|
||||
## Consequences
|
||||
|
||||
The browser retains two narrowly matched interceptors for its lifetime. The scenario controls their behavior without interrupting concurrent directory reads, and browser closure owns their cleanup. Product code, goldens, test deadlines, and CI worker counts are unchanged.
|
||||
|
||||
Live-page `unrouteAll` calls also remain in [sidebar-terminal](../../../../apps/web/tests/sidebar-terminal.e2e.ts), [queue-image](../../../../apps/web/tests/queue-image.e2e.ts), [subagent-conversation](../../../../apps/web/tests/subagent-conversation.e2e.ts), and [subagent-interrupt-ui](../../../../apps/web/tests/subagent-interrupt-ui.e2e.ts). These are candidates for separate overlap investigations; this scenario's reproduction does not establish the cause of failures in those tests.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 让插件安装拦截器持续到页面关闭
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-plugin-install-interceptor-lifetime.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[插件安装场景](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) 在取消安装后,可能留下一次未结束的目录读取。场景在安装阶段之间移除 Playwright 路由时,Host 发出的失效通知仍会触发后台读取。停滞的读取使已安装的包无法进入列表,因此启用操作虽然关闭了对话框,却始终不显示高亮卡片。
|
||||
|
||||
## Decision
|
||||
|
||||
场景在导航前注册 `installBundle` 和 `cancelInstall` 拦截器,并保留到浏览器关闭。每个拦截器调用一个带类型的处理函数,场景切换阶段时替换该函数。请求交付和取消屏障保持原有顺序,`activeReplySettled` 在下一次安装开始前等待丢失响应的处理函数结束。普通请求仍然到达真实 Host。
|
||||
|
||||
[输入状态决策](2026-09-27-web-lane-assertions-name-their-input-state.zh.md) 负责另一项等待:高亮卡片的状态变为 `running`。持续启用拦截使目录读取能够完成;轮询其结果仍负责确立预期输出记录的状态。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**用 `unrouteAll({ behavior: 'wait' })` 移除路由。** 等待匹配的处理函数结束并不意味着其他浏览器请求已结束。捕获的 Chromium 协议记录显示,`Fetch.disable` 后紧接着发出 `pluginManager/listPlugins` 请求,直到清理前都没有响应或失败事件。直接调用 Host 的目录和插件方法仍能得到答复。
|
||||
|
||||
**推进浏览器时钟或延长 locator 超时。** 推进已暂停的时钟不能释放停滞的请求。包始终未进入目录,不是 2.4 秒的高亮到期计时器造成的。
|
||||
|
||||
**增加通用路由切换辅助函数。** 一个场景中的两个端点处理函数不需要另一层 fixture(测试前置数据)API。局部委托函数保留了现有取消和丢失响应场景,也不改变浏览器的拦截配置。
|
||||
|
||||
## Verification
|
||||
|
||||
未修改的场景单独运行通过,四个并发独立进程中有一个在同一高亮卡片 locator 处失败。插桩失败将未获响应的目录请求定位到第二次 `unrouteAll` 的执行区间内;推进页面时钟后请求仍未获响应,而直接读取 Host 可以完成。协议记录独立于 DOM 断言确认了拦截状态的切换。
|
||||
|
||||
`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/plugin-install-cancel.e2e.ts` 在四个并发独立进程中全部通过,每个进程均为 1/1。一次协议记录观察到一次 `Fetch.enable`、零次 `Fetch.disable`,且全部 33 次目录请求均收到响应。现有预期输出覆盖取消先于请求交付、取消运行中的子进程、恢复 manifest(元数据清单)与 lockfile、重试、已启用卡片状态、两种主题与动画偏好、高亮到期,以及丢失结果恢复。
|
||||
|
||||
## Consequences
|
||||
|
||||
浏览器在整个生命周期内保留两个精确匹配的拦截器。场景控制其行为时不会中断并发目录读取,浏览器关闭负责清理它们。产品代码、预期输出、测试超时和 CI worker 数保持不变。
|
||||
|
||||
[sidebar-terminal](../../../../apps/web/tests/sidebar-terminal.e2e.ts)、[queue-image](../../../../apps/web/tests/queue-image.e2e.ts)、[subagent-conversation](../../../../apps/web/tests/subagent-conversation.e2e.ts) 和 [subagent-interrupt-ui](../../../../apps/web/tests/subagent-interrupt-ui.e2e.ts) 中也仍有页面存续期间的 `unrouteAll` 调用。这些位置可分别调查请求重叠;本场景的复现并不能确定那些测试失败的原因。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md
|
||||
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions:
|
||||
en: 015053d7e4cc8b74
|
||||
zh: 8afd22975bcb9814
|
||||
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/problem:
|
||||
en: 27ce4e01ca063f29
|
||||
zh: cd8c872750c725bc
|
||||
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/decision:
|
||||
en: 49b7f008e97f05cb
|
||||
zh: 00571e867ba82057
|
||||
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/alternatives-considered:
|
||||
en: 51c1315b1d424915
|
||||
zh: 2f2f537cfdde2469
|
||||
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/verification:
|
||||
en: 316dbc106810d68f
|
||||
zh: 8771fd22b885e4ab
|
||||
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/consequences:
|
||||
en: 03eedcc3cfdad180
|
||||
zh: 8d4843e01448fe83
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Web lane moves spreadsheet selections by keyboard before reflow assertions
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) opens `meeting.xlsx`, clicks the sheet overlay at `{ x: 60, y: 40 }` to select `A1`, then widens and narrows the sidebar four times and asserts after each reflow that the name box still reads `A1`. Ten of the eleven `CI master` runs of the serial self-hosted Linux lane between 2026-09-24 and 2026-09-27 failed one or both locale cases of that scenario with `expected 'B1' to be 'A1'` at the first reflow, and hosted `node 24 / snapshots and artifacts` runs failed the same way (runs 36110572677 and 36213089675). The failure is not locale-bound: en-US failed alone in four master runs, both locales in three, zh-CN alone in three. Polling the read (#5243) changed nothing because `B1` is the committed selection, not a stale sample.
|
||||
|
||||
`A1` is the workbook's initial selection, so the polls that follow the click are satisfied before the click commits and never verify what the click selected. FortuneSheet's `SheetOverlay` handles the press as `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))`, and `handleCellAreaMouseDown` resolves the cell from `cellArea.getBoundingClientRect()` and the event's page coordinates at the moment the updater runs. React runs `useState` updaters during render and runs them again when the hook queue holds a lower-priority update ahead of them; the freshly mounted workbook still has such updates pending, and Playwright precedes the press with a `mousemove` whose FortuneSheet handler is a continuous-priority update on the same state. An instrumented verbatim copy of the scenario on the Blacksmith runner recorded four executions of `handleCellAreaMouseDown` for the one click: two before the click returned reading `left=969.5`, and two after the scenario had already set the panel to `1000px`, reading `left=725.5`. The stored pointer x of 985 then resolves to 259.5 inside the grid, and because the meeting sheet has two columns `colLocation` clamps it to the last one: `B1`. On a machine that commits the workbook's initial selection normalization before the click, the same point lies on A1's own selection box, whose `onMouseDown` stops propagation; the click then selects nothing, no geometry-bound updater is queued, and the scenario passes without having tested the click.
|
||||
|
||||
## Decision
|
||||
|
||||
The meeting-sheet step selects by keyboard. The scenario focuses the sheet overlay (`tabindex="-1"`), asserts the focus landed, presses `ArrowRight` and asserts `B1` with an empty formula bar, then presses `ArrowLeft` and asserts `A1` with `会议纪要`, and only then starts the reflow loop. `handleArrowKey` → `moveHighlightCell` computes the target from committed state — the current selection and `visibledatacolumn` — so a re-executed updater yields the same cell whatever the grid's geometry is by then, and the two moves make the interaction observable because `B1` differs from the initial selection. The reflow loop, its `A1` assertion, the sheet-tab scroll-control counts, and the canvas identity check are unchanged.
|
||||
|
||||
The budget-sheet click at the start of the scenario stays a pointer click: it targets a cell other than the initial selection, its result is verified by the `46281` formula read, and keyboard, clipboard, and layout reads separate it from the first reflow by far more than one deferred render. The `values.csv` click followed by a narrowing stays too: a re-execution against the narrowed grid resolves a negative x, which `colLocation` clamps to column A, the same cell.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Poll the name box after each reflow.** #5243 did this. The re-executed updater's result is the final committed state, so the poll observes `B1` for its whole budget; there is no later state to wait for.
|
||||
|
||||
**Wait for the selection box to render before clicking.** That restores the passing path in which the click lands on A1's own drag handle and selects nothing, so the assertions would verify the initial selection rather than an interaction. Clicking a cell other than the initial selection instead keeps the geometry-bound updater and its re-execution window open across the reflow that follows.
|
||||
|
||||
**Wait for the renderer to go idle before the first reflow.** No product state names "React has no re-execution pending"; an idle or fixed wait sized to the runner is the flake-masking wait [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) rejects.
|
||||
|
||||
**Extend the `@fortune-sheet/react` patch to resolve the cell outside the updater.** `handleCellAreaMouseDown` lives in `@fortune-sheet/core`, and the window a user would need to hit is a pointer press and a sidebar reflow inside one deferred render. It stays an upstream concern; the product change is out of this test fix.
|
||||
|
||||
## Verification
|
||||
|
||||
The instrumented verbatim copy of the shipped scenario, run on the hosted Linux lane of PR #5291 while it was a draft, reproduced `expected 'B1' to be 'A1'` at the first reflow in 7 of 38 runs across three lane runs (2 of 6, 1 of 6, 4 of 20). Every failure recorded `mousedown` targeting `#luckysheet-sheettable_0` rather than the drag handle and the `B1` commit preceding the deferred `cellInput.focus()` that only the press updater schedules; the five failures in the two lane runs that also hooked the cell area's `getBoundingClientRect` all carried the four-execution signature above. The keyboard-driven copy ran in those same two lane runs and passed 26 of 26. Locally, `Emulation.setCPUThrottlingRate` at 20× makes the click reach the cell area and records three executions of `handleCellAreaMouseDown` per click; the keyboard-driven copy passes 6 of 6 under that throttle and 6 of 6 with React's scheduler wake-ups deferred by 200 ms.
|
||||
|
||||
`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` passed both locale cases in 10 of 10 local repetitions, and the whole file passed its 6 cases in 2 of 3 local runs; the third failed the unrelated `Coding Tools` switch poll at `:849` while documentation gates ran concurrently, with the failure screenshot showing the switch on.
|
||||
|
||||
## Consequences
|
||||
|
||||
The scenario verifies a keyboard-made selection surviving sidebar reflows and no longer depends on where React's deferred render lands relative to the reflow. A pointer click in this lane remains valid when its target differs from the current selection and its effect is asserted before any layout change, or when a re-execution against the changed layout provably resolves the same cell; a click whose result feeds a reflow assertion within a few round trips does not. FortuneSheet's geometry-bound updater is unchanged, so a real pointer press followed within one deferred render by a sidebar reflow can still move the selection in the product.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Web lane moves spreadsheet selections by keyboard before reflow assertions
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) 打开 `meeting.xlsx`,在表格 overlay 的 `{ x: 60, y: 40 }` 处点击以选中 `A1`,随后把侧栏加宽、收窄各两次,并在每次重排后断言名称框仍显示 `A1`。2026-09-24 到 2026-09-27 之间 `CI master` 的 serial 自托管 Linux lane 的 11 次运行中有 10 次在第一次重排处以 `expected 'B1' to be 'A1'` 失败该场景的一个或两个 locale 用例,托管的 `node 24 / snapshots and artifacts` 也以同样方式失败(run 36110572677 与 36213089675)。失败与 locale 无关:四次 master 运行只有 en-US 失败,三次两者都失败,三次只有 zh-CN 失败。把读取改为轮询(#5243)没有作用,因为 `B1` 是已提交的选区,不是过期采样。
|
||||
|
||||
`A1` 是工作簿的初始选区,所以点击之后的两个轮询在点击提交前就已满足,从未验证点击选中了什么。FortuneSheet 的 `SheetOverlay` 以 `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))` 处理按下,而 `handleCellAreaMouseDown` 在 updater 执行的那一刻用 `cellArea.getBoundingClientRect()` 与事件的页面坐标解析目标单元格。React 在渲染期间运行 `useState` 的 updater,并且当 hook 队列里有更低优先级的更新排在它前面时会再次运行它;刚挂载的工作簿仍有这类更新待处理,而 Playwright 在按下之前先派发一次 `mousemove`,其 FortuneSheet 处理器是同一状态上的 continuous 优先级更新。在 Blacksmith runner 上运行的带探针的逐字副本记录到这一次点击触发了四次 `handleCellAreaMouseDown`:两次在点击返回前读到 `left=969.5`,两次在场景已把面板设为 `1000px` 之后读到 `left=725.5`。保存下来的指针 x 985 于是在网格内解析为 259.5,而会议表只有两列,`colLocation` 把它钳到最后一列:`B1`。在点击前就已提交工作簿初始选区归一化的机器上,同一位置落在 A1 自身的选区框上,其 `onMouseDown` 阻止了传播;点击于是什么也没选中,没有几何绑定的 updater 入队,场景在没有测试到点击的情况下通过。
|
||||
|
||||
## Decision
|
||||
|
||||
会议表这一步改为键盘选择。场景聚焦表格 overlay(`tabindex="-1"`),断言焦点已落在其上,按 `ArrowRight` 并断言 `B1` 且公式栏为空,再按 `ArrowLeft` 并断言 `A1` 且公式栏为 `会议纪要`,然后才开始重排循环。`handleArrowKey` → `moveHighlightCell` 只依据已提交状态(当前选区与 `visibledatacolumn`)计算目标,所以被重新执行的 updater 无论此时网格几何如何都得到同一单元格,而两次移动使交互可观察,因为 `B1` 与初始选区不同。重排循环、其中的 `A1` 断言、表页滚动控件计数与 canvas 身份检查保持不变。
|
||||
|
||||
场景开头预算表的点击仍是指针点击:它的目标不是初始选区,其结果由 `46281` 公式读取验证,而键盘、剪贴板和布局读取把它与第一次重排隔开远超一次延迟渲染。`values.csv` 的点击及其后的收窄也保持不变:针对收窄后的网格重新执行会解析出负的 x,`colLocation` 把它钳到 A 列,即同一单元格。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**每次重排后轮询名称框。** #5243 这样做了。被重新执行的 updater 的结果就是最终提交状态,所以轮询在整个预算内都观察到 `B1`;没有更晚的状态可等。
|
||||
|
||||
**点击前等待选区框渲染。** 这会恢复点击落在 A1 自身拖拽手柄上、什么也不选的通过路径,断言于是验证的是初始选区而不是一次交互。改为点击初始选区以外的单元格则会保留几何绑定的 updater,其重新执行窗口跨越随后的重排。
|
||||
|
||||
**第一次重排前等待渲染器空闲。** 没有产品状态能表达“React 没有待重新执行的更新”;按 runner 调校的空闲等待或固定等待正是 [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) 拒绝的掩盖式等待。
|
||||
|
||||
**扩展既有的 `@fortune-sheet/react` 补丁,在 updater 之外解析单元格。** `handleCellAreaMouseDown` 位于 `@fortune-sheet/core`,而用户需要命中的窗口是一次指针按下与一次侧栏重排落在同一次延迟渲染内。它仍是上游问题;产品改动不在本次测试修复范围内。
|
||||
|
||||
## Verification
|
||||
|
||||
PR #5291 处于草稿阶段时在托管 Linux lane 上运行的带探针逐字副本,在三次 lane 运行共 38 次中有 7 次(2/6、1/6、4/20)在第一次重排处复现 `expected 'B1' to be 'A1'`。每次失败都记录到 `mousedown` 的目标是 `#luckysheet-sheettable_0` 而不是拖拽手柄,且 `B1` 的提交先于只有按下 updater 才会安排的延迟 `cellInput.focus()`;在同时给 cell area 的 `getBoundingClientRect` 加了钩子的两次 lane 运行里,5 次失败全部带有上述四次执行的特征。键盘驱动的副本在这两次 lane 运行中 26 次全部通过。本地以 20× 的 `Emulation.setCPUThrottlingRate` 运行可让点击到达 cell area,并记录到每次点击三次 `handleCellAreaMouseDown` 执行;键盘驱动的副本在该限速下 6 次全部通过,在 React 调度器唤醒被推迟 200 ms 时也 6 次全部通过。
|
||||
|
||||
`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` 在本地 10 次重复中 10 次都通过两个 locale 用例;整个文件的 6 个用例在 3 次本地运行中 2 次通过,第 3 次在文档 gate 并行运行时失败于无关的 `:849` `Coding Tools` 开关轮询,失败截图显示开关已打开。
|
||||
|
||||
## Consequences
|
||||
|
||||
场景验证的是键盘做出的选区在侧栏重排后仍然保留,不再依赖 React 的延迟渲染落在重排之前还是之后。此 lane 中的指针点击在目标不同于当前选区、且其效果在任何布局变化之前被断言时仍然有效,或者在针对变化后布局重新执行可证明解析为同一单元格时也有效;而结果在几个往返之内就被送入重排断言的点击则无效。FortuneSheet 的几何绑定 updater 未改动,所以产品中一次真实的指针按下若在一次延迟渲染内紧跟着侧栏重排,仍可能移动选区。
|
||||
+5
-5
@@ -13,10 +13,10 @@
|
||||
zh: 4fe1f6abd6fd4c1e
|
||||
/agent-note-web-lane-assertions-name-their-input-state/alternatives-considered:
|
||||
en: 6cd5940f55500c3d
|
||||
zh: ded68b931ed81e10
|
||||
zh: 4a2e13717e87b783
|
||||
/agent-note-web-lane-assertions-name-their-input-state/verification:
|
||||
en: 6d11abb7a60f81ae
|
||||
zh: 42bc43777a3b02b7
|
||||
en: 9a35f9dbc4285674
|
||||
zh: 0995a8b137912b6b
|
||||
/agent-note-web-lane-assertions-name-their-input-state/consequences:
|
||||
en: 2ecf084ca4df49c4
|
||||
zh: bbd8823fd0910609
|
||||
en: ad3d8ec4b171b04a
|
||||
zh: e254cc9515b33e0e
|
||||
+3
-1
@@ -40,10 +40,12 @@ Two negative controls reproduce the recorded signatures with the shipped asserti
|
||||
|
||||
Measured locally, one file at a time: `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 15/15 before the change and 20/20 after, turn-tail-actions 8/8 before and 20/20 after, plugin-install-cancel 12/12 before and 20/20 after. The lane configuration `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` passes all three files in three consecutive local runs.
|
||||
|
||||
The install-cancel scenario carries a residual exposure this change does not remove and whose cause is not diagnosed: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command, the highlighted card locator at `plugin-install-cancel.e2e.ts:217` stayed unmatched for 30 s after the enable click closed the dialog. The paused page clock rules out the highlight expiry timer, so the wait is not racing that; no local focused run or the three lane runs above reproduced it, and nothing here is known to hang the store's `load()`. The poll added by this change covers the status read after the card appears, not this locator.
|
||||
The install-cancel scenario also recorded a separate 30 s highlighted-card wait after the enable click closed the dialog: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command. The page clock was paused, excluding highlight expiry. The [interceptor-lifetime decision](2026-09-27-plugin-install-interceptor-lifetime.md) owns the diagnosed directory-request stall and its fixture fix. The status poll still covers the read after the card appears.
|
||||
|
||||
The WebKit case the serial master lane also failed is not a scenario race and stays out of this change: `declared-reasoning.e2e.ts` launches Playwright's WebKit, the lane's step fetched that browser without its host libraries, and `browserType.launch` failed with "Host system is missing dependencies to run browsers". [ci.yml](../../../../.github/workflows/ci.yml) states the persistent VM image owns Playwright's Linux system packages and installs the dependency set only off the self-hosted pool, so repairing the image or the pool's ownership is a runner change, not a scenario one. Installing WebKit's libraries on a developer machine makes the file's 8 cases pass.
|
||||
|
||||
## Consequences
|
||||
|
||||
The three scenarios render the aria their goldens record whatever state the harness arrives in, and no golden, product surface, or timeout changed. The input state is now owned by each capture: a scenario that captures a hovered row, a focused Tooltip anchor, or a value published by a later Host read must establish that state itself. `document-preview.e2e.ts`'s spreadsheet-selection read has the same class of defect and stays with its own change.
|
||||
|
||||
[The task-detail close note](2026-09-28-detail-close-waits-on-observed-state.md) records the same class in the client unit lane, where the sampled state arrived through a post-commit effect rather than through an unsettled input.
|
||||
+4
-2
@@ -32,7 +32,7 @@ install-cancel 场景改为轮询卡片状态,而不再单次采样,因此
|
||||
|
||||
**在场景内禁用悬停样式或 tooltip 的模态规则。** 两者都是 golden 所证明的已发布渲染与交互行为;场景级覆盖将测试一个产品并不提供的页面。
|
||||
|
||||
**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture 前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
|
||||
**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture(测试前置数据)前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -40,10 +40,12 @@ install-cancel 场景改为轮询卡片状态,而不再单次采样,因此
|
||||
|
||||
本地按文件实测:`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 改动前 15/15、改动后 20/20,turn-tail-actions 改动前 8/8、改动后 20/20,plugin-install-cancel 改动前 12/12、改动后 20/20。通道配置 `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` 连续三次运行中三个文件全部通过。
|
||||
|
||||
install-cancel 场景仍带有本次改动未消除、且原因尚未定位的残余暴露:`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都在启用点击关闭对话框后让 `plugin-install-cancel.e2e.ts:217` 的高亮卡片 locator 持续 30 s 未匹配。页面时钟已暂停,因此可排除高亮到期计时器,该等待并非与它竞争;上述三次通道运行与所有单文件运行都未复现,且尚无证据表明 store 的 `load()` 会挂起。本次改动新增的轮询覆盖的是卡片出现之后的状态读取,而不是这个 locator。
|
||||
install-cancel 场景还记录过独立的 30 s 高亮卡片等待:启用点击关闭对话框后,`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都未能找到卡片。当时页面时钟已暂停,因此可排除高亮到期。[拦截器生命周期决策](2026-09-27-plugin-install-interceptor-lifetime.zh.md) 负责已定位的目录请求停滞及其 fixture 修复。状态轮询仍覆盖卡片出现之后的读取。
|
||||
|
||||
串行 master 通道同时失败的 WebKit 用例不是场景竞态,也不属于本次改动:`declared-reasoning.e2e.ts` 启动 Playwright 的 WebKit,通道的步骤只取浏览器本体而未安装宿主库,于是 `browserType.launch` 以 “Host system is missing dependencies to run browsers” 失败。[ci.yml](../../../../.github/workflows/ci.yml) 明确说明持久 VM 镜像拥有 Playwright 的 Linux 系统包,并且只在非自托管池随浏览器安装依赖集,因此修复镜像或该池的归属是 runner 侧的改动,而非场景改动。在开发者机器上安装 WebKit 依赖后该文件的 8 个用例全部通过。
|
||||
|
||||
## Consequences
|
||||
|
||||
三个场景无论测试框架以何种状态进入,都渲染其 golden 记录的 aria,且没有任何 golden、产品界面或超时被改动。输入状态现在由每次捕获自己拥有:凡是捕获悬停行、聚焦的 Tooltip 锚点,或由后续 Host 读取发布的值的场景,都必须自行确立该状态。`document-preview.e2e.ts` 的电子表格选区读取属于同一类缺陷,仍留给它自己的改动。
|
||||
|
||||
[任务详情关闭断言](2026-09-28-detail-close-waits-on-observed-state.zh.md) 记录了同一类缺陷在客户端单测 lane 上的实例:被采样的状态来自一个提交后 effect,而不是来自尚未结算的输入。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-28-detail-close-waits-on-observed-state.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md
|
||||
/agent-note-the-task-detail-close-waits-on-the-observed-state:
|
||||
en: ffb94d33eb1c162c
|
||||
zh: 32845a4113ab27c1
|
||||
/agent-note-the-task-detail-close-waits-on-the-observed-state/problem:
|
||||
en: 0a3fa08f242a0f7e
|
||||
zh: fb8c39531405952b
|
||||
/agent-note-the-task-detail-close-waits-on-the-observed-state/decision:
|
||||
en: 3e4c20e32e3e9445
|
||||
zh: 157750a7e1f1fd59
|
||||
/agent-note-the-task-detail-close-waits-on-the-observed-state/alternatives-considered:
|
||||
en: 8ee7951bdf933b45
|
||||
zh: 4736d42c1d52c9b0
|
||||
/agent-note-the-task-detail-close-waits-on-the-observed-state/consequences:
|
||||
en: c0993f90f7af9c41
|
||||
zh: 6a877cb1287000e3
|
||||
/agent-note-the-task-detail-close-waits-on-the-observed-state/related:
|
||||
en: f35c1fe1c2ff83b3
|
||||
zh: 56a3357ab5a67822
|
||||
@@ -0,0 +1,44 @@
|
||||
# Agent Note: The task-detail close waits on the observed state
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-28-detail-close-waits-on-observed-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx`, case "keeps details after a refresh failure and closes them after a successful retry", failed intermittently on the `node 24 / coverage` lane's `thread-safe` partition with `AssertionError: expected <aside aria-label="Task details" …> to be null` at its final assertion. The case and the whole 269-case file pass in local runs; the lane's coverage instrumentation changes only how often the window between the two commits below is observed.
|
||||
|
||||
The case's last step retries a failed catalog read after a confirmed deletion, awaits `screen.findByText(en['list.empty'])`, and then reads `screen.queryByRole('complementary')` once. The empty list and the removed detail are separate commits:
|
||||
|
||||
- `catalog-source.ts` publishes the read's `records: []` together with `status: 'ready'` in one snapshot, so `TaskManagerPage` renders the `list.empty` status region in that commit.
|
||||
- `TaskDetail` derives `deleted = deletionConfirmed && !authoritative` in the same commit and closes through its passive effect. That effect calls `onDeleted` → `closeDetails`, wired at the page's `<TaskDetail>` element, which sets `selectedId` to null.
|
||||
- Only the following render drops `<TaskDetail>`, and with it the `complementary` region.
|
||||
|
||||
`findByText` resolves on the commit that renders the empty list; the close is scheduled by that commit's passive effect and its render lands after it, so a single read issued on the resolution of the first signal can still see the panel. The page-level effect that also closes a selection whose record left an authoritative ready snapshot does not fire in this case: `useTaskDetail` keeps the confirmed deletion's task as its draft, so `selected` stays defined through the frame in which the row disappears.
|
||||
|
||||
## Decision
|
||||
|
||||
The final assertion waits for the state it asserts: `await waitFor(() => { expect(screen.queryByRole('complementary')).toBeNull() })`. The `findByText` await stays as the case's check that the list fell back to its empty state; it is not treated as a signal that the close has already been committed.
|
||||
|
||||
The product behavior is unchanged. The close is a post-commit effect by design: while a deletion is confirmed or a save failed, the panel must survive a refresh that removes its row, so it cannot be derived away during the render that drops the row.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Read the panel once after a longer wait, or retry the read outside the assertion.** Rejected on the grounds the two Related notes record for fixed windows and retries that are not bound to the asserted condition. What is specific to this case is the signal being trusted: `findByText` resolves on the commit that renders the empty list, which is not the commit that drops the panel, so the retried read has to be bound to the asserted DOM rather than to that earlier signal.
|
||||
|
||||
**Drive the retry through a `Promise.withResolvers` deferred and one `await act(...)`, as the neighboring refresh-ordering cases do.** Not chosen here: those cases hold both the deletion's and the refresh's promises because their subject is the ordering between the two, while this case's subject is the state the retry button's own path settles into. A deferred retry would hand the case the refresh's resolution and flush the close into the same act boundary, replacing the user-visible `list.retry` path with a hand-driven one. Polling keeps that path, and the negative control under Consequences shows the failure still lands on the assertion when the panel never closes.
|
||||
|
||||
**Close the detail during render instead of in an effect.** Rejected: the detail must stay mounted while a deletion is confirmed or a save failed, including through refreshes that no longer report the row, so that its failure notice and its saved records remain reachable. Deriving the selection from the catalog would unmount it on the first refresh that drops the row, which is the state this case's earlier assertions pin as retained.
|
||||
|
||||
**Assert that `onDelete` settled instead of asserting the DOM.** Rejected: the case's subject is that the panel left the page. A settled callback does not prove the region was removed, and the deferred close is what the case exists to cover.
|
||||
|
||||
## Consequences
|
||||
|
||||
The case now observes the commit that closes the detail rather than the commit before it, and its cost is bounded by `waitFor`'s default timeout only when the panel genuinely does not close.
|
||||
|
||||
The case pins the close in both directions. With coverage instrumentation, which is the CI condition this failure appeared under, the pre-fix single read failed 1 of 5 local runs of the case with the CI message, and the waiting assertion passed 10 of 10 runs of the same command. A 25 ms `setTimeout` inserted between the `deleted` condition and `onDeleted` in `TaskDetail` makes the pre-fix single read fail at the same assertion while the waiting assertion stays green under the same delay; with the close call removed from that effect (`if (deleted) onDeleted`, which references the function without invoking it), the waiting assertion fails at that assertion, so a panel that never closes is still reported. The case ran 10 more times under `-t` and the whole 269-case file 3 times, with no failure.
|
||||
|
||||
## Related
|
||||
|
||||
- [Windows-lane observations wait on the state they read](2026-09-27-observation-waits-on-observed-state.md) states the rule this case applies: an observation waits on the state it reads, not on a wall-clock window or an earlier signal.
|
||||
- [Web lane assertions name their input state](2026-09-27-web-lane-assertions-name-their-input-state.md) records the same class on the web e2e lane, where five assertions sampled state a preceding action had not settled. This note stays separate because the lane, the committing mechanism (a passive effect), and the negative control differ.
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Agent Note: 任务详情关闭断言等待它所观测的状态
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-28-detail-close-waits-on-observed-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx` 的用例 "keeps details after a refresh failure and closes them after a successful retry" 在 `node 24 / coverage` lane 的 `thread-safe` 分区上间歇失败,失败点是它最后一条断言:`AssertionError: expected <aside aria-label="Task details" …> to be null`。该用例与本文件全部 269 个用例在本地都通过;该 lane 的覆盖率插桩只改变下面两个 commit 之间那个窗口被观测到的概率。
|
||||
|
||||
该用例最后一步在一次已确认删除之后重试失败的目录读取,`await screen.findByText(en['list.empty'])`,然后只读一次 `screen.queryByRole('complementary')`。空列表与被移除的详情并不在同一个 commit:
|
||||
|
||||
- `catalog-source.ts` 把这次读取的 `records: []` 与 `status: 'ready'` 发布在同一个快照里,因此 `TaskManagerPage` 在这个 commit 渲染 `list.empty` 状态区域。
|
||||
- `TaskDetail` 在同一个 commit 计算出 `deleted = deletionConfirmed && !authoritative`,并通过它的被动 effect 关闭。该 effect 调用 `onDeleted` → `closeDetails`(接线在页面的 `<TaskDetail>` 元素上),后者把 `selectedId` 置为 null。
|
||||
- 只有下一次渲染才移除 `<TaskDetail>`,`complementary` 区域随之消失。
|
||||
|
||||
`findByText` 在渲染空列表的那个 commit 上返回;关闭由该 commit 的被动 effect 排入,其渲染落在其后,因此在第一个信号返回时就发出的单次读取仍可能看到面板。页面级那个同样会在记录离开权威 ready 快照时关闭选区的 effect 在本用例中不触发:`useTaskDetail` 把已确认删除的任务保留为草稿,于是 `selected` 在行消失的那一帧里仍然有定义。
|
||||
|
||||
## Decision
|
||||
|
||||
最后一条断言等待它所要断言的状态:`await waitFor(() => { expect(screen.queryByRole('complementary')).toBeNull() })`。`findByText` 的等待保留,作为该用例对「列表回退到空状态」的检查;它不再被当作「关闭已经提交」的信号。
|
||||
|
||||
产品行为不变。关闭按设计是提交后 effect:删除已确认或一次保存失败时,面板必须在该行消失的刷新过程中保留,因此它不能在丢弃该行的同一次渲染里被推导掉。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在更长的等待之后只读一次面板,或在断言之外重试该读取。** 按 Related 两篇 note 为固定窗口、以及未与被断言条件绑定的重试所记录的同一理由不采纳。本用例特有的部分是它所信任的信号:`findByText` 在渲染空列表的那个 commit 上返回,而丢弃面板的并不是那个 commit,因此重试的读取必须绑定到被断言的 DOM,而不是绑定到这个更早的信号。
|
||||
|
||||
**按邻近的刷新顺序用例那样,用 `Promise.withResolvers` 延迟量驱动重试,再用一次 `await act(...)` 冲刷。** 本用例不采用:那些用例同时持有删除与刷新的 promise,因为它们的对象是两者之间的先后顺序;而本用例的对象是重试按钮自身路径结算后的状态。把重试做成延迟量会把刷新的结算权交给用例,并把关闭冲进同一个 act 边界,从而把用户可见的 `list.retry` 路径换成手工驱动的路径。轮询保留该路径,而 Consequences 里的负例对照表明:面板永不关闭时失败仍落在该断言上。
|
||||
|
||||
**把详情关闭改为渲染期推导,而不是放在 effect 里。** 不采纳:删除已确认或保存失败期间,详情必须在刷新不再报告该行时继续挂载,这样它的失败提示与已保存记录仍然可达。把选区从目录推导出来,会在第一次丢弃该行的刷新就卸载它,而该用例前面的断言固定的正是「保留」这一状态。
|
||||
|
||||
**断言 `onDelete` 已结算,而不是断言 DOM。** 不采纳:该用例的对象是面板离开了页面。回调已结算并不能证明该区域已被移除,而延迟的关闭正是这个用例存在的原因。
|
||||
|
||||
## Consequences
|
||||
|
||||
该用例现在观测的是关闭详情的那个 commit,而不是它之前的一个 commit;只有面板真的不关闭时,它才付出 `waitFor` 默认超时的代价。
|
||||
|
||||
该用例在两个方向上都能固定这次关闭。在覆盖率插桩下(也正是该失败出现的 CI 条件),修前的单次读取在 5 次本地运行中有 1 次以 CI 的消息失败,而等待式断言在同一命令的 10 次运行中全部通过。在 `TaskDetail` 中把 `deleted` 条件与 `onDeleted` 之间插入 25 毫秒的 `setTimeout`,修前的单次读取会在同一断言处失败,而同一延迟下等待式断言为绿;把该 effect 里的调用去掉(`if (deleted) onDeleted`:只引用函数、不调用)时,等待式断言在同一断言处失败,因此永不关闭的面板仍会被报告。改动后又用 `-t` 跑了 10 次、整文件 269 个用例跑了 3 次,均未失败。
|
||||
|
||||
## Related
|
||||
|
||||
- [Windows-lane observations wait on the state they read](2026-09-27-observation-waits-on-observed-state.zh.md) 给出了本用例所应用的规则:观测要等待它所读取的状态,而不是等待墙钟窗口或更早的信号。
|
||||
- [Web lane assertions name their input state](2026-09-27-web-lane-assertions-name-their-input-state.zh.md) 记录了同一类缺陷在 web e2e lane 上的实例:五条断言采样了前一个动作尚未结算的状态。本条单独成篇是因为 lane、提交机制(一个被动 effect)与负例对照都不同。
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
# Bilingual-pair consistency record for 2026-09-19-timed-user-question-two-settlements.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-19-timed-user-question-two-settlements.md
|
||||
/agent-note-timed-user-questions-as-two-settlements:
|
||||
en: 15c55d3b120b8fd4
|
||||
zh: 6b86ac34f4a1e9cd
|
||||
/agent-note-timed-user-questions-as-two-settlements/problem:
|
||||
en: f665127a9b5b8225
|
||||
zh: b19845f62310f3a2
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal:
|
||||
en: 56ca8c0f97976358
|
||||
zh: 69163bb5f064e069
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/modes-and-scope:
|
||||
en: d23e622ee4643b48
|
||||
zh: 3732c10c12344755
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/ownership:
|
||||
en: 0543678ad9d26fc5
|
||||
zh: aa6ba207e661c567
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/foreground-wait-and-claim-lifetime:
|
||||
en: 447713bbdf1ab4d8
|
||||
zh: 2757678ae47822aa
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/durable-state-and-late-answers:
|
||||
en: b8729d06ab7814ed
|
||||
zh: acaa8f4174701248
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/card-state-and-delivery-races:
|
||||
en: 8c55b693e7a5bca4
|
||||
zh: aea6e966ce3667e3
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/local-countdown-and-panel-actions:
|
||||
en: 4c0c9ec59fc51217
|
||||
zh: 322870eeb49176c7
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/reply-node-and-grouping:
|
||||
en: 10323b0e0340a40b
|
||||
zh: a17fc3a4d4d820a7
|
||||
/agent-note-timed-user-questions-as-two-settlements/alternatives-considered:
|
||||
en: ce8ff9d5bc4b85b7
|
||||
zh: 49024fe3a5b194b2
|
||||
/agent-note-timed-user-questions-as-two-settlements/acceptance-criteria:
|
||||
en: 5a993484c73befe9
|
||||
zh: 38cd3209c68178c3
|
||||
/agent-note-timed-user-questions-as-two-settlements/risks:
|
||||
en: aae260b0cd95554f
|
||||
zh: 3d7fbd6cbe9e2d37
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: agent-experience
|
||||
description: Use when writing or changing a model-facing tool definition (name, description, parameter schema, or its system-prompt section), and when designing skills, context loading, or multi-step workflows, to make information discoverable and use context efficiently.
|
||||
metadata:
|
||||
date: "2026-09-11"
|
||||
---
|
||||
|
||||
- **Start with minimal context:** expose purpose, available actions, and constraints first; load detailed instructions when needed.
|
||||
- **Make discovery explicit:** every deferred resource needs a clear description and a reliable way to retrieve it.
|
||||
- **Keep critical constraints visible:** permissions, destructive effects, and required validation should appear before the relevant action.
|
||||
- **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents.
|
||||
- **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more.
|
||||
- **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes.
|
||||
|
||||
## Tool definitions
|
||||
|
||||
- **Delete obvious constraints.** Omit rules the model learns from the call result, such as a missing file failing to read or a failed send not being delivered.
|
||||
- **Describe behavior, not implementation.** State what the tool does and returns; omit internal mechanisms, output markers the model sees in results, and enforcement details.
|
||||
- **Put parameter rules on the parameter.** Defaults, ranges, pairing, and when-to-set rules belong in that parameter's description or schema; a description that varies with configuration usually means the varying part belongs to a parameter.
|
||||
- **Say each fact once.** Do not repeat the tool definition in its system-prompt section, a parameter description in the tool description, or one tool's rules in another tool.
|
||||
- **Measure the change.** Compare first-turn prompt tokens before and after a tool-definition change.
|
||||
@@ -0,0 +1 @@
|
||||
../../../packages/preset/agent-preset/skills/agent-experience/SKILL.md
|
||||
@@ -157,5 +157,12 @@ jobs:
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The same key and value the coverage and serial lanes grant: on the
|
||||
# shared 3-vCPU runner, cases that inherit Vitest's 5000 ms default timed
|
||||
# out in spawn.spec.ts and changes.spec.ts (run 36370945526) while
|
||||
# passing in under a second on a quiet host. The ci-unit aggregate is
|
||||
# `pnpm run test` through the path that consumes the variable.
|
||||
- name: Unit tests (darwin parity)
|
||||
run: pnpm run test
|
||||
env:
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
|
||||
run: pnpm run check:ci:unit
|
||||
@@ -90,6 +90,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||
| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT |
|
||||
| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
|
||||
| [`got`](https://github.com/sindresorhus/got) | MIT |
|
||||
| [`immer`](https://github.com/immerjs/immer) | MIT |
|
||||
| [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
|
||||
| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
|
||||
"version": "0.1.7-rc.2",
|
||||
"version": "0.2.0-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 12bba432c07573bf
|
||||
zh: ab6b365cfb95003f
|
||||
/dsh-cli-behavior-reference/shared-deployment-behavior:
|
||||
en: 24e3da3716d261fe
|
||||
zh: 060611966db4ca69
|
||||
en: 6a7abc9339e8f37f
|
||||
zh: 133adb834c8e195d
|
||||
/dsh-cli-behavior-reference/source-execution:
|
||||
en: 05c2b23790f7b296
|
||||
zh: 51f809020fb230a1
|
||||
@@ -126,7 +126,7 @@ New sessions in base-backed profiles default to the `workspace-write` permission
|
||||
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
|
||||
|
||||
Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
|
||||
Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
|
||||
|
||||
Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ dsh web --help
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
|
||||
|
||||
反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;将其 `enabled` 配置设为 `false` 可关闭上传。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
|
||||
反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
|
||||
|
||||
通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop-host",
|
||||
"description": "Private Node-mode host process for the Electron desktop application",
|
||||
"version": "0.1.7-rc.2",
|
||||
"version": "0.2.0-rc.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 31cbb7452be8e1ad
|
||||
zh: 502ec556e2ec301e
|
||||
/deepseek-harness-desktop/installation-ownership:
|
||||
en: 8d686d07b0809596
|
||||
zh: 9ca7658daf583a66
|
||||
en: 863ebc136313b73b
|
||||
zh: 6aa9a9925ceaa80a
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: 0d11af8639977a6d
|
||||
zh: 86b086b12d2210d3
|
||||
|
||||
@@ -92,6 +92,8 @@ Update copy preserves the complete version, including prerelease suffixes, witho
|
||||
|
||||
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
|
||||
|
||||
Windows and macOS relay native fullscreen transitions and the state after each page load to the renderer, so overlays can adapt their caption clearance.
|
||||
|
||||
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
|
||||
|
||||
On macOS the custom menu retains Electron's standard Window menu and application hide commands, including Minimize (⌘M) and Hide (⌘H). Linux keeps the application and Edit menus.
|
||||
|
||||
@@ -94,6 +94,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
|
||||
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
|
||||
|
||||
Windows 和 macOS 会在原生全屏切换及每次页面加载后向渲染器同步全屏状态,让浮层调整顶栏避让距离。
|
||||
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
|
||||
|
||||
macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏命令,包括 Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop",
|
||||
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
|
||||
"version": "0.1.7-rc.2",
|
||||
"version": "0.2.0-rc.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -238,9 +238,8 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
|
||||
if (['http:', 'https:'].includes(new URL(url).protocol)) void shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS hides the traffic lights in fullscreen; the page drops its
|
||||
// clearance for them off the html[data-fullscreen] flag this feeds.
|
||||
if (process.platform === 'darwin' || process.platform === 'win32') {
|
||||
// Fullscreen hides native window controls; overlays drop their caption clearance.
|
||||
const sendFullscreen = (): void => {
|
||||
if (!window.isDestroyed()) window.webContents.send(DESKTOP_IPC.windowFullscreen, window.isFullScreen())
|
||||
}
|
||||
@@ -249,6 +248,8 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
|
||||
// Reloads and navigations re-register the preload listener; resend the
|
||||
// current state so a fullscreen reload does not fall back to windowed CSS.
|
||||
window.webContents.on('did-finish-load', sendFullscreen)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
// Deminiaturize reattaches the NSVisualEffectView material late
|
||||
// (electron/electron#25368), so a transparent window shows the desktop
|
||||
// through the sidebar until then. Paint an opaque base while minimized or
|
||||
|
||||
@@ -17,12 +17,13 @@ export function markDocumentPlatform(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the window's macOS fullscreen state onto `<html data-fullscreen>` so
|
||||
* CSS drops the traffic-light clearance while the lights are hidden. The main
|
||||
* Mirrors the window's macOS and Windows fullscreen state onto `<html data-fullscreen>` so
|
||||
* CSS drops the clearance for hidden native window controls. The main
|
||||
* process sends the state on every transition and after each load.
|
||||
*/
|
||||
export function syncWindowFullscreen(): void {
|
||||
if (process.platform !== 'darwin') return
|
||||
// The main process sends this IPC only on macOS and Windows; other platforms need no listener.
|
||||
if (process.platform !== 'darwin' && process.platform !== 'win32') return
|
||||
ipcRenderer.on(DESKTOP_IPC.windowFullscreen, (_event, fullscreen: boolean) => {
|
||||
const root = document.documentElement as HTMLElement | null
|
||||
if (root === null) return
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/** External PowerShell must execute skill resources carried inside the Desktop archive. */
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { finished } from 'node:stream/promises'
|
||||
import type { WriteStream } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const builderRequire = createRequire(require.resolve('app-builder-lib/package.json'))
|
||||
const { createPackageWithOptions } = builderRequire('@electron/asar') as {
|
||||
createPackageWithOptions: (source: string, destination: string, options: object) => Promise<WriteStream>
|
||||
}
|
||||
const packageDirectory = fileURLToPath(new URL('../../../packages/sandbox/sandbox-windows-acl/', import.meta.url))
|
||||
|
||||
it.skipIf(process.platform !== 'win32' || process.env.DSH_EXAMPLE_MODE !== 'lib')('executes the archived ACL skill through external PowerShell and disposes its copy', { retry: 0 }, async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-acl-asar-'))
|
||||
try {
|
||||
const source = join(root, 'package')
|
||||
// Electron reads the archived package here; full installer layout and SEA execution have separate coverage needs.
|
||||
await mkdir(source)
|
||||
for (const path of ['lib', 'assets', 'package.json']) await cp(join(packageDirectory, path), join(source, path), { recursive: true })
|
||||
const archive = join(root, 'app.asar')
|
||||
await finished(await createPackageWithOptions(source, archive, {}))
|
||||
const entry = join(root, 'probe.mjs')
|
||||
await writeFile(entry, `
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createRequire, registerHooks } from 'node:module';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const [packageDirectory, archive, root] = process.argv.slice(2);
|
||||
const require = createRequire(join(packageDirectory, 'package.json'));
|
||||
// Only the package under test is archived; resolve its real dependencies from the built workspace.
|
||||
const archiveUrl = pathToFileURL(archive).href;
|
||||
registerHooks({ resolve(specifier, context, next) {
|
||||
if (context.parentURL?.startsWith(archiveUrl) && !specifier.startsWith('.') && !specifier.startsWith('node:')) {
|
||||
return next(pathToFileURL(require.resolve(specifier)).href, context);
|
||||
}
|
||||
return next(specifier, context);
|
||||
} });
|
||||
const { Context } = await import(pathToFileURL(require.resolve('@deepseek-ai/cordis')));
|
||||
const { default: SkillRegistry } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-skill')));
|
||||
const { registerAclDiagnosisSkill, ACL_DIAGNOSIS_SKILL } = await import(pathToFileURL(join(archive, 'lib/index.js')));
|
||||
const ctx = new Context();
|
||||
try {
|
||||
await ctx.plugin(SkillRegistry);
|
||||
const fiber = await ctx.plugin({ name: 'acl-skill-archive-probe', inject: ['skills'], apply: registerAclDiagnosisSkill });
|
||||
const skill = await ctx.skills.get(ACL_DIAGNOSIS_SKILL);
|
||||
assert.equal(skill.resourceBase.kind, 'directory');
|
||||
const directory = skill.resourceBase.path;
|
||||
assert.ok(!directory.includes('.asar'));
|
||||
const output = execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-File',
|
||||
join(directory, 'scripts/diagnose-windows-sandbox-acl.ps1'), '-Path', root],
|
||||
{ encoding: 'utf8', timeout: 30000, windowsHide: true });
|
||||
assert.ok(output.includes('READABLE=True'));
|
||||
await fiber.dispose();
|
||||
assert.ok(!existsSync(directory));
|
||||
assert.deepEqual(await ctx.skills.list(), []);
|
||||
process.stdout.write('ACL_SKILL_ASAR_OK');
|
||||
} finally { await ctx.fiber.dispose(); }
|
||||
`)
|
||||
const electron = require('electron') as string
|
||||
const result = spawnSync(electron, [entry, packageDirectory, archive, root], {
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', TEMP: root, TMP: root }, encoding: 'utf8', timeout: 60_000, windowsHide: true,
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.signal).toBeNull()
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toBe('ACL_SKILL_ASAR_OK')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -717,8 +717,8 @@ describe('desktop main startup', () => {
|
||||
expect((await handler(new Request('dsh-app://foreign/index.html'))).status).toBe(404)
|
||||
})
|
||||
|
||||
it('relays the macOS fullscreen state on transitions and after each load', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
it.each(['darwin', 'win32'] as const)('relays the %s fullscreen state on transitions and after each load', async (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
const window = harness.windows[0]!
|
||||
@@ -740,7 +740,7 @@ describe('desktop main startup', () => {
|
||||
expect(sent()).toHaveLength(relayed)
|
||||
})
|
||||
|
||||
it.each(['win32', 'linux'] as const)('registers no fullscreen relay on %s', async (platform) => {
|
||||
it.each(['linux'] as const)('registers no fullscreen relay on %s', async (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
|
||||
@@ -12,14 +12,14 @@ afterEach(() => {
|
||||
on.mockClear()
|
||||
})
|
||||
|
||||
it.each(['win32', 'linux'] as const)('subscribes no fullscreen channel on %s', (platform) => {
|
||||
it.each(['linux'] as const)('subscribes no fullscreen channel on %s', (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
syncWindowFullscreen()
|
||||
expect(on).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mirrors the sent fullscreen state onto html[data-fullscreen] on darwin', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
it.each(['darwin', 'win32'] as const)('mirrors the sent fullscreen state onto html[data-fullscreen] on %s', (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
syncWindowFullscreen()
|
||||
expect(on).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.windowFullscreen, expect.any(Function))
|
||||
const handler = on.mock.calls[0]?.[1] as (event: unknown, fullscreen: boolean) => void
|
||||
|
||||
@@ -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.7-rc.2",
|
||||
"version": "0.2.0-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -33,6 +33,7 @@
|
||||
"@deepseek-ai/cordis-plugin-group": "workspace:~",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm-mock-server": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-dockkit": "workspace:*",
|
||||
|
||||
@@ -115,7 +115,8 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
|
||||
expect(slashPickerBox).not.toBeNull()
|
||||
expect(composerBox).not.toBeNull()
|
||||
expect(slashPickerBox!.width).toBeGreaterThanOrEqual(220)
|
||||
expect(composerBox!.width - slashPickerBox!.width).toBeGreaterThan(1)
|
||||
expect(slashPickerBox!.width).toBeCloseTo(composerBox!.width)
|
||||
expect(slashPickerBox!.x).toBeCloseTo(composerBox!.x)
|
||||
expect(Math.abs(composerBox!.y - slashPickerBox!.y - slashPickerBox!.height - 4)).toBeLessThan(1)
|
||||
|
||||
await slashPicker.getByRole('option', { name: 'Auto review EXP' }).click()
|
||||
|
||||
@@ -129,7 +129,8 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
const writeRow = mutationRows.find(row => row.getAttribute('data-variant') === 'write'
|
||||
&& row.textContent?.includes('new-demo.txt'))
|
||||
if (writeRow === undefined) throw new Error('fixture write row missing')
|
||||
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
|
||||
expect(within(writeRow).getAllByText('+1', { exact: true })).toHaveLength(1)
|
||||
expect(within(writeRow).getAllByText('-0', { exact: true })).toHaveLength(1)
|
||||
expect(writeRow.querySelector('[data-diff]')).toBeNull()
|
||||
for (const row of mutationRows) {
|
||||
const toggle = row.querySelector('[data-expandable]')
|
||||
@@ -140,7 +141,8 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
const writeDiff = writeRow.querySelector<HTMLElement>('[data-diff]')
|
||||
if (writeDiff === null) throw new Error('expanded fixture write diff missing')
|
||||
within(writeDiff).getByText('hello fixture', { exact: true })
|
||||
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
|
||||
expect(within(writeRow).getAllByText('+1', { exact: true })).toHaveLength(1)
|
||||
expect(within(writeRow).getAllByText('-0', { exact: true })).toHaveLength(1)
|
||||
expect(writeDiff.textContent).not.toContain('+1 -0')
|
||||
|
||||
// The web render intent reaches the assembled boot graph: the fixture's
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/** Browser geometry for settings, expanded plugin dialogs, and their menus under Windows caption markers. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole } from './scaffold.ts'
|
||||
import { openSettings, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
it('keeps settings and expanded plugin dialogs clear of the Windows caption across fullscreen transitions', async () => {
|
||||
const scaffold = await launchWebScaffold({
|
||||
profile: { packages: [] },
|
||||
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
})
|
||||
try {
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 520, height: 600 }, locale: ZH_BROWSER_LOCALE })
|
||||
const console = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.waitForSelector('[class*="frame"]')
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.setAttribute('data-windows-titlebar', '')
|
||||
document.documentElement.style.setProperty('--dsh-windows-titlebar-height', '40px')
|
||||
})
|
||||
const reopenSidebar = page.getByRole('button', { name: '打开侧边栏', exact: true })
|
||||
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
const settingsMask = settings.locator('..').locator(':scope > [aria-hidden="true"]')
|
||||
for (const fullscreen of [false, true, false]) {
|
||||
await page.evaluate(value => document.documentElement.toggleAttribute('data-fullscreen', value), fullscreen)
|
||||
const margin = fullscreen ? 24 : 60
|
||||
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(margin)
|
||||
expect((await settings.boundingBox())?.height).toBe(600 - 2 * margin)
|
||||
expect((await settingsMask.boundingBox())?.y).toBe(fullscreen ? 0 : 40)
|
||||
expect((await settings.locator('..').boundingBox())?.height).toBe(600)
|
||||
}
|
||||
expect(await page.evaluate(() => document.elementFromPoint(300, 20)?.getAttribute('role'))).toBe('presentation')
|
||||
await page.mouse.click(300, 20)
|
||||
expect(await settings.isVisible()).toBe(true)
|
||||
await settings.getByRole('button', { name: '工作区内修改', exact: true }).click()
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.waitFor()
|
||||
const menuRect = await menu.boundingBox()
|
||||
expect(menuRect?.y).toBeGreaterThanOrEqual(60)
|
||||
expect(menuRect!.y + menuRect!.height).toBeLessThanOrEqual(588)
|
||||
await page.keyboard.press('Escape')
|
||||
await menu.waitFor({ state: 'hidden' })
|
||||
expect(await settings.isVisible()).toBe(true)
|
||||
await settings.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
|
||||
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
await page.locator('[data-plugin-panel]').getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
const plugin = page.getByRole('dialog', { name: '添加插件', exact: true })
|
||||
await plugin.getByRole('button', { name: '插件安装引导和示例', exact: true }).click()
|
||||
await expect.poll(() => plugin.locator('button[aria-expanded="true"]').count()).toBe(1)
|
||||
for (const fullscreen of [false, true, false]) {
|
||||
await page.evaluate(value => document.documentElement.toggleAttribute('data-fullscreen', value), fullscreen)
|
||||
const margin = fullscreen ? 24 : 60
|
||||
await expect.poll(async () => (await plugin.boundingBox())?.y).toBe(margin)
|
||||
expect((await plugin.boundingBox())?.height).toBe(600 - 2 * margin)
|
||||
expect((await plugin.locator('..').locator(':scope > [aria-hidden="true"]').boundingBox())?.y).toBe(fullscreen ? 0 : 40)
|
||||
expect(await plugin.getByRole('button', { name: '关闭', exact: true }).isVisible()).toBe(true)
|
||||
expect(await plugin.getByRole('button', { name: '安装', exact: true }).isVisible()).toBe(true)
|
||||
}
|
||||
await plugin.getByRole('button', { name: /^安装源/ }).click()
|
||||
const registry = page.locator('[data-install-registry]')
|
||||
await registry.waitFor()
|
||||
const registryRect = await registry.boundingBox()
|
||||
expect(registryRect!.y).toBeGreaterThanOrEqual(60)
|
||||
expect(registryRect!.y + registryRect!.height).toBeLessThanOrEqual(588)
|
||||
await page.keyboard.press('Escape')
|
||||
await registry.waitFor({ state: 'hidden' })
|
||||
expect(await plugin.isVisible()).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await plugin.waitFor({ state: 'hidden' })
|
||||
for (const platform of ['darwin', 'browser']) {
|
||||
await page.evaluate((value) => {
|
||||
document.documentElement.removeAttribute('data-windows-titlebar')
|
||||
document.documentElement.removeAttribute('data-fullscreen')
|
||||
if (value === 'darwin') document.documentElement.setAttribute('data-platform', value)
|
||||
else document.documentElement.removeAttribute('data-platform')
|
||||
}, platform)
|
||||
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
|
||||
await openSettings(page, 'zh')
|
||||
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 24)
|
||||
expect((await settingsMask.boundingBox())?.y).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await settings.waitFor({ state: 'hidden' })
|
||||
}
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
} finally { await browser.close() }
|
||||
} finally { await scaffold.close() }
|
||||
})
|
||||
@@ -46,6 +46,7 @@ describe.skipIf(MODE === 'record').each([
|
||||
models: [
|
||||
{ id: 'acme-think', name: 'Acme Think' },
|
||||
{ id: 'acme-swift', name: 'Acme Swift' },
|
||||
{ id: 'acme-lite', name: 'Acme Lite' },
|
||||
].map(model => ({
|
||||
...model,
|
||||
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
|
||||
@@ -148,8 +149,10 @@ describe.skipIf(MODE === 'record').each([
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
} else {
|
||||
await page.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ }).click()
|
||||
await expect.poll(() => page.locator('[role="menuitemradio"][aria-checked="true"]')
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
const focused = pane === 'model'
|
||||
? page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
: page.locator('[role="menuitemradio"][aria-checked="true"]')
|
||||
await expect.poll(() => focused.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
@@ -172,22 +175,27 @@ describe.skipIf(MODE === 'record').each([
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
const current = page.getByRole('menuitemradio', { name: 'Acme Think', exact: true })
|
||||
const target = page.getByRole('menuitemradio', { name: 'Acme Swift', exact: true })
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id'))
|
||||
|
||||
// Native mousedown must not blur the focused row and unmount the menu before click in WebKit.
|
||||
// Native mousedown must not blur search and unmount the menu before click in WebKit.
|
||||
await target.getByText('Acme Swift', { exact: true }).hover()
|
||||
await page.mouse.down()
|
||||
try {
|
||||
await expect.poll(() => menu.count()).toBe(1)
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
await page.getByText('Acme Gateway', { exact: true }).hover()
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
// Later selection counts also include any request from this cancelled press.
|
||||
expect(selections).toBe(0)
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await search.press('ArrowUp')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id'))
|
||||
await search.press('ArrowDown')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id'))
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
@@ -222,16 +230,20 @@ describe.skipIf(MODE === 'record').each([
|
||||
}, { times: 1 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id'))
|
||||
await current.click()
|
||||
await page.getByRole('alert').waitFor()
|
||||
expect(selections).toBe(3)
|
||||
await compareOrRefreshGolden(POINTER_EXPECTED, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
|
||||
await compareOrRefreshGolden(POINTER_EXPECTED,
|
||||
await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), MODE)
|
||||
await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id'))
|
||||
await search.press('ArrowUp')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id'))
|
||||
expect(await search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
|
||||
@@ -80,7 +80,13 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'picker.expected.md'),
|
||||
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('menuitem', { name: /推理等级/ }).click()
|
||||
const effortWeights = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row =>
|
||||
getComputedStyle(row.querySelector('span span')!).fontWeight,
|
||||
))
|
||||
expect(new Set(effortWeights)).toEqual(new Set(['400']))
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
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, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -73,7 +73,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: MODEL, name: 'Acme Large' }],
|
||||
models: [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -92,6 +92,223 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('keeps command popup search borders transparent in both palettes', async () => {
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
const commandMenuBounds = await page.locator('[data-trigger-menu]').boundingBox()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
const search = page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await search.waitFor()
|
||||
try {
|
||||
const popupBounds = await page.locator('[aria-label="/model 选项"]').boundingBox()
|
||||
expect(popupBounds).not.toBeNull()
|
||||
expect(commandMenuBounds).not.toBeNull()
|
||||
expect(popupBounds!.width).toBeCloseTo(commandMenuBounds!.width)
|
||||
expect(popupBounds!.x).toBeCloseTo(commandMenuBounds!.x)
|
||||
expect(await search.getAttribute('placeholder')).toBe('搜索模型…')
|
||||
await page.getByRole('option').first().waitFor()
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/default-model/command-picker.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[aria-label="/model 选项"]', scaffold.workspaceCwd),
|
||||
webSnapshotMode(),
|
||||
)
|
||||
await search.fill('no-model-matches')
|
||||
await page.getByText('没有匹配的模型。', { exact: true }).waitFor()
|
||||
await search.fill('')
|
||||
const borders = await search.evaluate((input) => {
|
||||
const body = input.ownerDocument.body
|
||||
const previousTheme = body.getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
return [false, true].map((dark) => {
|
||||
body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
return getComputedStyle(input).borderColor
|
||||
})
|
||||
} finally {
|
||||
if (previousTheme === null) body.removeAttribute('data-ds-dark-theme')
|
||||
else body.setAttribute('data-ds-dark-theme', previousTheme)
|
||||
}
|
||||
})
|
||||
expect(borders).toEqual(['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'])
|
||||
} finally {
|
||||
await search.press('Escape')
|
||||
await composer.fill('')
|
||||
}
|
||||
})
|
||||
|
||||
it('shares provider order, fuzzy result order and sticky header material between both model pickers', async () => {
|
||||
const readGroups = (surface: Locator, role: 'option' | 'menuitemradio') => surface.locator('[data-menu-group]')
|
||||
.evaluateAll((groups, rowRole) => groups.map(group => ({
|
||||
label: group.querySelector('[data-menu-group-heading]')!.textContent,
|
||||
rows: [...group.querySelectorAll(`[role="${rowRole}"]`)].map(row => row.textContent),
|
||||
})), role)
|
||||
const checkSticky = async (surface: Locator, viewport: Locator) => {
|
||||
// Bound only this test's list viewport so native scrolling can cross a heading.
|
||||
await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 })
|
||||
const heading = surface.locator('[data-menu-group-heading]').first()
|
||||
await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull()
|
||||
const clear = await heading.evaluate(node => getComputedStyle(node).backgroundColor)
|
||||
expect(clear).toBe('rgba(0, 0, 0, 0)')
|
||||
await viewport.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(() => heading.getAttribute('data-stuck')).toBe('')
|
||||
const stuck = await heading.evaluate((node) => {
|
||||
const style = getComputedStyle(node)
|
||||
return { fill: style.backgroundColor, radius: style.borderRadius, font: style.fontSize, padding: style.padding }
|
||||
})
|
||||
await viewport.evaluate((node) => { node.scrollTop = 0 })
|
||||
await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull()
|
||||
return stuck
|
||||
}
|
||||
try {
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
const menu = page.getByRole('group', { name: '模型与推理等级', exact: true })
|
||||
const menuSearch = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
const order = await readGroups(menu, 'menuitemradio')
|
||||
const sticky = await checkSticky(menu, menu.getByRole('menu', { name: '模型', exact: true }))
|
||||
await menuSearch.fill(' ACMLG ')
|
||||
const filtered = await readGroups(menu, 'menuitemradio')
|
||||
await menuSearch.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
const popup = page.locator('[aria-label="/model 选项"]')
|
||||
await popup.getByRole('option').first().waitFor()
|
||||
expect(await readGroups(popup, 'option')).toEqual(order)
|
||||
expect(await checkSticky(popup, popup.getByRole('listbox'))).toEqual(sticky)
|
||||
const popupSearch = page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await popupSearch.fill(' ACMLG ')
|
||||
expect(await readGroups(popup, 'option')).toEqual(filtered)
|
||||
expect(await popupSearch.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.locator('[data-composer-input]').first().fill('')
|
||||
}
|
||||
})
|
||||
|
||||
it('hides search only in the button menu when four models remain', async () => {
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
const setModels = (expanded: boolean) => scaffold.ctx.settings.update('llm-pi-ai', {
|
||||
providers: { [ROUTE]: {
|
||||
displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1',
|
||||
models: expanded ? [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }]
|
||||
: [{ id: MODEL, name: 'Acme Large' }],
|
||||
} },
|
||||
})
|
||||
try {
|
||||
await setModels(false)
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(4)
|
||||
expect(await search.count()).toBe(0)
|
||||
const current = page.getByRole('menuitemradio', { name: 'Origin Large', exact: true })
|
||||
await expect.poll(() => current.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
const rows = page.getByRole('menuitemradio')
|
||||
const currentId = await current.getAttribute('id')
|
||||
for (let index = 0; index < 4; index++) await page.keyboard.press('ArrowDown')
|
||||
expect(await page.evaluate(() => document.activeElement?.id)).toBe(currentId)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => trigger.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await trigger.evaluate(node => getComputedStyle(node).boxShadow)).toBe('none')
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
const commandSearch = page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await commandSearch.waitFor()
|
||||
expect(await commandSearch.getAttribute('placeholder')).toBe('搜索模型…')
|
||||
await commandSearch.press('Escape')
|
||||
await page.locator('[data-composer-input]').first().fill('')
|
||||
expect(await rows.count()).toBe(0)
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await setModels(true)
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(5)
|
||||
await search.waitFor()
|
||||
await search.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
|
||||
it('paints only pinned provider headings across themes, filtering, and reopening', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-model-sticky-headings'))
|
||||
const originalViewport = page.viewportSize()!
|
||||
const attributes = await page.evaluate(() => ({
|
||||
platform: document.documentElement.getAttribute('data-platform'),
|
||||
theme: document.body.getAttribute('data-ds-dark-theme'),
|
||||
}))
|
||||
const surface = page.getByRole('group', { name: '模型与推理等级', exact: true })
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
try {
|
||||
await page.setViewportSize({ width: 1680, height: 220 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
const scroller = page.getByRole('menu', { name: '模型', exact: true })
|
||||
const headings = surface.locator('section[role="group"] > div')
|
||||
const readPinned = () => headings.evaluateAll(nodes => nodes.map(node => node.hasAttribute('data-stuck')))
|
||||
const resetScroll = async (): Promise<void> => {
|
||||
await scroller.evaluate((node) => { node.scrollTop = 0 })
|
||||
await expect.poll(readPinned).toEqual([false, false, false])
|
||||
}
|
||||
for (const platform of ['web', 'win32', 'darwin']) {
|
||||
for (const dark of [false, true]) {
|
||||
await page.evaluate(({ platform, dark }) => {
|
||||
if (platform === 'web') document.documentElement.removeAttribute('data-platform')
|
||||
else document.documentElement.setAttribute('data-platform', platform)
|
||||
document.body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
}, { platform, dark })
|
||||
await resetScroll()
|
||||
expect(await headings.evaluateAll(nodes => [...new Set(nodes.map(node => getComputedStyle(node).backgroundColor))]))
|
||||
.toEqual(['rgba(0, 0, 0, 0)'])
|
||||
const secondTop = await scroller.evaluate((node) => {
|
||||
const second = node.querySelectorAll('section')[1]!
|
||||
return Math.ceil(second.getBoundingClientRect().top - node.getBoundingClientRect().top) + 1
|
||||
})
|
||||
expect(await scroller.evaluate(node => node.scrollHeight - node.clientHeight)).toBeGreaterThanOrEqual(secondTop)
|
||||
await scroller.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(readPinned).toEqual([true, false, false])
|
||||
expect(await headings.first().evaluate(node => getComputedStyle(node).backgroundColor))
|
||||
.toBe(dark ? 'rgba(48, 49, 54, 0.94)' : 'rgba(248, 249, 250, 0.94)')
|
||||
await scroller.evaluate((node, top) => { node.scrollTop = top }, secondTop)
|
||||
await expect.poll(readPinned).toEqual([false, true, false])
|
||||
await scroller.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(readPinned).toEqual([true, false, false])
|
||||
await resetScroll()
|
||||
}
|
||||
}
|
||||
await scroller.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(readPinned).toEqual([true, false, false])
|
||||
await search.fill('Origin Large')
|
||||
await expect.poll(() => headings.count()).toBe(1)
|
||||
await expect.poll(readPinned).toEqual([false])
|
||||
await page.getByRole('button', { name: '清除搜索', exact: true }).click()
|
||||
await expect.poll(readPinned).toEqual([false, false, false])
|
||||
await search.fill('zzzz')
|
||||
await expect.poll(() => surface.locator('[data-stuck]').count()).toBe(0)
|
||||
await page.getByRole('button', { name: '清除搜索', exact: true }).click()
|
||||
await expect.poll(readPinned).toEqual([false, false, false])
|
||||
await search.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
expect(await search.inputValue()).toBe('')
|
||||
await resetScroll()
|
||||
} finally {
|
||||
if (await surface.count()) await search.press('Escape')
|
||||
if (await trigger.getAttribute('aria-expanded') === 'true') await page.keyboard.press('Escape')
|
||||
await page.setViewportSize(originalViewport)
|
||||
await page.evaluate(({ platform, theme }) => {
|
||||
if (platform === null) document.documentElement.removeAttribute('data-platform')
|
||||
else document.documentElement.setAttribute('data-platform', platform)
|
||||
if (theme === null) document.body.removeAttribute('data-ds-dark-theme')
|
||||
else document.body.setAttribute('data-ds-dark-theme', theme)
|
||||
}, attributes)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes the switched model as the default and leaves a logged session alone', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
|
||||
// A session that has already run a turn, spelled as the fact a turn
|
||||
@@ -106,7 +323,109 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400')
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
const modelMenuBounds = await page.getByRole('menu', { name: '模型与推理等级', exact: true }).boundingBox()
|
||||
const composerBounds = await page.locator('[data-composer-card]').first().boundingBox()
|
||||
expect(modelMenuBounds!.width).toBeLessThan(composerBounds!.width)
|
||||
const modelCell = page.getByRole('menuitem', { name: /模型/ })
|
||||
await trigger.press('ArrowDown')
|
||||
expect(await modelCell.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
expect(await modelCell.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
|
||||
const focusedBackground = await modelCell.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await trigger.focus()
|
||||
await modelCell.hover()
|
||||
expect(await modelCell.evaluate(element => getComputedStyle(element).backgroundColor)).toBe(focusedBackground)
|
||||
await modelCell.click()
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
const rowIds = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row => row.id))
|
||||
const initialHighlight = rowIds.indexOf(await search.getAttribute('aria-activedescendant') ?? '')
|
||||
expect(initialHighlight).toBeGreaterThanOrEqual(0)
|
||||
for (let step = 1; step <= rowIds.length + 1; step++) {
|
||||
await search.press('ArrowDown')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(rowIds[(initialHighlight + step) % rowIds.length])
|
||||
expect(await search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true)
|
||||
}
|
||||
await search.press('ArrowUp')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(rowIds[initialHighlight])
|
||||
const searchStyle = await search.evaluate((input) => {
|
||||
const wrapper = input.parentElement!
|
||||
const caption = input.ownerDocument.createElement('span')
|
||||
caption.style.color = 'var(--dsw-alias-label-caption)'
|
||||
wrapper.append(caption)
|
||||
try {
|
||||
return {
|
||||
placeholderColor: getComputedStyle(input, '::placeholder').color,
|
||||
captionColor: getComputedStyle(caption).color,
|
||||
iconCount: wrapper.querySelectorAll('svg').length,
|
||||
background: getComputedStyle(wrapper).backgroundColor,
|
||||
radius: getComputedStyle(wrapper).borderRadius,
|
||||
padding: getComputedStyle(wrapper).padding,
|
||||
fontSize: getComputedStyle(input).fontSize,
|
||||
}
|
||||
} finally {
|
||||
caption.remove()
|
||||
}
|
||||
})
|
||||
expect(searchStyle.placeholderColor).toBe(searchStyle.captionColor)
|
||||
expect(searchStyle.iconCount).toBe(0)
|
||||
expect(searchStyle.background).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(searchStyle.radius).toBe('12px')
|
||||
expect(searchStyle.padding).toBe('5px 7px')
|
||||
expect(searchStyle.fontSize).toBe('12px')
|
||||
const modelWeights = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row =>
|
||||
getComputedStyle(row.querySelector('span span')!).fontWeight,
|
||||
))
|
||||
expect(new Set(modelWeights)).toEqual(new Set(['400']))
|
||||
const headingPalettes = await page.getByRole('group', { name: '模型与推理等级', exact: true }).evaluate((menu) => {
|
||||
const html = menu.ownerDocument.documentElement
|
||||
const body = menu.ownerDocument.body
|
||||
const previousPlatform = html.getAttribute('data-platform')
|
||||
const previousTheme = body.getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
return ['web', 'win32', 'darwin'].flatMap(platform => [false, true].map((dark) => {
|
||||
if (platform === 'web') html.removeAttribute('data-platform')
|
||||
else html.setAttribute('data-platform', platform)
|
||||
body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
const headings = [...menu.querySelectorAll('section[role="group"] > div')]
|
||||
return {
|
||||
platform, dark,
|
||||
headingFills: [...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))],
|
||||
positions: [...new Set(headings.map(heading => getComputedStyle(heading).position))],
|
||||
headingRadii: [...new Set(headings.map(heading => getComputedStyle(heading).borderRadius))],
|
||||
optionRadius: getComputedStyle(menu.querySelector('[role="menuitemradio"]')!).borderRadius,
|
||||
menuFill: getComputedStyle(menu.querySelector(':scope > [aria-hidden="true"]')!).backgroundColor,
|
||||
searchBorder: getComputedStyle(menu.querySelector('input')!.parentElement!).borderColor,
|
||||
}
|
||||
}))
|
||||
} finally {
|
||||
if (previousPlatform === null) html.removeAttribute('data-platform')
|
||||
else html.setAttribute('data-platform', previousPlatform)
|
||||
if (previousTheme === null) body.removeAttribute('data-ds-dark-theme')
|
||||
else body.setAttribute('data-ds-dark-theme', previousTheme)
|
||||
}
|
||||
})
|
||||
for (const palette of headingPalettes) {
|
||||
expect(palette.headingFills, `${palette.platform}, dark=${String(palette.dark)}`)
|
||||
.toEqual(['rgba(0, 0, 0, 0)'])
|
||||
expect(palette.menuFill).toBe(palette.dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)')
|
||||
expect(palette.searchBorder).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(palette.positions).toEqual(['sticky'])
|
||||
expect(palette.headingRadii).toEqual([palette.platform === 'darwin' ? '0px' : palette.optionRadius])
|
||||
}
|
||||
await search.fill('zzzz')
|
||||
await page.getByText('没有匹配的模型。', { exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: '清除搜索', exact: true }).click()
|
||||
expect(await search.inputValue()).toBe('')
|
||||
expect(await search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await page.getByRole('menuitemradio').count()).toBe(modelWeights.length)
|
||||
expect(await page.getByRole('button', { name: '清除搜索', exact: true }).count()).toBe(0)
|
||||
await search.fill(' ACMLG ')
|
||||
expect(await page.getByRole('menuitemradio').allTextContents()).toEqual(['Acme Large'])
|
||||
expect(await page.getByRole('group', { name: 'Origin Gateway', exact: true }).count()).toBe(0)
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/default-model/search.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd),
|
||||
webSnapshotMode(),
|
||||
)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const blocked = scaffold.ctx.hmr.runExclusive(async () => {
|
||||
@@ -115,9 +434,15 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
})
|
||||
try {
|
||||
await entered.promise
|
||||
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
|
||||
await search.press('ArrowDown')
|
||||
await expect.poll(() => search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant'))
|
||||
.toBe(await page.getByRole('menuitemradio', { name: 'Acme Large' }).getAttribute('id'))
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false')
|
||||
await expect.poll(() => trigger.textContent()).toContain('Acme Large')
|
||||
await expect.poll(() => trigger.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).toBe('none')
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/background-save.expected.md', import.meta.url)), aria, webSnapshotMode())
|
||||
@@ -143,6 +468,10 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
.toEqual({ provider: ROUTE, model: MODEL })
|
||||
// ...while the one holding a logged route keeps deriving from its log.
|
||||
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
await trigger.press('Tab')
|
||||
await trigger.focus()
|
||||
expect(await trigger.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).not.toBe('none')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -271,9 +271,17 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan
|
||||
const meetingCanvas = await canvas.elementHandle()
|
||||
if (meetingCanvas === null) throw new Error('meeting spreadsheet canvas is unavailable')
|
||||
await expectExcelLayout(excel)
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 60, y: 40 } })
|
||||
await expect.poll(() => formula.innerText()).toBe('会议纪要')
|
||||
// Arrow keys pick the cell from committed state, so a later render cannot re-resolve it
|
||||
// against the reflowed grid the way it re-resolves a pointer press.
|
||||
const overlay = excel.locator('.fortune-sheet-overlay')
|
||||
await overlay.focus()
|
||||
await expect.poll(() => overlay.evaluate(node => document.activeElement === node)).toBe(true)
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await expect.poll(() => selection.innerText()).toBe('B1')
|
||||
await expect.poll(() => formula.innerText()).toBe('')
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await expect.poll(() => selection.innerText()).toBe('A1')
|
||||
await expect.poll(() => formula.innerText()).toBe('会议纪要')
|
||||
for (const width of [1000, 360, 1000, 360]) {
|
||||
await layout.evaluate((node, width) => { node.textContent = `[data-sidebar-right-panel] { width: ${width}px !important; }` }, width)
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}"
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [expanded]
|
||||
- button "Wrote files, called tools, searched code, etc." [expanded]
|
||||
- button "Write site/report.html +1 -0":
|
||||
- text: Write
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
- 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"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [expanded]
|
||||
- button "Called tools" [expanded]
|
||||
- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package."
|
||||
- button "Inspect plugins 0 dynamic plugins"
|
||||
@@ -42,8 +42,8 @@
|
||||
- button "Plugin status updated 9/1 {{clock}}":
|
||||
- text: Plugin status updated
|
||||
- time: 9/1 {{clock}}
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: The Cordis Plugin is running.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -51,8 +51,8 @@
|
||||
- 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"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [expanded]
|
||||
- button "Called tools" [expanded]
|
||||
- text: Stop Cordis Plugin snap-1
|
||||
- button "Inspect"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
- menu "模型与推理等级":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "DeepSeek-V4-Flash"
|
||||
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Think"
|
||||
- menuitemradio "Acme Swift" [checked]
|
||||
- group "模型与推理等级":
|
||||
- searchbox "搜索模型…"
|
||||
- menu "模型":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "DeepSeek-V4-Flash"
|
||||
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Think"
|
||||
- menuitemradio "Acme Swift" [checked]
|
||||
- menuitemradio "Acme Lite"
|
||||
@@ -1,5 +1,6 @@
|
||||
- menu "模型与推理等级":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "Messages Flash" [checked]
|
||||
- menuitemradio "DeepSeek-V4-Pro"
|
||||
- group "模型与推理等级":
|
||||
- menu "模型":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "Messages Flash" [checked]
|
||||
- menuitemradio "DeepSeek-V4-Pro"
|
||||
@@ -0,0 +1,14 @@
|
||||
- textbox "筛选选项":
|
||||
- /placeholder: 搜索模型…
|
||||
- listbox "/model 匹配项":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- option "DeepSeek-V4-Flash"
|
||||
- option "DeepSeek-V4-Flash-Vision-Exp"
|
||||
- group "Origin Gateway":
|
||||
- text: Origin Gateway
|
||||
- option "Origin Large"
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- option "Acme Large" [selected]
|
||||
- option "Acme Small"
|
||||
@@ -0,0 +1,7 @@
|
||||
- group "模型与推理等级":
|
||||
- searchbox "搜索模型…": ACMLG
|
||||
- button "清除搜索"
|
||||
- menu "模型":
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Large"
|
||||
@@ -20,8 +20,6 @@
|
||||
- button "减小字号"
|
||||
- text: px 工作步骤展示 选择希望看到多少工具调用细节
|
||||
- button "标准"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
|
||||
- switch "代码工作工具" [checked]
|
||||
- text: 快捷键
|
||||
@@ -29,4 +27,8 @@
|
||||
- button "编辑快捷键"
|
||||
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品
|
||||
- switch "在使用官方模型 API 时上传 Session Log"
|
||||
- text: 当前版本:{{version}}
|
||||
@@ -20,8 +20,6 @@
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Standard"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
|
||||
- switch "Coding Tools" [checked]
|
||||
- text: Keyboard shortcuts
|
||||
@@ -29,4 +27,8 @@
|
||||
- button "Edit shortcuts"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Upload Session Log when using the official model API Help improve DeepSeek models and products.
|
||||
- switch "Upload Session Log when using the official model API"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -1,11 +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"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}"
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}"
|
||||
- text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}"
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}"
|
||||
- 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"
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
- button "GitHub event received {{clock}}":
|
||||
- text: GitHub event received
|
||||
- time: {{clock}}
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: "Review complete: no actionable findings."
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
- button "GitHub event received {{clock}}":
|
||||
- text: GitHub event received
|
||||
- time: {{clock}}
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: "Review complete: no actionable findings."
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Render adjacent CJK strong emphasis. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- heading "CJK strong emphasis" [level=2]
|
||||
- paragraph:
|
||||
- strong: 注意:
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Show the Markdown image policy. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- heading "Markdown images" [level=2]
|
||||
- paragraph:
|
||||
- 'button "View full image: Remote test image"':
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Show the local preview URL. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- heading "Inline code links" [level=2]
|
||||
- paragraph:
|
||||
- text: "Preview:"
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Render this mathematical proof. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- heading "Math rendering" [level=2]
|
||||
- paragraph:
|
||||
- text: Inline dollar
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
- dialog "内测声明":
|
||||
- heading "内测声明" [level=2]
|
||||
- paragraph: DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。
|
||||
- paragraph: 我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。
|
||||
- dialog "预览版说明":
|
||||
- heading "预览版说明" [level=2]
|
||||
- paragraph: DeepSeek Harness 目前的 0.2 版本仍处于预览阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者和用户的反馈建议。现在,新的桌面端面向广泛用户,开发者相关的进阶功能可在配置中开启使用。预计 DeepSeek Harness 的产品功能以及插件 API 都会继续快速迭代、持续演化,并逐渐趋于稳定。
|
||||
- paragraph: 我们期待与全球用户和开发者一起,在开源、可复用、可组合的基础设施之上,共同探索智能上限。欢迎大家用 DeepSeek Harness 将想法变成现实,与社区一起丰富插件生态。
|
||||
- button "继续"
|
||||
@@ -7,8 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Research notes what changed? Referenced session · Research notes {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{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
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Detailed"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
|
||||
- switch "Coding Tools"
|
||||
- text: Keyboard shortcuts
|
||||
@@ -29,4 +27,8 @@
|
||||
- button "Edit shortcuts"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Upload Session Log when using the official model API Help improve DeepSeek models and products.
|
||||
- switch "Upload Session Log when using the official model API"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -20,8 +20,6 @@
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Detailed"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
|
||||
- switch "Coding Tools"
|
||||
- text: Keyboard shortcuts
|
||||
@@ -29,4 +27,8 @@
|
||||
- button "Edit shortcuts"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Upload Session Log when using the official model API Help improve DeepSeek models and products.
|
||||
- switch "Upload Session Log when using the official model API"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -20,8 +20,6 @@
|
||||
- button "减小字号"
|
||||
- text: px 工作步骤展示 选择希望看到多少工具调用细节
|
||||
- button "详细"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
|
||||
- switch "代码工作工具"
|
||||
- text: 快捷键
|
||||
@@ -29,4 +27,8 @@
|
||||
- button "编辑快捷键"
|
||||
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品
|
||||
- switch "在使用官方模型 API 时上传 Session Log"
|
||||
- text: 当前版本:{{version}}
|
||||
@@ -23,7 +23,7 @@
|
||||
- text: Add workspace ⌥ ⌘ O
|
||||
- listitem:
|
||||
- button "Edit shortcut for Rename session"
|
||||
- text: Rename session ⇧ ⌘ R
|
||||
- text: Rename session ⌥ ⌘ G
|
||||
- listitem:
|
||||
- button "Edit shortcut for Fork session"
|
||||
- text: Fork session ⇧ ⌘ F
|
||||
@@ -32,7 +32,7 @@
|
||||
- text: Archive session ⌥ ⌘ A
|
||||
- listitem:
|
||||
- button "Edit shortcut for Open settings"
|
||||
- text: Open settings ⌘ ,
|
||||
- text: Open settings ⌥ ⌘ ,
|
||||
- listitem:
|
||||
- button "Edit shortcut for Toggle right sidebar"
|
||||
- text: Toggle right sidebar ⇧ ⌘ B
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
- text: Add workspace Ctrl + Alt + O
|
||||
- listitem:
|
||||
- button "Edit shortcut for Rename session"
|
||||
- text: Rename session Ctrl + Shift + R
|
||||
- text: Rename session Ctrl + Alt + G
|
||||
- listitem:
|
||||
- button "Edit shortcut for Fork session"
|
||||
- text: Fork session Ctrl + Shift + F
|
||||
@@ -32,7 +32,7 @@
|
||||
- text: Archive session Ctrl + Alt + A
|
||||
- listitem:
|
||||
- button "Edit shortcut for Open settings"
|
||||
- text: Open settings Ctrl + ,
|
||||
- text: Open settings Ctrl + Alt + ,
|
||||
- listitem:
|
||||
- button "Edit shortcut for Toggle right sidebar"
|
||||
- text: Toggle right sidebar Ctrl + Shift + B
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
- text: 添加工作区 ⌥ ⌘ O
|
||||
- listitem:
|
||||
- button "修改重命名会话快捷键"
|
||||
- text: 重命名会话 ⇧ ⌘ R
|
||||
- text: 重命名会话 ⌥ ⌘ G
|
||||
- listitem:
|
||||
- button "修改分叉会话快捷键"
|
||||
- text: 分叉会话 ⇧ ⌘ F
|
||||
@@ -32,7 +32,7 @@
|
||||
- text: 归档会话 ⌥ ⌘ A
|
||||
- listitem:
|
||||
- button "修改打开设置快捷键"
|
||||
- text: 打开设置 ⌘ ,
|
||||
- text: 打开设置 ⌥ ⌘ ,
|
||||
- listitem:
|
||||
- button "修改展开/收起右侧栏快捷键"
|
||||
- text: 展开/收起右侧栏 ⇧ ⌘ B
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
|
||||
- text: and confirm the fixture wiring {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
|
||||
- text: and confirm the fixture wiring {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
- button "Jump to turn 28"
|
||||
- text: m1 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r1
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -45,8 +45,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m2 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r2
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -54,8 +54,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m3 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r3
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -63,8 +63,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m4 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r4
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -72,8 +72,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m5 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r5
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -81,8 +81,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m6 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r6
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -90,8 +90,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m7 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r7
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -99,8 +99,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m8 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r8
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -108,8 +108,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m9 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r9
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -117,8 +117,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m10 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r10
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -126,8 +126,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m11 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r11
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -135,8 +135,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m12 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r12
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -144,8 +144,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m13 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r13
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -153,8 +153,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m14 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r14
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -162,8 +162,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m15 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r15
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -171,8 +171,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m16 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r16
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -180,8 +180,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m17 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r17
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -189,8 +189,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m18 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r18
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -198,8 +198,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m19 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r19
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -207,8 +207,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m20 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r20
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -216,8 +216,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m21 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r21
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -225,8 +225,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m22 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r22
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -234,8 +234,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m23 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r23
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -243,8 +243,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m24 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r24
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -252,8 +252,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m25 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r25
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -261,8 +261,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m26 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r26
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -270,8 +270,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m27 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r27
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -279,8 +279,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m28 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- paragraph: r28
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
- button "Copy"
|
||||
- text: "Interjection: include the word ORANGE in your final reply."
|
||||
- button "Copy"
|
||||
- status: Deep diving...
|
||||
- text: Deep diving for {{duration}}...
|
||||
- status: Deep diving
|
||||
- text: Deep diving for {{duration}} ···
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
- 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"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- button "Asked questions" [expanded]
|
||||
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
|
||||
- button "Ask question 1/1 answered"
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
- 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"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}" [disabled]
|
||||
- button "Asked questions"
|
||||
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
- button "Copy"
|
||||
|
||||
@@ -155,6 +155,32 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
|
||||
expect(sessionEvents.flatMap(event =>
|
||||
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
|
||||
await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2)
|
||||
const trigger = page.locator('[data-turn-trigger]').first()
|
||||
const process = page.locator('[data-turn-process]').first()
|
||||
const originalFontSize = await page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))
|
||||
await process.click()
|
||||
try {
|
||||
for (const fontSize of [10, 14, 22]) {
|
||||
await scaffold!.ctx.settings.update('ui-theme', { fontSize })
|
||||
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
|
||||
.toBe(`${String(fontSize)}px`)
|
||||
const group = page.locator('[data-process-activity]:visible').first()
|
||||
const groupFontSize = await group.evaluate(element => getComputedStyle(element).fontSize)
|
||||
expect(groupFontSize).toBe(`${String(fontSize)}px`)
|
||||
expect(await process.evaluate(element => getComputedStyle(element).fontSize)).toBe(groupFontSize)
|
||||
expect(await trigger.getByText('Continuing goal', { exact: true })
|
||||
.evaluate(element => getComputedStyle(element).fontSize)).toBe(groupFontSize)
|
||||
expect(await process.evaluate(element => element.getBoundingClientRect().top))
|
||||
.toBe(await trigger.evaluate(element => element.getBoundingClientRect().bottom) + 16)
|
||||
expect(await group.evaluate(element => element.getBoundingClientRect().top))
|
||||
.toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16)
|
||||
}
|
||||
} finally {
|
||||
await scaffold!.ctx.settings.update('ui-theme', { fontSize: Number.parseFloat(originalFontSize) })
|
||||
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
|
||||
.toBe(originalFontSize)
|
||||
await process.click()
|
||||
}
|
||||
expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0)
|
||||
expect(await page.locator(
|
||||
'[data-chat-flow-kind="system-prompt"]',
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await reasoningComplete.promise
|
||||
expect(await thinking.getAttribute('data-preview')).toBeNull()
|
||||
expect(await thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await thinking.locator('[data-streaming]').isVisible()).toBe(false)
|
||||
expect(await thinking.locator('[data-streaming]:not([inert] *)').isVisible()).toBe(false)
|
||||
}
|
||||
observedReasoning.resolve(undefined)
|
||||
return await settled
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
() => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(true)
|
||||
await page.locator('[data-streaming="true"]')
|
||||
await page.locator('[data-streaming="true"]:not([inert] *)')
|
||||
.getByText('partial', { exact: true })
|
||||
.waitFor({ timeout: 30_000 })
|
||||
const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
@@ -195,7 +195,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
// (awaited above), but the abort frame reaches the browser over SSE — the
|
||||
// frozen-partial swap is eventually consistent, so poll rather than count.
|
||||
await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(() => page.locator('[data-streaming="true"]:not([inert] *)').count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(() => page.getByRole('tooltip').count()).toBe(0)
|
||||
// Golden of the aborted end-state: the prompt bubble plus the frozen
|
||||
// partial ('partial' is the hang entry's replayed prefix) and no more.
|
||||
@@ -222,7 +222,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
// AUTH is outside llm-retry's retryable set: no retry record.
|
||||
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0)
|
||||
await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
|
||||
expect(await page.locator('[data-streaming="true"]:not([inert] *)').count()).toBe(0)
|
||||
const errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' })
|
||||
await errorStatus.waitFor({ timeout: 10_000 })
|
||||
expect(await errorStatus.textContent()).toContain('API key is invalid')
|
||||
@@ -292,6 +292,40 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('wraps an active retry label inside a narrow conversation column', async () => {
|
||||
await launch(() => ({ patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' } }] }),
|
||||
{ mode: 'normal', maxRetries: 1, retryableCodes: ['SERVER'],
|
||||
backoff: { initialDelayMs: 60_000, maxDelayMs: 60_000, jitterRatio: 0 } })
|
||||
await scaffold!.ctx.settings.update('ui-theme', { fontSize: 17 })
|
||||
const { settled } = await sendPrompt(90_000)
|
||||
const retry = page.locator('details[data-active="true"]')
|
||||
await retry.waitFor()
|
||||
const originalStyle = await retry.getAttribute('style')
|
||||
try {
|
||||
const geometry = await retry.evaluate((element) => {
|
||||
element.style.width = '200px'
|
||||
const summary = element.querySelector('summary')!
|
||||
const status = summary.querySelector('[role="status"]')!
|
||||
return { width: summary.getBoundingClientRect().width,
|
||||
scrollWidth: summary.scrollWidth, clientWidth: summary.clientWidth,
|
||||
height: status.getBoundingClientRect().height,
|
||||
lineHeight: Number.parseFloat(getComputedStyle(status).lineHeight) }
|
||||
})
|
||||
expect(geometry.width).toBeLessThanOrEqual(200)
|
||||
expect(geometry.scrollWidth).toBe(geometry.clientWidth)
|
||||
expect(geometry.height).toBeGreaterThan(geometry.lineHeight)
|
||||
expect(await retry.getByRole('status').textContent()).toContain('Retrying')
|
||||
} finally {
|
||||
await retry.evaluate((element, style) => {
|
||||
if (style === null) element.removeAttribute('style')
|
||||
else element.setAttribute('style', style)
|
||||
}, originalStyle)
|
||||
await page.getByRole('button', { name: 'Stop generating', exact: true }).click()
|
||||
await settled
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('recovers a transient SERVER failure through llm-retry and completes', async () => {
|
||||
const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
|
||||
expect(derived).toHaveLength(1)
|
||||
@@ -366,7 +400,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('error')
|
||||
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(2)
|
||||
await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
|
||||
expect(await page.locator('[data-streaming="true"]:not([inert] *)').count()).toBe(0)
|
||||
// The terminal error row must render even though the turn owns a retry
|
||||
// chain: exhausted recovery shares the failing turn, so suppressing the
|
||||
// row by retry history would leave the failure invisible.
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/** Built-profile browser coverage for sticky model groups; counts only their synchronous rect reads, not FPS or latency. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium, webkit } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Test-private count for MenuGroup sections and their direct scroll viewport. */
|
||||
__modelMenuRectReads: number
|
||||
}
|
||||
}
|
||||
|
||||
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
|
||||
const PROVIDERS = [
|
||||
{ id: 'origin-gateway', name: 'Origin Gateway', prefix: 'Origin' },
|
||||
{ id: 'amber-gateway', name: 'Amber Gateway', prefix: 'Amber' },
|
||||
{ id: 'cedar-gateway', name: 'Cedar Gateway', prefix: 'Cedar' },
|
||||
{ id: 'quartz-gateway', name: 'Quartz Gateway', prefix: 'Quartz' },
|
||||
].map((provider, providerIndex) => ({
|
||||
...provider,
|
||||
models: Array.from({ length: 7 }, (_, index) => providerIndex === 0 && index === 0
|
||||
? { id: 'origin-large', name: 'Origin Large' }
|
||||
: { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }),
|
||||
}))
|
||||
const EXPECTED_GROUPS = [
|
||||
{ label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] },
|
||||
...PROVIDERS.map(provider => ({ label: provider.name, rows: provider.models.map(model => model.name) })),
|
||||
]
|
||||
|
||||
// Native IO/RO callbacks need rendering opportunities, not elapsed-time sleeps.
|
||||
async function frames(page: Page): Promise<void> {
|
||||
await page.evaluate(() => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
|
||||
}))
|
||||
}
|
||||
|
||||
function readGroups(viewport: Locator) {
|
||||
return viewport.locator('[data-menu-group]').evaluateAll(groups => groups.map(group => ({
|
||||
label: group.querySelector('[data-menu-group-heading]')!.textContent,
|
||||
rows: [...group.querySelectorAll('[role="option"], [role="menuitemradio"]')].map(row => row.textContent),
|
||||
})))
|
||||
}
|
||||
|
||||
function readPinned(viewport: Locator) {
|
||||
return viewport.locator('[data-menu-group-heading]').evaluateAll(headings =>
|
||||
headings.map(heading => heading.hasAttribute('data-stuck')))
|
||||
}
|
||||
|
||||
async function assertNoRectReads(page: Page): Promise<void> {
|
||||
await frames(page)
|
||||
expect(await page.evaluate(() => window.__modelMenuRectReads)).toBe(0)
|
||||
}
|
||||
|
||||
async function assertPinned(page: Page, viewport: Locator, index: number, count = 5): Promise<void> {
|
||||
const expected = Array.from({ length: count }, (_, i) => i === index)
|
||||
await expect.poll(() => readPinned(viewport)).toEqual(expected)
|
||||
await assertNoRectReads(page)
|
||||
expect(await readPinned(viewport)).toEqual(expected)
|
||||
}
|
||||
|
||||
async function scrollToGroup(viewport: Locator, index: number, inside = 1): Promise<void> {
|
||||
// Sections share an offset parent. Their normal-flow offsets are test positioning, not rect instrumentation.
|
||||
await viewport.evaluate((node, target) => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const groups = node.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')
|
||||
node.scrollTop = groups[target.index]!.offsetTop - groups[0]!.offsetTop + target.inside
|
||||
resolve()
|
||||
})
|
||||
}), { index, inside })
|
||||
}
|
||||
|
||||
async function headingMaterial(viewport: Locator) {
|
||||
return viewport.locator('[data-menu-group-heading]').first().evaluate((heading) => {
|
||||
const body = heading.ownerDocument.body
|
||||
const theme = body.getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
return [false, true].map((dark) => {
|
||||
body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
const style = getComputedStyle(heading)
|
||||
return {
|
||||
fill: style.backgroundColor, radius: style.borderRadius, position: style.position,
|
||||
font: style.fontSize, weight: style.fontWeight, padding: style.padding,
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
if (theme === null) body.removeAttribute('data-ds-dark-theme')
|
||||
else body.setAttribute('data-ds-dark-theme', theme)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(webSnapshotMode() === 'record').each([
|
||||
{ name: 'Chromium', engine: chromium },
|
||||
{ name: 'WebKit', engine: webkit },
|
||||
])('web e2e: model menu observers ($name)', ({ engine }) => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let crashed = false
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
// Catalog-only settings traffic: no prompt, selection, replay fixture, or model request.
|
||||
await scaffold.ctx.settings.update('llm-pi-ai', {
|
||||
providers: Object.fromEntries(PROVIDERS.map(provider => [provider.id, {
|
||||
displayName: provider.name,
|
||||
api: 'openai-completions',
|
||||
baseURL: `https://${provider.id}.example/v1`,
|
||||
models: provider.models,
|
||||
}])),
|
||||
})
|
||||
browser = await engine.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
await page.addInitScript(() => {
|
||||
window.__modelMenuRectReads = 0
|
||||
// oxlint-disable-next-line typescript/unbound-method -- The wrapper supplies the original Element receiver below.
|
||||
const original = Element.prototype.getBoundingClientRect
|
||||
Element.prototype.getBoundingClientRect = function (): DOMRect {
|
||||
if (this.hasAttribute('data-menu-group') || [...this.children].some(child => child.hasAttribute('data-menu-group'))) {
|
||||
window.__modelMenuRectReads++
|
||||
}
|
||||
return original.call(this)
|
||||
}
|
||||
})
|
||||
page.on('crash', () => { crashed = true; console.error('observer test page crashed') })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
await scaffold?.close()
|
||||
}
|
||||
})
|
||||
|
||||
// FIXME: WebKit 26.5 crashes while filtering the grouped model rows in this scenario.
|
||||
it.skipIf(engine === webkit)('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, `web-e2e-model-menu-observer-${engine.name()}`))
|
||||
let buttonMaterial: Awaited<ReturnType<typeof headingMaterial>> | undefined
|
||||
for (const entry of ['button', 'command'] as const) {
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
const surface = entry === 'button'
|
||||
? page.getByRole('group', { name: '模型与推理等级', exact: true })
|
||||
: page.locator('[aria-label="/model 选项"]')
|
||||
const viewport = entry === 'button'
|
||||
? surface.getByRole('menu', { name: '模型', exact: true })
|
||||
: surface.getByRole('listbox')
|
||||
const search = entry === 'button'
|
||||
? page.getByRole('searchbox', { name: '搜索模型…', exact: true })
|
||||
: page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
const open = async (): Promise<void> => {
|
||||
if (entry === 'button') {
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
} else {
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
}
|
||||
await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS)
|
||||
await expect.poll(() => search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
await assertNoRectReads(page)
|
||||
await page.mouse.move(0, 0)
|
||||
await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 })
|
||||
await assertPinned(page, viewport, -1)
|
||||
}
|
||||
const close = async (): Promise<void> => {
|
||||
if (crashed || page.isClosed() || await surface.count() === 0) return
|
||||
await search.press('Escape')
|
||||
if (entry === 'button') await page.keyboard.press('Escape')
|
||||
await surface.waitFor({ state: 'detached' })
|
||||
await composer.fill('')
|
||||
}
|
||||
|
||||
await open()
|
||||
try {
|
||||
expect(await viewport.locator('[role="option"], [role="menuitemradio"]').count()).toBe(30)
|
||||
expect(await viewport.evaluate(node => node.scrollTop)).toBe(0)
|
||||
expect(await viewport.locator('[data-menu-group-heading]').evaluateAll(headings =>
|
||||
[...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))]))
|
||||
.toEqual(['rgba(0, 0, 0, 0)'])
|
||||
await scrollToGroup(viewport, 0)
|
||||
await assertPinned(page, viewport, 0)
|
||||
const material = await headingMaterial(viewport)
|
||||
expect(material.map(style => style.fill)).toEqual(['rgba(248, 249, 250, 0.94)', 'rgba(48, 49, 54, 0.94)'])
|
||||
for (const style of material) {
|
||||
expect(style).toMatchObject({ position: 'sticky', font: '11px', weight: '500', padding: '4px 7px 2px' })
|
||||
}
|
||||
if (entry === 'button') buttonMaterial = material
|
||||
else expect(material).toEqual(buttonMaterial)
|
||||
|
||||
for (const index of [2, 4, 1]) {
|
||||
await scrollToGroup(viewport, index)
|
||||
await assertPinned(page, viewport, index)
|
||||
const height = await viewport.evaluate(node => node.clientHeight)
|
||||
expect(height).toBeGreaterThan(0)
|
||||
expect(height).toBeLessThanOrEqual(80)
|
||||
expect(await viewport.locator('[data-menu-group]').nth(index).evaluate(node => node.clientHeight))
|
||||
.toBeGreaterThan(height * 2)
|
||||
await scrollToGroup(viewport, index, height + 1)
|
||||
await assertPinned(page, viewport, index)
|
||||
expect(await viewport.evaluate((node, groupIndex) => {
|
||||
const headings = node.querySelectorAll('[data-menu-group-heading]')
|
||||
const firstStart = node.querySelector('[data-menu-group-start]')!
|
||||
const viewportTop = firstStart.getBoundingClientRect().top + node.scrollTop
|
||||
return Math.abs(headings[groupIndex]!.getBoundingClientRect().top - viewportTop)
|
||||
}, index)).toBeLessThan(1)
|
||||
}
|
||||
await scrollToGroup(viewport, 0, 0)
|
||||
await assertPinned(page, viewport, -1)
|
||||
|
||||
for (const [key, steps] of [['ArrowDown', 18], ['ArrowUp', 10]] as const) {
|
||||
const visited = new Set<number>()
|
||||
let observedPinnedHighlight = false
|
||||
for (let step = 0; step < steps; step++) {
|
||||
const rows = viewport.locator('[role="option"], [role="menuitemradio"]')
|
||||
const before = await rows.evaluateAll(nodes => nodes.findIndex(node =>
|
||||
node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))
|
||||
await search.press(key)
|
||||
const expected = (before + (key === 'ArrowDown' ? 1 : -1) + 30) % 30
|
||||
await expect.poll(() => rows.evaluateAll(nodes => nodes.findIndex(node =>
|
||||
node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))).toBe(expected)
|
||||
await frames(page)
|
||||
await expect.poll(() => viewport.evaluate((node) => {
|
||||
const groups = [...node.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')]
|
||||
const base = groups[0]!.offsetTop
|
||||
return groups.every((group) => {
|
||||
const top = group.offsetTop - base
|
||||
const shouldStick = top < node.scrollTop && top + group.offsetHeight > node.scrollTop
|
||||
return group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') === shouldStick
|
||||
})
|
||||
})).toBe(true)
|
||||
const highlight = await viewport.evaluate((node) => {
|
||||
const groups = [...node.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')]
|
||||
const index = groups.findIndex(group => group.querySelector('[data-highlighted], [aria-selected="true"]') !== null)
|
||||
const group = groups[index]!
|
||||
return { index, above: group.offsetTop - groups[0]!.offsetTop < node.scrollTop,
|
||||
stuck: group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') }
|
||||
})
|
||||
visited.add(highlight.index)
|
||||
if (highlight.above) {
|
||||
expect(highlight.stuck).toBe(true)
|
||||
observedPinnedHighlight = true
|
||||
}
|
||||
expect(await search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
await assertNoRectReads(page)
|
||||
}
|
||||
expect(visited.size).toBeGreaterThan(1)
|
||||
expect(observedPinnedHighlight).toBe(true)
|
||||
}
|
||||
|
||||
await scrollToGroup(viewport, 0)
|
||||
await assertPinned(page, viewport, 0)
|
||||
await search.fill('Quartz Model 1')
|
||||
await expect.poll(() => readGroups(viewport)).toEqual([{ label: 'Quartz Gateway', rows: ['Quartz Model 1'] }])
|
||||
await assertPinned(page, viewport, -1, 1)
|
||||
expect(await viewport.evaluate(node => node.scrollTop)).toBe(0)
|
||||
await search.fill('')
|
||||
await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS)
|
||||
await assertPinned(page, viewport, -1)
|
||||
await close()
|
||||
await open()
|
||||
expect(await search.inputValue()).toBe('')
|
||||
|
||||
await scrollToGroup(viewport, 3, 90)
|
||||
await assertPinned(page, viewport, 3)
|
||||
await viewport.evaluate((node) => { node.style.maxHeight = '64px' })
|
||||
await expect.poll(() => viewport.evaluate(node => node.clientHeight)).toBeLessThanOrEqual(64)
|
||||
await assertPinned(page, viewport, 3)
|
||||
await scrollToGroup(viewport, 4, 100)
|
||||
await assertPinned(page, viewport, 4)
|
||||
await scrollToGroup(viewport, 1, 100)
|
||||
await assertPinned(page, viewport, 1)
|
||||
await scrollToGroup(viewport, 0, 0)
|
||||
await assertPinned(page, viewport, -1)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
}
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -157,7 +157,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
;(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings = sightings
|
||||
setInterval(() => {
|
||||
if (document.querySelector(
|
||||
'[role="dialog"][aria-label="内测声明"], '
|
||||
'[role="dialog"][aria-label="预览版说明"], '
|
||||
+ '[role="dialog"][aria-label="开始你的创作"], '
|
||||
+ '[role="dialog"][aria-label="添加一个 API Key 开始使用"]',
|
||||
) !== null) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Locator, type Page } from 'playwright'
|
||||
import { chromium, type Locator, type Page, type Route } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE } from './support.ts'
|
||||
@@ -87,6 +87,24 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
|
||||
`)
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const tripwire = watchConsole(page)
|
||||
// Hold request delivery and the real Host's cancellation reply independently.
|
||||
// Cancellation reaches the Host before the install it names.
|
||||
const cancellationArrived = Promise.withResolvers<undefined>()
|
||||
let installBundleRoute = async (route: Route): Promise<void> => {
|
||||
await delivery.promise
|
||||
await route.continue()
|
||||
}
|
||||
let cancelInstallRoute = async (route: Route): Promise<void> => {
|
||||
const response = await route.fetch()
|
||||
cancellationArrived.resolve(undefined)
|
||||
await cancellationReply.promise
|
||||
await route.fulfill({ response })
|
||||
}
|
||||
// Host invalidations keep reading the directory between install phases.
|
||||
// Interception stays enabled from before navigation until page closure,
|
||||
// so phase changes cannot strand those requests.
|
||||
await page.route('**/api/pluginManager/installBundle', route => installBundleRoute(route))
|
||||
await page.route('**/api/pluginManager/cancelInstall', route => cancelInstallRoute(route))
|
||||
await page.clock.install()
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.waitForSelector('[class*="frame"]')
|
||||
@@ -120,19 +138,6 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/invalid-registry.expected.md', import.meta.url)),
|
||||
`${invalidRegistryAria}\n\n${JSON.stringify({ registry: registryStyles, packageName: packageStyles }, null, 2)}`, webSnapshotMode())
|
||||
await packageName.fill('slow-package')
|
||||
// Hold request delivery and the real Host's cancellation reply independently.
|
||||
// Cancellation reaches the Host before the install it names.
|
||||
const cancellationArrived = Promise.withResolvers<undefined>()
|
||||
await page.route('**/api/pluginManager/installBundle', async (route) => {
|
||||
await delivery.promise
|
||||
await route.continue()
|
||||
})
|
||||
await page.route('**/api/pluginManager/cancelInstall', async (route) => {
|
||||
const response = await route.fetch()
|
||||
cancellationArrived.resolve(undefined)
|
||||
await cancellationReply.promise
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
await dialog.getByText('正在准备安装…', { exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/preparing.expected.md', import.meta.url)),
|
||||
@@ -156,10 +161,10 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
|
||||
await page.getByText('已取消安装,插件未启用,下载的文件可能保留', { exact: true }).waitFor()
|
||||
expect(await readFile(manifestPath, 'utf8')).toBe(manifest)
|
||||
expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n')
|
||||
await page.unrouteAll({ behavior: 'wait' })
|
||||
cancelInstallRoute = route => route.continue()
|
||||
// Drop the browser's response while the real Host still owns the child process.
|
||||
const activeReplySettled = Promise.withResolvers<undefined>()
|
||||
await page.route('**/api/pluginManager/installBundle', async (route) => {
|
||||
installBundleRoute = async (route) => {
|
||||
const response = route.fetch().then(() => undefined, (error: unknown) => error)
|
||||
try {
|
||||
await loseActiveReply.promise
|
||||
@@ -168,7 +173,7 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
|
||||
} finally {
|
||||
activeReplySettled.resolve(undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
await dialog.getByRole('textbox').fill('slow-package')
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
@@ -186,7 +191,7 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
|
||||
expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n')
|
||||
expect(await dialog.getByRole('textbox').inputValue()).toBe('slow-package')
|
||||
await activeReplySettled.promise
|
||||
await page.unrouteAll({ behavior: 'wait' })
|
||||
installBundleRoute = route => route.continue()
|
||||
const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
|
||||
.split(process.execPath).join('{{node}}')
|
||||
.split(scaffold.harnessHome).join('{{harnessHome}}')
|
||||
@@ -257,10 +262,10 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
|
||||
}
|
||||
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
await dialog.getByRole('textbox').fill('recovered-package')
|
||||
await page.route('**/api/pluginManager/installBundle', async (route) => {
|
||||
installBundleRoute = async (route) => {
|
||||
await route.fetch()
|
||||
await route.abort('failed')
|
||||
})
|
||||
}
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
await dialog.getByText('未能获取安装结果', { exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/unknown.expected.md', import.meta.url)),
|
||||
|
||||
@@ -373,6 +373,18 @@ describe('web e2e: plugin manager', () => {
|
||||
const panel = await openPluginsPanel()
|
||||
|
||||
const info = panel.getByRole('button', { name: '插件说明' })
|
||||
const infoBounds = await info.boundingBox()
|
||||
const iconBounds = await info.locator('svg').boundingBox()
|
||||
const subtitleBounds = await panel.getByText('安装、启用和配置插件', { exact: true }).boundingBox()
|
||||
if (infoBounds === null || iconBounds === null || subtitleBounds === null) {
|
||||
throw new Error('Plugin information button, icon, and subtitle must be visible')
|
||||
}
|
||||
expect(infoBounds.width).toBe(20)
|
||||
expect(infoBounds.height).toBe(20)
|
||||
expect(iconBounds.width).toBe(11)
|
||||
expect(iconBounds.height).toBe(11)
|
||||
expect(infoBounds.x - subtitleBounds.x - subtitleBounds.width).toBeCloseTo(4)
|
||||
expect(iconBounds.y + iconBounds.height / 2).toBeCloseTo(subtitleBounds.y + subtitleBounds.height / 2)
|
||||
await info.hover()
|
||||
const hoverHelp = page.getByRole('tooltip')
|
||||
await hoverHelp.waitFor()
|
||||
@@ -440,6 +452,45 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps off-state switch thumbs light in both themes', async () => {
|
||||
// The off-state thumb must stay light so an operable toggle cannot read as
|
||||
// disabled, whose only other difference is reduced opacity.
|
||||
for (const [theme, label] of [['light', '浅色'], ['dark', '深色']] as const) {
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
await settings.getByRole('button', { name: '通用设置', exact: true }).click()
|
||||
await settings.getByRole('button', { name: label, exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme'))).toBe(theme === 'dark')
|
||||
const panel = await openPluginsPanel()
|
||||
const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle', exact: true })
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
const appearance = await toggle.evaluate((element) => {
|
||||
const thumb = element.firstElementChild
|
||||
if (thumb === null) throw new Error('Switch thumb is missing')
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
thumb: getComputedStyle(thumb).backgroundColor,
|
||||
opacity: getComputedStyle(element).opacity,
|
||||
}
|
||||
})
|
||||
expect(appearance).toEqual({
|
||||
width: 36,
|
||||
height: 20,
|
||||
thumb: theme === 'dark' ? 'rgb(173, 178, 184)' : 'rgb(255, 255, 255)',
|
||||
opacity: '1',
|
||||
})
|
||||
}
|
||||
// Leave the shared page in the default theme for the tests after this one.
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
await settings.getByRole('button', { name: '通用设置', exact: true }).click()
|
||||
await settings.getByRole('button', { name: '浅色', exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme'))).toBe(false)
|
||||
await closeSettings()
|
||||
})
|
||||
|
||||
it('decodes manifest icons for disabled bundles and independent plugin rows', async () => {
|
||||
const panel = await openPluginsPanel()
|
||||
const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}`
|
||||
|
||||
@@ -122,6 +122,8 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
|
||||
expect(await page.getByText('产物', { exact: true }).count()).toBe(0)
|
||||
// The scaffold workspace is not a git repository; the write call supplies its single changed file.
|
||||
const changes = page.locator('[data-changed-files]')
|
||||
// Changed-file cards wait for a separate summary request after the presentation events arrive.
|
||||
await changes.waitFor({ state: 'visible' })
|
||||
expect(await changes.count()).toBe(1)
|
||||
expect(await changes.getByText(`已编辑 ${FILE}`, { exact: true }).count()).toBe(1)
|
||||
expect(await changes.getByRole('list').count()).toBe(0)
|
||||
|
||||
Loaded 100 of 869 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user