Merge pull request #3110 from deepseek-harness/worktree/fix-question-drafts-session-switch

fix(web): preserve ask_user_question drafts across Sessions
This commit is contained in:
Yichen Jiang
2026-08-26 16:10:37 +08:00
committed by GitHub
27 changed files with 387 additions and 101 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md
2026-08-26-question-drafts-survive-session-switch.md: 2279c4efd5e79c51a0e2e2347f21fae4b0a47b7a
2026-08-26-question-drafts-survive-session-switch.zh.md: af275a704bef723cb3ed6fde83138a2be5b5cb3f
@@ -0,0 +1,39 @@
# Agent Note: Question drafts survive Session switches
Status: implemented
English | [中文](2026-08-26-question-drafts-survive-session-switch.zh.md)
## Problem
`conversation.composer` is a strict Session-scoped slot, so selecting another Session unmounts its question entry. The generic `QuestionFlow` kept its current question index, selected labels, custom text, and skip flags in React component state. A still-pending request therefore returned with empty answers after an A → B → A Session switch even though the pending carrier remained owned by Session A.
The draft is transient presentation state: it must follow its Session within the current page, but it must not become mutable state on the pending business carrier or a user preference synchronized through Host settings.
## Decision
The question entry declares a non-persisted `createQuestionDraftStore` handle when it registers into `conversation.composer`. The renderer owns one instance per Session scope and retains that instance across selection changes, so remounting the same Session reads the same progress.
The store holds at most one request identity and one progress value: current question index plus one selected/custom/skipped draft per question. `QuestionFlow` reads the stored value only when the local pending-request key and question count match. A new request therefore renders empty immediately and its first write atomically replaces the older value instead of accumulating request records. Successful answer and cancellation settlements clear only their matching request key, so a stale completion cannot erase a later draft.
Busy state, failure feedback, collapse state, and focus bookkeeping remain component-local because they describe the mounted interaction rather than the unfinished answer. The `plan-review` presentation has no multi-question draft and does not read the store.
This realizes the existing [Session-scope rule](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) that remount-surviving state belongs in a Session-bound source, while retaining the [Host-backed preference decision](2026-08-06-host-backed-web-preferences.md): drafts remain page-local and never enter settings, `localStorage`, or disk. The answer semantics from [multi-select custom composition](2026-07-30-multi-select-custom-answer-composition.md) are unchanged.
## Testing
The store test pins keyed replacement and stale-cleanup isolation. The component test unmounts and remounts the strict entry over one store instance and requires its page, selected option, and custom text to return. The keyless assembled Web scenario types both answer forms, switches to a new Session, returns to the waiting Session, snapshots the restored composer, and submits the restored values through the real question waterfall.
## Alternatives considered
**Keep the state in `QuestionFlow`.** Rejected because a strict Session switch deliberately destroys that React instance; a component-local key cannot outlive the unmount it is intended to identify.
**Put mutable drafts on `PendingQuestion`.** Rejected because the carrier represents pending request settlement, not React presentation state, and mutations there would bypass the Slot store's subscribed read/write surface and lifecycle ownership.
**Use a module-level map keyed by Session and request.** Rejected because plugin reload and Session pruning would not own its disposal, and completed request entries could accumulate independently of the renderer's scope lifecycle.
**Persist drafts through Host settings or browser storage.** Rejected because switching Sessions within one page needs remount continuity, not cross-page or cross-process durability. Persistence would synchronize transient answer text beyond the interaction that owns it.
## Consequences
Unsubmitted generic-question answers survive ordinary Session navigation in the current page, including the current question and explicit skips. They still reset after a page reload, Session-scope prune, or replacement pending-request identity. The per-Session memory cost is bounded to one request progress value and is released with the Slot store's Session scope.
@@ -0,0 +1,39 @@
# Agent Note: 提问草稿在 Session 切换后保留
Status: implemented
[English](2026-08-26-question-drafts-survive-session-switch.md) | 中文
## Problem
`conversation.composer` 是严格按 Session 划分 scope 的 slot,因此选择另一个 Session 会卸载其提问条目。通用 `QuestionFlow` 把当前题号、已选标签、自定义文本和跳过标记保存在 React 组件状态中。因此,即使待处理载体仍归 Session A 所有,一个仍在等待的请求经过 A → B → A 的 Session 切换后,也会以空答案重新出现。
草稿是临时呈现状态:它必须在当前页面内跟随所属 Session,但不能变成待处理业务载体上的可变状态,也不能成为通过 Host settings 同步的用户偏好。
## Decision
提问条目注册到 `conversation.composer` 时声明一个非持久化的 `createQuestionDraftStore` handle。renderer 为每个 Session scope 拥有一个实例,并在选择切换期间保留该实例,因此重新挂载同一 Session 时会读到相同进度。
store 最多保存一个请求标识和一个进度值:当前题号,以及每道题各一份 selected/custom/skipped 草稿。只有本地待处理请求 key 与题目数量都相符时,`QuestionFlow` 才读取已存值。因此,新请求会立即渲染为空,并在首次写入时原子替换旧值,而不会累积请求记录。成功回答和取消落定后只清除与自身相符的请求 key,因此过期的完成动作不会删除较新的草稿。
忙碌状态、失败提示、折叠状态和焦点记录仍留在组件本地,因为它们描述当前已挂载交互,而不是未完成的答案。`plan-review` 呈现界面没有多题草稿,也不读取该 store。
这落实了既有的 [Session scope 规则](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md):需要跨重新挂载保留的状态应归 Session 绑定的数据源;同时保留[由 Host 持久化偏好的决策](2026-08-06-host-backed-web-preferences.zh.md):草稿仍只存在于当前页面,从不进入 settings、`localStorage` 或磁盘。[多选自定义答案组合](2026-07-30-multi-select-custom-answer-composition.zh.md)规定的答案语义保持不变。
## Testing
store 测试固定按 key 替换和过期清理隔离。组件测试在同一个 store 实例上卸载并重新挂载严格 Session 条目,并要求题号、已选选项和自定义文本全部恢复。无密钥的组装 Web 场景会输入两种答案、切换到新 Session、返回仍在等待的 Session、对恢复后的编辑器生成快照,再经真实提问 waterfall 提交恢复的值。
## Alternatives considered
**继续把状态留在 `QuestionFlow`。** 不采用,因为严格 Session 切换会刻意销毁该 React 实例;组件本地 key 无法比其试图标识的卸载过程活得更久。
**把可变草稿放进 `PendingQuestion`。** 不采用,因为载体表示待处理请求的落定过程,而不是 React 呈现状态;在其中做变更还会绕过 Slot store 提供的订阅读写界面和生命周期归属。
**使用按 Session 和请求建立索引的模块级 map。** 不采用,因为 plugin 重载与 Session 裁剪不拥有其清理过程,已完成请求的条目还可能脱离 renderer 的 scope 生命周期不断累积。
**通过 Host settings 或浏览器存储持久化草稿。** 不采用,因为同一页面内切换 Session 需要的是跨重新挂载连续性,而不是跨页面或跨进程耐久性。持久化会把临时答案文本同步到拥有它的交互之外。
## Consequences
未提交的通用提问答案现在能在当前页面的普通 Session 导航中保留,包括当前题号和显式跳过状态。刷新页面、Session scope 被裁剪或待处理请求标识被替换后,草稿仍会重置。每个 Session 的内存成本被限制为一个请求进度值,并随 Slot store 的 Session scope 一起释放。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
2026-08-08-native-windows-pull-request-ci.md: 98f48029a86a8b07e53cd4498b27d637508e450b
2026-08-08-native-windows-pull-request-ci.zh.md: b2dba91e4a88d0da637521aae2de937672b869a3
2026-08-08-native-windows-pull-request-ci.md: 8faec10fdc8538c994954213ac477a2ed52e800c
2026-08-08-native-windows-pull-request-ci.zh.md: f692aa85e33e2e02d04cd72d9316ed4fc139aced
@@ -14,17 +14,17 @@ A coverage audit found that stale branch state had restored temporary exclusions
The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup` standalone mode, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable retargets all four jobs to the in-house pool. Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict.
`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts.
The 16-core lane admits four concurrent outer gates. Workspace build and production-site validation start immediately. Instrumented and exempt-heavy coverage both wait for the complete build: the instrumented corpus includes packer assertions over built `lib/` output, while the exempt gate's temporary Oxlint contract probes must not race source compilation and its packed-image suite must read a complete artifact tree. Every observational gate waits for both coverage gates to settle, regardless of outcome, before entering an available slot; its own `needs` edges still require their predecessors to pass. This also keeps later static gates that create temporary contract files from racing either coverage scan. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses four single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`, for about six active coverage execution units after build. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The SQLite busy-journal pacing fixture injects two busy results followed by success under the normal busy budget and observes each inter-attempt delay, keeping schema-setup scheduling outside its timing assertion. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
`windows-coverage` completes a workspace build before [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) starts four single-worker instrumented shards beside a two-worker exempt-heavy gate. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, Knip, NodeNext, package, and documentation processes. The SQLite busy-journal pacing fixture injects two busy results followed by success under the normal busy budget and observes each inter-attempt delay, keeping schema-setup scheduling outside its timing assertion. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66122.01 seconds. Under the current post-build graph, sixteen instrumented shards plus two exempt workers would schedule eighteen coverage execution units on a 16-core runner before any production-site tail or system overhead; four shards plus two exempt workers schedule six. Four deliberately trades some single-job latency for lower process-creation pressure under high self-hosted concurrency. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers after the build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias.
Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling.
Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. The randomized SQLite differential property retains all 100 seeded runs and uses a 120-second Windows budget because simultaneous native jobs can contend for the shared runner host; POSIX keeps the 60-second budget.
Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary.
@@ -36,11 +36,11 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
## Alternatives considered
**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path.
**Make every native Windows result a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for coverage and the duplicated observational inventory. Requiring the build and targeted native-process suite retains fast native correctness signals while the other results remain automatic.
**Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken.
**Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism.
**Mark every non-blocking native job `continue-on-error`.** The observational job uses this setting because Linux owns its blocking verdict. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate.
**Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures.
@@ -50,8 +50,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
## Consequences
Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result.
Wine preserves the required aggregate's existing critical path and job identity. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine plus the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results.
Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup and repeat builds across the build, coverage, and observational workspaces, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native job uses the immutable workspace on the organization-owned 16-core Windows runner. A failure unique to either job must be classified against that boundary rather than weakened or silently skipped.
Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped.
@@ -14,17 +14,17 @@ Status: implemented
[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 的 standalone 模式提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build``windows-coverage``windows-native-tests``windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量会把这 4 个作业全部重定向到公司内部运行器池。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。
`windows-build``windows-native-tests` `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包构建产物的阻断性判定由 Linux 负责。
16 核通道最多同时运行 4 道外层门禁。工作区构建与生产网站验证会立即启动。插桩覆盖率与豁免重型覆盖率都等待完整构建:插桩语料包含针对已构建 `lib/` 输出的打包器断言,豁免门禁的临时 Oxlint 约定探针则不得与源码编译竞态,并且其 packed-image 套件必须读取完整的产物树。每道观测性门禁只等待两道覆盖率门禁以任意结果结算后再进入可用槽位;各门禁自身的 `needs` 边仍要求前置门禁通过。这也使随后创建临时约定文件的静态门禁不会与任一覆盖率扫描竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)使用 4 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker,因此构建完成后约有 6 个活动覆盖率执行单元。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。SQLite busy-journal 节奏 fixture 会在普通 busy 预算内先注入两次 busy 结果,再返回成功,并观察每次尝试之间的延迟,使 schema 设置的调度时间不进入该断言。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
`windows-coverage` 会先完成一次工作区构建,再由[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)启动 4 个单 worker 插桩分片,并与一个双 worker 的豁免重型门禁并行运行。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、Knip、NodeNext、包及文档进程争抢资源。SQLite busy-journal 节奏 fixture 会在普通 busy 预算内先注入两次 busy 结果,再返回成功,并观察每次尝试之间的延迟,使 schema 设置的调度时间不进入该断言。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。在当前的构建后拓扑中,16 个插桩分片加 2 个豁免 worker 会在 16 核运行器上调度 18 个覆盖率执行单元,且尚未计入生产网站的尾部工作或系统开销;4 个分片加 2 个豁免 worker 则调度 6 个。4 个分片刻意用部分单 job 延迟换取自托管高并发下更低的进程创建压力。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在构建后调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%``C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。
可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。
可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。SQLite 随机差分属性测试保留全部 100 次固定 seed 运行,并采用 120 秒 Windows 预算,因为多个原生作业可能争用共享的运行器宿主;POSIX 仍采用 60 秒预算。
原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。
@@ -36,11 +36,11 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
## 曾考虑的替代方案
**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最慢的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径
**让每项原生 Windows 结果都成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待覆盖率和重复的观测性清单。要求构建与定向原生进程套件通过,可以保留快速的原生正确性信号,同时继续自动产生其他结果
**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。
**将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留常规独立作业可维持诊断结论;仅从聚合流程 `needs` 中省略它,才是不阻断的机制
**将每个非阻断原生作业标记为 `continue-on-error`。** 观测性作业采用该设置,因为它的阻断性判定由 Linux 负责。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程
**排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。
@@ -50,8 +50,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
## 后果
Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。
Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用 Wine 加定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。
尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程与两项阻断构建,在标准镜像上明显更慢;但它也会暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程,并在构建、覆盖率与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用不可变工作区。任一作业独有的失败都必须依据该边界分类,不得削弱或静默跳过。
维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用相互独立的不可变工作区。任一拓扑独有的失败都必须依据该边界分类,不得削弱或静默跳过。
+1 -1
View File
@@ -452,7 +452,7 @@ jobs:
timeout-minutes: 120
env:
DSH_COVERAGE_MAX_WORKERS: '6'
DSH_COVERAGE_PARTITIONS: '6'
DSH_COVERAGE_PARTITIONS: '4'
DSH_COVERAGE_TEST_TIMEOUT_MS: '30000'
DSH_GATE_CONCURRENCY: '3'
steps:
@@ -94,7 +94,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
}
const input = page.locator('[data-composer-input]').first()
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
await input.fill(`/permission ${preset}`)
@@ -106,7 +106,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
await input.fill(PROMPTS[index] as string)
await input.press('Enter')
sessionId = await settled
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
await input.waitFor({ timeout: 10_000 })
}
await input.fill('/permission read-only')
+15
View File
@@ -195,6 +195,21 @@ describe('web e2e: resident question composer round trip', () => {
expect(await blue.getAttribute('aria-checked')).toBe('true')
expect(await custom.inputValue()).toBe('Include accessibility notes')
if (MODE !== 'record') {
// A strict Session-slot switch remounts the composer. Open a fresh blank
// Session, then return to the still-waiting request and require its
// Session-scoped store to restore both option and free-text drafts.
const originalRow = page.locator('[role="treeitem"]')
.filter({ hasText: 'Use the ask_user_question tool' }).first()
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
await page.getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => composer.count(), { timeout: 10_000 }).toBe(0)
await originalRow.click()
await composer.waitFor({ timeout: 15_000 })
expect(await blue.getAttribute('aria-checked')).toBe('true')
expect(await custom.inputValue()).toBe('Include accessibility notes')
// This golden now owns the composed state after a real A -> B -> A
// Session cycle, not merely the state before the remount.
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
}
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-user-questions/README.md
README.md: 51095ef4b8946657d786abddb5dc01cbd462eddb
README.zh.md: ec91254b1ace817ce2e2b667870e7512e2879a2d
README.md: 2462a3d25644cf073b4652d564a9f7a213e92250
README.zh.md: 296974703d77791393811b89fd046078969584b5
+6 -6
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
`dsh-client-ui-user-questions` is the web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot, so when the agent asks the user a question the composer is taken over by the question UI. The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges, and custom answers, and submits one structured answer batch for the whole request. A request whose single question declares a presentation intent renders as that intent's own surface instead — notably the `plan-review` waiting-approval card with `Chat about it` / `Refuse` / `Approve`. Its host half is empty on purpose: mounting `dsh-tool-ask-user` there would put the tool in the registry's global layer and merge it into every agent regardless of the preset that composed it.
`dsh-client-ui-user-questions` is the web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` chain, so when the agent asks the user a question the composer is taken over by the question UI. The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges, and custom answers, and submits one structured answer batch for the whole request. A request whose single question declares a presentation intent renders as that intent's own surface instead — notably the `plan-review` waiting-approval card with `Chat about it` / `Refuse` / `Approve`. Its host half is empty on purpose: mounting `dsh-tool-ask-user` there would put the tool in the registry's global layer and merge it into every agent regardless of the preset that composed it.
## Table of Contents
@@ -29,15 +29,15 @@ When the agent asks a question, the composer becomes the question surface: answe
### Answering
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`.
### The plan-review card
A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead.
A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card layout: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead.
### Failure and recovery
Selection state is local to a component keyed by the request rpcId: a replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
The generic question flow keeps its current page, selected labels, custom text, and explicit skips in a non-persisted Slot store scoped to the owning Session and keyed by the pending request's local render identity. Switching from Session A to B remounts the strict composer entry, but returning to A reuses A's store and restores the unfinished draft. A different request identity reads an empty draft and replaces the previous value on its first edit; a successful answer or cancellation clears the matching value. The host remains authoritative for whether the request is pending.
-----
@@ -76,7 +76,7 @@ These pages cover the composer host, the tool seam, and the plan-mode consumer.
<a id="model-experience"></a>
## Model Experience
Indirectly, through dsh-tool-ask-user, which the package mounts and which owns the model-visible schema and answer rendering.
Indirectly, through `dsh-tool-ask-user`, whose model-visible schema and answer rendering this package presents in the Web client.
#### KV Cache effect
@@ -89,7 +89,7 @@ No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and
These limits define draft durability and composer ownership; they are current package constraints.
- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts.
- **Unsubmitted drafts have page-and-Session lifetime** — Session navigation preserves them while that Session scope remains in the page, but a full page reload, Session pruning, or a newly delivered pending-request identity starts with an empty draft. The store never writes them to the Host, `localStorage`, or disk.
- **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.
<a id="dev-note"></a>
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
`dsh-client-ui-user-questions` 是 Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` 键控 slot 中,因此当 agent 向用户提问时,编辑器会被提问 UI 接管。组件每次渲染一个问题,提供进度导航、单选与多选选项、推荐徽标与自定义答案,并为整个请求提交一批结构化答案。若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面——最典型的是 `plan-review` 等待审批卡片,带 `Chat about it` / `Refuse` / `Approve`。其主机侧刻意为空:在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的全局层,并把它并入每一个 agent,无论它由哪个 preset 组装。
`dsh-client-ui-user-questions` 是 Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` chain 中,因此当 agent 向用户提问时,编辑器会被提问 UI 接管。组件每次渲染一个问题,提供进度导航、单选与多选选项、推荐徽标与自定义答案,并为整个请求提交一批结构化答案。若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面——最典型的是 `plan-review` 等待审批卡片,带 `Chat about it` / `Refuse` / `Approve`。其主机侧刻意为空:在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的全局层,并把它并入每一个 agent,无论它由哪个 preset 组装。
## 目录
@@ -29,15 +29,15 @@ kind: "package-reference"
### 作答
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected``custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected``custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
### plan-review 卡片
`plan-review` 意图——由 `dsh-plan-mode``exit_plan_mode` 审阅上设置——渲染等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。
`plan-review` 意图——由 `dsh-plan-mode``exit_plan_mode` 审阅上设置——渲染等待审批卡片的布局:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。
### 失败与恢复
选择状态存在于以请求 rpcId 为 key 的组件本地:使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态
通用提问流程把当前题号、已选标签、自定义文本和显式跳过状态存在非持久化 Slot store 中;该 store 归属对应 Session,并以待处理请求的本地渲染标识为 key。从 Session A 切换到 B 会重新挂载严格 Session 级编辑器条目,但返回 A 时会复用 A 的 store 并恢复未完成草稿。不同的请求标识读取空草稿,并在首次编辑时替换旧值;成功回答或取消会清除相符的值。请求是否仍在等待由主机保持权威
-----
@@ -76,7 +76,7 @@ kind: "package-reference"
<a id="model-experience"></a>
## 模型体验
间接影响模型体验:通过 `dsh-tool-ask-user` 实现,本包挂载该工具,而该工具拥有模型可见 schema 与答案渲染。
间接影响模型体验:本包在 Web 客户端呈现 `dsh-tool-ask-user` 拥有模型可见 schema 与答案渲染。
#### KV Cache 影响
@@ -89,7 +89,7 @@ kind: "package-reference"
这些限制定义草稿持久性与编辑器归属;它们是当前包约束。
- **未提交草稿不持久**:重新连接再同步或完整刷新页面时,会恢复主机拥有且 rpcId 相同的待处理请求,但编辑器卸载会重置本地选项和自定义文本草稿
- **未提交草稿的生命周期限于当前页面与 Session**:只要该 Session scope 仍留在页面内,Session 导航就会保留草稿;完整刷新页面、Session 被裁剪,或待处理请求以新的本地标识重新交付时,则从空草稿开始。store 从不把草稿写入主机、`localStorage` 或磁盘
- **每次只有一个请求拥有编辑器**:后续待处理请求仍留在会话快照中,并在较早请求落定后显示。
<a id="dev-note"></a>
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-user-questions",
"description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
"description": "Web ask_user_question composer takeover and plan-review presentation UI",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
@@ -69,6 +69,7 @@
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
@@ -10,15 +10,10 @@ import {
type QuestionAnswer, type QuestionComposerProps,
} from './contract/slots.ts'
import type { PendingQuestion } from './contract/slots.ts'
import type { QuestionDraftAnswer, QuestionDraftProgress } from './draft-store.ts'
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
selected: string[]
custom: string
skipped: boolean
}
/**
* Displayed feedback: validation feedback is stored as a dictionary KEY and
* translated at render, so already-shown feedback follows a locale switch;
@@ -46,9 +41,9 @@ function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
/** The free-text answer field shared by both question shapes. */
/** The free-text answer field shared by both question variants. */
interface AnswerFieldProps {
/** Which shape the field takes: the custom row's inline column, or the optionless question's own framed block. */
/** Visual variant: the custom row's inline column or the optionless question's framed block. */
variant: 'inline' | 'block'
/** Current draft text. */
value: string
@@ -79,7 +74,7 @@ interface AnswerFieldProps {
* Mirror and textarea MUST share font, line-height, padding and wrapping rules
* or the two heights diverge.
*
* @param props - field shape, draft text, and the field's event handlers.
* @param props - visual variant, draft text, and the field's event handlers.
* @returns The mirrored auto-growing field.
*/
function AnswerField(props: AnswerFieldProps) {
@@ -102,14 +97,15 @@ function AnswerField(props: AnswerFieldProps) {
}
/**
* Composer takeover boundary; the carrier key keys local drafts, so a
* same-request replay (same key, new carrier object) preserves them.
* Composer takeover router. Generic-question drafts live in this entry's
* Session-scoped Slot store, keyed by the pending carrier, so a strict Session
* entry remount restores the same request without exposing it to another one.
*
* One takeover, two shapes: a request that declares a presentation intent this
* package renders takes that shape (a plan review is one decision over one
* One takeover, two presentations: a request that declares a presentation intent this
* package renders uses that presentation (a plan review is one decision over one
* plan, not a question set), and every other request takes the generic flow.
* The routing lives here, at the one entry that owns the composer seat, so
* neither shape can claim a request the other is already rendering.
* neither presentation can claim a request the other is already rendering.
*
* @param props - the selector-matched pending question carrier plus the framework standard kit.
* @returns The question flow, or the intent's own surface, for this request.
@@ -118,47 +114,74 @@ export function QuestionComposer(props: QuestionComposerProps) {
const question = props.matched
const review = useMemo(() => planReviewOf(question.questions), [question])
return review === undefined
? <QuestionFlow key={question.key} pending={question} t={props.t} />
? (
<QuestionFlow
key={question.key}
pending={question}
t={props.t}
useStore={props.useStore}
actions={props.actions}
/>
)
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
}
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
type QuestionFlowProps =
{ pending: PendingQuestion } & Pick<QuestionComposerProps, 't' | 'useStore' | 'actions'>
function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) {
const questions = pending.questions
const markdownLabels = useMemo(() => ({
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
}), [t])
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
selected: [], custom: '', skipped: false,
})))
const initialProgress = useMemo<QuestionDraftProgress>(() => ({
index: 0,
drafts: questions.map(() => ({ selected: [], custom: '', skipped: false })),
}), [questions])
const storedProgress = useStore(state => (
state.requestKey === pending.key && state.progress.drafts.length === questions.length
? state.progress
: undefined
))
const { index, drafts } = storedProgress ?? initialProgress
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<Feedback | null>(null)
// Collapsed to the header strip so the conversation above stays readable
// while the user decides; the drafts survive because the state lives here.
// while the user decides; answer drafts live in the Session store above.
const [minimized, setMinimized] = useState(false)
// The free-form textarea autofocuses on first presentation; re-expanding a
// collapsed question must not steal focus from the expand toggle back into
// the input, so focus is granted once per question index.
const focusedQuestions = useRef(new Set<number>())
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// Every navigation write stays in bounds and drafts mirrors questions 1:1.
// oxlint-disable-next-line typescript/no-non-null-assertion
const question = questions[index]!
// oxlint-disable-next-line typescript/no-non-null-assertion
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0
const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
actions.replace(pending.key, { index: nextIndex, drafts: nextDrafts })
}
const cancelFlow = (): void => {
setBusy('cancel')
setError(null)
void pending.cancel().catch((cause: unknown) => {
setBusy(null)
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
void pending.cancel()
.then(() => { actions.clear(pending.key) })
.catch((cause: unknown) => {
setBusy(null)
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
}
const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
const updateDraft = (
update: (current: QuestionDraftAnswer) => QuestionDraftAnswer,
nextIndex = index,
): void => {
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index ? update(item) : item)
replaceProgress(nextIndex, nextDrafts)
setError(null)
}
@@ -171,27 +194,24 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
return { ...current, selected, skipped: false }
}
return { selected: [label], custom: '', skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
}
}, question.multiSelect !== true && index < questions.length - 1 ? index + 1 : index)
}
const answered = (item: DraftAnswer): boolean =>
const answered = (item: QuestionDraftAnswer): boolean =>
item.selected.length > 0 || item.custom.trim() !== ''
const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
const completed = (item: QuestionDraftAnswer): boolean => answered(item) || item.skipped
const submitDrafts = (values: DraftAnswer[]): void => {
const submitDrafts = (values: QuestionDraftAnswer[]): void => {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
replaceProgress(missing, values)
setError({ key: 'error.incomplete' })
return
}
const answer: QuestionAnswer = {
answers: questions.map((item, itemIndex) => {
const value = values[itemIndex] as DraftAnswer
const value = values[itemIndex] as QuestionDraftAnswer
if (value.skipped) return { id: item.id, selected: [] }
const custom = value.custom.trim()
return {
@@ -203,10 +223,12 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
}
setBusy('answer')
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
setBusy(null)
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
void pending.answer(answer)
.then(() => { actions.clear(pending.key) })
.catch((cause: unknown) => {
setBusy(null)
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
}
const continueFlow = (): void => {
@@ -215,7 +237,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
return
}
if (index < questions.length - 1) {
setIndex(current => current + 1)
replaceProgress(index + 1, drafts)
setError(null)
return
}
@@ -245,10 +267,9 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
? { selected: [], custom: '', skipped: true }
: item)
setDrafts(nextDrafts)
replaceProgress(index < questions.length - 1 ? index + 1 : index, nextDrafts)
setError(null)
if (index < questions.length - 1) {
setIndex(current => current + 1)
return
}
submitDrafts(nextDrafts)
@@ -382,7 +403,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
<button
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
onClick={() => { replaceProgress(index - 1, drafts); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
@@ -390,7 +411,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
<button
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
onClick={() => { replaceProgress(index + 1, drafts); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
@@ -1,10 +1,11 @@
/** Question composer props and one pending Remote waterfall response. */
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
// The client module declares the conversation.composer SlotMap entry required by PropsRuntime.
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
AskUserQuestionAnswer, AskUserQuestionItem,
} from '@deepseek-ai/dsh-user-questions'
import type { createQuestionDraftStore } from '../draft-store.ts'
declare module '@deepseek-ai/dsh-client-ui-session/client' {
interface SessionPendingInteractionMap {
@@ -38,7 +39,7 @@ function settlePendingComposer(settle: () => void, failureMessage: string): Prom
/**
* A request narrowed to the `plan-review` presentation intent: everything the
* decision card renders and answers with, so the panel never re-reads the
* request shape. `approve` and `decline` are the asker's own options — an
* request fields. `approve` and `decline` are the asker's own options — an
* answer must carry one of those labels verbatim — and `plan` is the markdown
* body under review.
*/
@@ -108,7 +109,7 @@ function questionError(message: string, code: 'ASK_ABORTED' | 'ASK_CANCELLED'):
export class PendingQuestion {
/** Presentation discriminator used by Session pending-interaction consumers. */
readonly kind: 'question' | 'plan-review'
/** Opaque render identity and local-draft remount axis. */
/** Opaque render identity and request key for the Session-scoped draft store. */
readonly key: string
/** The request's question list. */
readonly questions: readonly AskUserQuestionItem[]
@@ -217,4 +218,7 @@ export type QuestionWait = PendingQuestion
* whole behavior surface.
*/
export type QuestionComposerProps =
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>
PropsRuntime<'conversation.composer'>
& PropsStore<ReturnType<typeof createQuestionDraftStore>>
& { matched: QuestionWait }
& PropsLocale<'question'>
@@ -0,0 +1,57 @@
/**
* Session-scoped draft state for the generic question composer. The Slot
* registry owns store instances; this module exports only the factory so a
* plugin reload cannot reuse a module-global handle.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
/** One in-progress answer, including an explicit skip. */
export interface QuestionDraftAnswer {
/** Offered labels currently selected. */
selected: string[]
/** Human-authored alternative or additional answer. */
custom: string
/** Whether the user explicitly skipped this question. */
skipped: boolean
}
/** Navigation and answer drafts for one pending request. */
export interface QuestionDraftProgress {
/** Current question index. */
index: number
/** One draft per question, in request order. */
drafts: QuestionDraftAnswer[]
}
interface QuestionDraftState {
requestKey?: string
progress: QuestionDraftProgress
}
type QuestionDraftActions = {
replace: (draft: QuestionDraftState, requestKey: string, progress: QuestionDraftProgress) => void
clear: (draft: QuestionDraftState, requestKey: string) => void
}
const emptyProgress = (): QuestionDraftProgress => ({ index: 0, drafts: [] })
/**
* Declare the question composer's transient Session store.
* @returns a non-persisted store handle whose instance is owned by the Slot registry.
*/
export function createQuestionDraftStore(): EngineStoreHandle<QuestionDraftState, QuestionDraftActions> {
return defineStore({
init: (): QuestionDraftState => ({ progress: emptyProgress() }),
actions: {
replace: (draft, requestKey, progress) => {
draft.requestKey = requestKey
draft.progress = progress
},
clear: (draft, requestKey) => {
if (draft.requestKey !== requestKey) return
delete draft.requestKey
draft.progress = emptyProgress()
},
},
})
}
@@ -23,6 +23,7 @@ import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
import { PendingQuestion } from './contract/slots.ts'
import { createQuestionDraftStore } from './draft-store.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
@@ -86,6 +87,7 @@ async function answerQuestion(
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries')
const questionDraftStore = createQuestionDraftStore()
const registerPendingInteraction = ctx.uiSession.registerPendingInteraction<PendingQuestion>(
pending => pending.kind === 'plan-review' ? 2 : 1,
)
@@ -95,6 +97,7 @@ export function apply(ctx: ClientContext): void {
select: ({ pendingInteraction }: ComposerChainProps): PendingQuestion | null =>
pendingInteraction instanceof PendingQuestion ? pendingInteraction : null,
locale: NS,
store: questionDraftStore,
},
QuestionComposer,
))
@@ -6,6 +6,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { PendingQuestion } from '../src/client/contract/slots.ts'
import { createQuestionDraftStore } from '../src/client/draft-store.ts'
import { apply, inject } from '../src/client/index.ts'
const SESSION_ID = 'session-question' as SessionId
@@ -122,6 +123,10 @@ describe('apply', () => {
expect(entry.component).toBe(QuestionComposer)
expect(entry.inject).toBeUndefined()
expect(entry.locale).toBe('question')
const store = entry.store as ReturnType<typeof createQuestionDraftStore>
expect(store.create(SESSION_ID).getSnapshot()).toEqual({
progress: { index: 0, drafts: [] },
})
const pending = b.pending.getSnapshot()[0]!
const select = entry.select as (
owner: { pendingInteraction: PendingQuestion | undefined },
@@ -5,6 +5,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait,
} from '../src/client/contract/slots.ts'
import { createQuestionDraftStore } from '../src/client/draft-store.ts'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
@@ -93,6 +94,8 @@ const inputState: InputState = {
queue: [],
}
const questionDraftStore = createQuestionDraftStore().create(SID)
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
const kit: Omit<QuestionComposerProps, 'matched'> = {
sessionId: SID,
@@ -114,6 +117,8 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
pruneImages: () => { throw new Error('unused') },
submit: () => { throw new Error('unused') },
},
useStore: selector => selector(questionDraftStore.getSnapshot()),
actions: questionDraftStore.actions,
t: seatOver(zh, commonZh),
}
@@ -0,0 +1,36 @@
/** Question-composer Session store behavior. */
import { describe, expect, it } from 'vitest'
import { createQuestionDraftStore, type QuestionDraftProgress } from '../src/client/draft-store.ts'
const FIRST: QuestionDraftProgress = {
index: 1,
drafts: [{ selected: ['Fast'], custom: '', skipped: false }],
}
describe('createQuestionDraftStore', () => {
it('keeps one request progress and ignores cleanup from an obsolete request', () => {
const store = createQuestionDraftStore().create('session-one')
store.actions.replace('question:one', FIRST)
expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST })
store.actions.clear('question:older')
expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST })
store.actions.clear('question:one')
expect(store.getSnapshot()).toEqual({ progress: { index: 0, drafts: [] } })
})
it('replaces the previous request atomically instead of accumulating drafts', () => {
const store = createQuestionDraftStore().create('session-one')
const second: QuestionDraftProgress = {
index: 0,
drafts: [{ selected: [], custom: 'Careful', skipped: false }],
}
store.actions.replace('question:one', FIRST)
store.actions.replace('question:two', second)
expect(store.getSnapshot()).toEqual({ requestKey: 'question:two', progress: second })
})
})
@@ -1,8 +1,10 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useSyncExternalStore } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
import { createQuestionDraftStore } from '../src/client/draft-store.ts'
import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
@@ -91,10 +93,10 @@ const inputState: InputState = {
queue: [],
}
/** Framework standard-kit stubs: the composer consumes only the locale seat;
/** Framework standard-kit stubs: the composer consumes the locale and draft-store seats;
* the composed props type mandates delivery of the rest (framework hooks are
* plain stubs per the client testing discipline). */
const kit: Omit<QuestionComposerProps, 'matched'> = {
const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> = {
session: undefined,
sessionId: SID,
pendingInteraction: undefined,
@@ -118,6 +120,18 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
t: seatOver(zh, commonZh),
}
let kit: Omit<QuestionComposerProps, 'matched'>
beforeEach(() => {
const instance = createQuestionDraftStore().create(SID)
const useStore: QuestionComposerProps['useStore'] = selector => useSyncExternalStore(
listener => instance.subscribe(listener),
() => selector(instance.getSnapshot()),
() => selector(instance.getSnapshot()),
)
kit = { ...kitBase, useStore, actions: instance.actions }
})
const QUESTIONS: PendingQuestion['questions'] = [
{
id: 'profile', header: '偏好', question: '选择候选人类型',
@@ -363,13 +377,21 @@ describe('QuestionComposer', () => {
expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
})
it('keeps drafts when the same pending request rerenders', () => {
it('restores the current page and drafts after the strict Session entry remounts', () => {
const pending = wait()
const view = render(<QuestionComposer matched={pending.carrier} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '保留这段草稿' } })
expect(screen.getByText('2 / 3')).toBeTruthy()
view.rerender(<QuestionComposer matched={pending.carrier} {...kit} />)
view.unmount()
render(<QuestionComposer matched={pending.carrier} {...kit} />)
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(screen.getByPlaceholderText<HTMLTextAreaElement>('输入你的答案').value).toBe('保留这段草稿')
fireEvent.click(screen.getByLabelText('上一题'))
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('true')
})
})
@@ -29,6 +29,9 @@
{
"path": "../locale"
},
{
"path": "../store"
},
{
"path": "../ui-conversation"
},
@@ -193,6 +193,8 @@ const randomWorkload = fc.record({
batchSizes,
}))
const randomizedDifferentialTimeoutMs = process.platform === 'win32' ? 120_000 : 60_000
describe('SQLite cross-backend differential behavior', () => {
it('matches JSONL/Zstandard for every packed kind, scalar fallback, suffix, partition, and reopen', async () => {
const events = packingMatrixLog()
@@ -232,6 +234,6 @@ describe('SQLite cross-backend differential behavior', () => {
await verifyBackend(name, join(directory, name), events, batchSizes)
}
}), { numRuns: 100, seed: 0x5A17E })
}, 60_000)
}, randomizedDifferentialTimeoutMs)
})
+3
View File
@@ -3515,6 +3515,9 @@ importers:
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-store':
specifier: workspace:^
version: link:../store
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
+2 -2
View File
@@ -98,9 +98,9 @@ describe('CI workflow', () => {
))
expect(buildCommands.map(step => step.run)).toContain('pnpm run check:ci:windows-blocking')
// windows-coverage runs the 6-partition profile.
// windows-coverage uses the lower 4-partition profile.
expect(windowsCoverage.name).toBe('windows node 24 / coverage')
expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '6' })
expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '4' })
const coverageSteps = windowsCoverage.steps as unknown[]
const coverageCommands = coverageSteps.filter((step): step is Record<string, unknown> & { run: string } => (
isRecord(step) && typeof step.run === 'string'
+14
View File
@@ -194,6 +194,20 @@ describe('gate graph validation', () => {
}
})
it('runs the Windows built-bin smoke after other observational gates settle', () => {
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational'))
const builtBin = observational.find(gate => gate.id === 'built-bin-smoke')
expect(builtBin?.after).toEqual(
observational.filter(gate => gate.id !== 'built-bin-smoke').map(gate => gate.id),
)
const completeBuiltBin = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
.find(gate => gate.id === 'built-bin-smoke')
expect(completeBuiltBin?.after).toContain('windows-site')
expect(completeBuiltBin?.after).not.toContain('docs-site-build')
})
it('applies one configured test and polling timeout to both coverage gates', () => {
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
+14 -3
View File
@@ -507,7 +507,10 @@ function ciWindowsCompleteGates(): Gate[] {
.map(gate => ({
...gate,
allowFailure: true,
after: [...new Set([...coverageAfter, ...(gate.after ?? [])])],
after: [...new Set([
...coverageAfter,
...(gate.after ?? []).map(id => id === 'docs-site-build' ? 'windows-site' : id),
])],
}))
return [
ciBuildGate(),
@@ -518,7 +521,7 @@ function ciWindowsCompleteGates(): Gate[] {
}
function ciWindowsObservationalGates(): Gate[] {
return [
const predecessors = [
...ciStaticGates({ ownsBuild: true }),
// Linux owns required lint and snapshots; Windows omits those duplicates.
pnpmScript('duplication', 'duplication'),
@@ -528,7 +531,15 @@ function ciWindowsObservationalGates(): Gate[] {
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
return [
...predecessors,
{
...builtBinSmokeGate(),
// This smoke starts real application children with bounded startup
// deadlines. Let other Windows processes settle before measuring startup.
after: predecessors.map(gate => gate.id),
},
]
}