Merge remote-tracking branch 'origin/master' into codex/fix-migration

This commit is contained in:
Dudu-0223 committed 2026-09-20 15:45:12 +08:00
commit e848b6226f
65 files changed
+1142 -214

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 56c9770966647e97763e0eaf339e4589fba673fb
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 04351105a56304020f5696cf1ce82d7a83ed2ced
2026-07-10-single-file-executable-sdk-runtime-distribution.md: ff00327c388b0468e5e98d488141aa35eb40d104
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3f7f18a9907b2f43f7cd8467f054f0d0bfcef14a
@@ -23,6 +23,8 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h
`@yao-pkg/pkg` is an exact-pinned root `devDependency` invoked as `pnpm exec pkg`, with [`patches/@[email protected]`](../../../../patches/@[email protected]) removing the SEA bootstrap's `patchChildProcess` call. Unpatched, pkg rewrites spawned commands named `node` — including the string after a `-c`/`/c` flag, exactly the Bash tool's `bash -c` form — to the executable itself and stamps `PKG_EXECPATH` into every child environment, so a model-issued `node --version` silently boots the dsh CLI; Node's own SEA layer performs no such rewrite, and a SEA binary cannot impersonate plain Node because it always boots its embedded app. With the call removed, children resolve `node` through PATH like any other process (a machine without Node reports command-not-found honestly), no `PKG_EXECPATH` reaches children, absolute `process.execPath` spawns still re-enter the app, worker threads never applied the hook, and `process.pkg` sidecar selection is untouched.
The same patch makes the SEA bootstrap's native-addon extraction atomic. On the first `dlopen` of an addon inside the VFS, pkg's `patchDlopen` copies the addon's package tree to `$PKG_NATIVE_CACHE_PATH/pkg/<sha256 of the .node file>/` (default `~/.cache/pkg/...`), a per-user directory that every concurrent process of the same executable shares; upstream writes each file straight to its final path with `writeFileSync`, which truncates a file another process may already have mapped and lets that process's `existsSync`/hash check read a half-written file. Concurrent first starts therefore die with SIGBUS on Linux or fail with `ERR_DLOPEN_FAILED: Library not loaded` on sharp's libvips on macOS (#4664). The patch routes the three write sites through a same-directory temporary file and `renameSync`, so a cache path is either absent or complete, every process finishes its own copy pass before it loads, and concurrent renames of identical content need no lock. The cache location, hash keying, and per-file comparison are unchanged, so a consumer that isolates the cache per instance through `PKG_NATIVE_CACHE_PATH` keeps working; worker threads only define the hook and never install it. Measured (linux-x64, node24 target, pkg 6.21.0, a probe that loads sharp with its 18 MB libvips library, 16 concurrent first starts against one empty cache, five rounds): 39 of 80 processes died with SIGBUS before the change and 0 of 80 after it, with no temporary files left behind.
Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former.
### The serving interface is a plugin inside the dsh application
@@ -23,6 +23,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
`@yao-pkg/pkg` 是精确钉版的根 `devDependency`,经 `pnpm exec pkg` 调用,并以 [`patches/@[email protected]`](../../../../patches/@[email protected]) 移除 SEA bootstrap 中的 `patchChildProcess` 调用。未打补丁时,pkg 会把 spawn 的 `node` 命令——包括 `-c`/`/c` 标志后的命令串,恰是 Bash 工具的 `bash -c` 形态——改写为 exe 自身,并向每个子进程环境注入 `PKG_EXECPATH`,模型下发的 `node --version` 会静默启动 dsh CLI;Node 自身的 SEA 层没有这种改写,且 SEA 二进制永远启动内嵌应用、无法充当纯 Node。移除该调用后,子进程像普通进程一样经 PATH 解析 `node`(无 Node 的机器如实报 command not found),子进程环境不再出现 `PKG_EXECPATH`,以 `process.execPath` 绝对路径 spawn 的重入不受影响,worker 线程本来就未应用该钩子,`process.pkg` 侧车选择也不受影响。
同一补丁还把 SEA bootstrap 的原生插件解包改成原子写入。VFS 内的插件首次 `dlopen` 时,pkg 的 `patchDlopen` 会把该插件所在的包目录整棵拷到 `$PKG_NATIVE_CACHE_PATH/pkg/<.node 文件的 sha256>/`(默认 `~/.cache/pkg/...`),这是同一个 exe 的所有并发进程共用的按用户目录;上游用 `writeFileSync` 直接写目标路径,会把另一个进程可能已经映射的文件截断,并让该进程的 `existsSync`/哈希比对读到写了一半的文件。因此并发首次启动在 Linux 上以 SIGBUS 崩溃,在 macOS 上对 sharp 的 libvips 报 `ERR_DLOPEN_FAILED: Library not loaded`(#4664)。补丁让三处写入都经过同目录临时文件加 `renameSync`,于是缓存路径要么不存在、要么完整,每个进程都在加载前走完自己的拷贝,相同内容的并发 rename 不需要锁。缓存位置、哈希键与逐文件比对均未改变,通过 `PKG_NATIVE_CACHE_PATH` 按实例隔离缓存的消费方照常工作;worker 线程只定义该钩子而从不安装。 实测(linux-x64、node24 构建目标、pkg 6.21.0,一个加载 sharp 及其 18 MB libvips 库的探针,16 个进程对同一个空缓存并发首次启动,跑五轮):改动前 80 个进程中 39 个以 SIGBUS 崩溃,改动后 80 个全部正常,且无残留临时文件。
术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。
### 对外服务接口是 dsh 应用中的插件
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: 990a9b6eef862a7ac4a549358beae7f9081eaa0b
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5c90611fab36611766bb407138c4f0c8a6b3a01b
2026-07-25-web-client-session-scope-and-provide-channel.md: 32be87f3a0a4f8da92100ec7351af788862532f7
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b2e7efae98efb69d5f34d6719a382221ffc7a00d
@@ -28,7 +28,7 @@ Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one pi
- Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id.
- The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls `session.create({workspaceId})` on the spot and receives the complete entity.
- "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text).
- A "blank session" is just an ordinary materialized session whose log is still empty; to every Agent-scope plugin on the host (goal/plan/skill/…) it is indistinguishable from any session, so slash/plan are all naturally live.
- A "blank session" is an ordinary materialized session with no turn yet; to Agent-scope plugins on the host (goal/plan/skill/…) it remains an ordinary Session, so slash/plan are naturally live.
### Agent scope: the actx is the sole session carrier in the client-side cordis world
@@ -63,14 +63,14 @@ Session instances share the scope's lifecycle, while the catalog reports discove
A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable):
- The Host criterion is absence of `turn/start`; slash-command and configuration events do not clear `blank`. Live summaries use the Session projection. Cold summaries use cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md).
- The Host criterion is absence of `turn/start`: the `sessionListMetadata.blank` projection starts true and only that event clears it, so slash-command, configuration, and creation-checkpoint events leave it true even after they reach disk — blank does not mean unpersisted. Missing metadata falls back to `session.seq === 0` for live Sessions and false for cold ones; cold summaries otherwise read cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md).
- The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
- Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
- Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
- Client display also reflects accepted/running observations, whose retention the [blank rollback decision](../bug-fix/2026-09-15-client-session-blank-reconciliation.md) owns:
- The sender's own tab: the **successful response** to the first `prompt()` converts the current `New Session` row in place, adding no list row — acceptance is display memory, not proof that a turn or user message reached durable history. A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
- Other tabs: the `host/session-status (running:true)` frame converts it — a blank session never runs, so the first running necessarily means no longer blank;
- Reconnect alignment: a list pull updates Host summaries while the Manager keeps those observations, and a `sessionListMetadata` hint of existing history prevents re-blanking on top of that; an empty-history response cannot mark a converted session back to blank.
- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally.
- Blank Sessions can persist through creation checkpoints and command events. Browser reloads and Host restarts reuse eligible blanks after acquiring their writer; occupied blanks are skipped. Old logs remain on disk without automatic cleanup.
- Blank Sessions can persist through creation checkpoints and command events, and have no automatic garbage collection: browser reloads, Host restarts, and Workspace connections reuse eligible blanks after acquiring their writer and skip occupied candidates, while list-loading and multi-tab races can still create additional blank Sessions, whose old logs remain on disk.
### connectWorkspace: the sole entry point of New Session
@@ -129,12 +129,13 @@ Blank Sessions retain the header's leading and corner slots so navigation contro
| Components receiving wiring-callback bundles (two-layer inject→props pass-down) | The standard-kit channel lets components fetch their own; the public API converges to hooks + stable props |
| Swapping the no-session Hero view for the entire session Conversation | Even with the outer layout unchanged, the Hero, picker, and composer subtrees would remount together, making the whole UI region jump |
| Making InputBar itself `session-maybe` | The input state machine, keyboard command surface, and actions would all have to accept absent values; replacing only the disabled input body keeps optionality at the shell boundary |
| A dedicated conversion frame | `session-status(running:true)` semantically implies conversion (a blank session never runs); adding a frame buys zero information for one more wire type |
| A dedicated conversion frame | Existing acceptance and running signals provide the observations used by the display rule; a new frame is unnecessary for that rule |
## Consequences
- Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter.
- The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly.
- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse.
- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. The accepted/running display memory that guards the conversion is Client-local.
- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval.
- Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built).
@@ -28,7 +28,7 @@ host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作
- 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。
- 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。
- 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。
- 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill(技能)/…)它与任何会话无异,slash/plan 天然全活。
- 「空会话」是尚无轮次的普通实体化会话;对 host 上的 Agent-scope 插件(goal/plan/skill(技能)/…)而言,它仍是普通 Session,因此 slash/plan 自然可用。
### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体
@@ -63,14 +63,14 @@ Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持
「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变):
- Host 判据是没有 `turn/start`;slash 命令与配置事件不会清除 `blank`。Live summary 使用 Session 投影。Cold summary 使用缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。
- Host 判据是没有 `turn/start`:`sessionListMetadata.blank` 投影初始为 true,且只有该事件会清除它,因此 slash 命令、配置事件与创建检查点即使已落盘也不会清除——blank 不代表未持久化。metadata 缺失时,live Session 回退到 `session.seq === 0`,cold Session 回退到 false;cold summary 其余情况读缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。
- wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。
- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。
- 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank;
- 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
- 客户端展示还受已观察到的受理/运行影响,其保留期限由 [blank 回退修复决策](../bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 拥有:
- 发送方本地:首次 `prompt()` 的**成功响应**把当前 `New Session` 行原地转正,不新增列表行——受理是展示记忆,不证明轮次或用户消息已写入持久历史。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。
- 其他端:`host/session-status (running:true)` 帧转正——blank 会话从不 running,首次 running 必然已非 blank;
- 重连对齐:列表拉取更新 Host 摘要,Manager 则保留这些观察;在此基础上,`sessionListMetadata` 提示已有历史也会阻止回退 blank,因此空历史响应不能把已转正的会话标回 blank。
- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。
- 空白 Session 可以通过创建检查点与命令事件持久化。浏览器刷新与 Host 重启会在取得写锁后复用符合条件的空白会话,并跳过被占用的候选。旧日志保留在磁盘上,不自动清理。
- blank Session 可以通过创建检查点与命令事件持久化,且没有自动垃圾回收:浏览器刷新、Host 重启与工作区连接会在取得写锁后复用符合条件的空白会话并跳过被占用的候选;但列表加载与多 tab 竞态仍可能创建额外的 blank Session,其旧日志保留在磁盘上。
### connectWorkspace:New Session 的唯一入口
@@ -129,12 +129,13 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入
| 组件收 wiring 回调包(inject→props 两层下传) | 标准件通道让组件自取;公共 API 收敛为 hooks + 稳定 props |
| Hero 无会话视图与会话 Conversation 整支互换 | 即使外层 layout 不变,Hero、picker 与 composer 子树仍会一起重建,界面产生整块抖动 |
| 让 InputBar 自身变成 `session-maybe` | 输入状态机、键盘命令面与动作都被迫接受缺省值;只替换 disabled 输入体能把可选性留在外壳边界 |
| 专用「转正」帧 | `session-status(running:true)` 语义蕴含转正(blank 会话从不 running),加帧是 wire 多一型换零信息 |
| 专用「转正」帧 | 既有受理和运行信号已经提供展示规则所需的观察;该规则不需要新增帧 |
## 后果
- 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。
- client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。
- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。
- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。转正所依赖的受理/运行展示记忆仅保留在客户端。
- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。
- 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md
2026-09-15-client-session-blank-reconciliation.md: 7213636e32702c4c51887adba3da019f49b8703c
2026-09-15-client-session-blank-reconciliation.zh.md: 4b50340fea668b6d850c1d00787dda3874b61e0c
@@ -0,0 +1,43 @@
# Agent Note: Keep accepted Session presentation across list refreshes
Status: implemented
English | [中文](2026-09-15-client-session-blank-reconciliation.zh.md)
## Problem
A successful prompt response converts the Client's `New Session` row before a turn necessarily starts. A later `session.list` response can still report `blank: true` and undo that conversion. Reconnect triggers the same refresh. Keeping the conversion only inside a Session object also loses it when that object is replaced.
## Decision
The [Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) retains accepted/running observations in a private set keyed by Session id. List rows and the existing Session blank updates use `summary.blank && !engagedSessions.has(sessionId)`, with `sessionListMetadata` hints still applied on top. The existing summary mutations, Session snapshot fields, constructor, and projection stores retain their responsibilities.
Every successful prompt response invokes the existing `onEngaged` callback, including from a replaced Session object. The Manager updates its retained observation, the list row, and the currently resident Session by id. Rejection records no engagement. A `running: true` status, addition, or list baseline also records engagement; a baseline predating removal cannot restore that observation, even if the row is re-added during the pull.
The observation survives refresh, reconnect, and object replacement. Removal, drop, and successful list refresh forget it only when no list row, Session object, or child address retains the identity. Refresh evaluates retention after replaying in-flight mutations; failed and superseded pulls do not prune observations. Late acceptance cannot recreate a removed row or a Session object. Manager disposal clears the set and prevents late acceptance and outstanding list responses from publishing.
Engagement is display memory, not proof that a turn started, work remains queued, or a message reached durable storage. It is not persisted across page reload. Historical `sessionListMetadata.blank` and the Host summary defaults are unchanged.
## Alternatives considered
**Preserve every previously false summary bit.** A previous Host summary is not evidence of local acceptance or running. Making all false values permanent would also suppress later Host corrections without either observation.
**Introduce a shared Session record.** Combining this memory with projection stores requires constructor and cache-lifetime changes. Those changes are independent of preventing a blank rollback and are excluded.
**Wait for history before converting the row.** This changes the existing acceptance-time display rule and is a separate UI decision.
## Consequences
Accepted or observed-running Sessions retain their existing display conversion, including when accepted input never starts a turn. Drafts, submission-error recovery, sidebar filtering and title rules, creation/reuse, UUIDs, Host protocol, persistence, and projection-cache lifetimes are unchanged. Reverting this Client-only fix needs no data migration.
The Manager owns one additional per-id display flag. It does not replace Session-local presentation fields or implement a history/draft/presentation redesign.
For an identity with neither subagent origin nor a retained child address, a removal notification discards its projection store unless that store already carries a non-empty `subagentCatalog`; a retained Session object keeps the store it was constructed with — for example a current row removed while its Session is still staged. If its projection store was discarded and that identity returns to the list before its Session object is dropped, the Manager creates and seeds a second store that the resident object never reads, so list rows and that object's projection readers can disagree until a replacement instance adopts the current store. Repairing that projection ownership is deferred to its own change; blank display does not depend on it.
## Verification
Manager tests cover refresh without a turn, running-to-idle updates, object replacement, late acceptance, removal through notifications or list reconciliation, retained instances and child addresses, failed and superseded pulls, and disposal. They check both list and Session state where an instance exists. Session tests retain first-send rejection and later-send behavior. Browser verification uses the recorded first-turn scenario with acceptance held before `turn/start` and a same-page reconnect. A timestamp substituted only in the reconnect list response provides a visible synchronization marker; the Host's blank/running values and durable log remain unchanged.
## Related decisions
The [scope and provisioning decision](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) owns Session scope, creation/reuse, and the visible blank row. The [Session/Conversation ownership decision](../architecture/2026-08-20-client-session-conversation-ownership.md) owns transport, assembly, and snapshot contents.
@@ -0,0 +1,43 @@
# Agent Note: 在列表刷新后保留已受理会话的展示状态
Status: implemented
[English](2026-09-15-client-session-blank-reconciliation.md) | 中文
## 问题
提示词请求成功后,客户端会把 `New Session` 行转换为普通会话,但此时轮次不一定已经开始。后续 `session.list` 响应仍可能返回 `blank: true`,从而撤销该转换。重连会触发同样的刷新。若只在 Session 对象内保留转换状态,对象被替换时也会丢失。
## 决策
[Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 使用按 Session id 索引的私有集合,保留已观察到的受理/运行。列表行与既有 Session blank 更新使用 `summary.blank && !engagedSessions.has(sessionId)`,其上仍叠加 `sessionListMetadata` 提示。既有摘要变更、Session 快照字段、构造函数和投影存储保留原有职责。
每个成功的提示词响应都会调用既有 `onEngaged` 回调,包括来自已被替换的 Session 对象的响应。Manager 按 id 更新其保留的观察、列表行和当前驻留 Session。拒绝不会记录受理/运行。`running: true` 状态、添加事件或列表基线也会记录运行;早于移除的基线不能恢复该观察,即使该行在拉取期间重新加入。
该观察跨刷新、重连和对象替换保留。移除、drop 和成功的列表刷新仅在列表行、Session 对象及子会话地址均不再保留该身份时清除它。刷新在重放请求期间的变更后判断保留资格;失败及被替代的请求不会清理观察。迟到的受理不能重建已移除的行或 Session 对象。Manager 销毁会清空集合,并阻止迟到的受理和未完成的列表响应发布。
受理/运行是展示记忆,不证明轮次已经开始、工作仍在排队,或消息已经写入持久存储。它不会跨页面重载持久化。历史 `sessionListMetadata.blank` 与 Host 摘要默认值保持不变。
## 考虑过的替代方案
**保留所有曾为 false 的摘要位。** 先前的 Host 摘要不能证明客户端观察过受理或运行。将所有 false 永久保留,也会在两种观察均不存在时阻止后续 Host 校正。
**引入共享 Session 记录。** 将这份记忆与投影存储合并,需要修改构造参数及缓存生命周期。这些改动独立于防止 blank 回退,因此不纳入。
**等待历史后再转换行。** 这会改变现有的受理时展示规则,属于独立的 UI 决策。
## 后果
已受理或观察过运行的 Session 保持既有展示转换,包括已受理输入始终没有开始轮次的情况。草稿、发送错误恢复、侧边栏筛选与标题规则、创建/复用、UUID、Host 协议、持久化及投影缓存生命周期保持不变。回退这项纯客户端修复不需要数据迁移。
Manager 额外维护一个按 id 保存的展示标记。它不替代 Session 本地展示字段,也不实施历史/草稿/展示状态重构。
对于来源不是 subagent、也没有保留子会话地址的身份,移除通知会丢弃它的投影存储——除非该存储已带有非空的 `subagentCatalog`;而仍被保留的 Session 对象继续持有构造时采用的存储,例如当前行被移除而它的 Session 仍留在舞台上。若投影存储已被丢弃,且该身份在其 Session 对象被销毁前回到列表,Manager 会新建并写入第二个存储,而常驻对象从不读取它,因此在替换实例采用当前存储之前,列表行与该对象的投影读取方可能不一致。该投影归属的修复延期到独立的改动;blank 展示不依赖它。
## 验证
Manager 测试覆盖无轮次刷新、运行转空闲、对象替换、迟到受理、通过通知或列表对账移除、保留实例与子会话地址、失败及被替代的请求,以及销毁。在存在实例的场景中,测试同时检查列表和 Session 状态。Session 测试保留首发拒绝及后续发送行为。浏览器验证使用已有首轮录制场景,在受理后、`turn/start` 前暂停并进行同页重连。测试仅替换重连列表响应中的时间戳,用其显示结果作为同步标记;Host 的 blank/running 值及持久日志保持不变。
## 相关决策
[作用域与供给决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md) 拥有 Session 作用域、创建/复用及可见 blank 行。[Session/Conversation 归属决策](../architecture/2026-08-20-client-session-conversation-ownership.zh.md) 拥有传输、装配及快照内容。
@@ -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/bug-fix/2026-09-16-desktop-window-menus.md
2026-09-16-desktop-window-menus.md: 85ac42aa287b1cd9e595ade7cb44b965dea787dd
2026-09-16-desktop-window-menus.zh.md: 1376b90058ceafa7e378b4fd543d261064f5d8aa
2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47
2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253
@@ -12,7 +12,7 @@ The Desktop shell replaces Electron's default application menu with a custom tem
On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus.
Electron's role labels are English string literals inside Electron (`lib/browser/api/menu-item-roles.ts`, `filemenu`, `windowmenu`, `close`, `minimize`, `hide`) with no locale lookup, and Electron re-applies them to the native menu items before the menu is shown, so a non-English Desktop shows them in English; the existing Edit menu behaves the same way. No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
## Alternatives considered
@@ -28,8 +28,8 @@ Electron's role labels are English string literals inside Electron (`lib/browser
## Consequences
macOS regains the window and application commands the custom menu suppressed, at the cost of four menu roles. The labels stay English on a non-English Desktop.
macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale.
## Testing
A `apps/desktop/tests/main-startup.spec.ts` case pins the declared menu roles per platform, including the macOS hide commands. Role-based menu items execute natively, so a programmatic `click()` and the vitest Electron mock cannot exercise the shortcuts; a real Electron 44 run of the same template showed the standard File, Window, and application items present only after this change, with the same English labels while `app.getLocale()`, `getSystemLocale()`, and `getPreferredSystemLanguages()` all reported `zh-CN`.
`apps/desktop/tests/main-startup.spec.ts` verifies the declared menu roles per platform and records English and Chinese application-label snapshots. Role-based menu items execute natively, so a programmatic `click()` and the Vitest Electron mock cannot exercise the shortcuts. The isolated Electron 44 demo confirms that explicit application labels appear in native menus; the template snapshots do not qualify native shortcut execution.
@@ -12,7 +12,7 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该
macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。
这些 role 的标签是 Electron 内部的英文常量(`lib/browser/api/menu-item-roles.ts` 的 `filemenu`、`windowmenu`、`close`、`minimize`、`hide`),没有语言查询,而且 Electron 在菜单显示前会把这些标签重新写到本机菜单项上,因此在非英文的 Desktop 上它们仍是英文;现有的 Edit 菜单同样如此。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
## 考虑过的替代方案
@@ -28,8 +28,8 @@ macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之
## 影响
macOS 恢复了自定义菜单压掉的窗口和应用命令,代价是四个菜单 role。在非英文的 Desktop 上这些标签仍是英文。
macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。
## 测试
`apps/desktop/tests/main-startup.spec.ts` 中的一个用例固定了各平台声明的菜单 role,包括 macOS 的隐藏命令。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 vitest 的 Electron mock 都无法触达这些快捷键;用真实 Electron 44 运行同一模板显示,只有在本改动之后才出现标准的 File、Window 和应用菜单项,并且在 `app.getLocale()`、`getSystemLocale()` 和 `getPreferredSystemLanguages()` 都报告 `zh-CN` 时标签保持不变。
`apps/desktop/tests/main-startup.spec.ts` 验证各平台声明的菜单 role,并记录中英文应用标签快照。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 Vitest 的 Electron mock 都无法触达这些快捷键。独立 Electron 44 demo 确认显式应用标签会显示在原生菜单中;模板快照不能验证原生快捷键的执行。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md
2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2
2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c
2026-09-17-developer-tools-settings.md: 7819b2a2df3c9eaf2db1e1271d7b419768affaff
2026-09-17-developer-tools-settings.zh.md: 5ff9f2c1e2380f086969cc392bf649edd13e2893
@@ -10,9 +10,9 @@ Diagnostic views, preset selection and changed-file summaries add complexity to
## Decision
The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and the settings row that allows switching Agent modes, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
@@ -26,4 +26,4 @@ Plugin assembly translates the shared preference into component-specific capabil
Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority.
Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold uses the shipped default unless a scenario specifies a preference. Settings chrome, document preview and changed-files scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
@@ -10,9 +10,9 @@ Status: implemented
## Decision
设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换agent模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
@@ -26,4 +26,4 @@ Status: implemented
基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架使用产品默认值,除非场景指定偏好。设置外壳、文档预览和改动文件场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md
2026-09-20-developer-tools-default-on.md: 42c89faed11b966ac8ea0882236af9bf13557702
2026-09-20-developer-tools-default-on.zh.md: a684844f5ba53a0bebdd1c3a3a3ade1ed150cc31
@@ -0,0 +1,31 @@
# Agent Note: Developer tools default on after preference resolution
Status: implemented
English | [中文](2026-09-20-developer-tools-default-on.zh.md)
## Problem
New installations need access to developer views, preset selection, change summaries and interactive HTML previews without first finding a Settings switch. A saved disabled preference must still prevent those features from activating during startup, including script execution that cannot be undone after it occurs.
## Decision
The Host schema defaults `ui-developer-tools.enabled` to `true`. Remote browsers with process-local preferences also start enabled. Saved Host values take precedence, including `false`. This supersedes only the initial default-off choice in [Shared developer-tool settings](2026-09-17-developer-tools-settings.md); that note retains ownership of shared persistence and rendering policy.
Host-backed clients publish disabled until an accepted schema-resolved value arrives. Loading, failed initial reads and an unavailable namespace do not grant interactive-preview capabilities. The Host supplies the default for an absent stored field; the client does not interpret an absent response as consent to enable scripts. An accepted value remains authoritative during later refreshes.
Only the Trajectory tab follows the developer-tools preference; third-party Views remain available. The conversation shell retains the explicit Trajectory id because the requested rule names that View. No current requirement makes arbitrary diagnostic Views share the switch.
## Alternatives considered
**Keep the default off.** This minimizes the initial interface but requires a separate opt-in for the features new installations now expose. Users can still save the disabled preference.
**Default the Host client to enabled before settings arrive.** This makes rendering immediate, but briefly activates features for users who disabled them. Unmounting a scripted HTML iframe cannot undo scripts or requests already executed.
**Add a generic developer-tool flag to View registration.** This expands the plugin API and allows other Views to join a switch whose current requirement is specific to Trajectory. A future requirement can justify that API separately.
## Consequences
Fresh profiles expose developer features after settings resolve; users with a saved disabled choice retain the simpler presentation. New users also receive interactive HTML previews with the capabilities documented by the [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md). Host initialization can briefly withhold developer controls even when the eventual preference is enabled.
Settings tests cover initial withholding, unavailable namespaces, explicit disabled values and process-local defaults. HTML renderer coverage checks that pending and disabled preferences neither read related files nor create script-enabled frames. Browser settings coverage verifies the default and a disabled choice across reload; document-preview replay pins the explicitly disabled scenario.
@@ -0,0 +1,31 @@
# Agent Note: 偏好解析后默认开启开发者工具
Status: implemented
[English](2026-09-20-developer-tools-default-on.md) | 中文
## Problem
新安装需要直接使用开发者视图、预设选择、改动摘要和交互式 HTML 预览,无需先寻找设置开关。已保存的关闭偏好仍必须阻止这些功能在启动期间激活,包括发生后无法撤销的脚本执行。
## Decision
Host schema 将 `ui-developer-tools.enabled` 默认为 `true`。使用进程内偏好的远程浏览器也从开启状态启动。已保存的 Host 值优先,包括 `false`。这仅取代[共享开发者工具设置](2026-09-17-developer-tools-settings.zh.md)中最初默认关闭的选择;该记录继续负责共享持久化和渲染策略。
使用 Host 偏好的客户端在收到经过 schema 解析并接受的值之前发布关闭状态。加载中、首次读取失败和命名空间不可用均不授予交互式预览能力。缺少存储字段时由 Host 提供默认值;客户端不会把缺少响应解释为允许开启脚本。后续刷新期间,已接受的值继续有效。
只有 Trajectory 标签页受开发者工具偏好控制;第三方 View 保持可用。对话外壳保留显式 Trajectory id,因为当前需求明确指定此 View。没有现行需求要求任意诊断 View 共用该开关。
## Alternatives considered
**保持默认关闭。** 这可简化初始界面,但新安装现在直接提供的功能将需要额外启用。用户仍可保存关闭偏好。
**在设置到达前让 Host 客户端默认开启。** 这可以立即渲染,却会短暂激活用户已关闭的功能。卸载可执行脚本的 HTML iframe 无法撤销已经执行的脚本或请求。
**在 View 注册中增加通用开发者工具标记。** 这会扩展插件 API,并允许其他 View 加入当前仅针对 Trajectory 的开关。未来需求可以单独论证该 API。
## Consequences
新 profile 在设置解析后展示开发者功能;已保存关闭选择的用户保留简化展示。新用户也会获得带有[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)所述能力的交互式 HTML 预览。即使最终偏好为开启,Host 初始化期间也可能短暂不展示开发者控件。
设置测试覆盖首次等待、命名空间不可用、显式关闭值和进程内默认值。HTML 渲染器覆盖验证等待中及关闭偏好均不读取关联文件,也不创建允许脚本的 frame。浏览器设置覆盖验证默认值和关闭选择在刷新后保持;文档预览回放固定显式关闭的场景。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: 6c9a6a8784732e6fa1369df5632391b789786b22
README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31
README.md: da4db5806b8ca50e14fc2e95d579cbcf0e50bb77
README.zh.md: 8d43dbde6b829555203b39d06c71b367f318e14b
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
+1 -1
View File
@@ -4,7 +4,7 @@
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
+8
View File
@@ -3,6 +3,10 @@
export const en = {
application: 'Application',
aboutMenu: 'About DeepSeek Harness',
hideApplication: 'Hide DeepSeek Harness',
hideOtherApplications: 'Hide Others',
showAllApplications: 'Show All',
quitApplication: 'Quit DeepSeek Harness',
edit: 'Edit',
menuBar: 'Application menu',
delete: 'Delete',
@@ -94,6 +98,10 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
export const zh = {
application: '应用',
aboutMenu: '关于 DeepSeek Harness',
hideApplication: '隐藏 DeepSeek Harness',
hideOtherApplications: '隐藏其他',
showAllApplications: '显示全部',
quitApplication: '退出 DeepSeek Harness',
edit: '编辑',
menuBar: '应用菜单',
delete: '删除',
+6 -2
View File
@@ -608,12 +608,15 @@ async function main(): Promise<void> {
})
// A custom application menu replaces Electron's default menu, so macOS needs
// its standard menus and application hide commands declared explicitly.
// Keep app.name stable: Electron derives its default userData directory from it.
const darwin = process.platform === 'darwin'
const platformMenus: MenuItemConstructorOptions[] = darwin
? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]
: [{ role: 'editMenu' }]
const hideCommands: MenuItemConstructorOptions[] = darwin
? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }]
? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication },
{ role: 'hideOthers', label: currentDesktopLocale().messages.hideOtherApplications },
{ role: 'unhide', label: currentDesktopLocale().messages.showAllApplications }, { type: 'separator' }]
: []
const applicationItems = (): MenuItemConstructorOptions[] => [
{ label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
@@ -621,7 +624,8 @@ async function main(): Promise<void> {
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
{ type: 'separator' },
...hideCommands,
{ role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) },
{ role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication }
: process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) },
]
Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{
label: darwin ? app.name : currentDesktopLocale().messages.application,
@@ -0,0 +1,18 @@
[
{
"role": "hide",
"label": "Hide DeepSeek Harness"
},
{
"role": "hideOthers",
"label": "Hide Others"
},
{
"role": "unhide",
"label": "Show All"
},
{
"role": "quit",
"label": "Quit DeepSeek Harness"
}
]
@@ -0,0 +1,18 @@
[
{
"role": "hide",
"label": "隐藏 DeepSeek Harness"
},
{
"role": "hideOthers",
"label": "隐藏其他"
},
{
"role": "unhide",
"label": "显示全部"
},
{
"role": "quit",
"label": "退出 DeepSeek Harness"
}
]
+16
View File
@@ -672,6 +672,22 @@ describe('desktop main startup', () => {
expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce()
})
it.each(['en-US', 'zh-CN'])('localizes macOS visibility and quit commands without changing the application name (%s)', async (locale) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale)
const originalName = harness.app.name
harness.app.name = '@deepseek-ai/dsh-desktop'
try {
await import('../src/main.ts')
await harness.preparing.promise
const commands = applicationMenuItems().filter(item =>
item.role === 'hide' || item.role === 'hideOthers' || item.role === 'unhide' || item.role === 'quit')
await expect(JSON.stringify(commands, null, 2) + '\n')
.toMatchFileSnapshot(`./expected/application-menu-${locale}.json`)
expect(harness.app.name).toBe('@deepseek-ai/dsh-desktop')
} finally { harness.app.name = originalName }
})
it('attaches Host socket credentials only to the owned application origin and window', async () => {
await import('../src/main.ts')
await harness.preparing.promise
+9 -11
View File
@@ -99,7 +99,7 @@ interface CapturedFixture {
}
export interface AssembledRemoteOptions {
/** Supply an enabled Host preference for diagnostic View scenarios. */
/** Override the schema-resolved Host preference for developer-tool scenarios. */
readonly developerTools?: boolean
/** Return the fixture's image-dimension admission error from Session prompt. */
readonly rejectPrompt?: boolean
@@ -142,16 +142,14 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
const mock = RemoteMock.create().load(remoteDefaultResponses)
mock.load({
unary: {
'settings/describe': options.developerTools === true
? ok({
...fixture.settingsDescribe.value,
namespaces: [...fixture.settingsDescribe.value.namespaces, {
ns: 'ui-developer-tools',
schema: { type: 'object', dict: { enabled: { type: 'boolean' } } },
value: { enabled: true }, applies: 'live', secrets: [], revision: 0,
}],
})
: structuredClone(fixture.settingsDescribe),
'settings/describe': ok({
...fixture.settingsDescribe.value,
namespaces: [...fixture.settingsDescribe.value.namespaces, {
ns: 'ui-developer-tools',
schema: { type: 'object', dict: { enabled: { type: 'boolean' } } },
value: { enabled: options.developerTools ?? true }, applies: 'live', secrets: [], revision: 0,
}],
}),
'credentials/describe': structuredClone(fixture.credentialsDescribe),
'session/modelCatalog': structuredClone(fixture.modelCatalog),
'agentPresets/list': structuredClone(fixture.agentPresets),
+20 -9
View File
@@ -1,4 +1,4 @@
/** Shared settings are off on a fresh Host and survive browser reload through the ordinary settings UI. */
/** Developer tools default on for a fresh Host and survive browser reload through the ordinary settings UI. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { chromium } from 'playwright'
@@ -7,7 +7,7 @@ import { launchWebScaffold } from './scaffold.ts'
import { newEnglishPage } from './support.ts'
it('persists developer tools in the Host settings document and restores the accepted choice', async () => {
const scaffold = await launchWebScaffold({ developerTools: false })
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
@@ -15,15 +15,26 @@ it('persists developer tools in the Host settings document and restores the acce
await page.goto(scaffold.authenticatedUrl)
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const toggle = page.getByRole('switch', { name: 'Developer tools' })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
expect(await toggle.getAttribute('aria-checked')).toBe('true')
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:')
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: true')
await page.reload()
await page.getByRole('button', { name: 'Settings', exact: true }).click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: false')
await page.reload()
await page.getByRole('button', { name: 'Settings', exact: true }).click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
await page.getByRole('button', { name: 'Agent presets', exact: true }).click()
const pickerToggle = page.getByRole('switch', { name: 'Allow switching Agent modes' })
await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1)
expect(await pickerToggle.count()).toBe(0)
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true })
await expect.poll(() => pickerToggle.count()).toBe(1)
await expect.poll(() => pickerToggle.isEnabled()).toBe(true)
const pickerEnabled = await pickerToggle.getAttribute('aria-checked')
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: false })
await expect.poll(() => pickerToggle.count()).toBe(0)
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true })
await expect.poll(() => pickerToggle.isEnabled()).toBe(true)
await expect.poll(() => pickerToggle.getAttribute('aria-checked')).toBe(pickerEnabled)
})
+1 -1
View File
@@ -365,7 +365,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
await successShot(page, 'html-basic')
sections.push([
'## Basic HTML', '',
'- Developer tools: off by default on Web and desktop',
'- Developer tools: disabled for this scenario',
'- Sandbox: no permissions',
`- Inline script: ${await basicHtml.locator('#result').innerText()}`,
`- Local script: ${await basicHtml.locator('#local-result').innerText()}`,
+120 -25
View File
@@ -4,20 +4,26 @@
// or the live adapter (record). Drive steps run in every mode and wait only
// on generic completion (whenTurnSettled — never model-content selectors, so
// record cannot hang on a live model answering differently); assertion steps
// run in replay/refresh only. Settled states only — streaming fidelity is
// asserted from the durable embedded Assistant stream, not transient DOM.
// run in replay/refresh only. Streaming fidelity is asserted from the durable
// embedded Assistant stream, not transient DOM.
// Record: DSH_SNAPSHOT=record writes session.v3.jsonl, then a keyless
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
// Suite setup (beforeAll, not per step): one fixed page clock
// (page.clock.setFixedTime) and one routed Remote mux socket
// (page.routeWebSocket) serve every step below, so the reconnect checkpoints
// rely on both without re-pinning or re-routing them.
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import type { Browser, Page, Route, WebSocketRoute } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionListValue } from '@deepseek-ai/dsh-api-session-controller/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import {
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
@@ -29,6 +35,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-t
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url))
const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url))
const ACCEPTED_RECONNECT_EXPECTED = join(SNAPSHOT_DIR, 'accepted-reconnect.expected.md')
const BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-reload.expected.md')
const UI_EXPANDED_EXPECTED = fileURLToPath(
new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url),
@@ -41,6 +48,8 @@ const MODE = webSnapshotMode()
// drift apart.
const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.'
type SessionListResponse = { result: RemoteResult<SessionListValue> }
/** Rendered text of the system prompt surface node, or undefined when the surface carries none. */
function systemPromptText(session: Session): string | undefined {
const message = session.deriveMessages().find(candidate => candidate.role === 'system')
@@ -53,7 +62,9 @@ describe('web e2e: fresh round trip through the real assembly', () => {
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let settledSessionId: SessionId | undefined
let remoteSocket: WebSocketRoute | undefined
const sessionEvents: SessionEvent[] = []
const browserNow = Date.now()
beforeAll(async () => {
scaffold = await launchWebScaffold({
@@ -63,6 +74,11 @@ describe('web e2e: fresh round trip through the real assembly', () => {
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
await page.clock.setFixedTime(browserNow)
await page.routeWebSocket('**/api/remote.mux', (route) => {
remoteSocket = route
route.connectToServer()
})
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -94,26 +110,104 @@ describe('web e2e: fresh round trip through the real assembly', () => {
}
const input = page.locator('[data-composer-input]').first()
await input.waitFor({ timeout: 10_000 })
// Arm the host-side settled barrier BEFORE the send click.
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
const echoSnapshot = await input.evaluate(async (element, prompt) => {
element.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', code: 'Enter', bubbles: true, cancelable: true,
}))
// sendSession registered its paint yield first. This frame observes the
// committed echo while admission remains queued on the following task.
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const echo = document.querySelector<HTMLElement>('[data-submission-echo]')
return [
`echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`,
`composer: ${JSON.stringify(element.textContent ?? '')}`,
`contenteditable: ${element.getAttribute('contenteditable')}`,
].join('\n')
}, PROMPT)
await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE)
const sessionId = await settled
settledSessionId = sessionId
const sessionId = await page.evaluate(() => {
const selected = JSON.parse(localStorage.getItem('dsh.sessions.current')!) as { sessionId: string }
return selected.sessionId
}) as SessionId
const agent = scaffold.ctx.agents.get(sessionId)
if (agent === undefined) throw new Error('the selected Web Session has no live Agent')
await agent.whenIdle()
const release = Promise.withResolvers<undefined>()
// Maintenance leaves status idle and accepts inbox input without starting a turn.
const maintenance = agent.runMaintenance(() => release.promise)
let accepted = false
try {
await input.fill(PROMPT)
const admitted = page.waitForResponse('**/api/session/prompt')
const [echoSnapshot, response] = await Promise.all([
input.evaluate(async (element, prompt) => {
element.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', code: 'Enter', bubbles: true, cancelable: true,
}))
// sendSession registered its paint yield first. This frame observes the
// committed echo while admission remains queued on the following task.
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const echo = document.querySelector<HTMLElement>('[data-submission-echo]')
return [
`echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`,
`composer: ${JSON.stringify(element.textContent ?? '')}`,
`contenteditable: ${element.getAttribute('contenteditable')}`,
].join('\n')
}, PROMPT),
admitted,
])
expect(await response.json()).toMatchObject({ result: { ok: true, value: { accepted: true } } })
accepted = true
await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE)
const row = page.getByRole('tree', { name: 'Sessions', exact: true })
.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => row.getAttribute('draggable'), { timeout: 5_000 }).toBe('true')
expect(sessionEvents.some(event => event.type === 'turn/start')).toBe(false)
const disconnected = remoteSocket
if (disconnected === undefined) throw new Error('the Web Remote socket never connected')
const warningStart = tripwire.warnings.length
// Only this list response carries the 2d timestamp. Its rendered label
// confirms the refresh reached the row, without altering blank/running.
const listUrl = new URL('/api/session/list', scaffold.baseUrl).href
const markListResponse = async (route: Route): Promise<void> => {
const response = await route.fetch()
try {
const body = await response.json() as SessionListResponse
if (body.result.ok) {
body.result = { ...body.result, value: {
...body.result.value,
items: body.result.value.items.map(item => item.sessionId === sessionId
? { ...item, updatedAt: browserNow - 2 * 86_400_000 }
: item),
} }
}
await route.fulfill({ response, json: body })
} finally {
await response.dispose()
}
}
await page.route(listUrl, markListResponse, { times: 1 })
try {
const refreshing = page.waitForResponse(listUrl)
const [, refreshed] = await Promise.all([disconnected.close(), refreshing])
// Cross-network reconnect: allow more than expect.poll's default.
await expect.poll(() => remoteSocket !== disconnected, { timeout: 15_000 }).toBe(true)
const baseline = await refreshed.json() as SessionListResponse
expect(baseline.result.ok).toBe(true)
if (!baseline.result.ok) throw new Error('the reconnect list request failed')
const hostSummary = baseline.result.value.items.find(item => item.sessionId === sessionId)
expect(hostSummary).toMatchObject({ blank: true, running: false })
await row.getByText('2d', { exact: true }).waitFor()
expect(await row.getAttribute('draggable')).toBe('true')
expect(await row.getByText('New Session', { exact: true }).count()).toBe(0)
expect(sessionEvents.some(event => event.type === 'turn/start')).toBe(false)
const checkpoint = [
`selected sidebar rows: ${await row.count()}`,
`new-session labels: ${await row.getByText('New Session', { exact: true }).count()}`,
`row draggable: ${await row.getAttribute('draggable')}`,
`host history blank: ${hostSummary?.blank}`,
`composer: ${JSON.stringify(await input.textContent())}`,
`contenteditable: ${await input.getAttribute('contenteditable')}`,
].join('\n')
await compareOrRefreshGolden(ACCEPTED_RECONNECT_EXPECTED, checkpoint, MODE)
acknowledgeReloadConnectionLoss(tripwire, warningStart)
} finally {
await page.unroute(listUrl, markListResponse)
}
} finally {
// No turn can settle before maintenance releases the accepted input.
const settled = accepted ? scaffold.whenTurnSettled() : undefined
release.resolve(undefined)
await maintenance
if (settled !== undefined) settledSessionId = await settled
await agent.whenIdle()
}
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
@@ -237,12 +331,13 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
})
it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
it.skipIf(MODE === 'record')('stayed clean after the intentional reconnect: no pageerrors or server errors', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'blank-reload.expected.md',
'session.v3.jsonl',
'accepted-reconnect.expected.md',
'submission-echo.expected.md',
'system-prompt.expected.md',
'tool-schemas.expected.json',
+3 -3
View File
@@ -302,7 +302,7 @@ export interface WebScaffold {
/** Options for {@link launchWebScaffold}. */
export interface LaunchOptions {
/** The scaffold enables developer tools unless false preserves the shipped default. */
/** Override the developer-tools preference; omitted uses the shipped default. */
developerTools?: boolean
/** Enable the real Open In rows with deterministic launch-environment facts. */
openInAppEnvironment?: LaunchEnvironmentSnapshot
@@ -818,8 +818,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}
await ctx.loader.await()
await auditStartupEntries(ctx, 'web e2e scaffold')
if (options.developerTools !== false) {
await ctx.settings.update('ui-developer-tools', { enabled: true })
if (options.developerTools !== undefined) {
await ctx.settings.update('ui-developer-tools', { enabled: options.developerTools })
}
if (options.welcomeNoticePending !== true) {
await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
README.md: 7f4654a17b165bff8cd4d26f52afe1a663b15509
README.zh.md: cbd27d898ca1f1db16f98b3a62a62d9be3e0494e
README.md: 49f38e5af8f0939dd8f1ccafcd7928b10954ca33
README.zh.md: 57112fc5ce88567ecfe415ed7ee4dd5ac9d34224
@@ -39,6 +39,8 @@ Client list refreshes retain unchanged row objects and reuse the items array whe
Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes.
Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
@@ -99,6 +101,8 @@ No direct effect; model requests remain owned by the Agent and LLM packages.
- A failed follow resumption remains visible to the caller instead of retrying indefinitely.
- The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero.
- File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata.
- Accepted/running display memory lives only in Client memory and is lost on page reload.
- That memory is not shared between tabs: one tab can show a converted row while another still shows `New Session` for the same Session.
<a id="dev-note"></a>
@@ -39,6 +39,8 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时
投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。
已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
@@ -99,6 +101,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
- follow 恢复失败会对调用方可见,而不会无限重试。
- 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第零字节重新传输整个文件。
- 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。
- 受理/运行的展示记忆只存在于客户端内存,页面重载后即丢失。
- 该记忆不在 tab 之间共享:同一会话可能在一个 tab 中已转正,在另一个 tab 中仍显示为 `New Session`。
<a id="dev-note"></a>
@@ -19,7 +19,10 @@ export interface SessionListEntry {
title?: string
updatedAt: number
running: boolean
/** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */
/**
* New Session presentation and reuse eligibility, reconciled with
* `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer).
*/
blank: boolean
parentSessionId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
@@ -87,6 +87,15 @@ export class SessionManager {
private readonly sessions = new Map<SessionId, Session>()
/** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */
private readonly sessionDisposals = new Set<Promise<void>>()
/**
* Accepted/running presentation must survive a later empty-history list
* response. Host-asserted running is recorded even before a row, instance, or
* address holds the identity — the listing that would hold it may not have
* landed yet — while the client-local acceptance callback requires a current
* holder, because it can arrive from a replaced or already-dropped Session.
*/
private readonly engagedSessions = new Set<SessionId>()
private disposed = false
/** Per-session projection value stores, retained independently of instance arrival (the
* title-snapshot precedent, generalized): push frames land here whether or not the Session
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -184,6 +193,7 @@ export class SessionManager {
if (session !== expected) return Promise.resolve()
this.sessions.delete(sessionId)
this.addresses.delete(sessionId)
this.pruneEngagement(sessionId, this.retainedIds(this.summaries))
return this.startSessionDisposal(session)
}
@@ -192,6 +202,10 @@ export class SessionManager {
* @returns once catalog requests and every Session stream have stopped.
*/
async dispose(): Promise<void> {
this.disposed = true
this.listMutations = null
this.listInflight = null
this.engagedSessions.clear()
const reads = [...this.projectionInflight.values()]
for (const { controller } of reads) controller.abort()
this.projectionInflight.clear()
@@ -235,7 +249,7 @@ export class SessionManager {
// instance (consistency when the list precedes open).
const summary = this.summaries.find(s => s.sessionId === sessionId)
if (summary !== undefined) {
session.handleBlank(summary.blank)
session.handleBlank(this.effectiveBlank(summary))
session.handleRunning(summary.running)
} else {
const address = this.addresses.get(sessionId)
@@ -268,12 +282,44 @@ export class SessionManager {
// The sender's local first-send flip mirrors into the list row so the
// session surfaces (lists filter on blank) before any host frame lands.
onEngaged: (engaged) => {
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
if (this.disposed || !this.retainedIds(this.summaries).has(engaged.sessionId)) return
// An identity already on the ledger needs no second row mutation.
if (!this.engagedSessions.has(engaged.sessionId)) {
this.engagedSessions.add(engaged.sessionId)
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
}
this.sessions.get(engaged.sessionId)?.handleBlank(false)
},
projections: this.projectionStore(sessionId),
})
}
private effectiveBlank(summary: SessionSummary): boolean {
return summary.blank && !this.engagedSessions.has(summary.sessionId)
}
/**
* Identities an engagement may still belong to: the given list rows, resident
* Session instances, and retained child addresses.
* @param summaries - list rows of the caller's snapshot.
* @returns the retained identity set.
*/
private retainedIds(summaries: readonly SessionSummary[]): Set<SessionId> {
const retained = new Set(summaries.map(summary => summary.sessionId))
for (const sessionId of this.sessions.keys()) retained.add(sessionId)
for (const sessionId of this.addresses.keys()) retained.add(sessionId)
return retained
}
/**
* Forget one engagement that no retained identity holds.
* @param sessionId - identity whose engagement may be dropped.
* @param retained - identities from {@link retainedIds} for the caller's snapshot.
*/
private pruneEngagement(sessionId: SessionId, retained: ReadonlySet<SessionId>): void {
if (!retained.has(sessionId)) this.engagedSessions.delete(sessionId)
}
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
private projectionStore(sessionId: SessionId): ProjectionValueStore {
let store = this.projectionStores.get(sessionId)
@@ -367,15 +413,27 @@ export class SessionManager {
const baseline: SessionSummary[] = this.listPhase === 'pending'
? [...result.value.items]
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
this.summaries = mutations.reduce(applyMutation, baseline)
// A removal supersedes running observed in the pull, including after re-addition.
const removedSincePull = new Set<SessionId>()
for (const mutation of mutations) {
if (mutation.kind === 'remove') removedSincePull.add(mutation.sessionId)
}
for (const s of baseline) {
if (s.running && !removedSincePull.has(s.sessionId)) this.engagedSessions.add(s.sessionId)
}
const summaries = mutations.reduce(applyMutation, baseline)
this.summaries = summaries
// A full list can remove identities without a removal frame.
const retained = this.retainedIds(summaries)
for (const sessionId of this.engagedSessions) this.pruneEngagement(sessionId, retained)
this.listState = 'idle'
this.listPhase = 'ready'
this.updateParentAvailability()
// Sessions reconcile list blank hints with their current metadata projection.
// Resident Sessions and list rows share one display blank, reconciled with the metadata projection.
for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId)
if (session === undefined) continue
session.handleBlank(s.blank)
session.handleBlank(this.effectiveBlank(s))
session.handleRunning(s.running)
}
// Land each row's projection block in the per-session value store
@@ -524,6 +582,7 @@ export class SessionManager {
/** Apply immediately and retain for replay when a list response is in flight. */
private recordMutation(mutation: SessionListMutation): void {
if (this.disposed) return
this.listMutations?.push(mutation)
this.summaries = applyMutation(this.summaries, mutation)
this.notifier.markDirty()
@@ -599,7 +658,8 @@ export class SessionManager {
*/
handleSessionAdded(summary: SessionSummary): void {
this.mergeSummary(summary)
this.sessions.get(summary.sessionId)?.handleBlank(summary.blank)
if (!this.disposed && summary.running) this.engagedSessions.add(summary.sessionId)
this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary))
if (summary.projections !== undefined) this.applyListBlock(summary.sessionId, summary.projections)
}
@@ -644,6 +704,7 @@ export class SessionManager {
if (!durableSubagent && (catalog === undefined || catalog.length === 0)) {
this.projectionStores.delete(sessionId)
}
this.pruneEngagement(sessionId, this.retainedIds(this.summaries))
this.projectionInflight.get(sessionId)?.controller.abort()
this.projectionInflight.delete(sessionId)
this.projectionLoads.delete(sessionId)
@@ -660,6 +721,7 @@ export class SessionManager {
* @param running - current Agent running state.
*/
handleSessionStatus(sessionId: SessionId, running: boolean): void {
if (!this.disposed && running) this.engagedSessions.add(sessionId)
this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true })
this.updateParentAvailability()
this.sessions.get(sessionId)?.handleRunning(running)
@@ -716,7 +778,7 @@ export class SessionManager {
return {
...summary,
// Cached list hints can precede a history opening or control update.
blank: summary.blank && metadata?.blank !== false,
blank: this.effectiveBlank(summary) && metadata?.blank !== false,
updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0),
...(typeof title === 'string' && title !== '' ? { title } : {}),
...(projectionValues === undefined ? {} : { projectionValues }),
@@ -786,8 +848,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
case 'remove':
return summaries.filter(summary => summary.sessionId !== mutation.sessionId)
case 'status':
// running:true doubles as the cross-client blank flip (a blank session
// never runs, so the first running frame proves a message landed).
// Running converts display state; it does not establish durable history.
return summaries.map(summary => summary.sessionId === mutation.sessionId
&& (summary.running !== mutation.running
|| summary.agentAvailable !== mutation.agentAvailable || (mutation.running && summary.blank))
@@ -39,7 +39,8 @@ export interface SessionSummary {
/** Local ownership counts; Host metadata refreshes cannot overwrite them. */
readonly retainedBy: SessionRetainInfo['retainedBy']
/**
* Blankness from the Host summary reconciled with `sessionListMetadata`.
* New Session presentation and reuse eligibility, derived from the Host
* summary, `sessionListMetadata`, and client acceptance/running observations.
* New Session reuses a blank one targeting the same workspace. Filtering
* stays with the consumer: the store carries every row, while the Workspace
* browser shows only the selected blank entry.
@@ -66,12 +66,9 @@ export interface SessionOptions {
/** Whether the exact direct parent Agent is available in Host summaries; absent until known. */
parentAvailable?: boolean
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
* prompt RPC's success response): the manager mirrors the blank→false flip
* into its list row so the session surfaces without waiting for a host
* frame. Acceptance is the flip point because it proves the user message
* is in the host log; a rejected first prompt keeps the session blank
* (hidden, still reusable by connectWorkspace).
* Publish each accepted prompt to the Manager, including after this Session
* object is replaced. Acceptance converts display state, but does not
* establish that a turn started or reached durable history.
*/
onEngaged?(session: Session): void
/**
@@ -117,7 +114,7 @@ export class Session implements SessionFace {
private promptAttempted = false
/** A first accepted prompt stays in the engaging phase until its turn is observable. */
private firstPromptPendingTurn = false
/** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */
/** New Session display state; unknown bare sessions begin conservatively blank. */
private blankBit = true
private removed = false
private promptError: PromptError | null = null
@@ -291,19 +288,12 @@ export class Session implements SessionFace {
this.notifier.markDirty()
return result
}
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
// conversation's first turn on the host (the host criterion — a logged
// turn/start — is fact, not optimism; standalone command and projection
// events never flip it), while a rejected first prompt must keep the
// session blank — the client-side blank mirror only ever lowers, so
// flipping early on a failure would surface the session forever and
// strip its connectWorkspace reuse eligibility against the host's
// authority.
// Rejection must leave a first prompt blank and eligible for workspace reuse.
if (this.blankBit) {
this.blankBit = false
this.options.onEngaged?.(this)
this.notifier.markDirty()
}
this.options.onEngaged?.(this)
return result
}
@@ -514,8 +504,7 @@ export class Session implements SessionFace {
* @param running - the new running state.
*/
handleRunning(running: boolean): void {
// Turn-start conversion: a blank session never runs, so the first
// running:true proves another side's first message landed.
// Running converts display state without establishing durable turn history.
if (running && this.blankBit) {
this.blankBit = false
this.notifier.markDirty()
@@ -553,9 +542,12 @@ export class Session implements SessionFace {
}
/**
* Relay list blankness without overriding a started conversation established
* by the current projection, a local prompt, or running state.
* @param blank - whether the list or projection reports an unstarted conversation.
* Apply the Manager's effective display blank, further reconciled with the
* current `sessionListMetadata` projection. Local send attempts and current
* running state prevent re-blanking; an earlier false summary alone does not.
* The Manager retains acceptance and earlier running observations across
* Session-object replacement.
* @param blank - New Session display state after Manager reconciliation.
*/
handleBlank(blank: boolean): void {
blank = blank && this.projections.values().sessionListMetadata?.blank !== false
@@ -3,8 +3,9 @@
* frame routing, and control baselines for uninstantiated sessions.
*/
import { describe, expect, vi } from 'vitest'
import { describe, expect, onTestFinished, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
@@ -48,6 +49,318 @@ function makeManager(
}
describe('SessionManager instances', () => {
it.for(['event', 'list'] as const)(
'forgets running when an unretained identity disappears through a %s',
async (source, { mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] }))
await manager.refreshList()
manager.handleSessionStatus(S1, true)
manager.handleSessionStatus(S1, false)
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
if (source === 'event') manager.handleSessionRemoved(S1)
else {
remote.session.list.mockResolvedValueOnce(ok({ items: [] }))
await manager.refreshList()
}
expect(manager.getListSnapshot().items).toEqual([])
manager.handleSessionAdded(summary(S1, { blank: true }))
expect(manager.getListSnapshot().items[0]?.blank).toBe(true)
},
)
it('retains acceptance while an unlisted Session remains resident, then forgets it on drop', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
const session = manager.get(S1)
await session.prompt([{ type: 'text', text: 'first' }], 'queue')
remote.session.list.mockResolvedValue(ok({ items: [] }))
await manager.refreshList()
expect(manager.getListSnapshot().items).toEqual([])
expect(session.getSnapshot().blank).toBe(false)
manager.handleSessionAdded(summary(S1, { blank: true }))
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
await manager.refreshList()
await manager.drop(S1, session)
manager.handleSessionAdded(summary(S1, { blank: true }))
expect(manager.getListSnapshot().items[0]?.blank).toBe(true)
})
it('retains running for an unlisted child with only a retained address', async ({ mock, remote }) => {
const address: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.resolveTarget(address)
manager.handleSessionAdded(summary(S2, { blank: true }))
manager.handleSessionStatus(S2, true)
manager.handleSessionStatus(S2, false)
remote.session.list.mockResolvedValueOnce(ok({ items: [] }))
await manager.refreshList()
expect(manager.getListSnapshot().items).toEqual([])
manager.handleSessionAdded(summary(S2, { blank: true }))
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
})
it('retains running for a row added during a pull whose baseline omits it', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
manager.handleSessionStatus(S1, true)
manager.handleSessionStatus(S1, false)
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
remote.session.list.mockReturnValueOnce(response.promise)
const refreshing = manager.refreshList()
manager.handleSessionAdded(summary(S1, { blank: true }))
response.resolve(ok({ items: [] }))
await refreshing
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
})
it.for(['result', 'transport'] as const)(
'retains running observed before listing when the pull fails through %s',
async (source, { mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionStatus(S1, true)
manager.handleSessionStatus(S1, false)
const error = new RemoteError('gateway/internal', 'list unavailable', {})
if (source === 'result') remote.session.list.mockResolvedValueOnce(err(error))
else remote.session.list.mockRejectedValueOnce(error)
await manager.refreshList()
expect(manager.getListSnapshot().state).toBe('error')
manager.handleSessionAdded(summary(S1, { blank: true }))
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
},
)
it('accepts a later blank baseline without an acceptance or running observation', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: false }))
const session = manager.get(S1)
expect(session.getSnapshot().blank).toBe(false)
remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] }))
await manager.refreshList()
expect(manager.getListSnapshot().items[0]?.blank).toBe(true)
expect(session.getSnapshot().blank).toBe(true)
})
it('retains acceptance received before the first list response', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
remote.session.list.mockReturnValueOnce(response.promise)
const refreshing = manager.refreshList()
const session = manager.get(S1)
await session.prompt([{ type: 'text', text: 'first' }], 'queue')
expect(manager.getListSnapshot().items).toEqual([])
response.resolve(ok({ items: [summary(S1, { blank: true })] }))
await refreshing
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
expect(session.getSnapshot().blank).toBe(false)
})
it('keeps a rejected first prompt blank across refresh and object replacement', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
remote.session.prompt.mockResolvedValueOnce(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' })))
const session = manager.get(S1)
expect((await session.prompt([{ type: 'text', text: 'first' }], 'queue')).ok).toBe(false)
remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] }))
await manager.refreshList()
await manager.drop(S1, session)
expect(manager.getListSnapshot().items[0]?.blank).toBe(true)
expect(manager.get(S1).getSnapshot().blank).toBe(true)
})
it('ignores running from a list response that predates removal, including after re-addition', async ({ mock, remote }) => {
for (const readdBeforeResponse of [false, true]) {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
remote.session.list.mockReturnValueOnce(response.promise)
const refreshing = manager.refreshList()
manager.handleSessionRemoved(S1)
if (readdBeforeResponse) manager.handleSessionAdded(summary(S1, { blank: true }))
response.resolve(ok({ items: [summary(S1, { blank: true, running: true })] }))
await refreshing
if (!readdBeforeResponse) {
expect(manager.getListSnapshot().items).toEqual([])
manager.handleSessionAdded(summary(S1, { blank: true }))
}
expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: true, running: false })
}
})
it('retains running observations after re-addition while an older list response is pending', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
remote.session.list.mockReturnValue(response.promise)
const refreshing = manager.refreshList()
manager.handleSessionRemoved(S1)
manager.handleSessionAdded(summary(S1, { blank: true }))
manager.handleSessionStatus(S1, true)
manager.handleSessionStatus(S1, false)
response.resolve(ok({ items: [summary(S1, { blank: true })] }))
await refreshing
expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, running: false })
})
it('applies late acceptance to a re-added list row without materializing its Session', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
const acceptance = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.prompt>>>()
remote.session.prompt.mockReturnValue(acceptance.promise)
const session = manager.get(S1)
const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue')
manager.handleSessionRemoved(S1)
await manager.drop(S1, session)
manager.handleSessionAdded(summary(S1, { blank: true }))
const getSession = vi.spyOn(manager, 'get')
onTestFinished(() => { getSession.mockRestore() })
acceptance.resolve(ok({ accepted: true }))
await sending
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
expect(getSession).not.toHaveBeenCalled()
})
it('publishes a late acceptance to the replacement Session with the same id', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
const acceptance = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.prompt>>>()
remote.session.prompt.mockReturnValue(acceptance.promise)
const session = manager.get(S1)
const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue')
await manager.drop(S1, session)
const replacement = manager.get(S1)
expect(manager.getListSnapshot().items[0]?.blank).toBe(true)
expect(replacement.getSnapshot().blank).toBe(true)
acceptance.resolve(ok({ accepted: true }))
await sending
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
expect(replacement.getSnapshot().blank).toBe(false)
await manager.drop(S1, session)
expect(manager.get(S1)).toBe(replacement)
})
it('keeps accepted presentation when its Session is rebuilt', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
const session = manager.get(S1)
await session.prompt([{ type: 'text', text: 'first' }], 'queue')
await manager.drop(S1, session)
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
expect(manager.get(S1).getSnapshot().blank).toBe(false)
})
it('publishes acceptance from a nonblank sender after its list row is replaced', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
const sender = manager.get(S1)
await sender.prompt([{ type: 'text', text: 'first' }], 'queue')
const acceptance = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.prompt>>>()
remote.session.prompt.mockReturnValueOnce(acceptance.promise)
const sending = sender.prompt([{ type: 'text', text: 'next' }], 'queue')
manager.handleSessionRemoved(S1)
await manager.drop(S1, sender)
manager.handleSessionAdded(summary(S1, { blank: true }))
const replacement = manager.get(S1)
expect(manager.getListSnapshot().items[0]?.blank).toBe(true)
expect(replacement.getSnapshot().blank).toBe(true)
acceptance.resolve(ok({ accepted: true }))
await sending
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
expect(replacement.getSnapshot().blank).toBe(false)
})
it('keeps observed running presentation across idle empty-history summaries', async ({ mock, remote }) => {
for (const source of ['addition', 'refresh'] as const) {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
const session = manager.get(S1)
manager.handleSessionStatus(S1, true)
manager.handleSessionStatus(S1, false)
expect(session.getSnapshot().blank).toBe(false)
if (source === 'addition') manager.handleSessionAdded(summary(S1, { blank: true }))
else {
remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] }))
await manager.refreshList()
}
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
expect(session.getSnapshot().blank).toBe(false)
}
})
it('does not restore a removed row or publish after disposal when acceptance arrives late', async ({ mock, remote }) => {
for (const dispose of [false, true]) {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S1, { blank: true }))
const acceptance = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.prompt>>>()
remote.session.prompt.mockReturnValue(acceptance.promise)
const session = manager.get(S1)
const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue')
manager.handleSessionRemoved(S1)
if (dispose) await manager.dispose()
else await manager.drop(S1, session)
const notify = vi.fn()
const unsubscribe = manager.subscribe(notify)
onTestFinished(unsubscribe)
acceptance.resolve(ok({ accepted: true }))
await sending
expect(manager.getListSnapshot().items).toEqual([])
expect(notify).not.toHaveBeenCalled()
}
})
it('keeps accepted presentation across a later list refresh without a turn', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { blank: true })] as never[] }))
await manager.refreshList()
await manager.get(S1).prompt([{ type: 'text', text: 'first' }], 'queue')
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
await manager.refreshList()
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
})
it('lazily builds one resident instance per id and syncs the running bit from the list', async ({ mock, remote }) => {
remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] }))
const manager = makeManager(mock, remote)
@@ -895,6 +1208,32 @@ describe('remaining branches', () => {
})
describe('connected generation', () => {
it('does not prune running observations from a superseded empty list response', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
const oldList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
const newList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
remote.session.list.mockReturnValueOnce(oldList.promise).mockReturnValueOnce(newList.promise)
const oldPull = manager.refreshList()
manager.handleSessionStatus(S1, true)
manager.handleSessionStatus(S1, false)
manager.handleConnected()
const newPull = manager.refreshList()
try {
oldList.resolve(ok({ items: [] }))
await oldPull
expect(manager.getListSnapshot().state).toBe('loading')
newList.resolve(ok({ items: [summary(S1, { blank: true })] }))
await newPull
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
} finally {
oldList.resolve(ok({ items: [] }))
newList.resolve(ok({ items: [] }))
await Promise.all([oldPull, newPull])
}
})
it.for(['old-first', 'new-first'] as const)(
'ignores a previous generation list response (%s)',
async (order, { mock, remote }) => {
@@ -625,6 +625,30 @@ describe('prompt and cancel errors', () => {
expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false })
})
it('does not await a first turn when the history already contains one', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
session.handleBlank(false)
const inFlight = session.prompt([{ type: 'text', text: '继续' }], 'queue')
expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: true, awaitingFirstTurn: false })
expect((await inFlight).ok).toBe(true)
expect(session.getSnapshot()).toMatchObject({ blank: false, awaitingFirstTurn: false })
})
it('keeps an accepted first prompt converted when a later prompt is rejected', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
session.handleBlank(true)
expect((await session.prompt([{ type: 'text', text: '第一次' }], 'queue')).ok).toBe(true)
session.handleRunning(true)
session.handleRunning(false)
mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' })))
expect((await session.prompt([{ type: 'text', text: '第二次' }], 'queue')).ok).toBe(false)
expect(session.getSnapshot()).toMatchObject({
blank: false, running: false, awaitingFirstTurn: false,
promptError: { op: 'send', error: { code: 'session/agent-busy' } },
})
})
it('keeps the attempted-first-prompt state when the Host rejects the prompt', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
session.handleBlank(true)
@@ -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-agent-preset/README.md
README.md: c7a87da591c1cafc433fbb3bb5fa216d94ab85ef
README.zh.md: a54b47baed132e790fc4f44ad1155f48a19c430b
README.md: 5f2c9acaa5840e71d6ca237c5fc5eccb52724cda
README.zh.md: 5c61823c25f145c0e516f3f2a16c2de05c250ef1
+3 -3
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition.
Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default while Developer tools is enabled; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition.
## Table of Contents
@@ -25,9 +25,9 @@ Use this package to choose the agent preset for a new Web GUI session, see the a
<a id="use-this-package"></a>
## Use this package
The new-session picker additionally requires Developer tools to be enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy.
The new-session picker and the management section’s “Allow switching Agent modes” row are visible only while Developer tools is enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy.
Mount this plugin alongside the settings and conversation packages; the management section then shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history.
Mount this plugin alongside the settings and conversation packages; once Developer tools is enabled, the management section shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history.
### Managing the roster
+3 -3
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。
使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器在通用设置开启开发者工具时默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。
## 目录
@@ -25,9 +25,9 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
新会话选择器还要求在通用设置中开启开发者工具。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。
新会话选择器和管理区域中的「允许切换agent模式」整行设置仅在通用设置中开启开发者工具时显示。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。
与设置和对话包一起挂载本插件;管理分区随后显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。
与设置和对话包一起挂载本插件;通用设置开启开发者工具后,管理分区会显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。
### 管理名单
@@ -16,7 +16,7 @@ import {
Button, IconBrowseOutlineRegular, IconCopyOutlineRegular, IconFolderOpenOutlineRegular,
IconPlusOutlineRegular, IconTrashOutlineRegular, Modal, Switch, Tag, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { draftBlocker, type AgentPresetSectionState } from './section-store.ts'
import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts'
@@ -27,6 +27,8 @@ export interface AgentPresetSectionInjected {
hooks: {
/** Page snapshot bound by the renderer as useAgentPresetSection. */
agentPresetSection: SnapshotStore<AgentPresetSectionState>
/** Shared preference controlling the picker-policy row. */
developerTools: ObservableSnapshot<boolean>
}
/** Read the roster; called once when the section first renders. */
load: () => Promise<void>
@@ -181,6 +183,7 @@ function CardDescription({ text }: { text: string }): ReactNode {
export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
const { useAgentPresetSection, t, load } = props
const state = useAgentPresetSection(snapshot => snapshot)
const developerTools = props.useDeveloperTools(enabled => enabled)
const viewedId = state.view?.id
const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId)
const viewedTitle = state.view === null
@@ -236,21 +239,23 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
<div className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
<p className={css.intro}>{t('sectionIntro')}</p>
<div className={css.pickerPreference}>
<div className={css.pickerPreferenceCopy}>
<span className={css.pickerPreferenceTitleRow}>
<span className={css.pickerPreferenceTitle}>{t('showPicker')}</span>
<Tag>{t('showPickerBeta')}</Tag>
</span>
<p className={css.pickerPreferenceDescription}>{t('showPickerDescription')}</p>
{developerTools && (
<div className={css.pickerPreference}>
<div className={css.pickerPreferenceCopy}>
<span className={css.pickerPreferenceTitleRow}>
<span className={css.pickerPreferenceTitle}>{t('showPicker')}</span>
<Tag>{t('showPickerBeta')}</Tag>
</span>
<p className={css.pickerPreferenceDescription}>{t('showPickerDescription')}</p>
</div>
<Switch
checked={state.showPicker}
label={t('showPicker')}
disabled={state.status !== 'ready' || state.policySaving}
onChange={(next) => { void props.setPickerVisible(next) }}
/>
</div>
<Switch
checked={state.showPicker}
label={t('showPicker')}
disabled={state.status !== 'ready' || state.policySaving}
onChange={(next) => { void props.setPickerVisible(next) }}
/>
</div>
)}
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
{([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => {
const group = state.rows
@@ -203,7 +203,7 @@ export function apply(ctx: ClientContext): void {
}
const sectionInjected = (): AgentPresetSectionInjected => ({
hooks: { agentPresetSection: section.store },
hooks: { agentPresetSection: section.store, developerTools: ctx.settingsScope.developerTools.enabled },
load: () => section.load(),
view: (id: string) => section.view(id),
closeView: () => { section.closeView() },
@@ -17,6 +17,8 @@ import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
const READY: AgentPresetSectionState = {
status: 'ready',
error: null,
@@ -35,6 +37,10 @@ const READY: AgentPresetSectionState = {
revealedPaths: {},
}
function unusedHook(): never {
throw new Error('This section does not read global slot sources')
}
/**
* Render the section over a fixed snapshot, with every action a spy.
* @param state - the snapshot to render.
@@ -42,7 +48,7 @@ const READY: AgentPresetSectionState = {
*/
function renderSection(
state: Partial<AgentPresetSectionState> = {},
options: { creator?: boolean } = {},
options: { creator?: boolean; developerTools?: boolean } = {},
) {
const store = createSnapshotStore<AgentPresetSectionState>({ ...READY, ...state })
const actions = {
@@ -63,11 +69,18 @@ function renderSection(
makeDefault: vi.fn(() => Promise.resolve()),
setPickerVisible: vi.fn(() => Promise.resolve()),
}
const props = {
const props: AgentPresetSectionProps = {
...actions,
usePanelInfo: unusedHook,
useSessions: unusedHook,
useSessionStatus: unusedHook,
useSessionRetainInfo: unusedHook,
useWorkspaces: unusedHook,
useResource: unusedHook,
useAgentPresetSection: bindSnapshotSelector(store),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetSectionProps
useDeveloperTools: bindSnapshotSelector(createSnapshotStore(options.developerTools ?? true)),
t: key => translations.get(key) ?? key,
}
render(<AgentPresetSection {...props} />)
return actions
}
@@ -82,6 +95,13 @@ function rowFor(id: string): HTMLElement {
}
describe('the preset list', () => {
it('hides the complete picker-policy row while developer tools are off', () => {
renderSection({}, { developerTools: false })
expect(screen.queryByRole('switch', { name: en.showPicker })).toBeNull()
expect(screen.queryByText(en.showPickerDescription)).toBeNull()
expect(screen.queryByText(en.showPickerBeta)).toBeNull()
})
it('reads the roster once when it first renders', async () => {
const actions = renderSection()
@@ -348,14 +368,8 @@ describe('the preset list', () => {
})
it('renders nothing when the deployment composes no presets', () => {
const { container } = render(<AgentPresetSection {...({
useAgentPresetSection: bindSnapshotSelector(
createSnapshotStore<AgentPresetSectionState>({ ...READY, status: 'unavailable', rows: [] })),
t: (key: keyof typeof en) => en[key],
load: vi.fn(() => Promise.resolve()),
} as unknown as AgentPresetSectionProps)} />)
expect(container.firstChild).toBeNull()
renderSection({ status: 'unavailable', rows: [] })
expect(screen.queryByRole('heading')).toBeNull()
})
it('offers a retry when the roster could not be read', () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: fef3a6551b517af2ac636687883864525aac70ee
README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5
README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd
README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe
+1 -1
View File
@@ -40,7 +40,7 @@ The shared image slot props keep display choices separate from durable reference
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
With Developer tools off, the shell exposes only Chat and hides the View tab bar. Turning it off while another View is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes registered Views available again. View owners receive the available roster so their navigation actions can follow the same visibility.
With Developer tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Developer tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
+1 -1
View File
@@ -40,7 +40,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
关闭开发者工具时,外壳仅展示 Chat 并隐藏 View 标签栏。在其他 View 激活时关闭开关会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,已注册的 View 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
关闭开发者工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭开发者工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
@@ -35,7 +35,7 @@ import { ConversationHeader } from './skeleton/ConversationHeader.tsx'
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
import { InputBar } from './skeleton/InputBar.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { DEFAULT_VIEW_ID, resolveActiveView } from './view-selection.ts'
import { DEVELOPER_TOOLS_VIEW_ID, resolveActiveView } from './view-selection.ts'
import { en, NS, zh, type ConversationKey } from './locales.ts'
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
@@ -159,7 +159,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
for (const entry of slots.entries('conversation.view')) {
/* v8 ignore next -- list registration validates id at load. */
if (entry.options.id === undefined) continue
if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id !== DEFAULT_VIEW_ID) continue
if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id === DEVELOPER_TOOLS_VIEW_ID) continue
tabs.push({
id: entry.options.id,
label: resolveSlotLabel(entry.options.label) ?? entry.options.id,
@@ -1,8 +1,11 @@
import type { ViewTab } from './contract/views.ts'
/** Chat remains available when developer tools are disabled. */
/** Fallback View selected when no stored preference resolves to a registered View. */
export const DEFAULT_VIEW_ID = 'chat'
/** The one View the Developer tools preference gates; other Views stay registered by their owners. */
export const DEVELOPER_TOOLS_VIEW_ID = 'trajectory'
/**
* Resolve a preferred registered View, then Chat, without choosing another View.
* @param tabs - currently registered Views.
@@ -201,24 +201,46 @@ describe('Conversation inject API', () => {
const unsubscribe = source.subscribe(changed)
expect(source.getSnapshot()).toBeUndefined()
const removeDefinition = b.runtime.ctx.uiConversation.views.register({
target: 'custom-inspector',
target: 'trajectory',
toolCallFocus: callId => `tool:${callId}`,
create: () => ({ empty: null, replace: () => null, apply: () => null }),
})
expect(source.getSnapshot()).toBeUndefined()
const removeView = b.slots.register(
{ name: 'conversation.view', id: 'custom-inspector' }, (() => null) as never,
{ name: 'conversation.view', id: 'trajectory' }, (() => null) as never,
)
await b.runtime.flush()
const inspect = source.getSnapshot()!
expect(inspect).toBeTypeOf('function')
expect(source.getSnapshot()).toBe(inspect)
inspect('call-1')
expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'custom-inspector', focus: 'tool:call-1' })
expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'trajectory', focus: 'tool:call-1' })
await b.runtime.ctx.settingsScope.developerTools.setEnabled(false)
expect(source.getSnapshot()).toBeUndefined()
await b.runtime.ctx.settingsScope.developerTools.setEnabled(true)
expect(source.getSnapshot()).toBe(inspect)
// Trajectory still owns inspection while both Views are visible; hiding
// it must leave a third-party View's inspection capability reachable.
const removePipelineDefinition = b.runtime.ctx.uiConversation.views.register({
target: 'pipeline',
toolCallFocus: callId => `stage:${callId}`,
create: () => ({ empty: null, replace: () => null, apply: () => null }),
})
const removePipelineView = b.slots.register(
{ name: 'conversation.view', id: 'pipeline' }, (() => null) as never,
)
await b.runtime.flush()
expect(source.getSnapshot()).toBe(inspect)
await b.runtime.ctx.settingsScope.developerTools.setEnabled(false)
const pipelineInspect = source.getSnapshot()!
expect(pipelineInspect).toBeTypeOf('function')
pipelineInspect('call-2')
expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'pipeline', focus: 'stage:call-2' })
await b.runtime.ctx.settingsScope.developerTools.setEnabled(true)
removePipelineView()
removePipelineDefinition()
await b.runtime.flush()
expect(source.getSnapshot()).toBe(inspect)
removeView()
await b.runtime.flush()
expect(source.getSnapshot()).toBeUndefined()
@@ -44,18 +44,18 @@ function entry(
}
describe('target-neutral Conversation apply wiring', () => {
it('offers only Chat while developer tools are disabled and restores registered views when enabled', async () => {
it('hides only the Trajectory View while developer tools are disabled and restores it when enabled', async () => {
const b = await bench()
const header = b.runtime.slots.entries('conversation.session.header')[0]!
const source = (header.inject!() as { hooks: { conversationViews: ObservableSnapshot<readonly ViewTab[]> } }).hooks.conversationViews
for (const id of ['chat', 'trajectory']) {
for (const id of ['chat', 'trajectory', 'probe']) {
b.runtime.slots.register({ name: 'conversation.view', id, label: id }, (() => null) as never)
}
await vi.waitFor(() => { expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory']) })
await vi.waitFor(() => { expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory', 'probe']) })
b.developerTools.set(false)
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat'])
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'probe'])
b.developerTools.set(true)
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory'])
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory', 'probe'])
await b.runtime.dispose()
})
it('waits for the layout-owned conversation declaration before registering its subtree', async () => {
+2 -2
View File
@@ -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-settings/README.md
README.md: d70cb35878d77c1d3e928aa222a2c7e96874ea2c
README.zh.md: 87687ac3ccc7f19d7b34f5724eba260a02e92044
README.md: d688442cb003f980b037354f76997c18a2ca0be2
README.zh.md: 92e2026d04f188854fd6205fe4d35e8c9c8e57b2
+2 -2
View File
@@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re-
### Binding a namespace
`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `false`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording.
`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `true`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. Host-backed clients keep developer features disabled until the first accepted schema-resolved value arrives; missing or failed initial responses do not enable them. Later refreshes retain the last accepted choice.
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. These methods return `true` for Host acceptance and `false` for refusal or skipped writes; transport failures reject. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
@@ -53,7 +53,7 @@ The package realizes one ownership rule: the browser keeps one shared mirror of
### The describe mirror
The Host half registers the `ui-developer-tools` schema and its default-off value with settings. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in).
The Host half registers the `ui-developer-tools` schema and its default-on value with settings. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in).
### Scope derivation
+2 -2
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
### 绑定命名空间
`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `false`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。
`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `true`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 使用 Host 偏好的客户端在首个经过 schema 解析并接受的值到达前保持开发者功能关闭;首次响应缺失或失败不会启用它们。后续刷新保留已接受的选择。
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。这些方法在 Host 接受时返回 `true`,拒绝或跳过写入时返回 `false`;传输失败会 reject。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
@@ -53,7 +53,7 @@ kind: "package-reference"
### Describe 镜像
Host 部分向 settings 注册 `ui-developer-tools` schema 及其默认关闭值。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。
Host 部分向 settings 注册 `ui-developer-tools` schema 及其默认开启值。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。
### Scope 派生
@@ -3,11 +3,11 @@ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-c
import type { DeveloperToolsSettings } from '../developer-tools-settings.ts'
import type { SettingsScope } from './settings-contract.ts'
/** Shared preference; no accepted value means developer tools are disabled. */
/** Shared preference; Host-backed features stay disabled until an accepted value arrives. */
export class DeveloperToolsPreference {
/** Accepted enablement, observable through renderer-bound hooks. */
readonly enabled: ObservableSnapshot<boolean>
private readonly local = createSnapshotStore(false)
private readonly local = createSnapshotStore(true)
/**
* @param scope - settings-owned namespace controller.
@@ -10,7 +10,7 @@ export interface DeveloperToolsSettings {
enabled: boolean
}
/** New installations and missing values use the basic interface. */
/** New installations and missing values enable the full interface. */
export const DeveloperToolsSettingsSchema: z<DeveloperToolsSettings> = z.object({
enabled: z.boolean().default(false),
enabled: z.boolean().default(true),
})
@@ -39,6 +39,39 @@ describe('developer tools settings', () => {
expect(ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(false)
})
it.each([false, true])('withholds features during a delayed Host read before accepting %s', async (enabled) => {
const ctx = new Context()
const accepted = { ok: true, value: {
writable: true, hasDocument: true, namespaces: [{
ns: DEVELOPER_TOOLS_NAMESPACE,
schema: DeveloperToolsSettingsSchema.toJSON(),
value: { enabled }, revision: 1, applies: 'live', secrets: [],
}],
} }
const pending = Promise.withResolvers<typeof accepted>()
onTestFinished(async () => { pending.resolve(accepted); await ctx.fiber.dispose() })
new TestRemote(ctx, { settings: { describe: () => pending.promise } })
await ctx.plugin({ inject, apply: clientApply }).await()
const preference = ctx.settingsScope.developerTools
const changed = vi.fn()
const dispose = preference.enabled.subscribe(changed)
onTestFinished(dispose)
expect(preference.enabled.getSnapshot()).toBe(false)
pending.resolve(accepted)
await ctx.settingsScope.describe().ensure()
expect(preference.enabled.getSnapshot()).toBe(enabled)
expect(changed).toHaveBeenCalledTimes(enabled ? 1 : 0)
})
it('keeps features disabled when the initial Host read fails', async () => {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
new TestRemote(ctx, { settings: { describe: () => Promise.reject(new Error('disconnected')) } })
await ctx.plugin({ inject, apply: clientApply }).await()
await ctx.settingsScope.describe().ensure()
expect(ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(false)
})
it('shares one remote-browser preference across consumers and disposes it with the plugin', async () => {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
@@ -49,13 +82,14 @@ describe('developer tools settings', () => {
await fiber.await()
const preference = ctx.settingsScope.developerTools
expect(fiber.ctx.settingsScope.developerTools.enabled).toBe(preference.enabled)
expect(preference.enabled.getSnapshot()).toBe(true)
await preference.setEnabled(true)
expect(fiber.ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(true)
expect(describeCall).not.toHaveBeenCalled()
await fiber.dispose()
expect(ctx.get('settingsScope')).toBeUndefined()
})
it('defaults off, persists valid choices and removes its schema on disposal', async () => {
it('defaults on, keeps an explicit false, persists valid choices and removes its schema on disposal', async () => {
const ctx = new Context()
const provider = ctx.plugin(MemorySettings)
onTestFinished(() => provider.dispose())
@@ -63,6 +97,8 @@ describe('developer tools settings', () => {
const fiber = ctx.plugin({ apply })
onTestFinished(() => fiber.dispose())
await fiber.await()
expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true })
await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: false })
expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: false })
await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: true })
expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true })
@@ -71,19 +107,19 @@ describe('developer tools settings', () => {
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(DEVELOPER_TOOLS_NAMESPACE)
})
it('stays off until accepted settings arrive and follows external changes', async () => {
it('stays off until accepted settings arrive, then follows a stored false and external changes', async () => {
const host = stubSettingsScope<DeveloperToolsSettings>()
const preference = new DeveloperToolsPreference(host.scope)
expect(preference.enabled.getSnapshot()).toBe(false)
const notify = vi.fn()
const dispose = preference.enabled.subscribe(notify)
host.publish({ status: 'ready', value: { enabled: false } })
expect(preference.enabled.getSnapshot()).toBe(false)
host.publish({ status: 'ready', value: { enabled: true } })
expect(notify).not.toHaveBeenCalled()
await preference.setEnabled(true)
expect(host.set).toHaveBeenCalledWith('enabled', true)
host.publish({ value: { enabled: true } })
expect(preference.enabled.getSnapshot()).toBe(true)
expect(notify).toHaveBeenCalledOnce()
await preference.setEnabled(false)
expect(host.set).toHaveBeenCalledWith('enabled', false)
host.publish({ value: { enabled: false } })
expect(preference.enabled.getSnapshot()).toBe(false)
dispose()
})
})
@@ -94,13 +130,14 @@ it('keeps remote browser choices local and publishes only changed values', async
const preference = new DeveloperToolsPreference(host.scope)
const notify = vi.fn()
const dispose = preference.enabled.subscribe(notify)
expect(preference.enabled.getSnapshot()).toBe(false)
await preference.setEnabled(true)
expect(preference.enabled.getSnapshot()).toBe(true)
await preference.setEnabled(true)
expect(notify).toHaveBeenCalledOnce()
await preference.setEnabled(false)
expect(preference.enabled.getSnapshot()).toBe(false)
await preference.setEnabled(false)
expect(notify).toHaveBeenCalledOnce()
await preference.setEnabled(true)
expect(preference.enabled.getSnapshot()).toBe(true)
expect(notify).toHaveBeenCalledTimes(2)
expect(host.set).not.toHaveBeenCalled()
dispose()
})
@@ -117,3 +154,10 @@ it('ignores host revisions that do not change enablement', () => {
expect(notify).toHaveBeenCalledOnce()
dispose()
})
it('keeps an unavailable Host namespace disabled', () => {
const host = stubSettingsScope<DeveloperToolsSettings>()
const preference = new DeveloperToolsPreference(host.scope)
host.publish({ status: 'unavailable' })
expect(preference.enabled.getSnapshot()).toBe(false)
})
@@ -3,6 +3,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { DeveloperToolsPreference } from '@deepseek-ai/dsh-client-ui-settings/src/client/developer-tools.ts'
import type { DeveloperToolsSettings } from '@deepseek-ai/dsh-client-ui-settings/src/developer-tools-settings.ts'
import type { Resources, ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client'
import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -153,6 +156,31 @@ describe('HtmlBody', () => {
expect(screen.getByRole('alert').textContent).toBe(en.failed)
})
it('follows the shared developer-tools preference from loading through a stored false to enabled', async () => {
const host = stubSettingsScope<DeveloperToolsSettings>()
const preference = new DeveloperToolsPreference(host.scope)
const readRelated = vi.fn<HtmlBodyProps['readRelated']>().mockResolvedValue({ ok: true, value: {
absolutePath: '/workspace/asset.js', version: 'asset-v1', offset: 0, eof: true,
data: btoa('window.loaded = true'),
} })
const scripted: HtmlBodyProps = {
...props('<p>Preview</p><script src="./asset.js"></script>'),
useInteractivePreview: bindSnapshotSelector(preference.enabled),
readRelated,
}
const view = render(<HtmlBody {...scripted} />)
expect(screen.getByTitle(en.frame).getAttribute('sandbox')).toBe('')
expect(readRelated).not.toHaveBeenCalled()
act(() => { host.publish({ status: 'ready', value: { enabled: false } }) })
expect(screen.getByTitle(en.frame).getAttribute('sandbox')).toBe('')
expect(readRelated).not.toHaveBeenCalled()
act(() => { host.publish({ value: { enabled: true } }) })
const advanced = await screen.findByTitle(en.frame)
expect(advanced.getAttribute('sandbox')).toBe('allow-scripts')
expect(readRelated).toHaveBeenCalledOnce()
view.unmount()
})
it('renders a Blob iframe with only scripts allowed, keeping it mounted for unrelated props', async () => {
const initial = props()
const view = render(<HtmlBody {...initial} />)
+53 -5
View File
@@ -1,8 +1,56 @@
diff --git a/prelude/sea-bootstrap.bundle.js b/prelude/sea-bootstrap.bundle.js
index ac14980e953134f2285e326a5111cf52de717cf5..19465f8004dcd7f75bc7459de2055546f232fd23 100644
index ac14980e953134f2285e326a5111cf52de717cf5..1b1c00d1274cba5f63487898a9d940e8870f4f19 100644
--- a/prelude/sea-bootstrap.bundle.js
+++ b/prelude/sea-bootstrap.bundle.js
@@ -5356,6 +5356,39 @@ var require_sea_vfs_setup = __commonJS({
@@ -63,6 +63,25 @@ var require_bootstrap_shared = __commonJS({
throw new Error("pkg: unknown compression codec id " + compression);
}
}
+ // Write `content` to `dest` through a same-directory temporary file and an
+ // atomic rename, so a concurrent process never observes a partially written
+ // addon: its existsSync/hash check and dlopen see either no file or a complete
+ // one. A direct writeFileSync truncates a file another process may already
+ // have mapped (SIGBUS on Linux, "Library not loaded" on macOS) and exposes the
+ // half-written bytes to that process's hash comparison.
+ function writeFileAtomic(dest, content) {
+ var tmp = dest + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp";
+ fs.writeFileSync(tmp, content);
+ try {
+ fs.renameSync(tmp, dest);
+ } catch (e) {
+ try {
+ fs.unlinkSync(tmp);
+ } catch (_) {
+ }
+ throw e;
+ }
+ }
function cpRecursive(src, dest) {
var st = fs.lstatSync(src);
if (st.isSymbolicLink()) {
@@ -100,7 +119,7 @@ var require_bootstrap_shared = __commonJS({
return;
}
}
- fs.writeFileSync(dest, srcContent);
+ writeFileAtomic(dest, srcContent);
}
function patchDlopen(insideSnapshot) {
var ancestor = process.dlopen;
@@ -134,10 +153,10 @@ var require_bootstrap_shared = __commonJS({
var dContent = fs.readFileSync(tmpModulePath);
var dHash = createHash("sha256").update(dContent).digest("hex");
if (hash !== dHash) {
- fs.writeFileSync(tmpModulePath, moduleContent);
+ writeFileAtomic(tmpModulePath, moduleContent);
}
} else {
- fs.writeFileSync(tmpModulePath, moduleContent);
+ writeFileAtomic(tmpModulePath, moduleContent);
}
newPath = tmpModulePath;
}
@@ -5356,6 +5375,39 @@ var require_sea_vfs_setup = __commonJS({
}
};
}
@@ -42,7 +90,7 @@ index ac14980e953134f2285e326a5111cf52de717cf5..19465f8004dcd7f75bc7459de2055546
var SEAProvider = class extends MemoryProvider {
constructor(seaManifest) {
super();
@@ -5465,12 +5498,21 @@ var require_sea_vfs_setup = __commonJS({
@@ -5465,12 +5517,21 @@ var require_sea_vfs_setup = __commonJS({
}
return -2;
}
@@ -67,7 +115,7 @@ index ac14980e953134f2285e326a5111cf52de717cf5..19465f8004dcd7f75bc7459de2055546
}
existsSync(filePath) {
perf2.count("existsSync calls");
@@ -5542,7 +5584,7 @@ var require_sea_vfs_setup = __commonJS({
@@ -5542,7 +5603,7 @@ var require_sea_vfs_setup = __commonJS({
var require_worker_bootstrap_string = __commonJS({
"prelude/_worker-bootstrap-string.js"(exports2, module2) {
"use strict";
@@ -76,7 +124,7 @@ index ac14980e953134f2285e326a5111cf52de717cf5..19465f8004dcd7f75bc7459de2055546
}
});
@@ -5560,7 +5602,6 @@ var require_sea_bootstrap_core = __commonJS({
@@ -5560,7 +5621,6 @@ var require_sea_bootstrap_core = __commonJS({
var insideSnapshot = vfs.insideSnapshot;
var SNAPSHOT_PREFIX = vfs.SNAPSHOT_PREFIX;
shared.patchDlopen(insideSnapshot);
+3 -3
View File
@@ -11,7 +11,7 @@ overrides:
patchedDependencies:
'@electron/[email protected]': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861
'@yao-pkg/[email protected]': 07f08c630d0d3886d81fd4600116d07e4871ddcd57f92cb22746b6d246cdc337
'@yao-pkg/[email protected]': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7
[email protected]: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0
importers:
@@ -86,7 +86,7 @@ importers:
version: 4.1.8([email protected])
'@yao-pkg/pkg':
specifier: 6.21.0
version: 6.21.0(patch_hash=07f08c630d0d3886d81fd4600116d07e4871ddcd57f92cb22746b6d246cdc337)([email protected])
version: 6.21.0(patch_hash=75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7)([email protected])
'@yarnpkg/cli-dist':
specifier: 4.17.1
version: 4.17.1
@@ -23016,7 +23016,7 @@ snapshots:
- bare-buffer
- react-native-b4a
'@yao-pkg/[email protected](patch_hash=07f08c630d0d3886d81fd4600116d07e4871ddcd57f92cb22746b6d246cdc337)([email protected])':
'@yao-pkg/[email protected](patch_hash=75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7)([email protected])':
dependencies:
'@babel/generator': 7.29.7
'@babel/parser': 7.29.7
-4
View File
@@ -481,10 +481,6 @@
"packages/client/ui-agent-preset/tests/section-store.client.spec.ts": {
"23e04af2a900d7a905ff7657de842e9833384a2d4c4afba33964477e43d8ba52": 1
},
"packages/client/ui-agent-preset/tests/section.client.spec.tsx": {
"4cc9284f9e4d9df997ed042df717f143f3763130fdfb9e142226da4b5b4c60e6": 1,
"67694f8f44eb8347cb38c39d368f4f0ce1b78bd33a6a57a55cf4664f3eaa7f98": 1
},
"packages/client/ui-agent-preset/tests/settings-store.client.spec.ts": {
"c0e852e6b0e2a8a040b65723f8ab1d650d9cb06c26be7f3a0709ddb9f7525595": 1,
"d6a04d954aef7a8edd403910851015c0ce1597f6f300292b5158a8bf993fc1d8": 1
@@ -17,7 +17,7 @@
## Basic HTML
- Developer tools: off by default on Web and desktop
- Developer tools: disabled for this scenario
- Sandbox: no permissions
- Inline script: pending
- Local script: pending
@@ -0,0 +1,6 @@
selected sidebar rows: 1
new-session labels: 0
row draggable: true
host history blank: true
composer: ""
contenteditable: true