Merge remote-tracking branch 'origin/master' into xtr/ptc-runner-imports

This commit is contained in:
_Kerman
2026-10-28 21:12:04 +08:00
1302 changed files with 78755 additions and 4270 deletions
@@ -9,8 +9,8 @@
en: 386864a282e5ad58
zh: a4ea75ec70eb7778
/agent-note-shared-native-cua-process-with-agent-scoped-state/decision:
en: ebf231a6aad109bb
zh: 53b553e679569da1
en: 11bab6870c3d802f
zh: a22114f2217a3a41
/agent-note-shared-native-cua-process-with-agent-scoped-state/alternatives-considered:
en: 883f71387f599160
zh: 4b5f1450be799397
@@ -12,6 +12,8 @@ On macOS 26.6.2, replayd derives capture-client identity from the executable pat
One user shares a native service per executable content digest across Agents and Hosts. A version lock elects the service; an additional canonical-executable-path lock prevents different builds published at one path from running together. Locks remain held until process exit. The managed startup command hands service lifetime to its client connections, and the last connection's completed cleanup ends the service. The diagnostic stdin/stdout entry proxies to this same service.
The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Installed packages retain their distributed signatures.
Each Agent connection owns its application handles, AX snapshots, limits and cancellation state. An app handle grants no exclusive use of the application. A FIFO orders complete desktop operations, including clipboard transactions, while response writes are independent. Agent disposal retires only its connection. The [object API decision](2026-09-17-experimental-cua-object-api.md) retains evaluator independence and coordinate semantics; the [consent decision](2026-09-26-cua-application-consent.md) retains application authorization.
Cancellation and timeout first stop one request and wait for acknowledgement. A timed-out screenshot retains the single system-RPC slot until the real callback completes; new screenshots fail with `CAPTURE_BUSY` instead of accumulating requests. Late pixels are discarded, while other AX and input operations remain available. Ending an await or creating a wrapper does not prove that the system connection has recovered. An operation or cleanup that cannot stop can invalidate the process, making every old handle unusable. Uncertain input is never replayed automatically.
@@ -12,6 +12,8 @@ Status: implemented
同一用户的多个 Agent 和 Host 按可执行文件内容摘要共享原生服务。版本锁选出服务实例,规范化可执行路径上的另一把锁阻止发布在同一路径的不同构建同时运行。进程退出才释放锁。受管启动命令将服务生命期交给客户端连接,最后一条连接清理完成后服务退出。诊断用 stdin/stdout 入口也代理到同一个服务。
服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。安装版保留分发时的签名。
每条 Agent 连接独立持有应用句柄、AX 快照、资源限制和取消状态。持有 app 句柄不代表独占应用。FIFO 队列串行执行包含剪贴板事务的完整桌面操作,响应写入独立进行。Agent 销毁只释放自己的连接。[对象接口决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责执行器独立性及坐标语义,[授权决策](2026-09-26-cua-application-consent.zh.md)继续负责应用许可。
取消或超时先停止单次请求并等待确认。截图超时后,唯一的系统 RPC 槽位保留到真实回调结束;新截图返回 `CAPTURE_BUSY`,不继续堆积请求。迟到图片被丢弃,其他 AX 与输入操作仍可执行。结束 await 或创建包装对象不能证明系统连接已恢复。操作或清理无法停止时,才可能使进程失效,届时所有旧句柄均不可用。结果不确定的输入不会自动重放。
@@ -9,14 +9,14 @@
en: f62d87b7623a9904
zh: 48bbb7c35298f96c
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
en: 125dea7427047966
zh: 1e459b1106597695
en: 10e9cfb7972cff03
zh: 8406ba64d72a1fcc
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
en: a5bcdf2a6133abfe
zh: 477d4915d61945e3
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity:
en: ac9b3d800889ea39
zh: 9db1065b6027f65f
en: 1ba89a61a733b371
zh: 4fa134ad1b2059a1
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/alternatives-considered:
en: 6119e2aff3b6be5a
zh: 4237c596c10e1ee9
@@ -38,3 +38,6 @@
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/related:
en: dd16f0c5815df991
zh: df3c1cf511ee52bf
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/follow-up-2026-10-26-lua-token-is-dropped:
en: 5262178f0035b215
zh: 10505c394ea33ee3
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ Status: implemented
## 决策
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
<a id="acl-diagnosis-and-recovery"></a>
## ACL 诊断与恢复
@@ -23,7 +23,7 @@ Status: implemented
## How the restriction works (why no new identity)
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
## 考虑过的替代方案
@@ -52,3 +52,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher
[pwsh 执行器决策](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。
[删除约束决策](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md)以 Low 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。
## 后续追记(2026-10-26):去掉 `LUA_TOKEN`
`CreateRestrictedToken` 仍传 `DISABLE_MAX_PRIVILEGE | WRITE_RESTRICTED`,不再传 `LUA_TOKEN`:带上该标志时,内置 Administrator 用户无法启动任何进程,受限启动全部失败。写限制仍来自 restricting SIDs 与 Low 标签。
@@ -15,8 +15,8 @@
en: db0a2deb9193a00d
zh: dab5a65b88957464
/agent-note-experimental-auto-review-before-each-tool-call/decision/one-complete-reviewer-request:
en: 7bd9fef90e8639b9
zh: e2a096885c9c1e83
en: 45ac7b4e759b29ad
zh: 5374cc89e587f076
/agent-note-experimental-auto-review-before-each-tool-call/decision/result-and-cancellation:
en: 647fe68c52b907e0
zh: 267a739bad44ad65
@@ -41,6 +41,8 @@ The integration uses only the latest `request/header.config` provider/model and
Each item is its own message and keeps the same text in later reviews: the pending call becomes a historical call, and the integration remembers, for each live Session, the schema each reviewed call showed so that it reappears at the same position. A review's messages therefore begin with all messages of the previous review. Providers that cache only at message ends, such as OpenAI implicit breakpoints on GPT-5.6 and later, or at a final marker with lookback, such as Anthropic `cache_control`, reuse the previous review; one growing user message let them reuse only the policy. The schema memory is process-local, so the first review after a restart omits earlier schemas and misses the cache once.
The request also carries the reviewed Session's `sessionId` and `purpose: 'auto-review'`. Routes that require a per-conversation identity or key prompt caching on one, such as the `x-opencode-session` header OpenCode requires and OpenAI `prompt_cache_key`, derive it from that id; without it OpenCode rejects every review, and Auto fails every call. The [DeepSeek Session-log upload](../../../../packages/session/session-log-deepseek/README.md#request-field) applies the same eligibility and registered exclusions to reviews as to other requests. The uploaded data is the canonical Session log; review prompts and decisions remain outside that log.
The reviewer builds the history and the pending call from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver.
The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only as the final message; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget.
@@ -41,6 +41,8 @@ Integration 只使用最新 `request/header.config` 的 provider/model 与 shi
每个条目各占一条消息,并在后续评审中保持相同文本:待审调用会变成历史调用,integration 为每个存活 Session 记住每次已审调用展示的 schema,使其在同一位置再次出现。因此一次评审的消息以上一次评审的全部消息开头。只在消息结尾缓存的 provider(如 GPT-5.6 及以后的 OpenAI 隐式断点),或在末尾标记处向前回溯的 provider(如 Anthropic `cache_control`),都能复用上一次评审;单条不断增长的 user 消息只能让它们复用策略。Schema 记忆只在进程内,因此重启后的第一次评审会缺少先前的 schema,并有一次缓存未命中。
请求还携带被评审 Session 的 `sessionId` 与 `purpose: 'auto-review'`。需要按对话标识路由或以其为 prompt 缓存键的路由(例如 OpenCode 要求的 `x-opencode-session` header 与 OpenAI 的 `prompt_cache_key`)由该 id 派生;缺少它时 OpenCode 会拒绝每次评审,Auto 会让每个调用失败。[DeepSeek session-log 上传](../../../../packages/session/session-log-deepseek/README.zh.md#request-field)对评审和其他请求应用相同的上传资格与注册排除条件。上传的是权威 Session 日志;评审提示词和决策仍不进入该日志。
reviewer 从 Session 的完整动作历史构建历史与待审调用:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。
主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只作为最后一条消息出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。
@@ -0,0 +1,55 @@
# Bilingual-pair consistency record for 2026-09-13-liveshare-protocol-and-owner-surfaces.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.md
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague:
en: e73b246e19c9b412
zh: c87594ad5eccbfb5
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/problem:
en: 17bc15d382139909
zh: 3eff34b5ddea951e
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision:
en: a15c7c13f167e5ac
zh: a15c7c13f167e5ac
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-a-share-is:
en: ff1dbb0f7c2355e4
zh: 3de92b2e6c5e9e15
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-a-reader-can-do:
en: 5652ed7f515a2a5d
zh: 993378f78f5a4120
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/two-driving-tiers:
en: 0b9303333bf482ae
zh: 6dfc68ad9e2924c3
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/default-visibility:
en: 754705367c350b31
zh: e10b6541458c4a45
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/how-shared-work-stays-inside-its-grant:
en: fa3ab42895a16ce1
zh: 429be63cfb8e55a6
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/keeping-one-branch-across-retries:
en: 6b0369dafe34a5aa
zh: aea9465216204122
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/who-the-owner-is:
en: 7e6d1305782d2822
zh: 66fb2cbef0f49d47
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-the-reader-sees-at-the-top:
en: 44d02a91cf0c5715
zh: dabbf6c59ff08e4a
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/when-the-connection-breaks:
en: 36ee6e090797806b
zh: 21809a0cf990d905
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/how-the-served-page-is-exercised:
en: d9cbb8277862587c
zh: 867fa6c4db95d09c
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-the-package-exposes:
en: d13a0df787f212d4
zh: 24794e8bf4d92492
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/alternatives-considered:
en: 2daeba6e763383e4
zh: fe0fe3f7f4d546e7
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/verification:
en: 76cc786336c5daf5
zh: 98bd2b9cc7d1791c
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/consequences:
en: 8a6e06a9fba917e2
zh: 68a9d19b15119345
@@ -0,0 +1,156 @@
# Agent Note: Liveshare — sharing a DSH session with a colleague
Status: implemented
English | [中文](2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md)
## Problem
A DSH session runs on your own computer. A colleague sometimes needs to watch one while you work — to follow a debugging session, or to see how a long task is going. Today there is no way to do that. Sending the session log is not the same thing: the colleague would need your plugins, configuration, and sandbox to render it, and the session only makes sense while it is running on your machine.
So a share needs three things at once. A **page the colleague opens in a browser** that shows the session running on your machine, without connecting them to it. A **limit on what that page can do**, starting with "read without writing" — anything the page offers beyond what you agreed is a security hole, not a convenience. And an **answer to "who shared this?"**, because a page that cannot say who its owner is cannot be trusted by whoever opens it.
This note covers the half that runs inside the owner's DSH — the share dialog, the owner-side service behind it, the package's browser face, and the page a reader opens — plus the protocol package both sides speak. The Liveshare server, the shared service in the middle, is its own package (`@deepseek-ai/dsh-experimental-liveshare-relay`) and is reviewed and deployed separately.
## Decision
### What a share is
An owner creates a share for one session from DSH itself. The share is published to the Liveshare server, a service both sides can reach, and DSH returns a link. Opening that link shows the session rendered by the owner's own DSH with its own plugins, so the reader never needs the owner's configuration. The reader's list contains the source Session and any children that reader created through the share. Workspace names, membership, and ordering are trimmed to those Sessions; unrelated Sessions and workspaces stay hidden.
A share created from the DSH Share dialog is an **organization link** share: it carries `accessMode: 'open'`, so anyone in the organization who has the link is admitted. The deployment's `LIVESHARE_ALLOWED_SUBJECTS` is unioned into every share's ACL: it narrows an `open` share to the subjects it lists, and on a stored `selected` record it adds those subjects beside the record's own selection.
**Specific colleagues (`selected`) and the older `approval` spelling are legacy stored records.** The Host still reads them with their recorded ACL and shows their access summary, and the Liveshare server still admits their viewers by that ACL, but the shipped Share dialog offers no way to create one. `liveshareShares.create` keeps its `accessMode` and `allowedSubjectIds` fields for those records and for callers that invoke the Remote directly; the shipped dialog sends neither.
### What a reader can do
The reader's page is the normal DSH client, not a cut-down copy. The sidebar, the session list, expanding, collapsing, and selecting are all there, because browsing is part of reading. What is missing is every action the reader is not allowed to perform: creating an unrelated session, renaming, archiving, deleting, and managing a workspace. Branching follows the share's own fork switch, prompting is available in both tiers and runs inside that tier's sandbox, and attachment upload follows the Sessions the reader can see; management permissions remain independent of the tier and the fork switch.
These actions are not hidden with CSS. One access snapshot decides what the page may do: the remote-access client follows the reader's own `remoteAccess/follow`, and each button and menu entry reads its endpoint's answer on the global resource, a Session, or a Workspace through `useRemoteAccess`, `useAccess` or `useSessionAccess`, or `useWorkspaceAccess`, rendering again whenever the answer changes. The owner's DSH computes the snapshot from this share's `share-viewer` policy, so an ability the owner withdraws closes without reloading the page — and the server refuses those actions anyway; the page only avoids offering what would be refused. The served index carries the reader's opening snapshot, so no entry appears before the page knows it is allowed, and after the snapshot stream fails every answer is refused. The owner's own page opens with the owner baseline and behaves exactly like an ordinary DSH. Per-Session refusal reasons are not part of the snapshot: the page states the page-level reason (share mode, stop, expiry, lost access) and generic read-only copy. A page that cannot read the Session at all — offline owner, stopped or expired share, lapsed sign-in — states that condition at the top and gives the reader an action, instead of leaving an empty shell or a bare code.
### Two driving tiers
Every share carries a `ViewerSessionMode` with two values, and both drive the shared Session:
- **view** — the reader drives the shared Session's loop through the normal prompt path, inside a read-only sandbox in the granted workspace, so the Session's tools never write a file. The read and search surface (`read`, `read_image`, `glob`, `grep`, `present`), the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`). Every declared path is checked before the tool runs, and a tool whose call cannot be checked is refused.
- **write** — the reader drives the same loop in a bounded writable sandbox that also modifies files inside the granted workspace, under the Host's write ceiling.
The sandbox is installed on the Session, but it confines only the turns a share viewer starts: `agent/pre-step` records whether an admitted turn's entering input names a share viewer, and the guard and the replacements read that answer for each execution. A turn the owner starts keeps the Session's tools; an owner's steering input inside a viewer's turn stays confined with it. The marker lives in Host memory, is recomputed for each turn, and never enters the Session log. Stopping every share, or letting them all expire, disposes the layer and gives the Session its own tools back.
The earlier vocabulary had three tiers — `view` watched, `fork-readonly` branched, and `write` could write. `view` and `fork-readonly` differed only in whether the reader could branch and drive a child, which the share's own `allowsFork` switch already decides; a third mode duplicated that switch and let the two answers disagree, so the two driving tiers replaced it.
Forking is not a tier. The share carries its own `allowsFork` switch, independent of the tier: a `view` share may allow it and a `write` share may refuse it. A fork creates a child the viewer owns with its own read-only or writable ceiling and leaves the source untouched.
Attachment upload is not a tier or a switch. `fileUploads/upload` rides the read role, so every Session a share serves the reader accepts files; visibility alone decides it, and the owner has no separate attachment switch.
A mode this version does not recognize, and a persisted record whose mode the host cannot establish, are not driven: the share stays readable, and the page reports what it could not establish. The withdrawn `fork-readonly` name is not read back as `view`; a persisted owner record that names it is rejected when the owner state loads.
The sandbox is a property of the Session, but it applies per turn: the Host records, in memory, whether the admitted input of the running turn names a share viewer, and confines that turn while leaving an owner-started turn on the Session's native tools. The earlier shipped design confined every turn, including the owner's; it needed no turn read, but a share changed how the owner's own work ran. Persisting a turn's requester was rejected: a committed-message projection had to be folded, re-read for every execution, and kept in step with the share that admitted it, and it still had to fail closed on mixed or unestablished requesters. The in-memory marker keeps the guard and the replacements reading one answer per execution without adding a Session event; a restart recomputes it from the next admitted step, and a rejected or never-admitted step leaves no marker.
### Default visibility
The package ships as a runtime dependency of every dsh installation, but a default installation must show it nowhere in the plugin manager: neither under **Built-in plugin settings**, nor under **Experimental plugins**, nor on the **More** page. A person enables it deliberately — by inserting the bundle's rows in the active profile's `cordis.patch.yml`, or by selecting the bundle in `dsh.profile.bundles`; the [package README](../../../../packages/experimental/liveshare/README.md) owns both routes. The package must still ship with the release and stay installable, and users who enabled it before this rule keep seeing it.
The Host's bundle `enabled` flag states whether the profile selected the bundle, which is not the same as whether the bundle's plugins run: inserting the bundle's rows through `cordis.patch.yml` starts its plugins while leaving the bundle unselected, so a visibility rule built on `enabled` would hide a bundle the person had just enabled and would strand the documented enable path.
The package declares the hiding intent in its own bundle manifest as `dsh.bundle.listedByDefault: false`; the manager keeps one generic visibility rule rather than a client-side name set. A bundle whose manifest does not declare `listedByDefault: false` is listed; one that declares it is listed only when the profile enables it, the installation carries it selected, or one of the rows its bundle patch declares is running (`pkg.rows.some(row => row.enabled)`). The filter applies before both lists are derived, so the name is absent from the main Experimental plugins group, the More page, Built-in plugin settings, and the Installed plugins group until one of those holds. The Host's `listBundles` contract, the profile format, and the installed package set are unchanged. `BundleInfo.rows` already joins each declared row with the Loader entry currently carrying it, and the client's `PackageView` carries whether that entry runs, so row state is the effective answer for both enable routes: removing the rows, disabling them, or deselecting the bundle stops them and the card leaves both lists again.
A default-hidden bundle is not addressable by ID while its card is hidden: `openPkg` resolves only the listed inventory, so `pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-liveshare')` renders no details until the same enablement conditions list it.
### How shared work stays inside its grant
The Owner, not the viewer page or the Liveshare server, authorizes each Session operation. A child receives its real scoped tool restrictions before it can accept a prompt, including after a cold resume: the host installs the limit from the base `agent/created` event. That listener runs after the agent entered the registry, but creation has not resolved, AgentLoop holds the agent's queued input, and a throw fails creation, so a refused limit still prevents any prompt. A missing host service or failed installation refuses the child; a configuration flag, a tool name, or a promise to install later is not enforcement.
A shared source Session can still serve its owner's own work. Every reader call is dispatched through the owner's own Connection and Gateway as that share-and-reader pair's own Peer, whose `share-viewer` principal (the share id and the server-asserted identity triple) is fixed by in-process owner code when the Peer is created; no wire field can change it. The Gateway decides the Peer's calls, and the Session notifications it forwards to the Peer, through remote access: this package registers the `share-viewer` policy, which binds a served reader to read roles on the shared Session, its own forks, and the Workspaces holding them, and to fork and write roles whose conditions apply the share mode; it binds nothing else, so every other endpoint and notification is refused, and it binds nothing once the share stops serving the reader. The owner invalidates the reader's access on every grant, revocation, mode change, and expiry, and disposes the reader's Peer when the grant ends or the share stops or expires. A reader's prompt enters the Session as a `user-rpc` message whose `source.principal` names that reader; owner messages carry no principal. While any share over that Session is active, the owner host mounts its own confined replacements for the read and search surface and the no-effect tools — plus bounded `edit` and `write` when some share may write — in the Session's own scope, and a viewer-started turn may run nothing else. An owner-started turn keeps the Session's native tools, so a share never narrows the owner's own work. The pre-step records which kind of turn is running, in memory only. Each operation re-reads the shares' current Relay grants and checks the resolved target against the granted root; the pre-step re-checks every entering message's viewer against the Relay's admission before a model step, so reader input queued before a revocation never drives the model. A reader's stop of the shared Session is dispatched through the Gateway like a prompt: the policy admits it while the share's tier is established and the Session is confined, and the owner's own Session controller stops whatever turn that Session is running, the owner's included. The general Peer, principal, and remote-access design is in [Peer principals and remote access](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md).
A confined file tool resolves every path its own arguments declare, checks that the granted root contains each result, and hands the deployment's tool that canonical path; a pathless search uses the Session's working directory, which the replacement checks like a named path. A bounded edit or write performs the same containment check and then edits or writes through the filesystem backend with the Session's current sandbox policy and the normal observation policy.
### Keeping one branch across retries
One fork request binds its viewer, share, source, operation identity, and completed prefix. The Owner reserves that operation durably before creating a child, and records the child the owner's fork answer returned; a retry of a recorded operation replays that answer instead of forking again. A different prefix under the same identity is refused. The owner's `SessionForkRequest` mints the child id, so the Host sends no child identity: a host that dies after the child was created but before the operation was recorded cannot name that child on retry and creates a new one. Closing that window needs a Session Controller request that accepts a caller-supplied child identity. The inherited prefix stops before unrelated queued input; only immediately following request metadata is retained. Revoked children keep their logs but cannot resume with broader access, and removing a share does not expose the source Session's unrestricted tools.
### Who the owner is
A share can only be created by a verified owner. The dialog shows the identity the owner's DSH read back — name, expiry, when it will renew — and uses a label derived from the identity when there is no display name. The code that creates the share refuses an unverified owner as well, so the question is not settled by whether a button was clickable.
Signing in happens once, in a window the page opens while the owner clicks. The DSH creates a short-lived transaction tied to the Liveshare server address, to the Liveshare server instance, and to the page allowed to finish it; the page opens the sign-in page the DSH names, accepts one one-time code only from that exact origin, window, and transaction, and hands it back to its own DSH.
The Owner redeems the code with the verifier it retained, stores the credential with owner-only permissions, and sends it only to the Liveshare server address that issued it. New registration requires that verified credential; existing-share reconnect uses the share's management token, and explicit identity claim preserves the share's ACL, mode, expiry, and token. A share's named colleagues are the owner's stored selection plus the deployment's own allowlist; nothing is derived from a display identity.
### What the reader sees at the top
The served page reuses the sidebar's existing brand row instead of adding a bar on top of the app: the usual Harness mark, plus a LiveShare entry that names who shared the session and which DSH version they run, with the Liveshare server's own release labeled separately. Clicking it returns to the page listing the shares you can open.
### When the connection breaks
A reader's feed can fail: a stream disappears, the Liveshare server cancels a request, a read times out. The page retries only the failures that are defined as repeatable, and stops for good on authentication, permission, and missing-share errors, showing the reason instead of reconnecting forever.
### How the served page is exercised
The page a viewer opens is the owner's own Web page. The Liveshare server asks the owner for its rendered index document, places a short carrier script at its top, and passes the page's shell assets and plugin bundles through from the owner, so the page runs every owner plugin at the owner's version. The carrier only turns the page's calls and streams into reads over the Liveshare server's channel; the Liveshare plugin itself recognizes a viewer page and mounts the viewer surfaces instead of the Share action. The owner renders that index with the reader's own access snapshot in place of the owner baseline, and the page then follows the reader's `remoteAccess/follow` snapshot; no other read carries a reader's grants.
The viewer page is tested in-process: the test assembles the page from the carrier, the shell, and the client plugins including Liveshare's, stands in for the Liveshare server's read channel, and answers the page's reads from a scripted owner, so the assertions see the real shell, carrier, and presentation plugins.
### What the package exposes
The package ships one Host program and one browser face. Its public entries are the Host export (`lib/index.js`), `./client` (`lib/client.js`, a closure-factory bundle), `./viewer-declarations`, the share-management Remote's `./typert` and `./remote` faces, `./types`, the bundle patch `./cordis.patch.yml`, and the locale JSON files. The manifest declares the browser face as `dsh.client` with `platform: web` and the client services it needs; that face reads `__DSH_LIVESHARE_VIEWER__`, mounts the viewer session plugin and its brand, model, permission, session-row, and share-path surfaces on a served viewer page, and otherwise mounts the owner's generated Remote before the Session-header Share action.
Share management is one `liveshareShares` Typert Remote with twelve methods — `create`, `list`, `stop`, `updatePermissions`, `setCapabilities`, `capabilities`, `relay`, `setRelay`, `identity`, `audit`, `testRelay`, and `testPublish` — over the operations the owner host runs. The only same-origin HTTP routes left are the four owner sign-in and credential routes: `/api/liveshare.ownerLoginStart`, `/api/liveshare.ownerLoginRedeem`, `/api/liveshare.ownerLoginCancel`, and `/api/liveshare.ownerCredential`.
The outgoing gateway is a supervised child process whose source entry is `src/owner-gateway/cli.ts` and whose published entry is `lib/owner-gateway/cli.js`; the directory name keeps it distinct from the DSH API gateway.
## Alternatives considered
- **Build a separate read-only page instead of serving the normal client.** Rejected: reading needs browsing, grouping, and selecting, so a second page would have to rebuild all of it and would quietly lose the navigation the product keeps.
- **Decide each button's availability where the button is.** Rejected: every entry reads one access snapshot the owner computes, so a screen added later cannot quietly reintroduce an action the reader may not perform.
- **Let the reader's page decide its own access.** Rejected: only the owner and the service decide what a share grants; the page states what it was told.
- **Treat an older share with no mode as writable.** Rejected: that would hand a reader access the owner never configured, so a share without a mode is read-only.
- **Put the sign-in code in the page address, or read the credential from the popup's cookies.** Rejected: addresses leak through history, referrers, and logs, and a page should not hold credentials.
- **Show the service's own error text when the page cannot explain a refusal.** Rejected: that text may contain a file path, a credential, or another reader's session, so the page shows the stable reason it can vouch for.
- **Put execution restrictions only in editable configuration or a sandbox mode.** Rejected: a child can change configuration, and per-call execution choices can outrank a mode. The real operation needs a monotonic guard and a verified implementation.
- **Check a model-supplied path only before dispatch, or globally constrain every filesystem read.** Rejected: a pre-dispatch check is separated from the tool call by other listeners, while a global read rule would also constrain unrelated Owner work. The containment check runs inside the authorized Session's own read and write.
- **Reuse the deployment's default root or copy the entire source tail into a child.** Rejected: both can include files or pending input the viewer was never granted.
- **Use a deployment-wide secret or self-reported display fields as owner identity.** Rejected: neither identifies the logged-in person who approved this publication.
- **Keep three tiers.** Rejected: `view` and `fork-readonly` differ only in whether the reader can branch and drive a child, which is already the independent fork switch. A third mode duplicates that switch and lets the two answers disagree.
- **Keep a per-share attachment-upload switch.** Rejected: an attachment is part of a prompt on a Session the reader can already see. The separate switch duplicated visibility and could only subtract from it, while the upload role already rides the read role.
- **Attribute every turn to a requester and exempt the owner's own turns.** Rejected after it shipped, then narrowed: persisting a turn's requester needed a Host-only projection over message sources, a turn-boundary read per execution, and a re-check of the share that admitted each turn, and it still had to fail closed on mixed or unestablished requesters. The shipped design keeps only an in-memory marker for the running turn — `agent/pre-step` reads the entering principals and the guard reads that answer — so an owner-started turn keeps the Session's native tools without adding a Session event. The accepted cost is that an owner's steering input inside a viewer's turn stays confined; a viewer message that reaches a later step narrows that turn.
- **Let each tool name its own tier or sandbox.** Rejected: the tier belongs to the share. A per-call choice would let one execution outrank the grant that admitted it.
- **Test the bundle's `enabled` flag directly.** Rejected: it would keep the code smallest, but the patch route starts the bundle's plugins while leaving the bundle unselected, so the card would stay hidden exactly when its plugins started, contradicting the product rule.
- **Derive effective enablement in the Host.** Rejected: `listBundles` could report a new or redefined effective state, but that would change a cross-package contract for one client-only presentation rule and invite every bundle consumer to reinterpret `enabled`, which must keep meaning "selected in the profile".
- **Ship LiveShare outside the installation's optional bundles.** Rejected: that would make it visible nowhere by construction, but the package must still ship as an npm package with the release and be installable, and it would also remove the bundle from the Host's list entirely, taking the details entry with it.
- **Keep the colleague picker, or replace it with a directory-backed selector.** Deferred: naming a colleague needs an identity directory the Liveshare server does not provide, so the dialog cannot offer a choice it can verify. Stored `selected` and `approval` records keep working through their ACL, and the creation entry point stays retired until a directory contract exists; `liveshareShares.create` keeps its fields for those records and direct Remote callers, so this defers the API rather than deleting it.
## Verification
- A reader's page keeps the normal navigation, and no action the reader may not perform appears on the shared session or on any branch.
- A DSH without the Liveshare bundle, and the owner's own page, behave exactly as before.
- A share uses one of two tiers and that tier's sandbox; unknown modes, unreadable answers, and shares without a mode stay readable but are never driven.
- A share cannot be created without a verified owner identity.
- A share created from the dialog carries `open`; a stored `selected` or `approval` record keeps its recorded ACL and access summary, and `create` still stores a requested `selected` mode with its subjects.
- Sign-in accepts one one-time code from the transaction's origin, window, and state, and nothing else; a failed or abandoned attempt discards the transaction, and no credential, cookie, or code reaches a URL, storage, or a log.
- Real Controller, ToolRuntime, filesystem, and profile tests cover fork identity, cold enforcement, bounded execution, revocation, and normal cancellation. The built viewer must use its real POST carrier and Owner authorization handler; deterministic display fixtures do not replace execution or persistence tests.
- Generated API documentation and both SDK projections remain aligned with the `user-rpc` source principal, which only the `agent/pre-step` admission check consumes.
- `ViewerSessionMode` has two members, and a persisted `fork-readonly` record is rejected when the owner state loads.
- A `view` share drives the source Session, refuses file writes and out-of-grant reads, and can fork when `allowsFork` is set.
- `fileUploads/upload` is allowed on every Session the reader can see, for every tier and fork combination.
- Source tool decisions read only the Session's mounted layer: the guard permits this installation's confined replacements for the read and search surface and the no-effect tools, plus bounded `edit` and `write` on a writable Session, and refuses every other name, with no turn, requester, or origin consulted.
- Unit and real-runtime tests cover the tier policy, confined reads and writes, fork ceilings, the layer's installation and release, and the pre-step admission check over the durable message source.
- The plugin-manager component tests hold the bundle out of both lists while its declared rows are off, list it once a declared row runs, and still open it by bundle ID while it is hidden; the recorded goldens and the refresh card count move with that rule.
## Consequences
The reader's page can only be as accurate as what the service tells it. Capability answers, the owner's identity status, the session mode, the owner's DSH version, and the sign-in endpoints all come from the owner's DSH and the Liveshare server; until those are deployed together with this layer, the page stays at its most conservative setting — read-only, with a sign-in that cannot complete — rather than guessing.
The page is not a security boundary. The owner's DSH and the Liveshare server refuse a reader's writes no matter what the page does; the access snapshot exists so the page does not offer actions that would only be refused.
Existing ACLs are retained. A record without a mode is readable but not driven, and contradictory mode fields are refused. Unattributed shares are shown as unverified history.
Owner reconnect state is atomically replaced and queued within one process, but multiple processes sharing one file can still lose updates or compete for a gateway. Cross-process execution ownership is unresolved; atomic replacement alone is not a transaction or a route claim. Containment is checked by path: a local process that replaces a directory inside the grant with a symlink between the check and the open can redirect a viewer's read, or a write elsewhere within the sandbox's writable roots. Operations anchored to a directory handle would close this window and are left to a separate change. Final viewer-artifact and SDK snapshot evidence remains required before release.
A `view` share is not watch-only: it runs the reader's prompts and tool calls inside the read-only sandbox. A deployment that wants a passive reader has no separate mode and must rely on the reader not sending prompts rather than on a tier name. Fork reach is the only independently switchable action; attachment upload cannot be disabled separately.
The sandbox reaches the owner's own turns too. While any share over a Session is active the owner can only read inside the granted workspace, and can write only when some share allows writing; a share that is merely published — even one nobody is using — therefore changes the owner's own tool surface until it is stopped or expires. Cancellation has no attribution either: a reader's stop ends whatever turn the shared Session is running.
The default cards list, the More page, and their recorded goldens lose one card, and the manager's refresh card count drops by one. A person who only knows the plugin page cannot discover LiveShare or switch it on from that page; the package README owns the two enable routes instead. A bundle enabled through the patch route shows its card with the switch off while its rows run, because the switch reflects the profile's bundle selection and writes that selection; the documentation states the two routes and this difference. The rule covers one explicitly named bundle rather than a manifest flag, so adding another hidden-until-running bundle, or retiring this one, is a one-line set edit with a test and docs update.
Shipped creation produces organization-link shares only: the Share dialog sends neither `accessMode` nor `allowedSubjectIds`, so every new share is `open`, and the deployment's `LIVESHARE_ALLOWED_SUBJECTS` is the only subject-level narrowing a new share can carry. Stored `selected` and `approval` records keep working and keep their access summaries, and the create fields stay on the Remote for those records and direct callers while no UI exposes them.
@@ -0,0 +1,156 @@
# Agent Note: Liveshare —— 把 DSH 会话分享给同事看
Status: implemented
[English](2026-09-13-liveshare-protocol-and-owner-surfaces.md) | 中文
## Problem
DSH 会话跑在你自己的电脑上。同事有时需要在你工作的同时看某个会话——跟着一起排查问题,或者看看一个长任务进展到哪了。现在没有这个能力。把会话日志发过去并不等价:对方要有你的插件、配置和沙箱才能渲染出来,而且会话只有在你机器上运行时才有意义。
所以一次分享要同时解决三件事:一个**同事用浏览器打开就能看的页面**,看到你机器上正在跑的会话,但不需要连到你的机器;一个**对页面能做什么的限制**,从"能读、不能写文件"开始——页面多给一个你没同意的操作就是安全漏洞,不是便利;以及一个**"这是谁分享的"的答案**,因为说不清归属的页面,打开它的人无从信任。
这条 Note 覆盖 owner 自己 DSH 里运行的那一半——分享对话框、背后的 owner 端服务、本包的浏览器端,以及读者打开的页面——以及双方共用的协议包。中间的 Liveshare 服务端是独立的包(`@deepseek-ai/dsh-experimental-liveshare-relay`),单独审查与部署。
## Decision
### 一次分享是什么
owner 在 DSH 里为某一个会话创建分享。分享发布到 Liveshare 服务端(双方都能访问的服务)后,DSH 返回一个链接。打开链接看到的是 owner 自己的 DSH 用自己插件渲染出的会话,读者不需要 owner 的配置。读者列表包含源会话,以及该读者通过分享创建的子会话。工作区名称、成员和顺序裁剪为这些会话;无关会话和工作区保持不可见。
从 DSH 分享弹窗新建的分享是**组织内持链接**分享:它携带 `accessMode: 'open'`,组织内拿到链接的人都能看。部署级 `LIVESHARE_ALLOWED_SUBJECTS` 会并入每份分享的 ACL:对 `open` 分享,它把准入收窄到列出的主体;对已存储的 `selected` 记录,它把列出的主体加在记录自己的选择旁边。
**指定同事(`selected`)与更早的 `approval` 拼写是旧版持久记录。** Host 仍按记录里的 ACL 读取它们并展示访问摘要,Liveshare 服务端仍按该 ACL 接纳它们的查看者,但随版本发布的分享弹窗不提供新建入口。`liveshareShares.create` 的 `accessMode` 与 `allowedSubjectIds` 字段保留给这些记录以及直接调用该 Remote 的调用方;随版本发布的弹窗两个都不发送。
### 读者能做什么
读者页面就是正常的 DSH 客户端,不是精简版:侧栏、会话列表、展开折叠、选择都在,因为浏览本来就是"看"的一部分。缺的只是读者无权执行的动作——创建无关会话、重命名、归档、删除、管理工作区。分支遵循分享自己的 fork 开关;发送消息在两档里都可用,并在该档的沙箱内运行;附件上传跟随读者可见的会话。管理权限与档位和 fork 开关都保持独立。
这些动作不是用 CSS 藏起来的。页面能做什么由一份访问快照决定:remote-access 客户端跟随读者自己的 `remoteAccess/follow`,每个按钮和菜单项经 `useRemoteAccess`、`useAccess` 或 `useSessionAccess`、`useWorkspaceAccess` 读取其端点在全局资源、某个会话或某个工作区上的答案,答案变化时重新渲染。owner 的 DSH 依据该分享的 `share-viewer` 策略计算快照,因此 owner 收回某项能力时,对应入口不用重新加载页面就会关闭——而且服务端本来就会拒绝这些操作,页面只是不去提供注定被拒绝的按钮。读者收到的 index 已带上读者的首份快照,因此页面在确认允许之前不会显示任何入口;快照流失败后,所有答案都为拒绝。owner 自己的页面以 owner 基线打开,行为与普通 DSH 完全一致。快照不包含逐会话的拒绝原因:页面陈述页面级原因(分享模式、停止、过期、失去访问权)和通用的只读文案。拿不到分享正文时,页面顶部直接陈述该状态,并且每一档都给出读者自己能做的动作(重试、重新登录、回到分享列表,或明确指引联系分享者),不会停在空白外壳或错误码上。
### 两档驱动
每个分享带一个 `ViewerSessionMode`,只有两个值,且两档都会驱动共享会话:
- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。
- **write** —— 读者在受宿主写入上限约束的可写沙箱里驱动同一个 loop,沙箱也会修改授予工作区内的文件。
沙箱安装在会话上,但只约束分享查看者发起的轮次:`agent/pre-step` 记录一个已准入轮次的进入输入是否带有 share-viewer principal,守卫与替代定义对该轮次的每次执行读取这一结论。所有者自己发起的轮次保留会话自己的工具;所有者在查看者轮次中追加的输入仍留在该受限轮次内。该标记只存在于 Host 内存,按轮次重算,不写入会话日志。所有分享停止或全部过期后执行层被释放,会话恢复自己的工具。
较早的词汇有三档——`view` 只观看、`fork-readonly` 分支、`write` 可写。`view` 与 `fork-readonly` 只差在读者能否分支并驱动子会话,而这已经由分享自己的 `allowsFork` 开关决定;第三档重复了该开关,还会让两处答案互相矛盾,因此由两档驱动取代。
fork 不是一档。分享自带独立的 `allowsFork` 开关,与档位无关:`view` 分享可以允许 fork,`write` 分享也可以拒绝。fork 创建的子会话归读者所有,带有自己的只读或可写上限,源会话保持不变。
附件上传既不是一档,也不是一个开关。`fileUploads/upload` 随读角色授予,因此分享向读者提供的每条会话都接受文件;只由可见性决定,所有者没有单独的附件开关。
本版本不认识的模式、以及宿主无法确定模式的持久记录,都不会被驱动:分享仍可读取,页面会说明它无法确定什么。已退场的 `fork-readonly` 名称不会读回为 `view`;持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。
沙箱是会话的属性,但按轮次生效:Host 在内存中记录运行中轮次的已准入输入是否带有 share-viewer principal,只约束该轮次,把所有者发起的轮次留在会话原生工具上。此前上线的实现限制每个轮次(包括所有者的),虽不需要读取轮次,却让分享改变了所有者自己的工作方式。持久化轮次请求者被否决:那需要从已提交消息来源折叠的投影、每次执行读取 turn boundary、并与接纳该轮次的分享保持同步,而且在混合或无法确立请求者时仍必须默认拒绝。内存标记让守卫与替代定义对每次执行读取同一个结论,且不新增会话事件;重启后从下一个准入步骤重新计算,被拒绝或从未准入的步骤不留下标记。
### 默认可见性
本包作为运行时依赖随每个 dsh 安装一同交付,但默认安装必须在插件管理器里完全不显示它:**内置插件配置**下不显示,**实验性插件**下不显示,**更多**页也不显示。用户主动启用它——在当前 profile 的 `cordis.patch.yml` 中插入组合包的行,或在 `dsh.profile.bundles` 中选中该组合包;两条启用路径由[包 README](../../../../packages/experimental/liveshare/README.zh.md)负责。该包仍必须随版本发布、保持可安装,且在此规则之前已启用的用户继续能看到它。
Host 的组合包 `enabled` 标志表示 profile 是否选中了这个组合包,这与它的插件是否在运行并不等价:通过 `cordis.patch.yml` 插入组合包的行会让插件运行,而组合包本身仍未选中;因此以 `enabled` 为准的可见性规则会把用户刚刚启用的组合包隐藏起来,使写入文档的启用路径失效。
因此管理器维护一个 `HIDDEN_UNTIL_RUNNING` 组合包名集合——当前只有 `@deepseek-ai/dsh-experimental-liveshare`——集合中的组合包只有在它 patch 声明的行至少有一条在运行时才会被列出(`pkg.rows.some(row => row.enabled)`)。过滤发生在两个列表派生之前,因此在满足条件前,它不会出现在主"实验性插件"组、**更多**页、**内置插件配置**和"已安装插件"组中。该集合是 `PluginManagerPage.tsx` 中的客户端数据;Host 的 `listBundles` 契约、profile 格式和已安装包集合都保持不变。`BundleInfo.rows` 已把每条声明的行与当前承载它的 Loader 条目关联起来,客户端的 `PackageView` 携带该条目是否在运行,因此行状态就是两条启用路径的有效答案:删除这些行、把它们置为 disabled,或取消选中组合包,都会让它们停止运行,卡片随之从两个列表消失。
卡片隐藏期间组合包 ID 仍可按 ID 打开:`openPkg` 会从未过滤的 Host 组合包列表中解析隐藏的组合包,因此 `pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-liveshare')` 仍能渲染其详情。其它按 ID 链接组合包的界面因此继续可用,组合包页也像其它未选中组合包的页面一样列出各行及其关闭状态。
### 分享工作如何限制在授权范围内
Owner 而不是查看页或 Liveshare 服务端授权每次会话操作。子会话必须先安装真实作用域工具限制,才能接受消息,冷恢复后也一样:宿主机从基础 `agent/created` 事件安装该限制。该监听器运行时智能体已登记进注册表,但创建尚未完成,AgentLoop 会扣住其排队输入,且监听器抛错会使创建失败,因此被拒绝的限制仍能阻止任何消息。宿主机服务缺失或安装失败都会拒绝子会话;配置开关、工具名或稍后安装的承诺都不是执行限制。
共享源会话仍可服务所有者自己的工作。每次读者调用都以「该分享与该读者」专属的 Peer 经所有者自己的 Connection 与 Gateway 分派,该 Peer 的 `share-viewer` principal(分享 ID 与服务端断言的身份三元组)由所有者进程内代码在创建 Peer 时确定,任何线路字段都无法改变它。Gateway 经 remote access 判定该 Peer 的调用以及转发给它的会话通知:本包注册 `share-viewer` 策略,把受服务的读者绑定到共享会话、其自己的 fork 及包含它们的工作区上的读取角色,以及条件按分享模式判定的 fork 与写入角色;除此之外不做任何绑定,因此其它端点和通知一律被拒绝,分享不再服务该读者后也不再有任何绑定。所有者在每次授权、撤权、模式变化与过期时使读者的访问失效,并在授权结束、分享停止或过期时释放读者的 Peer。读者的提示词以 `user-rpc` 消息进入会话,其 `source.principal` 指明该读者;所有者的消息不带 principal。只要该会话还有分享活跃,所有者 Host 就在会话自身作用域内挂载读取与检索面与无副作用工具的受限替代,以及(某个分享允许写入时)有界 `edit` 与 `write`,查看者发起的轮次只能运行这些。所有者自己发起的轮次保留会话原生工具,因此分享不会收窄所有者自己的工作。pre-step 只在内存中记录当前轮次属于哪一类。每次操作都会重新读取分享当前的 Relay 授权,并按授予根目录检查解析后的目标;pre-step 在模型步骤前按 Relay 准入重新检查每条进入消息的查看者,因此撤权前排队的读者输入永远不会驱动模型。读者对共享会话的停止与提示词一样经 Gateway 分派:策略在分享档位确立且会话受沙箱限制时放行,随后由所有者自己的会话控制器停止该会话当前运行的轮次,包括所有者发起的轮次。通用的 Peer、principal 与 remote access 设计见 [Peer principal 与 remote access](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。
受限文件工具解析其自身参数声明的每个路径,检查获授根目录包含解析结果,再把规范路径交给部署的工具;无路径的搜索使用会话工作目录,由替代定义按命名路径一样检查。受限编辑或写入执行同样的包含检查,然后按会话当前沙箱策略和正常观察策略经文件系统后端编辑或写入。
### 重试时保留同一个分支
一次 fork 请求绑定查看者、分享、源会话、操作身份和已完成前缀。Owner 在创建子会话前持久化预留该操作,并记录 owner 的 fork 应答返回的子会话;重试已记录的操作会重放该应答,而不是再次 fork。同一身份指定不同前缀会被拒绝。owner 的 `SessionForkRequest` 自己生成子会话 id,因此 host 不发送子会话身份:若 host 在子会话已创建、但操作尚未记录时退出,重试无法指名该子会话,会再创建一个。要关闭这一窗口,需要 Session Controller 的请求接受调用方提供的子会话身份。继承前缀停在无关排队输入之前;只保留紧随其后的请求元数据。被撤销的子会话保留日志,但不能以更宽权限恢复;移除分享不会暴露源会话不受限制的工具。
### owner 是谁
只有已验证的 owner 才能创建分享。对话框显示 owner 的 DSH 回读到的身份——名字、有效期、何时续期——没有显示名时用身份派生出来的标签。创建分享的代码本身也会拒绝未验证的 owner,不是只看按钮能不能点。
登录只做一次,且发生在 owner 点击时页面预留的窗口里。DSH 生成一个短寿命事务,绑定 Liveshare 服务端地址、Liveshare 服务端实例,以及允许完成它的页面;页面打开 DSH 指定的登录页,只接受来自该 origin、该窗口、该事务的一次性 code,并把它交回自己的 DSH。
Owner 使用自己保留的校验值兑换代码,以仅所有者可读写的权限保存凭据,并只向签发它的 Liveshare 服务端地址发送。首次登记需要已验证凭据;已有分享重连使用分享管理 token,显式身份认领保留该分享的 ACL、模式、过期时间和 token。分享指定的同事是 owner 的存储选择加上部署自身的允许列表;不会从显示身份派生任何内容。
### 读者页面顶部显示什么
被服务的页面复用侧栏原有的品牌行,而不是在应用上再压一条横条:原本的 Harness 标记,加上一个 LiveShare 入口,写明是谁分享的、对方跑的是哪个 DSH 版本(Liveshare 服务端自己的 release 另行标注)。点击它回到"我能看的分享"列表页。
### 连接断开时
读者的数据流可能失败:流消失、Liveshare 服务端取消请求、读取超时。页面只重试定义上可重复的失败;遇到认证、权限与分享不存在的错误就直接停下并显示原因,不会无休止地重连。
### 服务出去的页面怎么测
读者打开的页面就是 owner 自己的 Web 页面。Liveshare 服务端向 owner 要渲染好的 index 文档,在最前面插入一段很短的 carrier 脚本,页面的 shell 资源和插件 bundle 也从 owner 透传过来,所以页面跑的是 owner 版本的全部插件。carrier 只把页面的调用和流转成 Liveshare 服务端通道上的读请求;由 Liveshare 插件自己识别 viewer 页,挂上 viewer 界面,而不是分享按钮。owner 渲染这份 index 时用读者自己的访问快照取代 owner 基线,页面随后跟随读者的 `remoteAccess/follow` 快照;其他读请求都不夹带读者的授权。
读者页面在进程内测试:测试用 carrier、shell 和包括 Liveshare 在内的 client 插件拼出页面,顶替 Liveshare 服务端的读通道,并用脚本化的 owner 回答页面的读请求,因此断言看到的是真实 shell、carrier 与呈现插件。
### 本包对外提供什么
本包交付一个 Host 程序和一个浏览器端。公开入口包括 Host 导出(`lib/index.js`)、`./client`(`lib/client.js`,closure-factory bundle)、`./viewer-declarations`、分享管理 Remote 的 `./typert` 与 `./remote` 两个面、`./types`、组合包 patch `./cordis.patch.yml`,以及 locale JSON 文件。manifest 以 `dsh.client` 声明浏览器端,`platform` 为 `web`,并列出它需要的客户端服务;该入口读取 `__DSH_LIVESHARE_VIEWER__`:在被服务的查看页上挂载查看页 session 插件及其品牌、模型、权限、会话行与分享路径界面,否则先挂载 owner 生成的 Remote,再挂载 Session 头部操作。
分享管理是一个 `liveshareShares` Typert Remote,共十二个方法——`create`、`list`、`stop`、`updatePermissions`、`setCapabilities`、`capabilities`、`relay`、`setRelay`、`identity`、`audit`、`testRelay` 与 `testPublish`——背后是 owner 宿主执行的操作。仅存的同源 HTTP 路由是四条 owner 登录与凭据路由:`/api/liveshare.ownerLoginStart`、`/api/liveshare.ownerLoginRedeem`、`/api/liveshare.ownerLoginCancel` 与 `/api/liveshare.ownerCredential`。
出站网关是受监督的子进程,源码入口为 `src/owner-gateway/cli.ts`,发布入口为 `lib/owner-gateway/cli.js`;目录名让它与 DSH API gateway 明确区分。
## Alternatives considered
- **单独做一个只读页面,不服务正常客户端。** 否决:阅读需要浏览、分组与选择,另做一个页面就得把这些全部重建,还会悄悄丢掉产品要保留的导航。
- **每个按钮各自判断能不能点。** 否决:每个入口都读取 owner 计算的同一份访问快照,后加的界面才不会悄悄重新引入读者无权执行的动作。
- **让读者页面自己决定权限。** 否决:分享授予什么,只由 owner 与服务端决定;页面只陈述被告知的结果。
- **把没有模式字段的旧分享当作可写。** 否决:那会把 owner 从未配置的权限交给读者,因此没有模式的分享一律只读。
- **把登录 code 放进页面地址,或从弹窗的 cookie 读凭据。** 否决:地址会经浏览历史、referrer 和日志泄漏,页面也不应该持有凭据。
- **页面解释不了某个拒绝时,直接展示服务端的错误原文。** 否决:那段文本可能包含文件路径、凭据或别的读者的会话,所以页面只显示它能担保的稳定原因。
- **只在可编辑配置或沙箱模式中写执行限制。** 否决:子会话能够改变配置,而逐调用执行选择可能优先于模式。真实操作必须经过单调拒绝的守卫和已验证实现。
- **只在分派前检查模型路径,或全局限制所有文件系统读取。** 否决:分派前检查与工具调用之间还隔着其他监听器,而全局读取规则还会限制无关 Owner 工作。包含检查在已授权会话自身的读写中执行。
- **复用部署默认根目录,或把源会话整个尾部复制到子会话。** 否决:两者都可能包含查看者未获授的文件或待处理输入。
- **以部署共享密钥或自报显示字段作为所有者身份。** 否决:两者都不能识别批准本次发布的已登录人员。
- **保留三档。** 否决:`view` 与 `fork-readonly` 只差在读者能否分支并驱动子会话,而这已经是独立的 fork 开关。第三档重复了该开关,还会让两处答案互相矛盾。
- **保留分享级的附件上传开关。** 否决:附件是读者已经能看到的会话上的一条 prompt 的一部分。单独的开关重复了可见性,且只能从可见性里做减法,而上传角色本来就随读角色授予。
- **按轮次归属请求者并让所有者轮次豁免。** 上线后否决,之后缩小范围:持久化轮次请求者需要仅 Host 可见的消息来源投影、每次执行读取 turn boundary、重新检查接纳该轮次的分享,而且在混合或无法确立请求者时仍必须默认拒绝。现在的实现只为运行中轮次保留内存标记——`agent/pre-step` 读取进入消息的 principal,守卫读取该结论——因此所有者发起的轮次保留会话原生工具,且不新增会话事件。接受的代价是:所有者在查看者轮次中追加的输入仍留在受限轮次内;查看者消息若进入后续步骤,会立即收窄该轮次。
- **让每个工具自行声明档位或沙箱。** 否决:档位属于分享。逐调用选择会让某次执行凌驾于批准它的授权之上。
- **严格的 manifest 选中判断。** 否决:直接判断 `pkg.enabled` 代码最少,但默认安装启用 LiveShare 的唯一文档路径是编辑 `cordis.patch.yml`,那不会让组合包被选中。卡片会在插件恰好开始运行时仍然隐藏,与产品规则矛盾。
- **在 Host 派生有效启用状态。** 否决:`listBundles` 可以以新字段或改写字段报告有效状态,但这会为一条纯客户端的展示规则改动跨包契约,并诱使每个组合包消费方重新解释 `enabled`——它必须继续表示"profile 已选中"。
- **把 LiveShare 移出安装随附的可选组合包。** 否决:将它从可选集合移除即可从构造上让它处处不可见,但该包仍需随版本作为 npm 包发布并可安装,而且这会把组合包从 Host 列表中完全移除,详情入口也会随之消失。
- **保留选人器,或换成由目录支撑的选择器。** 推迟:点名同事需要身份目录,而 Liveshare 服务端目前不提供;弹窗无法给出它能验证的选择。已存储的 `selected` 与 `approval` 记录仍按 ACL 工作,在有目录契约之前新建入口保持退役;`liveshareShares.create` 的字段保留给这些记录与直接调用方,所以这是推迟 API,而不是删除它。
## Verification
- 读者页面保留正常导航,且在共享会话与任何分支上都不会出现读者无权执行的动作。
- 没有 Liveshare bundle 的 DSH 以及 owner 自己的页面,行为与以前完全一致。
- 分享使用两档之一及该档的沙箱;未知模式、读不懂的应答、没有模式的分享仍可读取,但绝不会被驱动。
- 没有已验证的 owner 身份,就无法创建分享。
- 弹窗创建的分享携带 `open`;已存储的 `selected` 或 `approval` 记录保留记录中的 ACL 与访问摘要,`create` 仍会存储调用方请求的 `selected` 模式及其主体。
- 登录只接受来自该事务 origin、窗口与 state 的一次性 code,别的都不接受;失败或放弃都会丢弃事务,凭据、cookie 与 code 都不进入 URL、存储或日志。
- 真实 Controller、ToolRuntime、文件系统和 profile 测试覆盖 fork 身份、冷恢复限制、有界执行、撤权和正常取消。已构建查看页必须使用真实 POST 载体和 Owner 授权处理器;确定性显示 fixture(测试前置数据)不替代执行或持久化测试。
- 生成的 API 文档和两种 SDK 投影与 `user-rpc` 来源 principal 保持一致;该字段只由 `agent/pre-step` 准入校验消费。
- `ViewerSessionMode` 只有两个成员;持久化的 `fork-readonly` 记录在 owner 状态加载时被拒绝。
- `view` 分享驱动源会话,拒绝文件写入与越界读取,并在设置 `allowsFork` 时可以 fork。
- 对读者可见的每条会话,无论档位与 fork 开关如何组合,`fileUploads/upload` 都被允许。
- 源会话的工具决策只读取会话上挂载的执行层:守卫放行本次安装的读取与检索面与无副作用工具的受限替代,可写会话下另有有界 `edit` 与 `write`,拒绝其它所有工具名,不读取轮次、请求者或来源。
- 单元测试与真实运行时测试覆盖档位策略、受限读写、fork 上限、执行层的安装与释放,以及基于持久消息来源的 pre-step 准入校验。
- 插件管理器的组件测试在声明行全部关闭时把该组合包排除在两个列表之外,在声明行有一条运行后列出它,并在它隐藏时仍能按组合包 ID 打开;录制的 golden 与刷新卡片数随该规则变化。
## Consequences
读者页面只能准确到服务端告诉它的程度。能力回答、owner 身份状态、会话模式、owner 的 DSH 版本、登录端点,都由 owner 的 DSH 与 Liveshare 服务端提供;在与本层一起部署之前,页面保持最保守的状态——只读,且登录无法完成——而不是去猜。
页面不是安全边界。无论页面做什么,owner 的 DSH 与 Liveshare 服务端都会拒绝读者的写入;这份访问快照的作用只是让页面不去提供注定被拒绝的按钮。
已有 ACL 保持不变。无模式记录仍可读取,但不会被驱动;互相矛盾的模式字段被拒绝。没有归属的分享显示为“未验证历史”。
Owner 重连状态以原子替换写入,并在单进程内排队,但多个进程共享一个文件仍可能丢失更新或争用网关。跨进程执行归属尚未解决;原子替换本身不是事务或路由认领。包含关系按路径检查:若本地进程在检查与打开之间把获授范围内的目录换成符号链接,可以让查看者的读取改指别处,或让写入落到沙箱可写根内的其他位置。基于目录句柄锚定的操作可以消除这一窗口,留待单独变更引入。发布前仍需完成查看页产物和 SDK 快照验证。
`view` 分享不是"只看":它会在只读沙箱里运行读者的 prompt 与工具调用。想要被动观看的部署没有单独模式,只能依赖读者不发送 prompt,而不能依赖档位名称。fork 是唯一可独立关闭的动作;附件上传无法单独关闭。
沙箱同样作用于所有者自己的轮次。只要某会话还有分享活跃,所有者也只能在授予工作区内读取,且只有在某个分享允许写入时才能写入;因此一条仅仅发布、并没有人在用的分享也会改变所有者自己的工具面,直到它停止或过期。取消同样没有归属:读者的一次停止会结束共享会话正在运行的任何轮次。
默认卡片列表、**更多**页及其录制 golden 各少一张卡片,管理器的刷新卡片数也减一。只认识插件页的人无法从该页发现 LiveShare 或直接开启它;启用路径由包 README 负责。通过 patch 路径启用的组合包,其卡片显示为关闭状态而行在运行,因为开关反映并写入 profile 的组合包选中状态;文档会说明两条路径及其差异。该规则覆盖一个显式命名的组合包而不是 manifest 标志,因此新增另一个"运行前隐藏"的组合包或退役这一个,都只是一行集合改动,并配一条测试和文档更新。
随版本发布的创建流程只产生组织链接分享:分享弹窗不发送 `accessMode` 与 `allowedSubjectIds`,因此新分享一律为 `open`,部署级 `LIVESHARE_ALLOWED_SUBJECTS` 是新分享唯一能获得的主体级收窄。已存储的 `selected` 与 `approval` 记录继续工作并保留访问摘要;`liveshareShares.create` 的字段仍留在 Remote 上供这些记录与直接调用方使用,而界面不暴露它们。
@@ -9,14 +9,14 @@
en: 344bcc7e9825bbc8
zh: c1d0f4b54f5b3786
/agent-note-change-request-panel-and-worktree-sessions/decision:
en: 494b10799ca74ab7
zh: fd55bf793ee6c643
en: 0b159e4e33409515
zh: 2bf1eafd644f2d26
/agent-note-change-request-panel-and-worktree-sessions/deferred:
en: 32ef8af85f4d2f66
zh: b82b67f94346399e
/agent-note-change-request-panel-and-worktree-sessions/alternatives-considered:
en: aa2ae6ca5d297127
zh: b7eba2e7deffe245
en: e471e16b6b6450ca
zh: 5fbc9242edc8d112
/agent-note-change-request-panel-and-worktree-sessions/consequences:
en: 97976f073b9f65e0
zh: 95be3f7f375b0876
@@ -10,7 +10,7 @@ English | [中文](2026-10-09-forge-panel.zh.md)
## Decision
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, and a toast. Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry.
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry.
The Remote inspects the checkout through the Session's subprocess provider under the Session's read-only sandbox policy, so it reads what the Session's commands see and cannot write. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun.
@@ -18,7 +18,7 @@ Status polling follows panel subscriptions: a Session's bindings are watched onl
An expanded card marks a draft ready for review and merges a change request the host would merge now. The forge seam carries both as provider actions that return a typed failure, and every snapshot says whether the host would merge the change request now for the signed-in user and which methods the repository allows, so the panel offers only actions that can succeed. A merge carries the head commit the card showed, and the host refuses it once new commits arrived. The panel acts only on change requests bound to the Session.
A new Session starts in a worktree through a request, not an immediate creation. Checking the option in the New Session row, a `conversation.hero.options` entry, appends a `plugin:client-ui-forge/worktree` record with the base branch; changing the base or clearing the option appends another, and nothing touches the repository until the user sends. The option is offered only where creation can succeed: under `workspace-write`, the checkout and its common Git directory must lie inside the writable root, which excludes a project that is itself a linked worktree. The worktree must exist before the first message's turn starts, so the turn runs there and its first request already names the new directory. When the message enters the Session's inbox, the panel's `agent/inbox/inserted` listener starts maintenance of the Agent before the delivery wakes it; the Agent latches the wake until the maintenance ends, and the panel creates and enters the worktree inside it. The creation's stage streams to a card above the composer, where Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting in the inbox, and the card keeps the failure and offers another attempt with a new branch name or Continue without a worktree, which withdraws the request and lets the message go on. An `agent/pre-step` listener still creates the worktree at the first step proposal of a Session whose request is open, because a message that enters while the Agent runs a turn or other maintenance gets no hold; it moves the working-directory notice into the proposed messages, and a failure ends the turn. The `forgeWorktree` projection ignores requests after the first `turn/start` and creations after the first `step/start`, so a running Session never moves. The tab's first row names the branch the Session works on.
A new Session starts in a worktree through a request, not an immediate creation. Checking the option in the New Session row, a `conversation.hero.options` entry, appends a `plugin:client-ui-forge/worktree` record with the base branch; changing the base or clearing the option appends another, and nothing touches the repository until the user sends. The option is offered only where creation can succeed: under `workspace-write`, the checkout and its common Git directory must lie inside the writable root, which excludes a project that is itself a linked worktree. The worktree must exist before the first message's turn starts, so the turn runs there and its first request already names the new directory. When the message enters the Session's inbox, the panel's `agent/inbox/inserted` listener starts maintenance of the Agent before the delivery wakes it; the Agent latches the wake until the maintenance ends, and the panel creates and enters the worktree inside it. The creation's stage streams to a card above the composer, where Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting in the inbox, and the card keeps the failure and offers another attempt with a new branch name or Continue without a worktree, which withdraws the request and lets the message go on. An `agent/pre-step` listener still creates the worktree at the first step proposal of a Session whose request is open, because a message that enters while the Agent runs a turn or other maintenance gets no hold; it moves the working-directory notice into the proposed messages, and a failure ends the turn. The `forgeWorktree` projection ignores requests after the first `turn/start` and creations after the first `step/start`, so a running Session never moves. The tab's first row names the branch the Session works on, and the Session list marks a Session that works in its created worktree.
The worktree a Session works in can be removed outside it. Each inspection checks whether the created worktree's directory still exists, and every message that enters while the Agent is idle starts a hold that checks again, so the message waits without starting its turn while the directory is missing. The check must come before the turn: while it assembles a step's context, the working-directory service moves a Session whose directory is gone back to its project, before any pre-step listener runs. Restore checks the branch out again at the recorded directory, or creates the branch again at its base commit when it is gone; Continue in the project moves the Session to the source checkout. Either runs inside the hold and lets the waiting message go on.
@@ -26,6 +26,8 @@ The panel does not move a Session between existing worktrees. Removing a change
The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from confined `git diff` in the Session's execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other.
Review comments capture the file, side, range, complete hunk, and reviewed head when the user selects code. Agent references enter the existing composer quote mechanism, so the user controls batching and the sent message records exactly what the model receives. GitHub writes belong to the optional forge provider operation: preflight checks the current head, the write addresses that commit, and an ambiguous outcome blocks another write until the user reconciles it. Review drafts and inline records belong to a browser tab occurrence, not to the Session log or a GitHub pending review. Switching Sessions retains that occurrence; closing it discards the local state and rejects late write completions. Published GitHub comments remain external records.
## Deferred
A left-navigation review page next to Plugins and Scheduled tasks, listing the user's pull requests or merge requests on the selected provider, is deferred. It attaches without changing this package's Session surfaces:
@@ -67,7 +69,7 @@ The changes view offers no scope for one commit or for the last turn yet; both w
**The creation card in the transcript.** Transcript nodes accept no plugin kinds; the input dock above the composer is the nearest list slot, and it renders on the New Session screen.
**A Session-row hover section and manual URL tracking.** Both duplicated the tab. Bindings come from `gh pr create`, the `gh pr` commands that act on one change request, pushes, and the branch the checkout tracks, and `track` remains for Undo.
**Manual URL tracking.** It duplicated the bindings the Session's own commands record. Bindings come from `gh pr create`, the `gh pr` commands that act on one change request, pushes, and the branch the checkout tracks, and `track` remains for Undo.
**Parsing patches in the browser.** The Remote would carry unbounded patch text, and the browser would need a patch parser; the Host bounds the read and parses it once.
@@ -79,6 +81,12 @@ The changes view offers no scope for one commit or for the last turn yet; both w
**Creating the change request from the card.** The panel would need to push the branch and compose the title and description; a message lets the Agent do both with its tools and the user's instructions, and keeps one way to bind change requests.
**Sending each line comment immediately to the Agent.** It starts a turn before the user can collect related comments or add a shared instruction; composer references preserve that choice.
**Keeping review drafts after their tab closes.** Reopening the same PR would restore abandoned review state. A tab occurrence gives closing a clear discard meaning while preserving Session switching.
**Automatically retrying a review write.** A lost response does not mean that GitHub rejected the request. Retaining the outgoing request and requiring reconciliation prevents a transport failure from silently duplicating comments.
## Consequences
The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to eleven short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once.
@@ -10,7 +10,7 @@ Status: implemented
## Decision
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片,以及一个 toast。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。
Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略下检查检出,因此它读到的与 Session 命令看到的相同,且无法写入。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。
@@ -18,7 +18,7 @@ Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略
展开的卡片可以把草稿标记为可评审,也可以合并主机现在就会合并的变更请求。forge seam 把两者作为提供方操作承载,失败时返回带类型的结果;每个快照都说明主机现在是否会为已登录用户合并该变更请求、仓库允许哪些合并方式,因此面板只提供能够成功的操作。合并带上卡片显示的 head 提交,有新提交到达后主机会拒绝。面板只操作绑定到该 Session 的变更请求。
新 Session 通过请求而不是立即创建来以 worktree 开始。勾选新会话那一行中的选项(一个 `conversation.hero.options` 条目)会追加一条带基准分支的 `plugin:client-ui-forge/worktree` 记录;更换基准或取消勾选会再追加一条;用户发送之前不会改动仓库。只有创建能够成功时才提供该选项:在 `workspace-write` 下,检出及其公共 Git 目录必须位于可写根之内,这排除了本身就是链接 worktree 的项目。worktree 必须在第一条消息的这一轮开始之前就存在,这样这一轮才会在其中运行,第一次请求也已经写明新目录。消息进入 Session 的收件箱时,面板的 `agent/inbox/inserted` 监听器在这次投递唤醒 Agent 之前启动 Agent 的维护任务;Agent 会暂存唤醒直到维护任务结束,面板在维护任务内创建并进入 worktree。创建的阶段以流的形式推送给输入框上方的卡片,卡片上的“取消”停止这次尝试。尝试被停止或失败后,消息继续在收件箱中等待,卡片保留失败信息,并提供用新分支名再试一次,或“不使用工作树”:撤回请求并让消息继续。一个 `agent/pre-step` 监听器仍会在请求未完成的 Session 的第一个步骤提议中创建 worktree,因为 Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留;它把工作目录通知移入本步提议的消息,失败时这一轮结束。`forgeWorktree` 投影忽略第一个 `turn/start` 之后的请求以及第一个 `step/start` 之后的创建,因此运行中的 Session 永远不会移动。标签页顶部一行写出 Session 所在的分支。
新 Session 通过请求而不是立即创建来以 worktree 开始。勾选新会话那一行中的选项(一个 `conversation.hero.options` 条目)会追加一条带基准分支的 `plugin:client-ui-forge/worktree` 记录;更换基准或取消勾选会再追加一条;用户发送之前不会改动仓库。只有创建能够成功时才提供该选项:在 `workspace-write` 下,检出及其公共 Git 目录必须位于可写根之内,这排除了本身就是链接 worktree 的项目。worktree 必须在第一条消息的这一轮开始之前就存在,这样这一轮才会在其中运行,第一次请求也已经写明新目录。消息进入 Session 的收件箱时,面板的 `agent/inbox/inserted` 监听器在这次投递唤醒 Agent 之前启动 Agent 的维护任务;Agent 会暂存唤醒直到维护任务结束,面板在维护任务内创建并进入 worktree。创建的阶段以流的形式推送给输入框上方的卡片,卡片上的“取消”停止这次尝试。尝试被停止或失败后,消息继续在收件箱中等待,卡片保留失败信息,并提供用新分支名再试一次,或“不使用工作树”:撤回请求并让消息继续。一个 `agent/pre-step` 监听器仍会在请求未完成的 Session 的第一个步骤提议中创建 worktree,因为 Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留;它把工作目录通知移入本步提议的消息,失败时这一轮结束。`forgeWorktree` 投影忽略第一个 `turn/start` 之后的请求以及第一个 `step/start` 之后的创建,因此运行中的 Session 永远不会移动。标签页顶部一行写出 Session 所在的分支,会话列表标出在所创建 worktree 中工作的 Session。
Session 所在的 worktree 可能在 Session 之外被删除。每次检查都会确认所创建 worktree 的目录是否还在;Agent 空闲时进入的每条消息都会启动一次暂留并再确认一次,目录缺失期间消息等待、不开始这一轮。这次确认必须发生在这一轮之前:组装一个步骤的上下文时,工作目录服务会把目录已不存在的 Session 移回其项目,而这发生在任何 pre-step 监听器运行之前。“恢复”在记录的目录重新检出分支;分支也已删除时,在其基准提交重新创建分支。“在项目目录继续”把 Session 移回源检出。两者都在暂留内运行,之后放行等待中的消息。
@@ -26,6 +26,8 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都
面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自 Session 执行环境中受限的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。
评审评论在用户选择代码时固定文件、侧、行范围、完整 hunk 与所评审的 head。Agent 引用进入既有输入框引用机制,由用户决定批量发送,发送后的消息记录模型实际收到的内容。GitHub 写入属于 forge 提供方的可选操作:预检核对当前 head,写入明确指向该提交;结果不明时阻止再次写入,直到用户核对。评审草稿与行内记录归浏览器页签实例所有,不进入 Session 日志,也不创建 GitHub 待提交评审。切换 Session 保留该实例;关闭时丢弃本地状态并拒绝迟到的写入结果。已发布的 GitHub 评论仍保留在外部平台。
## Deferred
在左侧导航“插件”“自动化任务”旁边新增代码审查页、列出用户在所选提供方上的 pull request 或 merge request,这项工作推迟。它的接入不需要改动本包面向 Session 的界面:
@@ -67,7 +69,7 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都
**把创建卡片放进对话记录。** 对话记录的节点不接受插件定义的类型;输入框上方的停靠区是最近的列表型 slot,而且它在新会话页也会渲染。
**Session 行悬停分区与手动粘贴 URL 跟踪。** 二者都与标签页重复。绑定来自 `gh pr create`、操作单个变更请求的 `gh pr` 命令、推送以及检出所跟踪的分支,`track` 保留给“撤销”使用。
**手动粘贴 URL 跟踪。** 它与 Session 自己的命令记录下的绑定重复。绑定来自 `gh pr create`、操作单个变更请求的 `gh pr` 命令、推送以及检出所跟踪的分支,`track` 保留给“撤销”使用。
**在浏览器中解析补丁。** Remote 将传输不受限的补丁文本,浏览器也需要补丁解析器;宿主约束读取并只解析一次。
@@ -79,6 +81,12 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都
**直接在卡片上创建变更请求。** 面板需要推送分支并撰写标题与描述;发送消息让 Agent 用自己的工具和用户的指示完成这两件事,绑定变更请求的方式也只保留一种。
**把每条行内评论立即发送给 Agent。** 用户还没来得及收集相关评论或补充共同指令,就会开始一轮;输入框引用保留用户的选择。
**关闭页签后仍保留评审草稿。** 重新打开同一个 PR 会恢复已放弃的评审状态。页签实例让关闭明确表示丢弃,同时保留切换 Session 的状态。
**自动重试评审写入。** 丢失响应不代表 GitHub 拒绝了请求。保留发出的请求并要求核对,可以避免传输失败悄悄产生重复评论。
## Consequences
面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十一个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-20-forge-session-list.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-20-forge-session-list.md
/agent-note-forge-state-in-the-session-list:
en: b5876f7873857921
zh: 56fac53370de206d
/agent-note-forge-state-in-the-session-list/problem:
en: 07ba0c3bf73a2446
zh: 2413783a6697133e
/agent-note-forge-state-in-the-session-list/decision:
en: e1d03cf4e1afd5aa
zh: b2386cb7c17c58dd
/agent-note-forge-state-in-the-session-list/alternatives-considered:
en: 3b08440ee2bf7809
zh: a90e3fcb89cf3f74
/agent-note-forge-state-in-the-session-list/consequences:
en: 25a17eac43beb705
zh: c138f693ff07c91d
@@ -0,0 +1,39 @@
# Agent Note: Forge state in the Session list
Status: implemented
English | [中文](2026-10-20-forge-session-list.zh.md)
## Problem
The [forge panel](2026-10-09-forge-panel.md) named a Session's branch in a chip beside the Session title. The header's action row does not shrink, so a long branch overlapped other header entries such as the background task label. Finding which Sessions work in their own worktrees, and which of their change requests need attention, also meant opening each Session's tab. The Session list offers seats for row icons and a hover card section, `sidebar.session.row.trailing` and `sidebar.session.row.hover`, whose occupants must not load or bind Sessions. A row needs two kinds of fact: what the Session log records, which is the worktree the panel created and entered and the change requests the Session binds, and what only polling knows, which is each change request's state and checks.
## Decision
The chip beside the Session title is gone, and the tab's first row names the branch. A Session row shows, after its time, a branch glyph while the Session works in the worktree the panel created for it, and the glyph of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. An open change request's glyph keeps the row's subdued color with a dot for its checks; merged and closed ones keep their state colors. The row's hover card section names the worktree branch and what it started from, then up to three change requests with their state and checks, and how many more there are.
Log facts reach the list as projection client views, which the Session summary carries. The `forgeWorktree` projection also records the Session's directory from its header and each `working-directory/change`, and its view is the created worktree while that directory is the worktree or lies below it, else null. The `forgeChangeRequests` view lists the bindings without status, as the [binding decision](2026-10-09-session-change-request-bindings.md) intended. The projection cache keeps both for Sessions that are not loaded.
Polled states reach the list through the `forgePanel.changeRequestStates` stream. One Host run serves every stream and stops with the last one. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of loaded Sessions from `ctx.forgeSession` and those of other Sessions from their cached summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through the shared `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. Every changed state is stored in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL, so a restart shows the last known states before the next poll, and unbound stored states are dropped only when every listed Session has known bindings. A stream sends a `baseline` of the states its caller may read, then `changed` frames, filtered through Remote access.
The browser keeps one source of states for every row and hover section: the first subscriber opens the stream, the last one closes it, and a transport reset reopens it.
## Alternatives considered
**Leaving change requests to the tab.** The forge panel first rejected a Session-row hover section because, for one Session, it repeated the tab. The list compares every Session at once, which no tab does, and the hover section adds only the facts a row's glyphs summarize.
**Keeping the branch beside the Session title.** Truncating the chip would hide the branch name, and the header shows only the open Session; the list shows every Session, and the tab names the branch in full.
**Polled states in a projection.** Projections fold the Session log, and polled states are not logged. A projection would either log every poll, adding events that no model request needs, or depend on process-local state that a replay cannot reconstruct.
**Following every listed Session's panel.** A panel inspects its checkout with Git in the Session's execution world and probes sign-in; the list needs only bindings and change request states.
**Loading listed Sessions to read their bindings.** Listing would replay every Session log; the cached summary answers without loading.
**Keeping the states only in memory.** Every row would show unknown states after a restart until each change request is polled again. The stored states are derived data, so a record that fails its schema is set aside instead of failing the run.
**A branch line for a Session that works in its project directory.** Reading that branch needs Git in the Session's execution world, which the list must not reach without loading the Session.
## Consequences
The list shows every Session's worktree and change request states without loading a Session, a restart shows the last known states at once, and the header no longer overlaps. While a row is shown, every open change request that a listed Session binds is polled, so many Sessions with open change requests mean more requests to the code host; closed requests keep polling at the idle cadence, and merged ones stop automatic polling. A Session without a cached summary shows its worktree and change requests in the list only after it is opened, and one last used before this change shows its worktree only then, because the `forgeWorktree` state changed and older cached rows are discarded. Checks that finish while no row is shown produce no notification.
@@ -0,0 +1,39 @@
# Agent Note: 会话列表中的 forge 状态
Status: implemented
[English](2026-10-20-forge-session-list.md) | 中文
## Problem
[forge 面板](2026-10-09-forge-panel.zh.md)原先在 Session 标题旁的标签中写出 Session 的分支。标题栏的操作行不会收缩,因此较长的分支名会与后台任务标签等其他标题栏条目重叠。想知道哪些 Session 在自己的 worktree 中工作、它们的哪些变更请求需要处理,也必须逐个打开 Session 的标签页。会话列表为行图标与悬浮卡片提供了位置,即 `sidebar.session.row.trailing` 与 `sidebar.session.row.hover`,其中的条目不得加载或绑定 Session。一行需要两类事实:Session 日志记录的事实,即面板创建并进入的 worktree 以及 Session 绑定的变更请求;以及只有轮询才知道的事实,即每个变更请求的状态与检查。
## Decision
Session 标题旁的标签已移除,由标签页顶部一行写出分支。Session 在面板为它创建的 worktree 中工作时,会话行在时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,并用圆点标出检查;已合并与已关闭的保留各自的状态颜色。会话行悬浮卡片中的部分写出 worktree 分支及其起点,再列出最多三个变更请求的状态与检查,并说明还有多少个。
日志事实经投影的客户端视图进入列表,由 Session 摘要携带。`forgeWorktree` 投影还从 Session 头部与每个 `working-directory/change` 记录 Session 的目录;该目录就是所创建的 worktree 或位于其下时,视图为该 worktree,否则为 null。`forgeChangeRequests` 视图列出绑定但不含状态,与[绑定决策](2026-10-09-session-change-request-bindings.zh.md)的设想一致。投影缓存为未加载的 Session 保留这两项。
轮询到的状态经 `forgePanel.changeRequestStates` 流进入列表。宿主用一次运行服务所有流,最后一条流结束时运行停止。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的摘要;它跟随已加载 Session 的绑定变化,并经共享的 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,因此重启后在下次轮询之前就能显示最近已知的状态;仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态。每条流先发送调用方可读状态的 `baseline`,之后发送 `changed` 帧,均经 Remote 访问控制过滤。
浏览器为所有会话行与悬浮卡片部分只保留一个状态来源:第一个订阅方打开流,最后一个关闭它,传输重置后重新打开。
## Alternatives considered
**把变更请求只留给标签页。** forge 面板最初否决了 Session 行悬浮分区,因为对单个 Session 而言它与标签页重复。列表能同时比较所有 Session,这是任何标签页都做不到的;悬浮部分也只补充会话行图标所概括的事实。
**保留 Session 标题旁的分支。** 截断标签会遮住分支名,而且标题栏只显示当前打开的 Session;列表显示每个 Session,标签页则完整写出分支。
**把轮询状态放进投影。** 投影折叠的是 Session 日志,而轮询到的状态不写入日志。放进投影要么为每次轮询记录事件,而这些事件没有任何模型请求需要;要么让投影依赖重放无法重建的进程内状态。
**为每个已列出的 Session 跟随其面板。** 面板会在 Session 的执行环境中用 Git 检查检出并检查登录状态;列表只需要绑定与变更请求状态。
**加载已列出的 Session 来读取绑定。** 列表将重放每个 Session 的日志;缓存的摘要无需加载就能回答。
**只把状态保存在内存中。** 重启后每一行都会显示未知状态,直到每个变更请求再次被轮询。已存状态是派生数据,因此不符合 schema 的记录会被放到一边,而不是让运行失败。
**为在项目目录中工作的 Session 显示分支一行。** 读取该分支需要在 Session 的执行环境中运行 Git,而列表不加载 Session 就无法到达那里。
## Consequences
列表无需加载 Session 就能显示每个 Session 的 worktree 与变更请求状态,重启后立即显示最近已知的状态,标题栏也不再重叠。有会话行显示期间,已列出 Session 绑定的每个打开中的变更请求都会被轮询,因此有大量带打开变更请求的 Session 时,对代码托管平台的请求会增多;已关闭的继续按空闲间隔轮询,已合并的停止自动轮询。没有缓存摘要的 Session 要打开之后才会在列表中显示其 worktree 与变更请求;此变更之前最后使用的 Session 也要到那时才显示其 worktree,因为 `forgeWorktree` 的状态已变化,旧的缓存行会被丢弃。没有会话行显示期间结束的检查不会有通知。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-22-listed-model-badges.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-22-listed-model-badges.md
/agent-note-model-selector-badges-come-from-the-model-listing:
en: cc4b8fee78e75a98
zh: 3cf21a37bea4410c
/agent-note-model-selector-badges-come-from-the-model-listing/problem:
en: ed011e743511ef25
zh: 2389e339f1a6dcbf
/agent-note-model-selector-badges-come-from-the-model-listing/decision:
en: 84b99f80d8824117
zh: 94cd96f59caf5b43
/agent-note-model-selector-badges-come-from-the-model-listing/alternatives-considered:
en: 079466e0dd56584e
zh: 8e33049c422d77cb
/agent-note-model-selector-badges-come-from-the-model-listing/consequences:
en: 3ba39c2a109e49cd
zh: 3eb16676854230f9
@@ -0,0 +1,47 @@
# Agent Note: Model selector badges come from the model listing
Status: implemented
English | [中文](2026-10-22-listed-model-badges.zh.md)
## Problem
The Web model selector showed `1017` beside `DeepSeek-V4.1-Flash` through a dedicated client package that matched two DeepSeek providers, one model id, and one exact catalog name. Changing or retiring that release marker, or labeling another model, required a client release, while both DeepSeek routes already read model metadata from the service's `GET /models` listing.
## Decision
- Each `/models` entry may declare `badge: { text, tone }` under `client_metadata["deepseek-harness"]`, keyed like `client_requirements` by the User-Agent product token. `client_metadata` holds client-specific data that every build may ignore, so the public model fields stay unchanged and later DeepSeek Harness fields join the same object. `text` uses the package-metadata `LocalizedText` form: one string, or translations keyed by lowercase language id that include `en`. Each text is trimmed and holds 1 to 12 user-perceived characters on one line; controls, Unicode line separators, bidi controls, and text made only of whitespace or default-ignorable characters are rejected.
- `tone` is `neutral`, `info`, `success`, or `warning`: the `Tag` palettes for a plain fact, a highlight, a benefit, and attention. The shipped release marker used `info`; the surveyed selectors draw plan and capability tags neutral, new features with an accent, and usage hints in a warning color. `danger` and `solid` keep their failure and selection meanings.
- [`parseModelListing`](../../../../packages/llm/llm-deepseek/src/listing.ts) decodes the badge beside the entry's other declarations, and [`resolveListedCatalog`](../../../../packages/llm/llm-deepseek/src/listed-catalog.ts) keeps the badges of offered models; both routes attach them through `catalogModelInfo`. An absent or `null` value at any level of that path shows no badge, and an absent or unrecognized tone shows `neutral`. A level that is not an object or an unreadable badge drops the badge, which is reported with the listing's other problems and never hides its model.
- `LlmModelInfo.badge` and `ModelCatalogModel.badge` carry the badge from discovery through the Host model catalog to the browser. `resolveModelInfo` omits it, and no model request, Session event, or model-facing tool reads it.
- [`ui-model-selection`](../../../../packages/client/ui-model-selection/README.md) renders the badge before the catalog name in the trigger and after it in the root-menu value and each option, resolving its text with `ctx.locale.resolveText`. A `conversation.input.model.label` contribution replaces the name and badge together and receives the badge in its owner props.
- The build ships no badge of its own: a route before its first listing shows none. The API-key route does not follow listings for customized catalogs or non-official endpoints; the account route follows the origin of its configured `baseURL`, including custom endpoints. The fixed `ui-deepseek-model-label-1017` package and its Web bundle row are removed.
- After first use, the credential-scoped cache refreshes automatically at `modelsMaxAgeMs` (ten minutes by default) and publishes changed catalogs through `llm/models-updated`. Successful listings replace all badge metadata, so omission or `null` clears an old badge. Failed refreshes retain the last successful listing, including badges: a failed request does not establish that the server withdrew a badge. This avoids independent polling in every browser. A zero interval preserves access-only refresh.
## Alternatives considered
**A top-level `badge` field.** The public `/models` fields describe model capabilities and limits for every client, while the badge is DeepSeek Harness release and campaign copy. A top-level field reads as a model attribute that other clients could start rendering, after which the service could no longer change or retire it freely, and two clients could not show different badges.
**`badge` inside `client_requirements["deepseek-harness"]`.** It adds no top-level field, and released builds ignore an extra key there. But an unreadable requirement hides its model on every build, released ones included, so a serializer that writes the emptied object as `null` after a badge is retired would hide the model. Requirements gate availability; display data must stay ignorable.
**A narrower map such as `client_presentation` or `client_annotations`.** These names limit the object to display data or descriptive notes, so the next client-specific field of another kind, such as a default, would need another top-level field; `client_metadata` takes any field a build may ignore.
**A semantic badge kind mapped to client dictionaries.** OpenCode, ZCode, and DeepSeek-Reasonix translate a closed set of client-owned tag kinds, and none renders text a server sends. A fixed vocabulary cannot express a release code such as `1017`, and each new wording would need the client release the listing exists to avoid. Listing text is provider data, which the [locale-owned copy decision](../architecture/2026-08-23-locale-owned-client-ui-copy.md) renders verbatim; the `LocalizedText` form still lets the service supply translations.
**Accept colors, CSS, or markup from the listing.** No surveyed product accepts styling from a catalog, and arbitrary colors bypass the theme's light and dark palettes and their contrast. A closed tone set maps onto the existing `Tag` palettes, and an unrecognized tone degrades to `neutral`, so a value a newer service adds never removes the text.
**Keep `1017` as a built-in default.** A bundled badge would keep the marker offline and on custom endpoints. Under the listing rule that an absent field keeps the build's value, the service could then remove it only with a special empty value, and old builds would keep the stale marker wherever no listing loads. Shipping no badge makes the listing the only source, so omitting the field removes the badge.
**An expiry or start time on the badge.** The listing cache lives in memory and refreshes on access after `modelsMaxAgeMs`, so a badge the service drops disappears within one refresh. Client-side time windows would add dependence on the client clock without removing reachable staleness.
**Drop the whole entry or listing for a bad badge.** OpenCode drops a model whose catalog status it cannot decode, and Codex fails its catalog decode on one unknown enum value. A badge is presentation only, so a bad one is dropped by itself.
**A DeepSeek-specific badge Remote beside the catalog.** Keeping the badge out of `LlmModelInfo` would need a second Remote, a second client fetch, and agreement between two caches about which models exist. One optional field on the existing catalog reaches every placement in one load.
## Consequences
- The service changes, localizes, or removes a badge without a client release; a running client shows the change after its next listing refresh and catalog reload.
- A condition that older builds must enforce belongs in `client_requirements`; every field under `client_metadata` must be safe for a build that does not read it.
- `deepseek-flash` shows no badge until the service declares one and a listing loads. Customized catalogs and non-official endpoints on the API-key route do not receive listed badges.
- The `/model` popup, the trigger's accessible name, and the Models settings page keep plain names.
- A profile patch that still targets `ui-deepseek-model-label-1017` matches no row and boots with a warning ([upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/model-badge-package-removal/guide.md)).
@@ -0,0 +1,47 @@
# Agent Note: 模型选择器角标来自模型列表
Status: implemented
[English](2026-10-22-listed-model-badges.md) | 中文
## 问题
Web 模型选择器通过一个专门的客户端包在 `DeepSeek-V4.1-Flash` 旁显示 `1017`,该包写死匹配两个 DeepSeek 提供方、一个模型 id 和一个确切的目录名称。修改或撤下这个版本标记、给其他模型加标签,都要发一次客户端版本;而两条 DeepSeek 路由早已从服务端的 `GET /models` 列表读取模型元数据。
## 决策
- 每个 `/models` 条目可以在 `client_metadata["deepseek-harness"]` 下声明 `badge: { text, tone }`,与 `client_requirements` 一样以 User-Agent 的产品名为键。`client_metadata` 存放所有版本都可以忽略的客户端专属数据,因此公开的模型字段保持不变,以后新增的 DeepSeek Harness 字段也放进同一个对象。`text` 采用包元数据的 `LocalizedText` 形式:单个字符串,或以小写语言 id 为键且包含 `en` 的译文。每段文字去掉首尾空白后须为单行,长度为 1 到 12 个用户可见字符;拒绝控制字符、Unicode 行分隔符、双向控制符及仅由空白或默认可忽略字符组成的文字。
- `tone` 取 `neutral`、`info`、`success` 或 `warning`,分别是 `Tag` 表示普通事实、强调、利好和需要注意的配色。已发布的版本标记使用 `info`;调研的选择器把套餐和能力标签画成中性色,新功能用强调色,用量提示用警示色。`danger` 和 `solid` 保留各自表示失败和当前选中的含义。
- [`parseModelListing`](../../../../packages/llm/llm-deepseek/src/listing.ts) 与条目的其他声明一起解码角标,[`resolveListedCatalog`](../../../../packages/llm/llm-deepseek/src/listed-catalog.ts) 保留已提供模型的角标;两条路由都通过 `catalogModelInfo` 附上角标。这条路径上任何一层缺省或为 `null` 时不显示角标;`tone` 缺省或无法识别时按 `neutral` 显示。某一层不是对象或角标无法读取时,角标被丢弃,并与列表的其他问题一起报告,但不会隐藏所属模型。
- `LlmModelInfo.badge` 与 `ModelCatalogModel.badge` 把角标从模型发现经 Host 模型目录送到浏览器。`resolveModelInfo` 不返回它,模型请求、Session 事件和面向模型的工具都不读取它。
- [`ui-model-selection`](../../../../packages/client/ui-model-selection/README.zh.md) 在触发按钮中把角标放在目录名称之前,在根菜单当前值和各选项中仍放在名称之后,并用 `ctx.locale.resolveText` 解析其文字。`conversation.input.model.label` 的扩展会同时替换名称与角标,并从 owner props 收到角标。
- 本版本不内置任何角标:路由加载到第一份列表之前不显示角标。API key 路由在自定义目录或非官方端点上不跟随列表;账号路由跟随配置的 `baseURL` 的源,包括自定义端点。写死的 `ui-deepseek-model-label-1017` 包及其 Web bundle 条目已移除。
- 按凭据区分的缓存首次使用后按 `modelsMaxAgeMs`(默认十分钟)自动刷新,通过 `llm/models-updated` 发布目录变化。成功加载会替换所有角标元数据,因此省略或 `null` 都会清除旧角标。刷新失败时保留上次成功加载的列表及其角标:请求失败并不能证明服务端撤下了角标。这样也避免了每个浏览器独立轮询。间隔为零时仍只在访问时刷新。
## 考虑过的替代方案
**顶层 `badge` 字段。** 公开的 `/models` 字段为所有客户端描述模型的能力与限制,而角标是 DeepSeek Harness 的发版和运营文案。放在顶层会被当作模型属性,其他客户端可能开始显示它,此后服务端就无法随意修改或撤下;两个客户端也无法显示不同的角标。
**把 `badge` 放进 `client_requirements["deepseek-harness"]`。** 这样不增加顶层字段,已发布版本也会忽略其中多出的键。但版本要求无法读取时,所有版本都会隐藏该模型,已发布的版本也不例外;撤下角标后,序列化器若把清空的对象写成 `null`,模型就会被隐藏。版本要求决定模型能否使用,展示数据必须可以忽略。
**范围更窄的映射,例如 `client_presentation` 或 `client_annotations`。** 这些名称把对象限定为展示数据或附加说明,下一个其他类型的客户端专属字段(例如默认值)又要新开一个顶层字段;`client_metadata` 可以容纳任何版本都能忽略的字段。
**用语义化的角标类别映射到客户端字典。** OpenCode、ZCode 和 DeepSeek-Reasonix 都翻译一组封闭的、由客户端持有的标签类别,没有一家直接渲染服务端发来的文字。固定词表表达不了 `1017` 这样的版本代号,每出现一种新说法都要发一次客户端版本,而列表正是为了免去这一步。列表文字属于提供方数据,按[客户端文案归 locale 管理的决定](../architecture/2026-08-23-locale-owned-client-ui-copy.zh.md)原样渲染;`LocalizedText` 形式仍允许服务端提供译文。
**接受列表下发的颜色、CSS 或标记。** 调研的产品都不接受目录下发的样式;任意颜色会绕过主题的明暗配色及其对比度。封闭的语调集合直接对应现有 `Tag` 配色;无法识别的语调降级为 `neutral`,因此较新的服务端新增的取值不会让文字消失。
**把 `1017` 保留为内置默认值。** 内置角标能让标记在离线和自定义端点下继续显示。但按照“字段缺失时沿用本版本的值”的列表规则,服务端只能用特殊的空值才能去掉它,而且旧版本在加载不到列表时会一直显示过时的标记。不内置角标,列表就是唯一来源,省略字段即可去掉角标。
**给角标加过期时间或生效时间。** 列表缓存只在内存中,超过 `modelsMaxAgeMs` 后在访问时刷新,服务端撤下的角标在一次刷新内就会消失。客户端时间窗口会引入对客户端时钟的依赖,却消除不了实际可能出现的陈旧数据。
**角标有误时丢弃整个条目或整份列表。** OpenCode 会丢掉目录状态无法解码的模型,Codex 遇到一个未知枚举值就让整份目录解码失败。角标只用于展示,有误时只丢弃角标本身。
**在目录之外为 DeepSeek 单独提供角标 Remote。** 让角标不进入 `LlmModelInfo`,就需要第二个 Remote、客户端的第二次请求,以及两份缓存对“有哪些模型”保持一致。在现有目录上增加一个可选字段,一次加载即可送到所有显示位置。
## 影响
- 服务端无需客户端发版即可修改、翻译或撤下角标;运行中的客户端在下一次列表刷新和目录重新加载后显示变化。
- 旧版本必须执行的条件放在 `client_requirements`;`client_metadata` 下的每个字段都必须保证不读取它的版本也能安全运行。
- 服务端声明并加载到列表之前,`deepseek-flash` 不显示角标。API key 路由的自定义目录与非官方端点不接收列表角标。
- `/model` 弹窗、触发按钮的无障碍名称和模型设置页仍只显示名称。
- 仍指向 `ui-deepseek-model-label-1017` 的 profile patch 不再匹配任何条目,启动时会给出警告([升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/model-badge-package-removal/guide.zh.md))。
@@ -18,11 +18,11 @@
en: 1310a89c351e5bfe
zh: e6386ae10b893c2d
/agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints:
en: 11937dfa4a349a19
zh: b91a136890fe92e8
en: add81088ce51033f
zh: c7065dc3052648fa
/agent-note-performance-baselines-for-large-session-corpora/calibration-evidence:
en: f934c95748dcbfe0
zh: df6a68286c21510f
en: adb815c213373d8e
zh: 44d4c6752a4df51f
/agent-note-performance-baselines-for-large-session-corpora/alternatives-considered:
en: 4eaa4666262a1aee
zh: 7e2eb44b94bad05d
@@ -47,6 +47,8 @@ The shared corpus and projection-list controller fixture awaits controller activ
The file must finish within five minutes on standard hosted CI, including seeding, and its last case asserts that limit. Search over 1,000 Sessions and one fork of the longest Session take about 170 s of it, so each has one sample. The list corpus is the largest that fits the remainder. Budgets enforce medians of list boot, first, and repeat, and of the fork median over strata and rank 990; search and the longest fork enforce their single samples. Search and fork use separate 1,000-Session roots, so fork children never reach the search index. Rank 988 is the highest top-level rank at the measured p99 (7,229 events); forking a subagent child adds a lineage lookup, and rank 990 already falls in the 99.3% anchor. Peak RSS of the list, search, and fork processes has a budget with the shared 1.25 headroom and no time scale. Each child's deadline equals the file limit, and each test's deadline outlasts its sequential children, so a failed case reaps every child before the scratch root is removed.
Fork workers use a 512 MiB V8 old-space limit so every runner measures the same heap constraint. The corpus, timed endpoints, and whole-process peak RSS budget stay unchanged; native buffers still count toward RSS. The longest fork retains about 193 MiB after collection, so the limit leaves room for live objects while forcing collection before a larger host-selected heap delays it. This measures a bounded-heap workload, not the default-heap peak of every deployment.
## Calibration evidence
Budgets use standard two-CPU hosted CI expectations (Linux x64, Node 24.21, PR #5403), rounded above the higher recorded median across the AMD EPYC 7763 and EPYC 9V74 runners and multiplied by the shared 1.25 headroom. Samples varied by up to 15% within one run; the runner models differed by up to 25% per endpoint. The hosted-to-reference ratio is 2.4 to 3.4, above the shared reference scale of 2, so reference-machine scaling would reject ordinary hosted runs.
@@ -67,6 +69,8 @@ With seeding in one process, the complete file took 294.5 s on the EPYC 7763 run
On Apple M5 Pro with Node 26.5, isolated runs measured list 1,000 at 311–358 ms first, list 5,000 at 1,364–1,603 ms first, search at 57 s first, and fork of the longest Session at 6.0–6.4 s. A 10,000-Session list took 3.2–5.4 s for the first call with 1.9 GB peak RSS.
Node 24.22 hosted runs of the unchanged longest-fork workload recorded 696 MiB peak RSS on EPYC 9V74 and 1,077.7 MiB on EPYC 7763, against the same 938 MiB budget. On Apple M5 Pro with Node 24.19, two default-heap runs retained 192.8–193.4 MiB and peaked at 770.7–812.6 MiB in 2.416–2.447 s; two 512 MiB old-space runs retained the same range and peaked at 764.2–764.7 MiB in 2.487–2.528 s. The fixed limit controls the heap policy without raising the RSS or timing budgets.
## Alternatives considered
- **Sanitized copies of real Sessions.** Rejected: benchmarks never use recorded user material.
@@ -47,6 +47,8 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新
此文件(包括播种)必须在标准托管 CI 上五分钟内完成,其最后一个用例断言该上限。1,000 个 Session 的搜索和最长 Session 的一次 fork 约占其中 170 s,因此两者各取一个样本。列表语料是剩余时间内能容纳的最大规模。预算约束列表启动、首次和重复的中位数,以及 fork 分层中位数和排名 990 的中位数;搜索和最长 fork 约束各自的单个样本。搜索和 fork 使用各自独立的 1,000 个 Session 根目录,因此 fork 子会话不会进入搜索索引。排名 988 是实测 p99(7,229 个事件)上最高的顶层排名;fork subagent 子会话会增加一次谱系查找,而排名 990 已落入 99.3% 锚点。列表、搜索和 fork 进程的峰值 RSS 设有预算,使用共享的 1.25 余量,不做时间缩放。每个子进程的截止时间等于文件上限,每个测试的截止时间长于其顺序子进程之和,因此失败的用例会在删除临时根目录之前回收所有子进程。
Fork worker 使用 512 MiB 的 V8 old-space 上限,让各运行器在相同的堆约束下测量。语料、计时终点和整个进程的峰值 RSS 预算保持不变,原生缓冲区仍计入 RSS。最长 fork 回收后保留约 193 MiB,因此该上限为存活对象保留空间,同时避免较大的宿主默认堆延迟回收。该用例测量受限堆下的工作负载,不代表每种部署默认堆下的峰值。
## 校准证据
预算使用标准双 CPU 托管 CI 的期望值(Linux x64、Node 24.21、PR #5403),取高于 AMD EPYC 7763 与 EPYC 9V74 运行器中较高记录中位数的整数,再乘以共享的 1.25 余量。同一次运行内的样本差异最多 15%;两种运行器型号在单个端点上最多相差 25%。托管与参考机器的比值为 2.4 到 3.4,高于共享的参考缩放系数 2,因此按参考机器缩放会拒绝正常的托管运行。
@@ -67,6 +69,8 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新
在 Apple M5 Pro 和 Node 26.5 上的隔离运行中,1,000 个 Session 的首次列表为 311–358 ms,5,000 个 Session 的首次列表为 1,364–1,603 ms,首次搜索为 57 s,fork 最长 Session 为 6.0–6.4 s。10,000 个 Session 的首次列表耗时 3.2–5.4 s,峰值 RSS 为 1.9 GB。
未改动的最长 fork 工作负载在 Node 24.22 托管运行中,EPYC 9V74 的峰值 RSS 为 696 MiB,EPYC 7763 为 1,077.7 MiB,使用的预算均为 938 MiB。在 Apple M5 Pro、Node 24.19 上,两次默认堆运行回收后保留 192.8–193.4 MiB,峰值为 770.7–812.6 MiB,耗时 2.416–2.447 s;两次 512 MiB old-space 运行保留量相同,峰值为 764.2–764.7 MiB,耗时 2.487–2.528 s。固定上限用于控制堆策略,没有提高 RSS 或耗时预算。
## 考虑过的替代方案
- **脱敏复制真实 Session。** 已否决:基准从不使用录制的用户材料。
+4
View File
@@ -67,6 +67,10 @@ Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, m
- Review every changed region for gaps: nothing sits flush against its neighbour without an intentional gap, and adjacent icons and elements keep the region's established spacing.
- Prefer symmetry for parallel elements; an unexplained one-off gap or offset usually signals a missed shared value.
- **For complex pages and containers, establish shared horizontal and vertical alignment axes before adjusting local spacing.** Consider hover, selected, and expanded/collapsed states, as well as the overall relationship with adjacent panels and other page elements. Choose from the following references according to layout similarity:
- **Sidebar:** align corresponding text starts and leading icons across vertically stacked rows. Align hover and selected row backgrounds with the buttons above the list to maintain visual balance.
- **Plugins page:** keep content left-aligned along a shared edge. List-row hover backgrounds may extend beyond that edge while the content stays in place.
- **Conversation and right-sidebar headers:** align the first header rows across both panels and match their heights. Apply the same rule to the second rows, and align corresponding underlines and dividers on shared horizontal baselines.
## Design review escalation
@@ -13,6 +13,8 @@ Keep recoverable session facts in Projection and expose them through their ownin
2. Do not expose projection definitions or handles as production read APIs. Keep the ready handle private; consumers use the owning service's domain methods. A handle is a process-local reader, recreated during activation. Require `sessionProjections` when the service depends on it; optional registration is only for optional contributions. Do not hide a missing required dependency with another recovery path.
A runtime that needs several projected states receives one typed object of callbacks created with `handle.readerFor(session)`. Add properties instead of a constructor parameter or feature-specific reader for each state. Each callback has type `() => S`; single-purpose collaborators receive it directly. These host-state readers are separate from wire snapshots and live Cordis Context; they own no registration, disposal, cache or I/O. Registration, Session preparation and handle lifetimes stay with their owners. Rule 9 defines synchronous API compatibility.
3. State recovery through `init` and `apply` is synchronous and deterministic. Do not perform I/O or trigger Cordis hooks, models, or tools. Runtime hooks own those effects. Replay must not repeat them. Return the same state reference for unrelated events. Keep views synchronous and preserve their references when visible values do not change.
4. Persist JSON state that its schema accepts. Follow the README's state-version and checkpoint-watermark rules. Restoring a checkpoint and replaying its suffix must match full replay. Change the version when state fields or recovery semantics change.
+11 -36
View File
@@ -1,6 +1,6 @@
---
name: record-browser-gif
description: Record browser or Web UI interaction demos as optimized GIFs using the available browser-control workflow, optional Playwright Videos for higher capture frame rates, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`, falling back to a dedicated assets branch where attach cannot apply. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow.
description: Record browser or Web UI interaction demos as optimized GIFs using the available browser-control workflow, optional Playwright Videos for higher capture frame rates, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`; publication requires gh v2.99.0 or later, and a GIF over 5 MiB is split into parts. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow.
---
# Record Browser GIF
@@ -17,8 +17,8 @@ The recording itself is part of the evidence: use a real server booted from that
## Keep recording separate from publication
- Recording produces local video or screenshots and one `.gif` artifact only; it never mutates remote state.
- Publication — attaching the GIF to a pull request body with `gh --attach`, or pushing it to an assets branch and embedding its URL where attach cannot apply — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch.
- Recording produces local video or screenshots; encoding produces one `.gif` artifact, or its [numbered parts](#attach-with-gh) when it exceeds the 5 MiB limit. Neither mutates remote state.
- Publication — attaching the GIF to a pull request body with `gh --attach` — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never commits to any branch.
- Preserve the requested recording conditions. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture.
- Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt.
@@ -117,13 +117,15 @@ Encoder maintenance: run `python3 -m unittest discover -s "$GIF_SKILL_DIR/script
Perform this step only when the task includes attaching the GIF to a pull request.
Never commit a GIF to the pull request's own branch or any branch that merges into a long-lived branch: binary media committed there bloats the repository history for every future clone. Prefer `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media.
Never commit a GIF to the pull request's own branch or any other branch: every default clone and full-history fetch of the repository downloads media committed to any branch. Publish only with `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media.
### Attach with gh
`gh --attach` requires `gh` v2.99.0 or later (`gh --version`), a repository on github.com — GitHub Enterprise Server is not supported — write access to the repository, and a GIF at or below 10 MB. Confirm the verified artifact fits that limit; when it does not, shrink it with `--max-width`, then `--colors` or `--fps`, before attaching.
Before attaching, check `gh --version`. `gh --attach` requires `gh` v2.99.0 or later, a repository on github.com — GitHub Enterprise Server is not supported — and write access to the repository. When `gh` is older than v2.99.0, stop and tell the developer to upgrade `gh` to v2.99.0 or later; keep the verified GIF for the retry, and do not install or upgrade `gh` without authorization. When any other requirement is unmet or the attach fails — authentication, write access, a repository outside github.com, or an upload error — stop and report it to the developer. Never publish the GIF any other way.
Write the GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites the reference in place to the uploaded URL, keeping its position and alt text:
Publish GIFs at or below 5 MiB, the encoder's default `--max-bytes`. When the encoder rejects the output for size, shrink it with `--max-width`, then `--colors` or `--fps`. When the GIF still exceeds 5 MiB at readable text quality, split it instead: encode consecutive, non-overlapping intervals of the same evidence run into numbered parts that each pass the encoder's size check — `--start` and `--end` over the one video, or consecutive screenshot subsets selected with `--pattern` — and verify every part as an artifact. Beside each embed, state its position (such as `2/3`) and its source interval or frames.
Write each GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites each reference in place to its uploaded URL, keeping its position and alt text, so the order of references in the body sets the display order of split parts:
```markdown
![<alt text>](<path/to/demo.gif>)
@@ -138,35 +140,8 @@ gh pr create --body-file <body.md> --attach <path/to/demo.gif> # new pull re
gh pr edit <pr> --body-file <body.md> --attach <path/to/demo.gif> # existing pull request
```
`--attach` is repeatable but refuses the same file twice. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '<path>#<alt text>'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the demonstrated live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected `<img>`, and fetch the uploaded URL once to confirm `200` and `image/gif`.
`--attach` is repeatable but refuses the same file twice. Publish split parts in one `gh pr create` or `gh pr edit` with one `--attach` per part, so a single pair of live-head checks covers every part. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '<path>#<alt text>'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the demonstrated live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected `<img>`, and fetch the uploaded URL once to confirm `200` and `image/gif`.
### Fall back to an assets branch
### Existing assets branches
Use the assets-branch workflow only when `gh --attach` cannot apply: the GIF still exceeds 10 MB, `gh` is older than v2.99.0, or the repository is not on github.com. GIFs then live on a dedicated orphan assets branch — a branch with no parent commit and nothing but media — and one assets branch serves a whole pull request series (named `<series>-assets`; list existing ones with `git ls-remote --heads origin '*assets*'`).
Before either workflow below pushes, verify that the assets branch contains media only and that the staged GIF's checksum matches the verified local artifact.
For an existing assets branch, work in a shallow single-branch scratch clone so the publication cannot touch your working tree:
```sh
git clone --branch <assets-branch> --single-branch --depth 1 <repo-url> /tmp/assets-checkout
cp /absolute/path/to/demo.gif /tmp/assets-checkout/<name>.gif
cd /tmp/assets-checkout
git add <name>.gif
git commit -m "assets: <what it shows> gif (#<pr>)"
git push origin <assets-branch>
```
For a new series, make a fresh shallow scratch clone (`git clone --depth 1 <repo-url> /tmp/assets-checkout`), create the orphan branch with `git switch --orphan <assets-branch>`, then add the GIF, commit, and push the same way.
After pushing, use authenticated GitHub API or raw requests to confirm the remote path, byte size, checksum, `200` response, and `image/gif` content type. An anonymous `404` does not disprove a private-repository asset; authenticate the verification instead. This proves the repository-member review path, not public availability.
Immediately before editing the pull-request body, re-read the demonstrated pull request's live head and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. After the edit, re-read the demonstrated live head and require it to remain at that recorded commit. Separately, render the body through GitHub's Markdown API and confirm that the expected `<img>` is present.
Embed the GIF in the pull request body with the raw blob URL; the `?raw=true` suffix is required, because the plain blob URL renders GitHub's file page instead of the image:
```markdown
![<alt text>](https://github.com/<owner>/<repo>/blob/<assets-branch>/<name>.gif?raw=true)
```
Never delete or rewrite an assets branch, and never force-push it: merged pull request bodies reference its URLs forever. Append new commits only.
Earlier pull request bodies embed GIFs from orphan `*-assets` branches in this repository. Never add to, delete, rewrite, or force-push them: merged pull request bodies reference their URLs.
+1 -1
View File
@@ -4,6 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win
The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes.
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job.
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates.
The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job.
+3 -2
View File
@@ -14,6 +14,7 @@ on:
- larger-runner-benchmark
- consolidated-runner-benchmark
- ssh-helper
- wine
# New master pushes and manual runs replace obsolete checks in this workflow/ref.
# Standby drills share cancellation; use completed runs as readiness evidence.
@@ -49,9 +50,9 @@ jobs:
secrets:
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
# Wine runs once here, independently of the native serial standby aggregates.
# Wine runs on master pushes or an explicit suite=wine dispatch, independently of native standbys.
windows:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
if: (github.event_name == 'push' && github.ref == 'refs/heads/master') || (github.event_name == 'workflow_dispatch' && inputs.suite == 'wine')
runs-on: ubuntu-latest
name: windows node 24 / wine
timeout-minutes: 15
+83
View File
@@ -0,0 +1,83 @@
name: Package CUA helper · macOS
on:
workflow_dispatch:
inputs:
source_ref:
description: '同仓库可信源码分支或 dsh-v* tag'
type: string
default: master
required: true
preset:
description: '机器上的签名配置'
type: choice
options: [test, production]
default: test
required: true
workflow_call:
inputs:
source_ref:
type: string
required: true
preset:
type: string
default: production
outputs:
artifact:
description: '包含两个架构的已签名、公证 helper 归档'
value: ${{ jobs.package.outputs.artifact }}
permissions:
contents: read
# Desktop uses the same keychain search list and optional system proxy transaction.
concurrency:
group: macos-desktop-package
cancel-in-progress: false
jobs:
package:
runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial]
timeout-minutes: 60
outputs:
artifact: ${{ steps.artifact.outputs.name }}
env:
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
DSH_TELEMETRY_DISABLED: '1'
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
steps:
- uses: actions/checkout@v7.0.1
with:
ref: ${{ inputs.source_ref }}
persist-credentials: false
clean: true
- name: Validate source selection
run: |
node -e 'const cp=require("node:child_process"); const fs=require("node:fs"); const ref=process.env.PACKAGE_SOURCE_REF;
if(ref.startsWith("refs/pull/") || /^[a-f0-9]{40}$/i.test(ref)) throw Error("Select a trusted repository branch or release tag");
const tag=ref.replace(/^refs\/tags\//, "");
if(tag.startsWith("dsh-v") && tag!=="dsh-v"+JSON.parse(fs.readFileSync("package.json","utf8")).version) throw Error("Release tag must match product version");
if(!["test","production"].includes(process.env.DSH_PACKAGING_PRESET)) throw Error("Invalid preset");'
- uses: pnpm/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
package-manager-cache: false
- name: Install locked dependencies
run: pnpm install --frozen-lockfile
- name: Build and verify notarized applications
run: |
mkdir -p dist
pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper
- name: Name retained artifact
id: artifact
run: echo "name=cua-helper-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v7.0.1
with:
name: ${{ steps.artifact.outputs.name }}
path: dist/cua-helper/*
if-no-files-found: error
compression-level: 0
retention-days: 14
+27 -5
View File
@@ -78,8 +78,19 @@ jobs:
if [[ ! "$BASE_SHA" =~ ^[0-9a-f]{40}$ || ! "$SINCE" =~ ^[0-9]+$ ]]; then
stop "the master diff check reported no base commit, so nothing was merged or compiled"
fi
git fetch --no-tags --no-write-fetch-head --shallow-since="$SINCE" origin "$HEAD_SHA" "$BASE_SHA" ||
# A shallow fetch starts a background maintenance run that rewrites .git/shallow, and the second shallow fetch
# below then fails with "shallow file has changed since we read it".
fetch() {
git -c maintenance.auto=false -c gc.auto=0 fetch --no-tags --no-write-fetch-head "$@" origin "$HEAD_SHA" "$BASE_SHA"
}
fetch --shallow-since="$SINCE" ||
stop "the base commit $BASE_SHA could not be fetched, so nothing was merged or compiled"
# A time window records a merge commit itself as shallow when one of its parents predates the window, which
# drops the other, in-window parent too; a depth bound keeps both parents of every commit it covers.
if ! git merge-base "$HEAD_SHA" "$BASE_SHA" >/dev/null; then
fetch --depth=20 --filter=blob:none ||
stop "the base commit $BASE_SHA could not be fetched, so nothing was merged or compiled"
fi
git -c user.name=pr-check -c user.email=pr-check@users.noreply.github.com merge --no-ff --no-edit -m "Merge $BASE_SHA into $HEAD_SHA" "$BASE_SHA" ||
stop "the pull request cannot merge $BASE_SHA; merge master into it and push, then this check runs again"
echo "merged=true" >> "$GITHUB_OUTPUT"
@@ -93,15 +104,26 @@ jobs:
node-version: '24'
package-manager-cache: false
- name: Run the pre-merge command
- name: Run the tree's pre-merge check
if: steps.merge.outputs.merged == 'true'
env:
CHECK_MODE: ${{ vars.MERGE_POLICY_PRE_MERGE }}
run: |
if ! bash -euxo pipefail -c 'pnpm install --frozen-lockfile && pnpm exec tsx scripts/compile-referenced-projects.ts host-contracts && pnpm exec tsx scripts/compile-referenced-projects.ts client'; then
# The check names one file and knows nothing else about how the merged tree builds itself.
if [ ! -f scripts/pre-merge-check.ts ]; then
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::the merged tree does not install or compile"
echo "::error title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked"
exit 1
fi
echo "::warning title=Pre-Merge Check::the merged tree does not install or compile; this check does not block"
echo "::warning title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked"
exit 0
fi
if node scripts/pre-merge-check.ts; then
exit 0
fi
# A check that crashes reports nothing, so the step reports its exit the way the mode asks.
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked"
exit 1
fi
echo "::warning title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked"
+74 -4
View File
@@ -1,12 +1,28 @@
# Publish the dsh release sequence to npm. This workflow is manual-only
# (workflow_dispatch) and intentionally does not listen to pull_request or push:
# publication must always be an explicit, reviewed act from a dsh-v* tag, and it
# must never appear as a PR check. It repacks the current tree before publishing
# publication is an explicit, reviewed act; dsh-v* tag validation is enabled by
# default and may be skipped through the dispatch input. It never appears as a PR check. It repacks the current tree before publishing
# so the bytes uploaded are exactly what this dispatch produced.
name: Release publish (dsh)
on:
workflow_dispatch:
inputs:
cua_run_id:
description: '已有 CUA 打包 run ID:留空重新打包;填写时复用同一源码和版本的已验证产物'
type: string
required: false
default: ''
cua_run_attempt:
description: 'CUA 打包 attempt:仅与 run ID 一起使用;留空选择最新一次,不回退到更早的成功构建'
type: string
required: false
default: ''
skip-tag-check:
description: Skip release tag validation for the selected ref; other publication checks still apply.
type: boolean
required: false
default: false
permissions:
contents: read
@@ -16,9 +32,23 @@ env:
DSH_TELEMETRY_DISABLED: '1'
jobs:
cua-helper:
if: (startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check) && inputs.cua_run_id == '' && inputs.cua_run_attempt == ''
uses: ./.github/workflows/cua-helper-package.yml
with:
source_ref: ${{ github.ref }}
preset: production
pack:
name: Pack npm tarballs
needs: cua-helper
if: >-
${{ !cancelled() && (needs.cua-helper.result == 'success'
|| (needs.cua-helper.result == 'skipped' && (inputs.cua_run_id != '' || inputs.cua_run_attempt != ''))) }}
runs-on: ubuntu-24.04
permissions:
contents: read
actions: read
steps:
# Complete history: the release scripts read tags.
- uses: actions/checkout@v7.0.1
@@ -55,10 +85,50 @@ jobs:
- name: Verify release version
env:
RELEASE_PUBLISH: 'true'
run: pnpm run release:verify --family dsh
run: pnpm run release:verify --family dsh ${{ inputs.skip-tag-check && '--skip-tag-check' || '' }}
- name: Select retained CUA helper
if: inputs.cua_run_id != '' || inputs.cua_run_attempt != ''
id: retained-cua
env:
GH_TOKEN: ${{ github.token }}
run: pnpm exec tsx scripts/cua-helper/select-artifact.ts
- name: Download retained CUA helper
if: inputs.cua_run_id != '' || inputs.cua_run_attempt != ''
uses: actions/download-artifact@v8.0.1
with:
artifact-ids: ${{ steps.retained-cua.outputs.artifact-id }}
run-id: ${{ inputs.cua_run_id }}
github-token: ${{ github.token }}
path: dist/cua-helper
- name: Build
run: pnpm run build:official
run: pnpm run build:official --serial
- name: Download freshly built CUA helper
if: inputs.cua_run_id == '' && inputs.cua_run_attempt == ''
uses: actions/download-artifact@v8.0.1
with:
name: ${{ needs.cua-helper.outputs.artifact }}
path: dist/cua-helper
- name: Stage verified native helper applications
run: |
mkdir dist/cua-helper/extracted
tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted
pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted
- name: Record CUA source and publication retry instructions
env:
CUA_RUN_ID: ${{ inputs.cua_run_id || github.run_id }}
CUA_RUN_ATTEMPT: ${{ steps.retained-cua.outputs.attempt || github.run_attempt }}
run: |
{
printf 'CUA helper: run `%s`, attempt `%s`.\n\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT"
printf '%s\n\n' 'npm 部分发布失败时,只重跑 Publish to npm job,复用原 dsh-npm-tarballs;不要重跑全部 job。'
printf '若需新建发布运行,填写 `cua_run_id=%s`、`cua_run_attempt=%s`,并使用相同源码 commit 和版本。\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT"
} >> "$GITHUB_STEP_SUMMARY"
- name: Pack release tarballs
run: pnpm run release:pack --family dsh --out dist/npm
+2 -34
View File
@@ -53,11 +53,6 @@ on:
type: string
default: ''
required: false
build_root:
description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区'
type: string
default: ''
required: false
permissions:
contents: read
@@ -82,7 +77,6 @@ jobs:
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
PACKAGE_SIGNING: ${{ inputs.signing }}
PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }}
PACKAGE_BUILD_ROOT: ${{ inputs.build_root }}
PACKAGE_WINDOWS_COMPRESSION_LEVEL: ${{ inputs.compression_level }}
steps:
- name: Reset packaging checkout
@@ -106,7 +100,7 @@ jobs:
run: |
$helpers = Join-Path $env:RUNNER_TEMP "packaging-helpers-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
New-Item -ItemType Directory -Path $helpers -ErrorAction Stop | Out-Null
foreach ($name in @('windows-packaging-priority.ps1', 'windows-build-workspace.mjs')) {
foreach ($name in @('windows-packaging-priority.ps1')) {
Copy-Item -LiteralPath (Join-Path 'apps/desktop/scripts' $name) -Destination $helpers -ErrorAction Stop
}
"PACKAGE_CI_HELPERS=$helpers" >> $env:GITHUB_ENV
@@ -140,17 +134,6 @@ jobs:
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Select packaging workspace
id: build_workspace
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
run: |
if (!$env:PACKAGE_BUILD_ROOT) {
"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT
} else {
node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs"
exit $LASTEXITCODE
}
- name: Configure persistent pnpm store
# PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory.
env:
@@ -167,7 +150,6 @@ jobs:
"DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV
"PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
- name: Install locked dependencies
working-directory: ${{ steps.build_workspace.outputs.directory }}
run: |
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))"
@@ -178,12 +160,10 @@ jobs:
exit $LASTEXITCODE
- name: Validate preset and resolve build version
id: settings
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
run: node apps/desktop/scripts/prepare-windows-ci.mjs
- name: Check packaging configuration and toolchain
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: |
@@ -192,23 +172,16 @@ jobs:
pnpm @packageArgs
exit $LASTEXITCODE
- name: Build installer and verify packaged runtime
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }}
run: |
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
if ($env:PACKAGE_BUILD_TEMP) {
$env:TEMP = $env:PACKAGE_BUILD_TEMP
$env:TMP = $env:PACKAGE_BUILD_TEMP
}
$packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION)
if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }
pnpm @packageArgs
exit $LASTEXITCODE
- name: Retain complete package outside the checkout
id: retained
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
@@ -219,7 +192,7 @@ jobs:
with:
name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: ${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/
path: ${{ github.workspace }}/apps/desktop/.desktop-build/packaging-runs/
if-no-files-found: warn
retention-days: 14
- name: Retain verified unsigned installer
@@ -242,8 +215,3 @@ jobs:
path: ${{ steps.retained.outputs.directory }}/*
if-no-files-found: error
retention-days: 14
- name: Remove isolated packaging workspace
if: success() && inputs.build_root != ''
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
run: node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove
+3
View File
@@ -47,3 +47,6 @@ worktrees/
.agents/worktrees/
.typert-*/
native/system/test/bin/
# Generated company-signed CUA release record.
/packages/experimental/cua-macos/cua-helper.json
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.2.1-rc.3",
"version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
@@ -104,9 +104,9 @@
"@deepseek-ai/dsh-experimental-ralph-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-session-titles-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-terminal-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-liveshare": "workspace:*",
"@deepseek-ai/dsh-experimental-session-search": "workspace:*",
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*",
"@deepseek-ai/dsh-cua-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*",
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*",
"@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private Node-mode host process for the Electron desktop application",
"version": "0.2.1-rc.3",
"version": "0.2.2-alpha.2",
"private": true,
"license": "MIT",
"type": "module",
@@ -0,0 +1,13 @@
{
"bucket": "replace-with-bucket-1250000000",
"origin": "https://download.deepseek.com",
"credentialsFile": "/absolute/path/to/packaging/secrets/cos-production.json",
"macOS": {
"signingIdentity": "REPLACE_WITH_DEVELOPER_ID_APPLICATION_IDENTITY",
"teamId": "XXXXXXXXXX"
},
"githubApp": {
"clientId": "REPLACE_WITH_GITHUB_APP_CLIENT_ID",
"privateKeyFile": "/absolute/path/to/packaging/publishing/keys/desktop-release-tagger.pem"
}
}
@@ -0,0 +1,9 @@
{
"bucket": "replace-with-bucket-1250000000",
"origin": "https://download-test.example.com",
"credentialsFile": "/absolute/path/to/packaging/secrets/cos-test.json",
"macOS": {
"signingIdentity": "REPLACE_WITH_DEVELOPER_ID_APPLICATION_IDENTITY",
"teamId": "XXXXXXXXXX"
}
}
@@ -0,0 +1,12 @@
{
"bucket": "replace-with-bucket-1250000000",
"origin": "https://download.deepseek.com",
"credentialsFile": "C:/absolute/path/to/packaging/secrets/cos-production.json",
"windows": {
"certificateThumbprint": "0000000000000000000000000000000000000000"
},
"githubApp": {
"clientId": "REPLACE_WITH_GITHUB_APP_CLIENT_ID",
"privateKeyFile": "C:/absolute/path/to/packaging/publishing/keys/desktop-release-tagger.pem"
}
}
@@ -0,0 +1,8 @@
{
"bucket": "replace-with-bucket-1250000000",
"origin": "https://download-test.example.com",
"credentialsFile": "C:/absolute/path/to/packaging/secrets/cos-test.json",
"windows": {
"certificateThumbprint": "0000000000000000000000000000000000000000"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
"version": "0.2.1-rc.3",
"version": "0.2.2-alpha.2",
"private": true,
"license": "MIT",
"author": {
+4 -4
View File
@@ -24,11 +24,11 @@
en: 5b4f0f80a117f202
zh: ff05be196c181087
/desktop-packaging/windows-actions-packaging:
en: 9dd2227f2878519e
zh: cd95dd6efd48298f
en: 253e17998951931c
zh: b582b377391d2ec2
/desktop-packaging/macos-self-hosted-packaging:
en: ee628e830d849ae9
zh: 44313f5fb842f042
en: f4cc5209bdaeefba
zh: 1db959d1ed345fcc
/desktop-packaging/unsigned-windows-test-installer:
en: f429bc0c280e2dfa
zh: 4adbcc9e906bdf80
+2 -5
View File
@@ -115,11 +115,9 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a
Open the completed run in Actions and download its Artifacts. `windows-x64-signed-<version>-<run_id>-<run_attempt>` and `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` contain the corresponding EXE; `windows-package-logs-<run_id>-<run_attempt>` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication.
For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package-<run-id>-<attempt>/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing.
For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory.
The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats.
The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. The priority helper is copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. A missing helper stops CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats.
Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression.
@@ -129,13 +127,12 @@ Installer uploads store the already-compressed release files without ZIP recompr
The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG.
All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing, cache measurement and isolated-workspace options follow these common fields.
All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing and cache measurement options follow these common fields.
Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior.
Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing).
ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets/<target>/unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds.
Install the user service from the checkout as the same account that registered the runner, without `sudo`:
+2 -5
View File
@@ -115,11 +115,9 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed-<version>-<run_id>-<run_attempt>` 与 `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` 包含对应 EXE;`windows-package-logs-<run_id>-<run_attempt>` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。
测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package-<run-id>-<attempt>/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。
测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。
依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。
依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。
安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。
@@ -129,13 +127,12 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量、独立构建目录选项位于公共字段之后。
三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量选项位于公共字段之后。
运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。
运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。
无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets/<target>/unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。
在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`:
+4 -4
View File
@@ -21,14 +21,14 @@
en: 0478d8e35f9a3d55
zh: 0aa3fd828bc02691
/desktop-ci-publication/machine-setup:
en: 6e7b928af17f4377
zh: 7c559078306df3fd
en: 0f43f2e193e95043
zh: 1c1efb062c06f7b8
/desktop-ci-publication/package-and-publish:
en: f7c12d343891e547
zh: da596ee1366c2046
/desktop-ci-publication/recovery-and-records:
en: 2e1e6b16d0d6bd41
zh: 6ff4ad5a8f1f57b4
en: b62a7484929f9941
zh: c9dd06c77098e6ef
/desktop-ci-publication/restore-a-channel:
en: d82e2ccf70cce397
zh: d93dcb77cc4b463e
+4 -2
View File
@@ -54,7 +54,9 @@ For a queued job, compare the job page's requested labels with **Settings → Ac
Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging directory. Completed attempts are copied to its sibling `releases/<run-id>/<attempt>/`; `DSH_DESKTOP_RELEASES_DIR` overrides that root with an absolute path. The inventory includes installers, update archives, blockmaps, completion metadata and a credential-free manifest. Debug packages are retained but cannot publish. Copies are checked before the attempt directory becomes visible, and an existing attempt cannot be replaced. GitHub retains the same file inventory for 14 days. Local files remain until an operator removes them; do not delete an attempt while it is being verified or published.
Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration.
Start from the [macOS test](../../config/publishing/macos/test.json.example), [macOS production](../../config/publishing/macos/production.json.example), [Windows test](../../config/publishing/windows/test.json.example), or [Windows production](../../config/publishing/windows/production.json.example) template. Create the `publishing/` directory and copy the selected file to `publishing/<preset>.json` below `DSH_PACKAGING_CONFIG_DIR`. Replace the example bucket, test origin, absolute paths, macOS signing identity and team ID or Windows certificate thumbprint, and production App client ID with the machine settings; keep the fixed production origin. Templates contain no credentials and do not grant COS, GitHub, or signing permissions. Store referenced secrets outside Git with access restricted to the publishing account. macOS uses the same configuration for arm64 and x64; the retained package determines the target.
Each configuration declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration.
Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`. Assign that label to exactly that runner. A target may be a single name or a nonempty list; the Mac entry above authorizes both packaging workflows on the same machine. Windows uses target `win-x64`. The retained target must match the selected packaging workflow. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other.
@@ -82,7 +84,7 @@ After resolving a failure, re-run the failed publishing job to keep its prepared
The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate.
If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically.
If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. A rejected tag creation retains its HTTP status and validated GitHub request ID; a follow-up lookup does not replace that failure with a missing-tag error. A repository-rule rejection directs the operator to the matching rulesets without logging the raw response.
Normal publication and manual restoration acquire the same local channel lock. A crash can leave a lock directory under the sibling `publish-locks/`; verify no writer is running before manually removing it. A runner label alone does not coordinate writers on other machines. Keep one publishing machine per channel and preserve its records when migrating.
+4 -2
View File
@@ -54,7 +54,9 @@ job 排队时,先对照 job 页面请求的标签与 **Settings → Actions
保持 `DSH_PACKAGING_CONFIG_DIR` 指向现有机器打包目录。完成的构建复制到其同级 `releases/<run-id>/<attempt>/`;`DSH_DESKTOP_RELEASES_DIR` 可用绝对路径覆盖该根目录。文件清单包含安装包、更新归档、blockmap、完成记录和不含凭据的 manifest。调试包会留存,但不能发布。复制内容通过校验后才开放 attempt 目录,已有 attempt 不能被替换。GitHub 保留相同文件清单 14 天。本地文件保留到操作员删除;校验或发布期间不要删除对应 attempt。
在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。
从 [macOS test](../../config/publishing/macos/test.json.example)、[macOS production](../../config/publishing/macos/production.json.example)、[Windows test](../../config/publishing/windows/test.json.example) 或 [Windows production](../../config/publishing/windows/production.json.example) 模板开始,创建 `publishing/` 目录,将所选文件复制到 `DSH_PACKAGING_CONFIG_DIR` 下的 `publishing/<preset>.json`。用机器实际配置替换示例 bucket、test origin、绝对路径、macOS 签名身份和 Team ID 或 Windows 证书指纹,以及 production App client ID;保留固定的 production origin。模板不含凭据,也不授予 COS、GitHub 或签名权限。引用的秘密文件保存在 Git 之外,只允许发布账户访问。macOS 的 arm64 和 x64 共用配置,目标由留存包决定。
每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。
将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`。该标签只能分配给这台 runner。目标可为单个名称或非空列表;上述 Mac 条目允许同一台机器处理两种架构的打包工作流。Windows 使用目标 `win-x64`。留存包的目标必须与所选打包工作流一致。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。
@@ -82,7 +84,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。
频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。
频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。tag 创建被拒绝时保留其 HTTP 状态及经过校验的 GitHub request ID,后续查询不会用 tag 不存在的错误替换创建失败。仓库规则拒绝会提示操作人员检查匹配的 ruleset,不记录原始响应。
正常发布和手动恢复获取同一把本地频道锁。崩溃可能在同级 `publish-locks/` 下留下锁目录;手动删除前确认没有写入任务运行。runner 标签本身不能协调其他机器的写入。每个频道保持单一发布机器,迁移时保留记录。
+27 -3
View File
@@ -40,8 +40,32 @@ export async function publishDesktopTag(
body: JSON.stringify({ ref: `refs/tags/${tag}`, sha: commit }), redirect: 'error', signal: AbortSignal.timeout(30_000),
})
if (response.status === 201) { await matches(response); return 'created' }
// A parallel platform release may have created the same version tag after our first read.
if (response.status === 422) { await response.body?.cancel(); await matches(await get()); return 'present' }
const requestId = response.headers.get('x-github-request-id')
const identity = requestId !== null && /^[A-Fa-f0-9:]{1,128}$/u.test(requestId) ? `; GitHub request ${requestId}` : ''
const failure = `desktop tag: creation failed (HTTP ${response.status}${identity})`
// A 422 can reject repository rules as well as report a concurrent tag creation.
if (response.status === 422) {
let detail: unknown
try { detail = await response.json() } catch (error) {
// Non-JSON error responses still retain the original HTTP status and request ID.
}
const rulesRejected = typeof detail === 'object' && detail !== null && 'message' in detail
&& typeof detail.message === 'string' && detail.message.startsWith('Repository rule violations found')
const guidance = rulesRejected ? 'repository rules rejected tag creation; inspect the matching tag rulesets'
: 'inspect repository rule insights and App permissions before retrying'
let reconciled: Response
try { reconciled = await get() } catch (cause) {
throw new Error(`${failure}; follow-up tag lookup failed; ${guidance}`, { cause })
}
if (!reconciled.ok) {
await reconciled.body?.cancel()
throw new Error(`${failure}; follow-up tag lookup returned HTTP ${reconciled.status}; ${guidance}`)
}
try { await matches(reconciled) } catch (cause) {
throw new Error(`${failure}; follow-up tag verification failed; inspect the existing tag before retrying`, { cause })
}
return 'present'
}
await response.body?.cancel()
throw new Error(`desktop tag: creation failed (HTTP ${response.status}); reconcile the tag before retrying`)
throw new Error(`${failure}; reconcile the tag before retrying`)
}
@@ -1,19 +0,0 @@
/** Identity of a Windows packaging run and its independent build root. */
export interface WindowsBuildWorkspaceOptions {
sourceDirectory: string
buildRoot: string
runId: string
runAttempt: string
sourceSha: string
}
/**
* Clone the clean selected commit into a new run-owned directory.
* @param options - Original checkout and Actions run identity.
* @returns Checkout and temporary directory owned by this run.
*/
export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string; temporaryDirectory: string }
/**
* Remove the directory only when its ownership record matches the run.
* @param options - Original checkout and Actions run identity.
*/
export function removeWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): void
@@ -1,96 +0,0 @@
/** Create and remove a run-owned checkout for Windows packaging. */
import { execFileSync } from 'node:child_process'
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute, join, parse, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
function assertUnlinkedAncestors(directory) {
for (let current = directory; ; current = dirname(current)) {
if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
throw new Error('Packaging workspace ancestors must not be links')
}
if (dirname(current) === current) break
}
}
function locations(options) {
const { buildRoot, runId, runAttempt, sourceSha } = options
if (!buildRoot || !isAbsolute(buildRoot) || /[\r\n\0]/.test(buildRoot)) {
throw new Error('Packaging build root must be a nonempty absolute directory')
}
const root = resolve(buildRoot)
if (root === parse(root).root) throw new Error('Packaging build root must not be a volume root')
if (!/^[1-9]\d*$/.test(runId) || !/^[1-9]\d*$/.test(runAttempt) || !/^[a-f0-9]{40}$/.test(sourceSha)) {
throw new Error('Packaging workspace requires a run identity and exact commit')
}
assertUnlinkedAncestors(root)
for (let current = root; ; current = dirname(current)) {
if (existsSync(join(current, '.git'))) throw new Error('Packaging build root must be outside existing Git checkouts')
if (dirname(current) === current) break
}
const owner = join(root, `windows-package-${runId}-${runAttempt}`)
assertUnlinkedAncestors(owner)
return { root, owner, directory: join(owner, 'source'), temporaryDirectory: join(owner, 'temp') }
}
function git(directory, args) {
return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
}
function ownership(options, paths) {
return JSON.stringify({ version: 1, root: paths.root, source: resolve(options.sourceDirectory), runId: options.runId, runAttempt: options.runAttempt, sourceSha: options.sourceSha })
}
/**
* Clone the validated commit into a new run-owned checkout; failures retain diagnostics.
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity.
* @returns {{directory: string, temporaryDirectory: string}} Checkout and temporary directory owned by this run.
*/
export function prepareWindowsBuildWorkspace(options) {
const paths = locations(options)
if (git(options.sourceDirectory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(options.sourceDirectory, ['status', '--porcelain'])) {
throw new Error('Packaging source must be clean and match the selected commit')
}
mkdirSync(paths.root, { recursive: true })
mkdirSync(paths.owner)
writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' })
mkdirSync(paths.temporaryDirectory)
git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory])
git(paths.directory, ['checkout', '--detach', options.sourceSha])
if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) {
throw new Error('Isolated packaging checkout does not match the selected commit')
}
return { directory: paths.directory, temporaryDirectory: paths.temporaryDirectory }
}
/**
* Remove only the matching run-owned directory after successful artifact upload.
* Transient filesystem failures retry up to three times; persistent failures propagate.
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity.
* @returns {void}
*/
export function removeWindowsBuildWorkspace(options) {
const paths = locations(options)
const marker = join(paths.owner, 'owner.json')
const stat = lstatSync(marker)
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) {
throw new Error('Packaging workspace ownership does not match this run')
}
rmSync(paths.owner, { recursive: true, maxRetries: 3, retryDelay: 100 })
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const options = {
sourceDirectory: process.env.GITHUB_WORKSPACE ?? '',
buildRoot: process.env.PACKAGE_BUILD_ROOT ?? '',
runId: process.env.GITHUB_RUN_ID ?? '',
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '',
sourceSha: process.env.PACKAGE_SOURCE_SHA ?? '',
}
if (process.argv[2] === '--remove') removeWindowsBuildWorkspace(options)
else {
if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments')
const result = prepareWindowsBuildWorkspace(options)
appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\ntemporaryDirectory=${result.temporaryDirectory}\n`)
}
}
+15 -11
View File
@@ -1,4 +1,4 @@
/** Cold pnpm requests exercise shipped CUA dependency/platform metadata with inert package contents. */
/** Cold pnpm requests exercise on-demand CUA dependency/platform metadata with inert package contents. */
import { createHash } from 'node:crypto'
import { createServer } from 'node:http'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -37,18 +37,20 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
const cli = await readManifest('apps/cli')
const bundle = await readManifest('packages/experimental/cua-bundle')
const native = await readManifest('packages/experimental/cua-macos')
expect(cli.dependencies).toHaveProperty(bundleName)
expect(cli.dependencies).not.toHaveProperty(bundleName)
expect(bundle.dependencies).not.toHaveProperty(providerName)
expect(bundle.optionalDependencies).toEqual({ [providerName]: 'workspace:*' })
expect(native.os).toEqual(['darwin'])
const version = '1.0.0'
const bundleDependencies = Object.fromEntries(Object.keys(bundle.dependencies as Record<string, string>).map(name => [name, version]))
const manifests = [
{ name: cli.name, version, dependencies: { [bundleName]: version } },
{ name: cli.name, version },
{ name: bundleName, version, dependencies: bundleDependencies, optionalDependencies: { [providerName]: version } },
...Object.keys(bundleDependencies).map(name => ({ name, version })),
{ name: providerName, version, os: native.os, files: native.files },
]
const generation = 'a'.repeat(64)
const nativePayload = `native/bin/darwin-arm64/${generation}/DSH Computer Use.app/Contents/MacOS/dsh-cua-native`
const packages = new Map<string, { manifest: Record<string, unknown>; bytes: Buffer }>()
for (const manifest of manifests) {
const name = String(manifest.name)
@@ -56,18 +58,20 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
await mkdir(directory)
await writeFile(join(directory, 'package.json'), JSON.stringify(manifest))
if (name === providerName) {
await mkdir(join(directory, 'native/bin/darwin-arm64'), { recursive: true })
await mkdir(join(directory, nativePayload, '..'), { recursive: true })
for (const file of ['.gitignore', '.npmignore']) {
await writeFile(join(directory, 'native', file), await readFile(join(repository, 'packages/experimental/cua-macos/native', file)))
}
await writeFile(join(directory, 'native/bin/darwin-arm64/dsh-cua-native'), 'inert native payload')
await writeFile(join(directory, 'native/bin/darwin-arm64/current.json'), JSON.stringify({ bundle: generation }))
await writeFile(join(directory, nativePayload), 'inert native payload')
}
await command(['pack', '--out', 'package.tgz'], directory)
const bytes = await readFile(join(directory, 'package.tgz'))
packages.set(name, { manifest, bytes })
if (name === providerName) {
const result = await execa('tar', ['-tzf', join(directory, 'package.tgz')])
expect(result.stdout).toContain('package/native/bin/darwin-arm64/dsh-cua-native')
expect(result.stdout).toContain('package/native/bin/darwin-arm64/current.json')
expect(result.stdout).toContain(`package/${nativePayload}`)
}
}
const requests: string[] = []
@@ -98,13 +102,13 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
const address = server.address()
if (!address || typeof address === 'string') throw new Error('fixture registry has no TCP address')
for (const target of ['darwin', 'win32', 'linux', 'linux-unrestricted']) {
for (const target of ['darwin-base', 'darwin', 'win32', 'linux', 'linux-unrestricted']) {
unrestricted = target === 'linux-unrestricted'
const project = join(root, target)
await mkdir(project)
await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [String(cli.name)]: version } }))
await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [target === 'darwin-base' ? String(cli.name) : bundleName]: version } }))
await writeFile(join(project, 'pnpm-workspace.yaml'), [
'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target}]`,
'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target === 'darwin-base' ? 'darwin' : target}]`,
' cpu: [arm64]', 'minimumReleaseAge: 0', '',
].join('\n'))
requests.length = 0
@@ -113,8 +117,8 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
`--config.registry=http://127.0.0.1:${address.port}/`, `--config.store-dir=${join(project, 'store')}`,
`--config.cache-dir=${join(project, 'cache')}`, `--config.state-dir=${join(project, 'state')}`,
], project)
expect(requests).toContain(`/${bundleName}.tgz`)
expect(requests).toContain('/@deepseek-ai/dsh-cua.tgz')
expect(requests.includes(`/${bundleName}.tgz`)).toBe(target !== 'darwin-base')
expect(requests.includes('/@deepseek-ai/dsh-cua.tgz')).toBe(target !== 'darwin-base')
expect(requests.includes(`/${providerName}.tgz`), `${target}: ${JSON.stringify(requests)}\n${output}`)
.toBe(target === 'darwin' || unrestricted)
}
+35 -2
View File
@@ -3,13 +3,13 @@ import { publishDesktopTag } from '../scripts/desktop-publish-tag.ts'
const commit = 'a'.repeat(40)
const tag = (sha = commit) => ({ object: { type: 'commit', sha } })
function transport(replies: Array<{ status: number; body?: object }>) {
function transport(replies: Array<{ status: number; body?: object; rawBody?: string; headers?: Record<string, string> }>) {
const calls: Array<{ url: string; init: RequestInit | undefined }> = []
const request: typeof fetch = async (url, init) => {
calls.push({ url: typeof url === 'string' ? url : url instanceof URL ? url.href : url.url, init })
const reply = replies.shift()
if (!reply) throw new Error('unexpected HTTP request')
return new Response(JSON.stringify(reply.body ?? {}), { status: reply.status })
return new Response(reply.rawBody ?? JSON.stringify(reply.body ?? {}), { status: reply.status, headers: reply.headers ?? {} })
}
return { calls, request }
}
@@ -57,3 +57,36 @@ it('explains an existing annotated tag without replacing it', async () => {
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/not a lightweight commit tag/u)
expect(http.calls).toHaveLength(1)
})
it('keeps the creation status and safe rule diagnostic when no competing tag exists', async () => {
const http = transport([{ status: 404 }, { status: 422,
headers: { 'x-github-request-id': 'ABCD:1234' },
body: { message: 'Repository rule violations found for refs/tags/desktop-v1.2.3', errors: ['secret-value'] } }, { status: 404 }])
const failure = publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)
await expect(failure).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234); follow-up tag lookup returned HTTP 404; repository rules rejected')
await expect(failure).rejects.not.toThrow('secret-value')
expect(http.calls.map(call => call.init?.method ?? 'GET')).toEqual(['GET', 'POST', 'GET'])
})
it('does not echo arbitrary GitHub response text or unrecognized request IDs', async () => {
const http = transport([{ status: 404 }, { status: 422,
headers: { 'x-github-request-id': 'fixture-token' }, body: { message: 'fixture-token' } }, { status: 403 }])
const failure = publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)
await expect(failure).rejects.toThrow('creation failed (HTTP 422); follow-up tag lookup returned HTTP 403')
await expect(failure).rejects.not.toThrow('fixture-token')
})
it('still rejects a competing tag on a different commit', async () => {
const http = transport([{ status: 404 }, { status: 422 }, { status: 200, body: tag('b'.repeat(40)) }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('creation failed (HTTP 422)')
})
it('retains creation context for a non-JSON rejection', async () => {
const http = transport([{ status: 404 }, { status: 422, rawBody: 'not JSON', headers: { 'x-github-request-id': 'ABCD:1234' } }, { status: 404 }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234)')
})
it('retains creation context when the follow-up request fails', async () => {
const http = transport([{ status: 404 }, { status: 422, headers: { 'x-github-request-id': 'ABCD:1234' } }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234); follow-up tag lookup failed')
})
@@ -0,0 +1,35 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, posix, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, it } from 'vitest'
import { readDesktopPublishConfig, type DesktopPublishConfig } from '../scripts/desktop-publish-config.ts'
for (const platform of ['macos', 'windows'] as const) {
for (const preset of ['test', 'production'] as const) {
it(`accepts the ${platform} ${preset} template after machine paths are supplied`, async () => {
const path = fileURLToPath(new URL(`../config/publishing/${platform}/${preset}.json.example`, import.meta.url))
const template = JSON.parse(await readFile(path, 'utf8')) as DesktopPublishConfig
const paths = platform === 'windows' ? win32 : posix
expect(paths.isAbsolute(template.credentialsFile)).toBe(true)
expect(template[platform === 'macos' ? 'macOS' : 'windows']).toBeDefined()
expect(Object.hasOwn(template, 'secretId')).toBe(false)
expect(Object.hasOwn(template, 'secretKey')).toBe(false)
if (preset === 'production') expect(paths.isAbsolute(template.githubApp?.privateKeyFile ?? '')).toBe(true)
const root = await mkdtemp(join(tmpdir(), 'publish-template-'))
try {
await mkdir(join(root, 'publishing'))
// The parser uses the current host's path syntax; examples retain their target platform's paths.
const config = { ...template, credentialsFile: join(root, 'absent-credentials.json'),
...(template.githubApp ? { githubApp: { ...template.githubApp, privateKeyFile: join(root, 'absent-key.pem') } } : {}) }
const output = join(root, 'publishing', `${preset}.json`)
await writeFile(output, JSON.stringify(config))
await expect(readDesktopPublishConfig(root, preset)).resolves.toEqual(config)
await writeFile(output, JSON.stringify({ ...config, bucket: undefined }))
const failure = readDesktopPublishConfig(root, preset)
await expect(failure).rejects.toHaveProperty('name', 'ZodError')
await expect(failure).rejects.toHaveProperty('issues.0.path', ['bucket'])
} finally { await rm(root, { recursive: true, force: true }) }
})
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-plugin-market-generator",
"description": "Reference generator of plugin market index files from a list of npm packages and Git repositories",
"version": "0.2.1-rc.3",
"version": "0.2.2-alpha.2",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-web-frontend",
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
"version": "0.2.1-rc.3",
"version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
+7 -4
View File
@@ -21,8 +21,9 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
document.documentElement.setAttribute('data-windows-titlebar', '')
document.documentElement.style.setProperty('--dsh-windows-titlebar-height', '40px')
})
const reopenSidebar = page.getByRole('button', { name: '打开侧边栏', exact: true })
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
await page.setViewportSize({ width: 521, height: 600 })
await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' })
const reopenSidebar = page.locator('[data-shell-leading]').getByRole('button', { name: '打开侧边栏', exact: true })
await openSettings(page, 'zh')
const settings = page.getByRole('dialog', { name: '设置', exact: true })
const settingsMask = settings.locator('..').locator(':scope > [aria-hidden="true"]')
@@ -51,7 +52,7 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
await menu.waitFor({ state: 'hidden' })
expect(await settings.isVisible()).toBe(true)
await settings.getByRole('button', { name: '关闭', exact: true }).click()
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
if (await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').count() > 0) await reopenSidebar.click()
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
await page.locator('[data-plugin-panel]').getByRole('button', { name: '添加插件', exact: true }).click()
const plugin = page.getByRole('dialog', { name: '添加插件', exact: true })
@@ -88,7 +89,9 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
if (value === 'darwin') document.documentElement.setAttribute('data-platform', value)
else document.documentElement.removeAttribute('data-platform')
}, platform)
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
// Publish the synthetic platform change through the frame's size observer.
await page.setViewportSize({ width: platform === 'darwin' ? 520 : 521, height: 600 })
await page.locator('[data-sidebar-drawer]').waitFor()
await openSettings(page, 'zh')
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 0)
expect((await settingsMask.boundingBox())?.y).toBe(0)
+20 -38
View File
@@ -7,8 +7,8 @@
*
* - A long answer leaves the folded group above the viewport. The scroll position changes in one direction
* only: the fold never scrolls the view away and then snaps it back.
* - A short answer leaves fold room below the tail. Growth fills that room without scrolling, so the running
* status keeps its entrance slide when the new Step's first row appears instead of moving in one frame.
* - A short answer leaves fold room below the tail. The new Turn's growth fills that room without scrolling
* through Turn end, the room never grows back, and the running status only moves down.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -26,7 +26,7 @@ import { newEnglishPage, openSettings } from './support.ts'
declare global {
interface Window {
openFoldTrace?: { readonly frames: TraceFrame[]; readonly slides: number[]; stop(): void }
openFoldTrace?: { readonly frames: TraceFrame[]; stop(): void }
}
}
@@ -43,8 +43,6 @@ interface TraceFrame {
readonly floor: number
/** Height of the fold room below the transcript. */
readonly spacer: number
/** The column marks the tail as pinned, which runs the status slide at zero duration. */
readonly pinned: boolean
/** The running status's top relative to the scrollport, when it is mounted. */
readonly status: number | null
readonly folding: boolean
@@ -162,17 +160,13 @@ async function openLatestGroup(page: Page): Promise<void> {
await page.mouse.move(0, 0)
}
/**
* Sample the scrollport on every animation frame, and record the duration of every status entrance slide
* that a flow change starts.
*/
/** Sample the scrollport on every animation frame. */
async function startTrace(page: Page): Promise<void> {
await page.evaluate((selector) => {
const host = document.querySelector<HTMLElement>(selector)!
const column = host.querySelector<HTMLElement>('[data-chat-flow]')!
const spacer = host.querySelector<HTMLElement>('[data-chat-turn-spacer]')!
const frames: TraceFrame[] = []
const slides: number[] = []
let frame = 0
const tick = (): void => {
const status = host.querySelector<HTMLElement>('[data-chat-running]')
@@ -180,32 +174,18 @@ async function startTrace(page: Page): Promise<void> {
top: host.scrollTop,
floor: Math.max(0, host.scrollHeight - host.clientHeight),
spacer: spacer.getBoundingClientRect().height,
pinned: column.hasAttribute('data-chat-tail-pinned'),
status: status === null ? null : status.getBoundingClientRect().top - host.getBoundingClientRect().top,
folding: column.hasAttribute('data-input-fold') || host.querySelector('[data-chat-motion="collapse"]') !== null,
})
frame = requestAnimationFrame(tick)
}
// The slide starts at the style update after the flow change; reading animations there sees its duration.
const observer = new MutationObserver(() => {
const status = host.querySelector<HTMLElement>('[data-chat-running]')
for (const animation of status?.getAnimations() ?? []) {
if (animation instanceof CSSAnimation && animation.animationName.includes('running-follows-output')) {
slides.push(Number(animation.effect?.getComputedTiming().duration ?? 0))
}
}
})
observer.observe(column, { childList: true, subtree: true })
window.openFoldTrace = { frames, slides, stop: () => { cancelAnimationFrame(frame); observer.disconnect() } }
window.openFoldTrace = { frames, stop: () => { cancelAnimationFrame(frame) } }
frame = requestAnimationFrame(tick)
}, ROOT)
}
async function readTrace(page: Page): Promise<{ frames: TraceFrame[]; slides: number[] }> {
return page.evaluate(() => {
const trace = window.openFoldTrace!
return { frames: [...trace.frames], slides: [...trace.slides] }
})
async function readTrace(page: Page): Promise<{ frames: TraceFrame[] }> {
return page.evaluate(() => ({ frames: [...window.openFoldTrace!.frames] }))
}
/** Per-frame scroll movements larger than subpixel rounding. */
@@ -339,26 +319,28 @@ describe.skipIf(MODE === 'record')('web e2e: next-message fold of an opened proc
}
})
it('keeps the running status slide while fold room below a short answer takes the new Step', async () => {
it('keeps the view still while fold room below a short answer takes the new Turn', async () => {
const world = await sendFromOpenedGroup(browser, 'short', 2)
try {
const folded = (await readTrace(world.page)).frames.at(-1)!
const foldedFrames = (await readTrace(world.page)).frames
const folded = foldedFrames.at(-1)!
expect.soft(folded.spacer, 'fold room below the short answer').toBeGreaterThan(0)
expect.soft(folded.pinned, 'tail pin while fold room remains').toBe(false)
world.releaseTurn()
// The running Step's group row is the first row after the user message; output stays held until then.
await world.page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS + 1)}"]`).waitFor({ timeout: 30_000 })
await world.page.locator(`${ROOT} [data-chat-running]`).waitFor()
await world.page.waitForTimeout(300)
const { frames, slides } = await readTrace(world.page)
const statusSteps = movements(frames.map(frame => ({ ...frame, top: frame.status ?? Number.NaN })))
.filter(step => Number.isFinite(step))
// The fold runs status moves at zero duration on its own, so only settled frames show the pin's effect.
const pinnedOverRoom = frames.filter(frame => !frame.folding && frame.pinned && frame.spacer > 0)
expect.soft(pinnedOverRoom, 'settled frames pinned over fold room').toEqual([])
expect.soft(slides.some(duration => duration > 0), `status slide durations ${JSON.stringify(slides)}; status movements ${JSON.stringify(statusSteps)}`).toBe(true)
world.releaseModel()
await world.ended
await stableScroll(world.page)
// Frames from the settled fold through Turn end.
const after = (await readTrace(world.page)).frames.slice(foldedFrames.length - 1)
const roomed = after.filter(frame => frame.spacer > 0)
const regrowth = roomed.some((frame, index) => index > 0 && frame.spacer > roomed[index - 1]!.spacer + 1)
const statusSteps = movements(after.map(frame => ({ ...frame, top: frame.status ?? Number.NaN })))
.filter(step => Number.isFinite(step))
expect.soft(movements(roomed), 'scroll movements while fold room remains').toEqual([])
expect.soft(regrowth, `fold room grows back; spacer ${JSON.stringify(roomed.map(frame => Math.round(frame.spacer)))}`).toBe(false)
expect.soft(statusSteps.every(step => step > 0), `running status movements ${JSON.stringify(statusSteps)}`).toBe(true)
expect(world.tripwire.pageErrors).toEqual([])
} finally {
await world.close()
+3 -3
View File
@@ -309,6 +309,9 @@ async function loadedFlowRows(page: Page): Promise<number> {
}
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
if (await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').count() > 0) {
await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click()
}
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
@@ -1700,9 +1703,6 @@ describe('web e2e: long Chat scroll contract', () => {
await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.setViewportSize({ width: 700, height: 900 })
// The narrow breakpoint auto-collapses the sidebar. Re-open it because
// this scenario switches sessions while pinning the narrow Chat scroll owner.
await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await nextPaint(world.page)
await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE)
+11 -10
View File
@@ -46,12 +46,13 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
const trailing = row.locator('[data-session-trailing]')
const observations: { stage: string; icons: string[]; time: boolean; pinned: boolean; actions: boolean }[] = []
const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row): Promise<void> => {
const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row,
timeVisible = iconsVisible): Promise<void> => {
const icons = target.locator('[data-session-trailing]')
const actions = target.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
const time = target.locator('[class*="time"]')
await expect.poll(() => icons.first().isVisible()).toBe(iconsVisible)
await expect.poll(() => time.isVisible()).toBe(iconsVisible)
await expect.poll(() => time.isVisible()).toBe(timeVisible)
await expect.poll(() => actions.isVisible()).toBe(actionsVisible)
observations.push({
stage,
@@ -113,7 +114,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await page.mouse.move(0, 0)
await observe('blur', true, false)
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click()
await page.mouse.move(0, 0)
await observe('narrow', true, false)
const bounds = await row.evaluate((element) => {
@@ -133,23 +134,23 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
try {
const touchConsole = watchConsole(touchPage)
await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await touchPage.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click()
const touchRow = touchPage.getByRole('treeitem').filter({ has: touchPage.getByText(SESSION_TITLE, { exact: true }) })
const touchTrigger = touchRow.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
await touchTrigger.waitFor()
expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false)
await observe('touch', true, true, touchRow)
await observe('touch', true, true, touchRow, false)
expect(await touchRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false)
expect(await touchRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false)
await touchTrigger.tap()
await touchPage.getByRole('menu').waitFor()
await observe('touch-menu-open', true, true, touchRow)
await observe('touch-menu-open', true, true, touchRow, false)
await touchPage.keyboard.press('Escape')
await observe('touch-menu-closed', true, true, touchRow)
await observe('touch-menu-closed', true, true, touchRow, false)
await touchTrigger.tap()
await touchPage.getByRole('menuitem', { name: 'Pin session', exact: true }).tap()
await touchRow.getByRole('img', { name: 'Pinned', exact: true }).waitFor()
await observe('touch-pinned', true, true, touchRow)
await touchRow.getByRole('button', { name: 'Unpin session', exact: true }).waitFor()
await observe('touch-pinned', true, true, touchRow, false)
const touchBounds = await touchRow.evaluate((element) => {
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
const resting = element.querySelector('[class*="rowResting"]')!.getBoundingClientRect()
@@ -173,7 +174,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('clicked-archived-row', true, false)
await scaffold.ctx.workspaceRegistry.unarchiveSession(sessionId)
await observe('touch-unarchived', true, true, touchRow)
await observe('touch-unarchived', true, true, touchRow, false)
expect(touchConsole.pageErrors).toEqual([])
} finally {
await touchPage.close()
+6 -8
View File
@@ -63,9 +63,9 @@ it.skipIf(process.platform !== 'darwin').each([
const dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill('@deepseek-ai/dsh-cua-bundle')
await dialog.getByRole('button', { name: '安装', exact: true }).click()
const ready = page.getByRole('dialog', { name: '插件已随 DSH 提供', exact: true })
const ready = page.getByRole('dialog', { name: '已安装', exact: true })
await ready.waitFor()
expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(1)
expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(0)
return ready.getByRole('button', { name: '立即启用', exact: true })
}
const enableNow = await offerCua()
@@ -199,8 +199,7 @@ it.skipIf(process.platform !== 'darwin').each([
const program = 'globalThis.counter = (globalThis.counter ?? 0) + 1'
expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(1)
await toggle.click()
await toggle.waitFor({ state: 'hidden' })
await card.waitFor({ state: 'hidden' })
await toggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' })
expect(scaffold.ctx.get('computerUse')).toBeUndefined()
expect(runtimeFiber() === persistentRuntimeFiber).toBe(true)
expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault)
@@ -211,7 +210,7 @@ it.skipIf(process.platform !== 'darwin').each([
}
expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(2)
expect(await readFile(settingsPath, 'utf8')).toBe(saved)
await (await offerCua()).click()
await toggle.click()
await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
await setup.waitFor()
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
@@ -249,9 +248,8 @@ it.skipIf(process.platform !== 'darwin').each([
expect.objectContaining({ id: 'agent-preset-registry', config: { default: 'standard', selectedDefault: 'ptc' } }),
]))
await toggle.click()
await toggle.waitFor({ state: 'hidden' })
await card.waitFor({ state: 'hidden' })
await (await offerCua()).click()
await toggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' })
await toggle.click()
await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
await setup.waitFor()
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
+3 -3
View File
@@ -29,7 +29,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
const helper = await installNativeFixture(root)
resources.closeFixture = await startFixtureHost(helper)
const state = join(root, 'permissions.json'), audit = join(root, 'helper-events.jsonl')
const launchApplication = { name: 'Visual Studio Code.app', path: '/Applications/Visual Studio Code.app',
const launchApplication = { name: 'DSH Computer Use.app', path: '/Applications/DSH Computer Use.app',
icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC' }
await writeFile(state, JSON.stringify({ accessibility: false, screenRecording: false, launchApplication }))
await writeFile(join(root, 'observe-helper'), '')
@@ -89,10 +89,10 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
expect(await panel.getByRole('region', { name: 'Manage applications computer use can access', exact: true }).count()).toBe(1)
await expect.poll(() => panel.locator('li img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
expect(await panel.getByText('/fixture/app', { exact: true }).count()).toBe(0)
expect(await accessibility.getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1)
expect(await accessibility.getByText('DSH Computer Use.app', { exact: true }).count()).toBe(1)
expect(await accessibility.textContent()).toContain('In System Settings, allow')
expect(await accessibility.textContent()).toContain('to read interface controls, click, and type.')
expect(await panel.getByRole('group', { name: 'Screen Recording', exact: true }).getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1)
expect(await panel.getByRole('group', { name: 'Screen Recording', exact: true }).getByText('DSH Computer Use.app', { exact: true }).count()).toBe(1)
await expect.poll(() => accessibility.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
await expect.poll(() => panel.getAttribute('aria-busy')).toBe('false')
await compareOrRefreshGolden(expected, await captureStableAria(page, '[data-cua-settings]', scaffold.workspaceCwd), webSnapshotMode())
@@ -1,107 +0,0 @@
/** The shipped browser plugin decorates both DeepSeek routes without changing catalog identity. */
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
const NAME = 'DeepSeek-V4.1-Flash'
const FIXTURE = fileURLToPath(new URL('./fixtures/deepseek-model-label-1017/session.v4.jsonl', import.meta.url))
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-model-label-1017/models.expected.md', import.meta.url))
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: temporary DeepSeek release badge', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
extraOverlayPath: fileURLToPath(new URL('./fixtures/deepseek-model-label-1017/account.overlay.yml', import.meta.url)),
replayFixture: FIXTURE,
replayProvidersOnly: true,
replayProviders: [
{ id: 'deepseek-account', name: 'DeepSeek Account', models: [{ id: 'deepseek-flash', name: NAME }] },
{ id: 'deepseek-official', name: 'DeepSeek', models: [
{ id: 'deepseek-flash', name: NAME }, { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
] },
{ id: 'external', name: 'External', models: [{ id: 'deepseek-flash', name: NAME }] },
],
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 900 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
})
afterAll(async () => {
try { await browser?.close() }
finally { await scaffold?.close() }
})
it('shows the same badge in all placements while preserving the chosen provider and model id', async () => {
const trigger = page.getByRole('button', { name: /^选择模型/ })
await trigger.getByText('1017', { exact: true }).waitFor()
expect(await trigger.textContent()).toContain(`${NAME} 1017`)
expect(await trigger.getAttribute('aria-label')).toBe(`选择模型,当前 ${NAME}`)
try {
await trigger.click()
const current = page.getByRole('menuitem', { name: /^模型/ })
expect(await current.getByText('1017', { exact: true }).count()).toBe(1)
await current.click()
const menu = page.getByRole('menu', { name: '模型', exact: true })
await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="menu"][aria-label="模型"]', scaffold.workspaceCwd), MODE)
expect(await menu.getByText('1017', { exact: true }).count()).toBe(2)
expect(await menu.getByRole('group', { name: 'External', exact: true }).getByText('1017', { exact: true }).count()).toBe(0)
const account = menu.getByRole('group', { name: 'DeepSeek 账号', exact: true })
await account.getByRole('menuitemradio', { name: `${NAME} 1017`, exact: true }).click()
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-account', model: 'deepseek-flash' })
await expect.poll(() => trigger.textContent()).toContain(`${NAME} 1017`)
const catalog = await scaffold.ctx.llm.listModels('deepseek-account')
expect(catalog.map(model => ({ id: model.id, name: model.name }))).toEqual([{ id: 'deepseek-flash', name: NAME }])
} finally {
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
}
})
it('keeps the release badge inside a 390px model menu and uses both theme palettes', async () => {
try {
await page.setViewportSize({ width: 390, height: 844 })
const trigger = page.getByRole('button', { name: /^选择模型/ })
await trigger.click()
await page.getByRole('menuitem', { name: /^模型/ }).click()
const menu = page.getByRole('menu', { name: '模型', exact: true })
const badge = menu.getByRole('group', { name: 'DeepSeek 账号', exact: true }).getByText('1017', { exact: true })
const box = await badge.boundingBox()
expect(box).not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(12)
expect(box!.x + box!.width).toBeLessThanOrEqual(378)
const colors = await badge.evaluate((node) => {
const body = node.ownerDocument.body
const previous = body.getAttribute('data-ds-dark-theme')
try {
return [false, true].map((dark) => {
body.toggleAttribute('data-ds-dark-theme', dark)
const style = getComputedStyle(node)
return { color: style.color, background: style.backgroundColor }
})
} finally {
if (previous === null) body.removeAttribute('data-ds-dark-theme')
else body.setAttribute('data-ds-dark-theme', previous)
}
})
expect(colors[0]).not.toEqual(colors[1])
for (const color of colors) expect(color.color).not.toBe(color.background)
} finally {
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await page.setViewportSize({ width: 1440, height: 900 })
}
expect(tripwire.pageErrors).toEqual([])
})
})
@@ -188,7 +188,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await blankColumn.locator('[data-sidebar-right-toggle]').click()
await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' })
await page.setViewportSize({ width: 767, height: blankViewport.height })
await expect.poll(() => columns(page)).toEqual([56, 711, 0])
await expect.poll(() => columns(page)).toEqual([0, 767, 0])
await page.locator('[data-sidebar-right-expand]').click()
// The fullscreen shell keeps its viewport box while its docked content closes.
await expect.poll(() => blankColumn.locator('[data-sidebar-right-open]').count()).toBe(1)
@@ -425,7 +425,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await expect.poll(() => columns(page)).toEqual([420, viewport.width - 420 - normalWidth, normalWidth])
await page.setViewportSize({ width: 767, height: viewport.height })
await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, width: 767, height: viewport.height })
await expect.poll(() => columns(page)).toEqual([56, 711, 0])
await expect.poll(() => columns(page)).toEqual([0, 767, 0])
expect(await sidebarSnapshot(page)).toMatchObject({ mode: 'fullscreen', resizeHandleWidth: 0, coversViewport: true })
await checkpoint('A automatic fullscreen at 767px')
await column.locator('[data-sidebar-right-mode="push"]').click()
@@ -86,7 +86,7 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"time": false,
"pinned": false,
"actions": true
},
@@ -96,7 +96,7 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"time": false,
"pinned": false,
"actions": true
},
@@ -106,7 +106,7 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"time": false,
"pinned": false,
"actions": true
},
@@ -116,8 +116,8 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": true,
"time": false,
"pinned": false,
"actions": true
},
{
@@ -136,7 +136,7 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"time": false,
"pinned": false,
"actions": true
}
@@ -10,11 +10,11 @@
- button "Use persistent mode"
- group "Accessibility":
- strong: Accessibility
- paragraph: In System Settings, allow Visual Studio Code.app to read interface controls, click, and type.
- paragraph: In System Settings, allow DSH Computer Use.app to read interface controls, click, and type.
- button "Request permission"
- group "Screen Recording":
- strong: Screen Recording
- paragraph: In System Settings, allow Visual Studio Code.app to capture application windows.
- paragraph: In System Settings, allow DSH Computer Use.app to capture application windows.
- button "Request permission"
- paragraph: Manage permissions in System Settings for the application named by macOS.
- region "Manage applications computer use can access":
@@ -0,0 +1 @@
- 'tooltip "Current branch: feature/a-long-current-branch-name-for-mobile-review"'
@@ -1,5 +1,6 @@
- img "变更请求状态:需要处理"
- text: "Add the pull request panel #7"
- button "提交评审"
- button "在浏览器中打开"
- button "全部 2 个文件"
- text: +4 −2
@@ -7,14 +8,64 @@
- button "全部折叠"
- button "左右对比" [pressed]
- button "自动换行" [pressed]
- button "收起 panel.ts" [expanded]
- text: src/panel.ts +2 −2
- button "panel.ts 的更多操作"
- text: 1 export const n1 = 1 1 export const n1 = 1 2 export const n2 = 2 2 export const n2 = 'two' 3 export const n3 = 3 3 export const n3 = 3 4 export const n4 = 4 4 export const n4 = 4 5 export const n5 = 5 5 export const n5 = 5
- button "6 行未变更"
- text: 12 export const n12 = 12 12 export const n12 = 12 13 export const n13 = 13 13 export const n13 = 13 14 export const n14 = 14 14 export const n14 = 14 15 export const n15 = 15 15 export const n15 = 'fifteen' 16 export const n16 = 16 16 export const n16 = 16 17 export const n17 = 17 17 export const n17 = 17 18 export const n18 = 18 18 export const n18 = 18
- button "其余未变更的行"
- button "收起 panel.md" [expanded]
- text: docs/panel.md 新文件 +2 −0
- button "panel.md 的更多操作"
- text: "1 + # Panel 2 + Shows the change requests of a Session."
- region "全部 2 个文件":
- button "收起 panel.ts" [expanded]
- text: src/panel.ts +2 −2
- button "panel.ts 的更多操作"
- button "选择第 L1 行": "1"
- text: export const n1 = 1
- button "选择第 R1 行": "1"
- text: export const n1 = 1
- button "选择第 L2 行": "2"
- text: export const n2 = 2
- button "选择第 R2 行": "2"
- text: export const n2 = 'two'
- button "选择第 L3 行": "3"
- text: export const n3 = 3
- button "选择第 R3 行": "3"
- text: export const n3 = 3
- button "选择第 L4 行": "4"
- text: export const n4 = 4
- button "选择第 R4 行": "4"
- text: export const n4 = 4
- button "选择第 L5 行": "5"
- text: export const n5 = 5
- button "选择第 R5 行": "5"
- text: export const n5 = 5
- button "6 行未变更"
- button "选择第 L12 行": "12"
- text: export const n12 = 12
- button "选择第 R12 行": "12"
- text: export const n12 = 12
- button "选择第 L13 行": "13"
- text: export const n13 = 13
- button "选择第 R13 行": "13"
- text: export const n13 = 13
- button "选择第 L14 行": "14"
- text: export const n14 = 14
- button "选择第 R14 行": "14"
- text: export const n14 = 14
- button "选择第 L15 行": "15"
- text: export const n15 = 15
- button "选择第 R15 行": "15"
- text: export const n15 = 'fifteen'
- button "选择第 L16 行": "16"
- text: export const n16 = 16
- button "选择第 R16 行": "16"
- text: export const n16 = 16
- button "选择第 L17 行": "17"
- text: export const n17 = 17
- button "选择第 R17 行": "17"
- text: export const n17 = 17
- button "选择第 L18 行": "18"
- text: export const n18 = 18
- button "选择第 R18 行": "18"
- text: export const n18 = 18
- button "其余未变更的行"
- button "收起 panel.md" [expanded]
- text: docs/panel.md 新文件 +2 −0
- button "panel.md 的更多操作"
- button "选择第 R1 行": "1"
- text: "+ # Panel"
- button "选择第 R2 行": "2"
- text: + Shows the change requests of a Session.
@@ -0,0 +1,77 @@
- img "变更请求状态:需要处理"
- text: "Add the pull request panel #7"
- button "提交评审"
- button "在浏览器中打开"
- button "全部 2 个文件"
- text: +4 −2
- button "刷新变更"
- button "全部折叠"
- button "左右对比" [pressed]
- button "自动换行" [pressed]
- button "文件树" [expanded] [pressed]
- region "全部 2 个文件":
- button "收起 panel.ts" [expanded]
- text: src/panel.ts +2 −2
- button "panel.ts 的更多操作"
- button "选择第 L1 行": "1"
- text: export const n1 = 1
- button "选择第 R1 行": "1"
- text: export const n1 = 1
- button "选择第 L2 行": "2"
- text: export const n2 = 2
- button "选择第 R2 行": "2"
- text: export const n2 = 'two'
- button "选择第 L3 行": "3"
- text: export const n3 = 3
- button "选择第 R3 行": "3"
- text: export const n3 = 3
- button "选择第 L4 行": "4"
- text: export const n4 = 4
- button "选择第 R4 行": "4"
- text: export const n4 = 4
- button "选择第 L5 行": "5"
- text: export const n5 = 5
- button "选择第 R5 行": "5"
- text: export const n5 = 5
- button "6 行未变更"
- button "选择第 L12 行": "12"
- text: export const n12 = 12
- button "选择第 R12 行": "12"
- text: export const n12 = 12
- button "选择第 L13 行": "13"
- text: export const n13 = 13
- button "选择第 R13 行": "13"
- text: export const n13 = 13
- button "选择第 L14 行": "14"
- text: export const n14 = 14
- button "选择第 R14 行": "14"
- text: export const n14 = 14
- button "选择第 L15 行": "15"
- text: export const n15 = 15
- button "选择第 R15 行": "15"
- text: export const n15 = 'fifteen'
- button "选择第 L16 行": "16"
- text: export const n16 = 16
- button "选择第 R16 行": "16"
- text: export const n16 = 16
- button "选择第 L17 行": "17"
- text: export const n17 = 17
- button "选择第 R17 行": "17"
- text: export const n17 = 17
- button "选择第 L18 行": "18"
- text: export const n18 = 18
- button "选择第 R18 行": "18"
- text: export const n18 = 18
- button "其余未变更的行"
- button "收起 panel.md" [expanded]
- text: docs/panel.md 新文件 +2 −0
- button "panel.md 的更多操作"
- button "选择第 R1 行": "1"
- text: "+ # Panel"
- button "选择第 R2 行": "2"
- text: + Shows the change requests of a Session.
- separator "调整文件树宽度"
- navigation "全部 2 个文件":
- textbox "筛选文件…": src
- button "src" [expanded]
- button "src/panel.ts": panel.ts +2 −2
@@ -0,0 +1,4 @@
- button "Local changes"
- text: HEAD → origin/main
- 'button "View the changes of this Session: 60 files, 60 lines added, 0 lines deleted"': +60 −0 60 files
- button "Create change request"
@@ -0,0 +1,7 @@
- region "评论 R2–4":
- text: 评论 R2–4
- textbox "留下评论…": 请核对边界条件
- button "取消"
- button "添加到输入框"
- button "开始评审"
- button "GitHub 评论选项"
@@ -0,0 +1,25 @@
> ## User Comment 2
> File: src/extra.ts
> Side: R
> Lines: 1
> Source: Local (branch)
> Diff hunk:
> ```diff
> @@ -0,0 +1,1 @@
> +export const extra = 1
> ```
> Comment:
> 请检查这项修改
> ## User Comment 1
> File: src/app.ts
> Side: R
> Lines: 1
> Source: Local (branch)
> Diff hunk:
> ```diff
> @@ -0,0 +1,1 @@
> +export const app = 1
> ```
> Comment:
> 请解释这行代码
@@ -0,0 +1,6 @@
- textbox "发消息或创建任务, / 调用指令, @ 文件或对话":
- paragraph:
- button "移除引用"
- text: "## User Comment 2 File: src/extra.ts Side: R Lines: 1 Source: Local (branch) Diff hunk: ```diff @@ -0,0 +1,1 @@ +export const extra = 1 ``` Comment: 请检查这项修改"
- button "移除引用"
- text: "## User Comment 1 File: src/app.ts Side: R Lines: 1 Source: Local (branch) Diff hunk: ```diff @@ -0,0 +1,1 @@ +export const app = 1 ``` Comment: 请解释这行代码"
@@ -0,0 +1,4 @@
- region "评论 R2–4":
- text: 你的评论 R2–4 已发布到 GitHub 请核对边界条件
- button "添加到输入框"
- button "在 GitHub 查看"
@@ -0,0 +1 @@
- text: "dsh-{{worktree}} 工作树 · 基于 release #7 Add the pull request panel 需要处理 · 检查失败"
@@ -0,0 +1,13 @@
- dialog "提交评审":
- heading "提交评审" [level=2]
- button "关闭"
- text: 待提交评论(1)
- textbox "评审总结…": 需要补充边界检查
- radio "评论"
- text: 评论
- radio "批准"
- text: 批准
- radio "请求更改" [checked]
- text: 请求更改
- button "取消"
- button "提交评审"
@@ -0,0 +1,3 @@
- button "当前分支:main": main
- checkbox "新建工作树"
- text: 新建工作树
@@ -4,6 +4,6 @@ grown: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":0,"height":
darwin: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":0,"height":440,"bottom":440},"sidebar":{"top":0,"height":440,"bottom":440},"rightbar":{"top":0,"height":440,"bottom":440},"overlay":{"top":0,"height":800,"bottom":800},"handles":[{"top":0,"height":440,"bottom":440}],"pageHeight":800}
windows: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":40,"height":400,"bottom":440},"sidebar":{"top":40,"height":400,"bottom":440},"rightbar":{"top":40,"height":400,"bottom":440},"overlay":{"top":0,"height":800,"bottom":800},"handles":[{"top":40,"height":400,"bottom":440}],"pageHeight":800}
browser: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":0,"height":440,"bottom":440},"sidebar":{"top":0,"height":440,"bottom":440},"rightbar":{"top":0,"height":440,"bottom":440},"overlay":{"top":0,"height":800,"bottom":800},"handles":[{"top":0,"height":440,"bottom":440}],"pageHeight":800}
narrow: {"bottom":{"top":240,"height":360,"bottom":600},"main":{"top":0,"height":240,"bottom":240},"sidebar":{"top":0,"height":240,"bottom":240},"rightbar":{"top":0,"height":240,"bottom":240},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600}
narrow: {"bottom":{"top":240,"height":360,"bottom":600},"main":{"top":0,"height":240,"bottom":240},"sidebar":{"top":0,"height":600,"bottom":600},"rightbar":{"top":0,"height":240,"bottom":240},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600}
unregistered: {"bottom":{"top":600,"height":0,"bottom":600},"main":{"top":0,"height":600,"bottom":600},"sidebar":{"top":0,"height":600,"bottom":600},"rightbar":{"top":0,"height":600,"bottom":600},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600}
collapsed: {"bottom":{"top":600,"height":0,"bottom":600},"main":{"top":0,"height":600,"bottom":600},"sidebar":{"top":0,"height":600,"bottom":600},"rightbar":{"top":0,"height":600,"bottom":600},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600}
@@ -0,0 +1,21 @@
- paragraph:
- button "Relative colon:24–30"
- text: ·
- button "Absolute colon:30"
- text: ·
- button "Fragment:12"
- text: ·
- button "Column:24:5"
- text: ·
- button "Zero line"
- text: ·
- button "Reversed range"
- text: ·
- link "Bare URL":
- /url: index.ts:24
- text: · Blocked script ·
- link "Development server":
- /url: localhost:3000
- text: ·
- link "Mail":
- /url: mailto:10086
@@ -4,8 +4,10 @@
- menuitemradio "DeepSeek-V4.1-Flash 1017"
- group "DeepSeek":
- text: DeepSeek
- menuitemradio "DeepSeek-V4.1-Flash 1017" [checked]
- menuitemradio "DeepSeek-V4.1-Flash 新" [checked]
- menuitemradio "DeepSeek-V4-Pro"
- menuitemradio "DeepSeek model with a very long release and capability description that exceeds the selector width Preview"
- menuitemradio "Invalid badge model"
- group "External":
- text: External
- menuitemradio "DeepSeek-V4.1-Flash"
@@ -0,0 +1,7 @@
- tabpanel "Result":
- region "Raw result":
- heading "Raw result" [level=3]
- paragraph: Raw results are only available for live tool calls
- region "Rendered result":
- heading "Rendered result" [level=3]
- text: LIVE_RESULT_RENDERED
@@ -0,0 +1,7 @@
- tabpanel "Result":
- region "Raw result":
- heading "Raw result" [level=3]
- paragraph: Running…
- region "Rendered result":
- heading "Rendered result" [level=3]
- paragraph: Running…
@@ -0,0 +1,10 @@
- tabpanel "Result":
- region "Raw result":
- heading "Raw result" [level=3]
- text: "{"
- tree "Result JSON":
- treeitem "answer:42"
- text: "}"
- region "Rendered result":
- heading "Rendered result" [level=3]
- text: LIVE_RESULT_RENDERED
@@ -6,4 +6,5 @@
- Tabs / 1280px: resting title 28, actions 28, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
- Tabs / 600px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
- Tabs / 470px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
- Tabs / 390px: resting title 28, actions 146, tabs 96; pinned actions 66, tabs 16, bar 114; stable range, reversible coordinates, same controls.
- Tabs / 390px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
- Tabs / 320px: resting title 28, actions 146, tabs 96; pinned actions 66, tabs 16, bar 114; stable range, reversible coordinates, same controls.
@@ -7,7 +7,7 @@ en 440px: title and New single-line, New beside the title; contained; New retain
en 390px: title and New single-line, New beside the title; contained; New retains focus
en 430px: title and New single-line, New beside the title; contained; New retains focus
en 1024px: title and New single-line, New beside the title; contained; New retains focus
en 509px with sidebar expanded: title single-line; New moves below the title
en 220px page column: title single-line; New moves below the title
zh 1680px: title and New single-line, New beside the title; contained; New retains focus
zh 800px: title and New single-line, New beside the title; contained; New retains focus
zh 520px: title and New single-line, New beside the title; contained; New retains focus
@@ -0,0 +1,20 @@
{
"wide": {
"height": 44,
"width": 260,
"footerCenterOffset": 0,
"leftOffset": -2,
"rightOffset": 2,
"paintedLeftOffset": -2,
"paintedRightOffset": 2
},
"rail": {
"height": 36,
"width": 36,
"footerCenterOffset": 0,
"leftOffset": -0.5,
"rightOffset": -0.5,
"paintedLeftOffset": -0.5,
"paintedRightOffset": -0.5
}
}
@@ -0,0 +1,20 @@
{
"wide": {
"height": 32,
"width": 264,
"footerCenterOffset": 0,
"leftOffset": -4,
"rightOffset": 4,
"paintedLeftOffset": -1,
"paintedRightOffset": 1
},
"rail": {
"height": 36,
"width": 36,
"footerCenterOffset": 0,
"leftOffset": -0.5,
"rightOffset": -0.5,
"paintedLeftOffset": -0.5,
"paintedRightOffset": -0.5
}
}
@@ -0,0 +1,7 @@
# Sidebar alignment
- search title is clipped: true
- unarchive hit target width: 24px
- clipped search title label gap: 6px
- clipped search title clearance from unarchive hit target: 2px
- clipped search title edge hit-tests to title: true
@@ -0,0 +1,24 @@
390px: closed sidebar has no rail; main width 390px
open: main geometry retained; background inert; no resize handle
close: Session navigation, Plugins navigation, mask and Escape
retained: sidebar DOM and opener focus
768px: 56px rail; main width 712px; drawer focus transfers to the rail toggle
breakpoint motion: collapsed rail hides immediately and returns compact without crossfades; expanded sidebar and explicit toggles retain their slides
767px: hidden drawer; main width 767px; no page overflow
global panels: browser top inset 48px; macOS keeps its existing clearance
tooltips: sidebar and workspace controls escape the drawer and fit the viewport
search input: 16px on touch; 13px with a fine pointer
simulated keyboard: homepage and drawer retain 844px layout height without following the 360px visual viewport; Settings stays within the viewport
keyboard close: layout height stays 844px; sidebar search retained; Settings close remains reachable
shell coverage: drawer backing and mask cover 844px without synthetic safe-area insets
rotation: 844x390 then 390x844 restores shell height and sidebar breakpoint
folded landscape: 951px frame splits at 475.5px without a resize handle; unfolding restores 280px and search focus; portrait retains the drawer
fold transitions: sidebar controls follow the divider in both directions; reduced motion changes width without animation
drawer mask: opacity transitions from 0 to 1 on open and 1 to 0 on close
reduced motion: drawer and mask settle without transitions
Web header: only the sidebar opener remains, with 48px leading clearance
below 768px: plugin and automation pages use 20px insets; Web view tabs align at 20px, while Desktop and wider Web tabs retain 28px
touch selected Session: More remains visible; update time is hidden; pinned rows offer Unpin before More with Workspace action spacing
touch Workspace: actions appear only while expanded, including after folding the focused row
fine pointer: folded Workspace actions remain available on hover
fine pointer: selected Session actions stay hidden until hover, including at 390px
@@ -0,0 +1,2 @@
View options and Session menus close when the sidebar becomes an inert drawer.
Focus returns to the visible sidebar opener; retained Session rows keep their DOM identity.
@@ -0,0 +1,10 @@
{"version":3,"isSeeded":false,"createdAt":0,"cwd":"{{cwd}}","delegationDepth":0,"type":"session","id":"{{session:1}}"}
{"type":"turn/start","data":{"turn":1}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:1}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Link src/example.txt using relative :24-30, absolute :30, and relative #L12 destinations, plus line/column and invalid locations, localhost:3000, index.ts:24, mailto:10086, and a blocked script link."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Colon file references","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"[Relative colon:24–30](src/example.txt:24-30) · [Absolute colon:30]({{cwd}}/src/example.txt:30) · [Fragment:12](src/example.txt#L12) · [Column:24:5](src/example.txt:24:5) · [Zero line](src/example.txt:0) · [Reversed range](src/example.txt:30-24) · [Bare URL](index.ts:24) · [Blocked script](javascript:alert) · [Development server](localhost:3000) · [Mail](mailto:10086)"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:3}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["[Relative colon:24–30](src/example.txt:24-30) · [Absolute colon:30]({{cwd}}/src/example.txt:30) · [Fragment:12](src/example.txt#L12) · [Column:24:5](src/example.txt:24:5) · [Zero line](src/example.txt:0) · [Reversed range](src/example.txt:30-24) · [Bare URL](index.ts:24) · [Blocked script](javascript:alert) · [Development server](localhost:3000) · [Mail](mailto:10086)"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"[Relative colon:24–30](src/example.txt:24-30) · [Absolute colon:30]({{cwd}}/src/example.txt:30) · [Fragment:12](src/example.txt#L12) · [Column:24:5](src/example.txt:24:5) · [Zero line](src/example.txt:0) · [Reversed range](src/example.txt:30-24) · [Bare URL](index.ts:24) · [Blocked script](javascript:alert) · [Development server](localhost:3000) · [Mail](mailto:10086)"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
+634 -5
View File
@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
import type {} from '@deepseek-ai/dsh-experimental-forge-session'
import { pluginRecordOf, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { initialShortcutConfig, type DesktopShortcutInput, type ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
import { chromium, type Browser, type Page } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
@@ -17,6 +18,12 @@ import {
} from './scaffold.ts'
import { connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE } from './support.ts'
declare global {
interface Window {
forgeKeyboard: { readonly revision: ShortcutRevision; deliver(input: DesktopShortcutInput): void }
}
}
const BUNDLE = fileURLToPath(new URL('../../../packages/experimental/forge-bundle', import.meta.url))
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v4.jsonl', import.meta.url))
const EXPECTED = fileURLToPath(new URL('./expected/forge-panel', import.meta.url))
@@ -70,13 +77,23 @@ if (args[0] === 'api' && args[5] === 'repos/octo/demo/pulls/9/merge') {
process.stdout.write(JSON.stringify({ sha: 'c'.repeat(40), merged: true, message: 'Pull Request successfully merged' }))
process.exit(0)
}
if (args[0] === 'api' && args[3] === 'repos/octo/demo/pulls/7') {
process.stdout.write(JSON.stringify({ head: { sha: 'a'.repeat(40) }, state: 'open' }))
process.exit(0)
}
if (args[0] === 'api' && args[4] === 'POST' && ['repos/octo/demo/pulls/7/comments', 'repos/octo/demo/pulls/7/reviews'].includes(args[5])) {
const body = JSON.parse(readFileSync(0, 'utf8'))
appendFileSync(join(here, 'reviews.jsonl'), JSON.stringify({ endpoint: args[5], body }) + '\\n')
process.stdout.write(JSON.stringify({ html_url: 'https://github.com/octo/demo/pull/7#pullrequestreview-1' }))
process.exit(0)
}
if (args[0] === 'pr' && args[1] === 'diff') {
const refused = join(here, 'pr-' + args[2] + '.refused')
if (existsSync(refused)) {
process.stderr.write(readFileSync(refused, 'utf8'))
process.exit(1)
}
process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8'))
await new Promise(resolve => process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8'), resolve))
process.exit(0)
}
if (args[0] === 'api' && args[3] === '-H' && args[4] === 'Accept: application/vnd.github.raw+json') {
@@ -152,6 +169,7 @@ const PR9_REFUSAL = [
/** Line `n` of `src/panel.ts` before (`old`) or after (`new`) pull request 7, which changes lines 2 and 15 of its 20. */
function panelLine(n: number, side: 'old' | 'new'): string {
if (n === 2) return side === 'old' ? 'export const n2 = 2' : 'export const n2 = \'two\''
if (n === 7) return 'export const n7 = "Long unchanged context stays wrapped while switching between Sessions and reviewing the same file"'
if (n === 15) return side === 'old' ? 'export const n15 = 15' : 'export const n15 = \'fifteen\''
return `export const n${n} = ${n}`
}
@@ -199,9 +217,12 @@ interface PanelLaunch {
* Launch the bundle against a GitHub checkout of `main` and `release` and open the New Session screen of that workspace.
* @param failureShot - name of the screenshot saved when the test fails.
* @param scenario - normal conversation, a restoration follow-up, or a numbered PR lookup.
* @param options - desktop transport, initial checkout branch, and touch input for the browser.
* @returns the scaffold, page, and checkout.
*/
async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal'): Promise<PanelLaunch> {
async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal',
options: { desktop?: 'darwin' | 'win32'; branch?: string; hasTouch?: boolean } = {}): Promise<PanelLaunch> {
const { desktop } = options
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-'))
onTestFinished(async () => {
@@ -257,6 +278,7 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up'
await git(project, 'remote', 'add', 'origin', 'https://github.com/octo/demo.git')
await git(project, 'branch', 'release')
await git(project, 'commit', '--quiet', '--allow-empty', '-m', 'main only')
if (options.branch !== undefined) await git(project, 'checkout', '--quiet', '-b', options.branch)
if (scenario === 'reference') {
// The model's confined command must own the CLI double's writable call log.
const modelBin = join(project, '.fixture-bin')
@@ -267,7 +289,31 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up'
const browser = await chromium.launch()
resources.browser = browser
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai' })
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
hasTouch: options.hasTouch ?? false })
if (desktop !== undefined) {
// Only Electron's keyboard transport is substituted; the product registry and menus remain real.
await page.addInitScript(({ device, config }) => {
const mark = () => { document.documentElement.dataset.platform = device }
if (document.documentElement === null) window.addEventListener('DOMContentLoaded', mark)
else mark()
const listeners = new Set<(input: DesktopShortcutInput) => void>()
Object.assign(window, {
forgeKeyboard: { revision: config.revision,
deliver: (input: DesktopShortcutInput) => { for (const listener of listeners) listener(input) } },
dshDesktop: { protocolVersion: 1,
shortcuts: { get: async () => config, subscribe: () => () => {}, recording: async () => {}, edit: async () => { throw new Error('Unused preference edit') } },
keyboard: {
subscribe: (listener: (input: DesktopShortcutInput) => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
closeWindow: async () => {},
},
},
})
}, { device: desktop, config: { ...initialShortcutConfig(), status: 'ready' as const } })
}
const tripwire = watchConsole(page)
onTestFailed(async () => { await saveFailureShot(page, failureShot) })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
@@ -297,7 +343,7 @@ async function requestReleaseWorktree(failureShot: string, followUp = false): Pr
const { page } = launched
const option = page.locator('[data-forge-worktree]')
await option.waitFor({ timeout: 20_000 })
await option.getByRole('checkbox', { name: '工作树', exact: true }).check()
await option.getByRole('checkbox', { name: '新建工作树', exact: true }).check()
await option.getByRole('button', { name: '基于 main', exact: true }).click()
await page.getByRole('menuitem', { name: 'release', exact: true }).click()
await option.getByRole('button', { name: '基于 release', exact: true }).waitFor()
@@ -391,6 +437,23 @@ it.skipIf(MODE === 'record')('starts a Session in a worktree before its first me
const header = page.locator('[data-forge-header]')
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理')
expect(await header.getAttribute('aria-pressed')).toBe('false')
// The Session row marks the worktree and its change request's state, and its hover card names both.
const row = page.locator(`[data-row-key="session:${sessionId}"]`)
await row.getByRole('img', { name: '在自己的工作树中', exact: true }).waitFor({ timeout: 20_000 })
await row.getByRole('img', { name: '变更请求状态:需要处理 · 检查失败', exact: true }).waitFor({ timeout: 20_000 })
// A fresh page cannot retain the previous browser source's status values.
await page.goto('about:blank')
await expect.poll(() => scaffold.ctx.storageDomain.get('forge_panel_change_requests')).toBeUndefined()
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await row.getByRole('img', { name: '变更请求状态:需要处理 · 检查失败', exact: true }).waitFor({ timeout: 20_000 })
await row.hover()
const rowHover = page.locator('[data-forge-session-hover]')
await rowHover.getByText('#7 Add the pull request panel', { exact: true }).waitFor({ timeout: 20_000 })
await compareOrRefreshGolden(join(EXPECTED, 'session-row-hover.expected.md'),
await captureStableAria(page, '[data-forge-session-hover]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE)
await page.mouse.move(0, 0)
await rowHover.waitFor({ state: 'detached' })
await header.click()
await expect.poll(() => header.getAttribute('aria-pressed')).toBe('true')
const panel = page.locator('[data-forge-panel]')
@@ -547,7 +610,7 @@ it.skipIf(MODE === 'record')('marks a draft change request ready for review and
})
it.skipIf(MODE === 'record')('shows the local changes of the Session by scope and the changes of its change requests, expanding unchanged lines', async () => {
const { scaffold, page, root, project, tripwire } = await launchPanel('web-e2e-forge-panel-changes')
const { scaffold, page, root, project, tripwire } = await launchPanel('web-e2e-forge-panel-changes', 'follow-up')
// origin/main stops at the twelve-line README; the branch adds a commit, a staged file, an edit, and an untracked file.
await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => `line ${index + 1}\n`).join(''))
await git(project, 'commit', '--quiet', '-am', 'readme')
@@ -582,8 +645,47 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an
.toEqual(['README.md', 'src/app.ts', 'src/extra.ts', 'notes.md'])
await local.locator('[data-forge-diff-file="notes.md"]').getByText('新文件 · 未提交', { exact: true }).waitFor()
const readme = local.locator('[data-forge-diff-file="README.md"]')
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await page.evaluate(() => navigator.clipboard.writeText('unchanged'))
await readme.locator('[data-forge-file-label]').click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged')
await readme.locator('[data-forge-path-spacer]').click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged')
await readme.getByRole('button', { name: '6 行未变更', exact: true }).click()
await readme.getByText('line 1', { exact: true }).first().waitFor({ timeout: 20_000 })
await local.getByRole('button', { name: '全部 4 个文件', exact: true }).click()
const tree = page.getByRole('menu')
await tree.getByRole('textbox', { name: '筛选文件…' }).fill('extra')
await tree.getByRole('menuitem', { name: 'src/extra.ts', exact: true }).click()
for (const [path, comment] of [['src/app.ts', '请解释这行代码'], ['src/extra.ts', '请检查这项修改']] as const) {
const section = local.locator(`[data-forge-diff-file="${path}"]`)
const lineNumber = section.getByRole('button', { name: '选择第 R1 行', exact: true })
await lineNumber.hover()
expect(await lineNumber.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
const content = await section.locator('[data-review-code]').first().boundingBox()
const fileBox = await section.boundingBox()
if (content === null || fileBox === null) throw new Error('Added file must be visible')
expect(content.x - fileBox.x).toBeLessThan(70)
await lineNumber.click()
expect(await section.getByRole('textbox', { name: '留下评论…' }).count()).toBe(0)
await section.getByRole('button', { name: '添加评论', exact: true }).click()
await section.getByRole('textbox', { name: '留下评论…' }).fill(comment)
await section.locator('[data-forge-comment]').getByRole('button', { name: '添加到输入框', exact: true }).click()
await section.locator('[data-forge-review-thread="agent"]').getByText(comment, { exact: true }).waitFor()
}
await compareOrRefreshGolden(join(EXPECTED, 'review-quotes.expected.md'),
await captureStableAria(page, '[data-composer-input]', scaffold.workspaceCwd), MODE)
expect((await readPersistedEvents(scaffold, sessionId)).some(event => event.type === 'user/message'
&& event.data.content.some(block => block.type === 'text' && block.text.includes('## User Comment')))).toBe(false)
const reviewed = scaffold.whenTurnSettled()
await page.locator('[data-composer-input]').first().press('Enter')
await reviewed
await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor()
const comments = (await readPersistedEvents(scaffold, sessionId)).flatMap(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' && block.text.includes('## User Comment') ? [block.text] : []) : [])
expect(comments).toHaveLength(1)
await compareOrRefreshGolden(join(EXPECTED, 'review-message.expected.md'), comments.join('').trimEnd(), MODE)
await local.getByRole('button', { name: '分支', exact: true }).click()
await page.getByRole('menuitem', { name: '已暂存', exact: true }).click()
await local.getByText('暂存区与 HEAD 比较', { exact: true }).waitFor()
@@ -599,12 +701,162 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an
await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click()
const view = page.locator('[data-forge-changes="change"]')
const panelFile = view.locator('[data-forge-diff-file="src/panel.ts"]')
const title = await view.getByText('Add the pull request panel', { exact: true }).boundingBox()
const submit = await view.getByRole('button', { name: '提交评审', exact: true }).boundingBox()
if (title === null || submit === null) throw new Error('PR title and submit action must be visible')
expect(Math.abs(title.y + title.height / 2 - submit.y - submit.height / 2)).toBeLessThan(1)
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).waitFor({ timeout: 20_000 })
await compareOrRefreshGolden(join(EXPECTED, 'changes-view.expected.md'),
await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE)
await page.getByRole('button', { name: '全屏', exact: true }).click()
await view.getByRole('navigation').getByRole('button', { name: 'src/panel.ts', exact: true }).waitFor()
const fileTree = view.getByRole('navigation')
const directoryName = await fileTree.getByText('src', { exact: true }).boundingBox()
const fileName = await fileTree.getByText('panel.ts', { exact: true }).boundingBox()
if (directoryName === null || fileName === null) throw new Error('Tree entries must be visible')
expect(fileName.x).toBeGreaterThan(directoryName.x)
await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('src')
await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('')
await fileTree.getByRole('button', { name: 'src', exact: true }).click()
await view.getByRole('button', { name: '文件树', exact: true }).click()
expect(await fileTree.isVisible()).toBe(false)
await view.getByRole('button', { name: '文件树', exact: true }).click()
expect(await fileTree.getByRole('button', { name: 'src', exact: true }).getAttribute('aria-expanded')).toBe('false')
await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('src')
await view.getByRole('button', { name: '文件树', exact: true }).click()
await view.getByRole('button', { name: '文件树', exact: true }).click()
expect(await fileTree.getByRole('textbox', { name: '筛选文件…' }).inputValue()).toBe('src')
await compareOrRefreshGolden(join(EXPECTED, 'file-tree-toggle.expected.md'),
await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE)
await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('')
await fileTree.getByRole('button', { name: 'src', exact: true }).click()
const resizeTree = view.getByRole('separator', { name: '调整文件树宽度', exact: true })
const originalWidth = Number(await resizeTree.getAttribute('aria-valuenow'))
const handle = await resizeTree.boundingBox()
if (handle === null) throw new Error('Tree resize handle must be visible')
await page.mouse.move(handle.x + handle.width / 2, handle.y + 100)
await page.mouse.down()
await page.mouse.move(handle.x - 80, handle.y + 100, { steps: 5 })
await page.mouse.up()
expect(Number(await resizeTree.getAttribute('aria-valuenow'))).toBeGreaterThan(originalWidth)
await resizeTree.press('Home')
expect(await fileTree.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
const searchBounds = await fileTree.getByRole('textbox').boundingBox()
const treeBounds = await fileTree.boundingBox()
if (searchBounds === null || treeBounds === null) throw new Error('Tree search must be visible')
expect(searchBounds.x + searchBounds.width).toBeLessThanOrEqual(treeBounds.x + treeBounds.width)
await resizeTree.press('ArrowLeft')
const resizedWidth = await resizeTree.getAttribute('aria-valuenow')
await view.getByRole('button', { name: '文件树', exact: true }).click()
await view.getByRole('button', { name: '文件树', exact: true }).click()
expect(await resizeTree.getAttribute('aria-valuenow')).toBe(resizedWidth)
for (const side of ['old', 'new'] as const) {
const firstLine = await panelFile.locator(`[data-review-code][data-${side}-line="2"]`).boundingBox()
const lastLine = await panelFile.locator(`[data-review-code][data-${side}-line="4"]`).boundingBox()
if (firstLine === null || lastLine === null) throw new Error('Review text must be visible')
await page.mouse.move(firstLine.x + 7, firstLine.y + firstLine.height / 2)
await page.mouse.down()
await page.mouse.move(lastLine.x + 120, lastLine.y + lastLine.height / 2, { steps: 5 })
await page.mouse.up()
await panelFile.getByRole('button', { name: '添加评论', exact: true }).waitFor()
expect(await panelFile.getByRole('textbox', { name: '留下评论…' }).count()).toBe(0)
const opposite = panelFile.locator(`[data-diff-side="${side === 'old' ? 'R' : 'L'}"]`).first()
expect(await opposite.evaluate(element => getComputedStyle(element).userSelect)).toBe('none')
await page.keyboard.press('ControlOrMeta+c')
const copied = await page.evaluate(() => navigator.clipboard.readText())
expect(copied).toContain(side === 'old' ? 'n2 = 2' : "n2 = 'two'")
expect(copied).not.toContain(side === 'old' ? "n2 = 'two'" : 'n2 = 2')
expect(copied.match(/n3 = 3/g)).toHaveLength(1)
}
await panelFile.getByRole('button', { name: '添加评论', exact: true }).click()
await panelFile.getByRole('region', { name: '评论 R2–4', exact: true }).waitFor()
await panelFile.getByRole('textbox', { name: '留下评论…' }).fill('请核对边界条件')
await panelFile.getByRole('button', { name: 'GitHub 评论选项', exact: true }).click()
await page.getByRole('menuitem', { name: '发布单条评论', exact: true }).waitFor()
await page.keyboard.press('Escape')
await compareOrRefreshGolden(join(EXPECTED, 'review-composer.expected.md'),
await captureStableAria(page, '[data-forge-comment]', scaffold.workspaceCwd), MODE)
await page.getByRole('button', { name: '退出全屏', exact: true }).click()
const commentForm = panelFile.locator('[data-forge-comment]')
expect(await commentForm.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
await page.getByRole('button', { name: '全屏', exact: true }).click()
await page.setViewportSize({ width: 390, height: 780 })
await commentForm.getByRole('textbox').focus()
expect(await commentForm.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
const narrowComment = await commentForm.boundingBox()
if (narrowComment === null) throw new Error('Comment form must remain visible')
expect(narrowComment.x + narrowComment.width).toBeLessThanOrEqual(390)
await page.setViewportSize({ width: 1680, height: 1000 })
await panelFile.getByRole('button', { name: '开始评审', exact: true }).click()
await panelFile.locator('[data-forge-review-thread="pending"]').getByText('请核对边界条件', { exact: true }).waitFor()
await page.setViewportSize({ width: 1680, height: 500 })
const changesScroll = view.locator('[data-forge-changes-scroll]')
await changesScroll.evaluate((element) => { element.scrollTop = element.scrollHeight })
const reviewScroll = await changesScroll.evaluate(element => element.scrollTop)
expect(reviewScroll).toBeGreaterThan(0)
await view.getByRole('button', { name: '提交评审 (1)', exact: true }).click()
const reviewDialog = page.getByRole('dialog', { name: '提交评审', exact: true })
await reviewDialog.waitFor()
expect(await reviewDialog.getByRole('textbox').count()).toBe(1)
expect(await reviewDialog.getByText('请核对边界条件', { exact: true }).count()).toBe(0)
expect(await changesScroll.evaluate(element => element.scrollTop)).toBe(reviewScroll)
await page.setViewportSize({ width: 390, height: 780 })
expect(await reviewDialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
const narrowDialog = await reviewDialog.boundingBox()
if (narrowDialog === null) throw new Error('Review dialog must remain visible')
expect(narrowDialog.x + narrowDialog.width).toBeLessThanOrEqual(390)
await page.setViewportSize({ width: 1680, height: 500 })
await page.keyboard.press('Escape')
expect(await reviewDialog.count()).toBe(0)
await view.getByRole('button', { name: '提交评审 (1)', exact: true }).click()
await reviewDialog.getByRole('textbox', { name: '评审总结…', exact: true }).fill('需要补充边界检查')
await reviewDialog.getByRole('radio', { name: '请求更改', exact: true }).check()
await compareOrRefreshGolden(join(EXPECTED, 'submit-review.expected.md'),
await captureStableAria(page, '[role="dialog"][aria-label="提交评审"]', scaffold.workspaceCwd), MODE)
await reviewDialog.getByRole('button', { name: '提交评审', exact: true }).click()
await page.getByRole('alert').filter({ hasText: '已提交评审' }).waitFor()
await panelFile.locator('[data-forge-review-thread="published"]').getByText('请核对边界条件', { exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'review-record.expected.md'), await captureStableAria(page, '[data-forge-review-thread="published"]', scaffold.workspaceCwd), MODE)
const published: unknown = JSON.parse((await readFile(join(root, 'reviews.jsonl'), 'utf8')).trim())
expect(published).toEqual({ endpoint: 'repos/octo/demo/pulls/7/reviews', body: { commit_id: HEAD7, event: 'REQUEST_CHANGES', body: '需要补充边界检查',
comments: [{ path: 'src/panel.ts', side: 'RIGHT', line: 4, start_line: 2, start_side: 'RIGHT', body: '请核对边界条件' }] } })
await page.getByRole('button', { name: '退出全屏', exact: true }).click()
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).click()
await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 })
await panelFile.getByRole('button', { name: '2 行未变更', exact: true }).waitFor()
await panelFile.getByRole('button', { name: 'panel.ts 的更多操作', exact: true }).click()
await page.getByRole('menuitem', { name: '复制路径', exact: true }).click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('src/panel.ts')
const docsFile = view.locator('[data-forge-diff-file="docs/panel.md"]')
await docsFile.getByRole('button', { name: '收起 panel.md', exact: true }).click()
await page.setViewportSize({ width: 1280, height: 500 })
const scrollport = view.locator('[data-forge-changes-scroll]')
await scrollport.evaluate((element) => { element.scrollTop = 100; element.dispatchEvent(new Event('scroll')) })
await panelFile.locator('[data-forge-file-label]').click()
const position = await scrollport.evaluate(element => element.scrollTop)
expect(position).toBeGreaterThan(0)
const originalFile = await panelFile.elementHandle()
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
await expect.poll(() => view.isVisible()).toBe(false)
await page.locator(`[data-row-key="session:${sessionId}"]`).click()
await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 })
expect(await panelFile.evaluate((element, original) => element === original, originalFile)).toBe(true)
await expect.poll(() => docsFile.getAttribute('data-expanded')).toBe(null)
await expect.poll(() => scrollport.evaluate(element => element.scrollTop)).toBe(position)
expect(await panelFile.locator('[data-forge-review-thread="published"]').count()).toBe(1)
await page.setViewportSize({ width: 1680, height: 1000 })
await page.locator('[data-dockkit-tab]').filter({ hasText: '#7 的变更' }).locator('[data-dockkit-tab-close]').click()
await expect.poll(() => view.count()).toBe(0)
if (await header.getAttribute('aria-pressed') !== 'true') await header.click()
await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click()
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).waitFor()
expect(await panelFile.locator('[data-forge-review-thread]').count()).toBe(0)
await view.getByRole('button', { name: '提交评审', exact: true }).click()
await reviewDialog.waitFor()
expect(await reviewDialog.getByRole('textbox').inputValue()).toBe('')
expect(await reviewDialog.getByRole('radio', { name: '评论', exact: true }).isChecked()).toBe(true)
await page.keyboard.press('Escape')
// GitHub refuses the diff of pull request 9 as too large, so its view offers the web page instead of a retry.
scaffold.ctx.forgeSession.track(session, DRAFT)
@@ -627,3 +879,380 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('retains a wrapped code line and tree width across tab and Session switches', async () => {
const { page, scaffold, root, tripwire } = await launchPanel('web-e2e-forge-reading-position')
const paths = ['src/before-0.ts', 'src/before-1.ts', 'src/before-2.ts', 'src/server.ts', 'src/after.ts']
const patch = paths.map(path => [
`diff --git a/${path} b/${path}`, 'index 1111111..2222222 100644', `--- a/${path}`, `+++ b/${path}`,
'@@ -1,650 +1,650 @@', '-const oldValue = true;', '+const newValue = true;',
...Array.from({ length: 649 }, (_, index) => ` const value_${index + 2} = "${'x'.repeat(24 + (index + 2) % 70)}";`), '',
].join('\n')).join('\n')
await writeFile(join(root, 'pr-7.diff'), patch)
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
const session = scaffold.ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('Session must stay loaded')
scaffold.ctx.forgeSession.track(session, PULL)
// Keep the file tree and the Session sidebar visible together on every platform.
await page.setViewportSize({ width: 2400, height: 700 })
await page.locator('[data-forge-header]').click()
await page.locator(`[data-forge-change="${PULL}"]`).getByRole('button', { name: /查看 #7 的变更/ }).click()
const view = page.locator('[data-forge-changes="change"]')
const scroll = view.locator('[data-forge-changes-scroll]')
const file = view.locator('[data-forge-diff-file="src/server.ts"]')
const code = file.locator('[data-review-code][data-new-line="300"]')
const separator = view.getByRole('separator', { name: '调整文件树宽度', exact: true })
const beforeFile = view.locator('[data-forge-diff-file="src/before-0.ts"]')
// ResizeObserver runs between rendering frames, including when an ancestor becomes hidden.
const layout = () => page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
}))
const reading = () => file.evaluate((element) => {
const viewport = element.closest('[data-forge-changes-scroll]')!
const top = Math.max(viewport.getBoundingClientRect().top, element.firstElementChild!.getBoundingClientRect().bottom)
const line = [...element.querySelectorAll<HTMLElement>('[data-review-code][data-new-line]')]
.find(row => row.getBoundingClientRect().bottom > top + 1)!
return { path: element.getAttribute('data-forge-diff-file'), line: line.dataset.newLine,
offset: line.getBoundingClientRect().top - top, scrollTop: viewport.scrollTop }
})
await code.waitFor()
for (const width of [240, 320]) {
if (width === 320) {
for (let step = 0; step < 4; step += 1) await separator.press('ArrowLeft')
await beforeFile.getByRole('button', { name: '收起 before-0.ts', exact: true }).click()
}
await layout()
await code.evaluate((element) => {
const viewport = element.closest('[data-forge-changes-scroll]')!
const header = element.closest('[data-forge-diff-file]')!.firstElementChild!
const top = viewport.getBoundingClientRect().top + header.getBoundingClientRect().height
viewport.scrollTop += element.getBoundingClientRect().top - top + 7
})
await layout()
const position = await reading()
expect(position.line).toBe('300')
expect(position.offset).toBe(-7)
for (const destination of ['tab', 'session', 'tab', 'session']) {
if (destination === 'tab') await page.getByRole('tab').filter({ hasText: '变更请求' }).click()
else await page.getByRole('button', { name: '新建会话', exact: true }).last().click()
await expect.poll(() => view.isVisible()).toBe(false)
await layout()
expect(await scroll.evaluate(element => element.clientWidth)).toBe(0)
if (destination === 'tab') await page.getByRole('tab').filter({ hasText: '#7 的变更' }).click()
else await page.locator(`[data-row-key="session:${sessionId}"]`).click()
await view.waitFor()
await layout()
expect(await separator.getAttribute('aria-valuenow')).toBe(String(width))
expect(await reading()).toEqual(position)
expect(await beforeFile.getAttribute('data-expanded')).toBe(width === 240 ? '' : null)
}
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while preserving vertical scrolling and comment editing', async () => {
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-file-keys')
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
for (const path of ['a.txt', 'b.txt', 'c.txt']) {
await writeFile(join(project, path), Array.from({ length: path === 'c.txt' ? 1 : 80 }, (_, line) => `line ${line + 1}\n`).join(''))
}
const { settled } = await sendPrompt(page, scaffold)
await settled
await page.locator('[data-forge-header]').click()
await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click()
await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更:3 个文件/ }).click()
const view = page.locator('[data-forge-changes="local"]')
const scroll = view.getByRole('region', { name: '全部 3 个文件', exact: true })
await scroll.waitFor()
await page.getByRole('button', { name: '全屏', exact: true }).click()
await page.setViewportSize({ width: 1280, height: 500 })
const tree = view.getByRole('navigation')
const a = view.locator('[data-forge-diff-file="a.txt"]')
const b = view.locator('[data-forge-diff-file="b.txt"]')
const c = view.locator('[data-forge-diff-file="c.txt"]')
await b.getByRole('button', { name: '收起 b.txt', exact: true }).click()
await scroll.evaluate((element) => { element.scrollTop = 0 })
await scroll.focus()
await page.keyboard.press('ArrowLeft')
expect(await scroll.evaluate(element => element.scrollTop)).toBe(0)
expect(await scroll.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
await page.keyboard.press('ArrowDown')
await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
await scroll.evaluate((element) => { element.scrollTop = 0 })
await page.keyboard.press('ArrowRight')
await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true')
expect(await b.getAttribute('data-expanded')).toBe('')
expect(await scroll.evaluate(element => document.activeElement === element)).toBe(true)
await page.keyboard.press('ArrowRight')
await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true')
const lastPosition = await scroll.evaluate(element => element.scrollTop)
const lastBox = await c.boundingBox(), viewport = await scroll.boundingBox()
if (lastBox === null || viewport === null) throw new Error('The last file and scroll viewport must be rendered')
expect(lastBox.y).toBeGreaterThan(viewport.y)
expect(lastBox.y).toBeLessThan(viewport.y + viewport.height)
await page.keyboard.press('ArrowRight')
expect(await scroll.evaluate(element => element.scrollTop)).toBe(lastPosition)
await page.keyboard.press('ArrowLeft')
await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true')
await page.keyboard.press('ArrowLeft')
await expect.poll(() => tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true')
await b.evaluate((element) => {
const viewport = element.closest('[data-forge-changes-scroll]')!
viewport.scrollTop += element.getBoundingClientRect().top - viewport.getBoundingClientRect().top + 100
})
await page.keyboard.press('ArrowRight')
await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true')
await tree.getByRole('button', { name: 'a.txt', exact: true }).click()
await a.getByRole('button', { name: '选择第 R1 行', exact: true }).click()
await a.getByRole('button', { name: '添加评论', exact: true }).click()
const input = a.getByRole('textbox', { name: '留下评论…', exact: true })
await input.fill('keep editing')
await input.press('ArrowLeft')
expect(await input.evaluate(element => (element as HTMLTextAreaElement).selectionStart)).toBe(11)
await input.press('ArrowRight')
expect(await input.inputValue()).toBe('keep editing')
expect(await tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true')
expect(await input.evaluate(element => document.activeElement === element)).toBe(true)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('keeps localized status columns separate and long file lists inside one vertical scroller', async () => {
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-layout')
await page.locator('[data-forge-current-branch]').waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'worktree-option.expected.md'),
await captureStableAria(page, '[data-forge-worktree]', scaffold.workspaceCwd), MODE)
await page.getByRole('button', { name: '收起侧边栏', exact: true }).click()
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => page.locator('[data-forge-worktree]')
.evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(390)
await page.setViewportSize({ width: 1680, height: 1000 })
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
await mkdir(join(project, 'src'))
await Promise.all(Array.from({ length: 60 }, (_, index) => writeFile(join(project, 'src', `file-${index}.ts`), `export const value = ${index}\n`)))
const { settled } = await sendPrompt(page, scaffold)
const session = scaffold.ctx.sessions.get(await settled)
if (session === undefined) throw new Error('The Session must stay loaded')
scaffold.ctx.forgeSession.track(session, PULL)
await page.locator('[data-forge-header]').click()
const panel = page.locator('[data-forge-panel]')
await panel.getByRole('button', { name: '刷新状态', exact: true }).click()
const local = panel.locator('[data-forge-local]')
await local.locator('button[aria-expanded]').click()
await expect.poll(() => local.locator('li').count(), { timeout: 20_000 }).toBe(60)
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 700 })
const outerScrollers = await panel.evaluate((element) => {
const scrollers: string[] = []
for (let ancestor = element.parentElement; ancestor !== null; ancestor = ancestor.parentElement) {
if (/auto|scroll/.test(getComputedStyle(ancestor).overflowY) && ancestor.scrollHeight > ancestor.clientHeight + 1) {
scrollers.push(ancestor.className)
}
}
return scrollers
})
expect(outerScrollers).toEqual([])
expect(await panel.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true)
await local.locator('li').last().scrollIntoViewIfNeeded()
expect(await local.locator('li').last().isVisible()).toBe(true)
}
await local.locator('button[aria-expanded]').click()
const change = panel.locator(`[data-forge-change="${PULL}"]`)
for (const [locale, labels] of [
['zh', ['检查', '评审', '合并', '变更']],
['en', ['Checks', 'Review', 'Merge', 'Changes']],
['de', ['Prüfungen', 'Review', 'Zusammenführen', 'Änderungen']],
] as const) {
await scaffold.ctx.settings.update('locale', { preference: locale })
await change.getByText(labels[0], { exact: true }).waitFor()
if (locale === 'en') {
await compareOrRefreshGolden(join(EXPECTED, 'local-card.expected.md'),
await captureStableAria(page, '[data-forge-local]', scaffold.workspaceCwd), MODE)
}
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 700 })
await expect.poll(() => change.evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(width)
await change.getByText(labels[3], { exact: true }).hover()
for (const label of labels) {
const geometry = await change.getByText(label, { exact: true }).evaluate((element) => {
const value = element.nextElementSibling
if (value === null) throw new Error('Status label must have a value')
const range = document.createRange()
range.selectNodeContents(element)
const name = range.getBoundingClientRect()
const content = value.getBoundingClientRect()
const row = element.parentElement!
const icon = element.previousElementSibling!
const rowRect = row.getBoundingClientRect()
return {
gap: content.left - name.right,
iconInset: icon.getBoundingClientRect().left - rowRect.left,
rowInset: rowRect.left - row.closest('[data-forge-change]')!.getBoundingClientRect().left,
labelWidth: element.getBoundingClientRect().width,
valueOffset: content.left - rowRect.left,
right: content.right,
width: document.documentElement.clientWidth,
}
})
expect(geometry.gap).toBeGreaterThanOrEqual(8)
expect(geometry.iconInset).toBe(8)
expect(geometry.rowInset).toBeGreaterThanOrEqual(6)
expect(geometry.rowInset).toBeLessThanOrEqual(7)
if (locale === 'zh') {
expect(geometry.labelWidth).toBe(32)
expect(geometry.valueOffset).toBe(76)
}
expect(geometry.right).toBeLessThanOrEqual(geometry.width)
}
}
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('keeps long branch details accessible by touch and keyboard across the hero row breakpoints', async () => {
const branch = 'feature/a-long-current-branch-name-for-mobile-review'
const { page, scaffold, tripwire } = await launchPanel('web-e2e-forge-branch-details', 'normal', { branch, hasTouch: true })
const option = page.locator('[data-forge-worktree]')
const chip = option.locator('[data-forge-current-branch]')
await chip.waitFor()
await page.getByRole('button', { name: '收起侧边栏', exact: true }).click()
for (const [locale, label] of [['zh', `当前分支:${branch}`], ['en', `Current branch: ${branch}`], ['de', `Aktueller Branch: ${branch}`]] as const) {
await scaffold.ctx.settings.update('locale', { preference: locale })
await option.getByRole('button', { name: label, exact: true }).waitFor()
for (const width of [390, 600, 601, 700, 1280]) {
await page.setViewportSize({ width, height: 844 })
await chip.scrollIntoViewIfNeeded()
await expect.poll(() => option.evaluate((element) => {
const row = element.closest('[class*="heroWorkspaceRow"]')!
const ancestors: Element[] = []
for (let node: Element | null = row; node !== null; node = node.parentElement) ancestors.push(node)
return {
fits: element.getBoundingClientRect().right <= innerWidth,
overflow: ancestors.filter(node => node.scrollWidth > node.clientWidth + 1).map(node => node.className),
}
})).toEqual({ fits: true, overflow: [] })
}
}
await scaffold.ctx.settings.update('locale', { preference: 'en' })
await option.getByRole('button', { name: `Current branch: ${branch}`, exact: true }).waitFor()
await page.setViewportSize({ width: 390, height: 844 })
expect(await chip.locator('span').evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByRole('tooltip', { exact: true, name: `Current branch: ${branch}` })
await chip.tap()
await tooltip.waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'branch-details.expected.md'), await tooltip.ariaSnapshot(), MODE)
await chip.tap()
await tooltip.waitFor({ state: 'hidden' })
await chip.tap()
await tooltip.waitFor()
await page.locator('[class*="heroWorkspaceRow"]').tap({ position: { x: 1, y: 1 } })
await tooltip.waitFor({ state: 'hidden' })
await chip.focus()
await page.keyboard.press('Tab')
await page.keyboard.press('Shift+Tab')
await tooltip.waitFor()
await page.keyboard.press('Escape')
await tooltip.waitFor({ state: 'hidden' })
await page.keyboard.press('Enter')
await tooltip.waitFor()
await page.keyboard.press('Escape')
await tooltip.waitFor({ state: 'hidden' })
expect(await option.getByRole('checkbox').isChecked()).toBe(false)
expect(await page.getByRole('menu').count()).toBe(0)
await scaffold.ctx.settings.update('locale', { preference: 'zh' })
await option.getByRole('checkbox', { name: '新建工作树', exact: true }).waitFor()
const { settled } = await sendPrompt(page, scaffold)
await settled
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record').each(['darwin', 'win32'] as const)('closes diff menus when the %s desktop shortcut deactivates a retained tab', async (platform) => {
const { page, scaffold, project, tripwire } = await launchPanel(`web-e2e-forge-menu-${platform}`, 'normal', { desktop: platform })
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
await writeFile(join(project, 'README.md'), 'review this change\n')
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
await page.locator('[data-forge-header]').click()
await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click()
await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更:1 个文件/ }).click()
const view = page.locator('[data-forge-changes="local"]')
await view.locator('[data-forge-diff-file="README.md"]').waitFor()
for (const name of ['分支', '全部 1 个文件', 'README.md 的更多操作']) {
await view.getByRole('button', { name, exact: true }).click()
await page.getByRole('menu').waitFor()
await page.evaluate((mac) => { window.forgeKeyboard.deliver({
kind: 'keyboard', frameName: '', revision: window.forgeKeyboard.revision, code: 'KeyN',
meta: mac, control: !mac, alt: false, shift: false, repeat: false,
}) }, platform === 'darwin')
await expect.poll(() => view.isVisible()).toBe(false)
await expect.poll(() => page.getByRole('menu').count()).toBe(0)
await page.locator(`[data-row-key="session:${sessionId}"]`).click()
await view.waitFor()
await expect.poll(() => page.getByRole('menu').count()).toBe(0)
}
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record').each([120, 4000])('types a review comment beside %i changed lines', async (count) => {
const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-review-typing')
const patch = ['diff --git a/src/typing.ts b/src/typing.ts', 'index 1111111..2222222 100644',
'--- a/src/typing.ts', '+++ b/src/typing.ts', `@@ -1,${count} +1,${count} @@`,
...Array.from({ length: count }, (_, index) => {
const context = index % 80 === 0 ? 'wrapped context '.repeat(600) : index % 5 === 0 ? 'context '.repeat(30) : ''
return `-export const value${index} = [${index}, '${context}']\n+export const value${index} = [${index + 1}, '${context}']`
}), ''].join('\n')
await writeFile(join(root, 'pr-7.diff'), patch)
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
const session = scaffold.ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('Session must stay loaded')
scaffold.ctx.forgeSession.track(session, PULL)
await page.locator('[data-forge-header]').click()
const change = page.locator(`[data-forge-change="${PULL}"]`)
await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click()
const file = page.locator('[data-forge-diff-file="src/typing.ts"]')
const collapsed = file.getByRole('button', { name: /^文件较大,/ })
await file.waitFor()
if (count > 120) await collapsed.click()
await file.getByRole('button', { name: '选择第 R5 行', exact: true }).click()
await file.getByRole('button', { name: '添加评论', exact: true }).click()
const input = file.getByRole('textbox', { name: '留下评论…', exact: true })
await input.waitFor()
const field = await input.boundingBox()
if (field === null) throw new Error('Comment field must be visible')
await page.mouse.move(field.x + field.width - 3, field.y + field.height - 3)
await page.mouse.down()
await page.mouse.move(field.x + field.width - 3, field.y + field.height + 77, { steps: 5 })
await page.mouse.up()
const resized = await input.boundingBox()
if (resized === null) throw new Error('Resized comment field must remain visible')
expect(resized.height).toBeGreaterThan(field.height + 50)
await input.fill('Review a wrapped line. '.repeat(80))
expect(await input.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true)
expect(await input.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
await input.fill('')
await page.keyboard.type('Check the boundary')
await page.keyboard.press('Enter')
const cdp = await page.context().newCDPSession(page)
try {
await cdp.send('Input.imeSetComposition', { text: '检查', selectionStart: 2, selectionEnd: 2 })
expect(await input.inputValue()).toBe('Check the boundary\n检查')
expect(await file.locator('[data-forge-review-thread]').count()).toBe(0)
await cdp.send('Input.insertText', { text: '检查边界条件' })
} finally { await cdp.detach() }
expect(await input.inputValue()).toBe('Check the boundary\n检查边界条件')
await input.fill(' \n')
expect(await file.getByRole('button', { name: '开始评审', exact: true }).isDisabled()).toBe(true)
await input.fill('检查边界条件')
await file.getByRole('button', { name: '开始评审', exact: true }).click()
await file.locator('[data-forge-review-thread="pending"]').getByText('检查边界条件', { exact: true }).waitFor()
expect(tripwire.pageErrors).toEqual([])
})
+32 -15
View File
@@ -95,18 +95,24 @@ it.each([false, true])('keeps workspace and archived search actions reachable wi
expect(await shortcut.count()).toBe(1)
expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch)
expect(await shortcut.isVisible()).toBe(!hasTouch)
if (hasTouch) {
await page.keyboard.press('Escape')
await page.setViewportSize({ width: 390, height: 900 })
await page.locator('[data-sidebar-drawer]').waitFor()
await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).tap()
await activate(actions)
await archive.waitFor()
}
for (const key of ['ArrowDown', 'Tab']) {
await page.keyboard.press(key)
expect(await page.locator('html').getAttribute('data-input-modality')).toBe('keyboard')
expect(await page.getByRole('menu').evaluate(menu => menu.contains(document.activeElement))).toBe(true)
expect(await shortcut.isVisible()).toBe(true)
if (hasTouch) {
await page.setViewportSize({ width: 390, height: 900 })
await expect.poll(() => shortcut.evaluate((element) => {
const bounds = element.getBoundingClientRect()
return bounds.left >= 0 && bounds.right <= window.innerWidth
})).toBe(true)
await page.setViewportSize({ width: 1280, height: 900 })
}
await page.keyboard.press('Escape')
if (!hasTouch) await row.hover()
@@ -114,6 +120,13 @@ it.each([false, true])('keeps workspace and archived search actions reachable wi
await archive.waitFor()
expect(await shortcut.isVisible()).toBe(!hasTouch)
}
if (hasTouch) {
await page.keyboard.press('Escape')
await page.setViewportSize({ width: 1280, height: 900 })
await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' })
await activate(actions)
await archive.waitFor()
}
await activate(archive)
await row.waitFor({ state: 'detached' })
await activate(page.getByRole('button', { name: 'View options', exact: true }))
@@ -224,19 +237,23 @@ it.each([false, true])('keeps actions and focus usable with touch=%s across layo
await page.getByRole('tooltip').waitFor({ state: 'detached' })
const blockedSplit = panel.locator('[data-dockkit-split-button]').last()
expect(await blockedSplit.isDisabled()).toBe(true)
const hit = await blockedSplit.evaluate((button) => {
const bounds = button.getBoundingClientRect()
const x = bounds.x + bounds.width / 2
const y = bounds.y + bounds.height / 2
const target = document.elementFromPoint(x, y)
return { x, y, wrapperReceivesInput: target === button.parentElement, cursor: target && getComputedStyle(target).cursor }
})
expect(hit.wrapperReceivesInput).toBe(true)
expect(['auto', 'default']).toContain(hit.cursor)
// Coordinate input exercises CSS hit testing over the disabled button itself.
if (hasTouch) await page.touchscreen.tap(hit.x, hit.y)
else await page.mouse.click(hit.x, hit.y)
await page.getByRole('tooltip').filter({ hasText: 'Two panes is the limit' }).waitFor()
if (hasTouch) {
expect(await blockedSplit.isVisible()).toBe(false)
expect(await page.getByRole('tooltip').count()).toBe(0)
} else {
const hit = await blockedSplit.evaluate((button) => {
const bounds = button.getBoundingClientRect()
const x = bounds.x + bounds.width / 2
const y = bounds.y + bounds.height / 2
const target = document.elementFromPoint(x, y)
return { x, y, wrapperReceivesInput: target === button.parentElement, cursor: target && getComputedStyle(target).cursor }
})
expect(hit.wrapperReceivesInput).toBe(true)
expect(['auto', 'default']).toContain(hit.cursor)
// Coordinate input exercises CSS hit testing over the disabled button itself.
await page.mouse.click(hit.x, hit.y)
await page.getByRole('tooltip').filter({ hasText: 'Two panes is the limit' }).waitFor()
}
expect(await panel.locator('[data-dockkit-pane]').count()).toBe(2)
expect(consoleWatch.pageErrors).toEqual([])
})
+2 -1
View File
@@ -42,7 +42,8 @@ it('reserves bottom content height without remounting it on main-panel navigatio
const viewportHeight = page.viewportSize()!.height
expect(result.bottom).toEqual({ top: viewportHeight - height, height, bottom: viewportHeight })
expect(result.main).toEqual({ top: caption, height: viewportHeight - caption - height, bottom: viewportHeight - height })
expect(result.sidebar).toEqual(result.main)
expect(result.sidebar).toEqual(page.viewportSize()!.width < 768
? { top: 0, height: viewportHeight, bottom: viewportHeight } : result.main)
expect(result.rightbar).toEqual(result.main)
expect(result.overlay).toEqual({ top: 0, height: viewportHeight, bottom: viewportHeight })
for (const handle of result.handles) expect(handle).toEqual(result.main)
+50
View File
@@ -156,6 +156,56 @@ describe('web e2e: Markdown file links', () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'links.expected.md', 'preview.expected.md'])
})
it('replays colon line suffixes as locations and shares tabs with fragment links', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-colon-file-links'))
const fixture = new URL('./fixtures/markdown-colon-file-links/session.v3.jsonl', import.meta.url)
const id = await seedSession(scaffold, await readFile(fixture, 'utf8'), 'colon-file-links')
await page.reload({ waitUntil: 'load' })
const workspace = page.getByRole('treeitem').first()
await workspace.waitFor()
if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click()
await page.locator(`[data-row-key="session:${id}"]`).click()
const relative = page.getByRole('button', { name: 'Relative colon:24–30', exact: true })
await relative.waitFor()
expect(await page.getByRole('button', { name: 'Development server', exact: true }).count()).toBe(0)
expect(await page.getByRole('link', { name: 'Development server', exact: true }).getAttribute('href')).toBe('localhost:3000')
expect(await page.getByRole('link', { name: 'Bare URL', exact: true }).getAttribute('href')).toBe('index.ts:24')
expect(await page.getByRole('link', { name: 'Blocked script', exact: true }).count()).toBe(0)
expect(await page.getByRole('button', { name: 'Blocked script', exact: true }).count()).toBe(0)
expect(await page.getByRole('link', { name: 'Mail', exact: true }).getAttribute('href')).toBe('mailto:10086')
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/markdown-colon-file-links.md', import.meta.url)),
await relative.locator('..').ariaSnapshot(), MODE,
)
const column = page.locator('[data-rightbar-col]')
for (const label of ['Zero line', 'Reversed range']) {
await page.getByRole('button', { name: label, exact: true }).click()
await expect.poll(() => column.locator('[data-textpreview-path]').textContent())
.toBe(join(scaffold.workspaceCwd, 'src/example.txt'))
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('source line 1\n')
expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt'])
}
for (const [label, line] of [['Relative colon:24–30', 24], ['Absolute colon:30', 30], ['Column:24:5', 24], ['Fragment:12', 12]] as const) {
const link = page.getByRole('button', { name: label, exact: true })
await link.focus()
await link.press('Enter')
await expect.poll(() => column.locator(`[data-textpreview-target="${line}"]`).textContent())
.toBe(`source line ${line}\n`)
await expect.poll(() => column.locator('[data-textpreview-path]').textContent())
.toBe(join(scaffold.workspaceCwd, 'src/example.txt'))
expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt'])
}
await page.reload({ waitUntil: 'load' })
await expect.poll(() => column.locator('[data-textpreview-path]').textContent())
.toBe(join(scaffold.workspaceCwd, 'src/example.txt'))
await relative.click()
await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('source line 24\n')
expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('opens parent-path prose and read-card links while retaining each address across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-file-links'))
// Return through the parent into this owned temp root; no shared parent file is created.
+2 -2
View File
@@ -216,8 +216,8 @@ async function awaitTableLayout(target: Page): Promise<void> {
if (element === null) return false
const tracks = getComputedStyle(element).gridTemplateColumns.split(' ').map(Number.parseFloat)
const root = element.querySelector<HTMLElement>('div[data-phase]')
// Mirrored from ui-layout's SIDEBAR_COLLAPSED; these tests use the Host compiler face.
return tracks[0] === 56 && tracks.at(-1) === 0
// Desktop retains the rail; the mobile drawer leaves the whole width to the transcript.
return tracks[0] === (element.hasAttribute('data-sidebar-drawer') ? 0 : 56) && tracks.at(-1) === 0
&& element.getAnimations().every(animation =>
animation.playState === 'finished' || animation.playState === 'idle')
&& root !== null
+185
View File
@@ -0,0 +1,185 @@
/** Catalog badges reach every selector placement through the Host catalog without changing catalog identity. */
import { fileURLToPath } from 'node:url'
import { LlmAdapter, type LlmModelBadge, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { parseModelListing } from '@deepseek-ai/dsh-llm-deepseek'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
const NAME = 'DeepSeek-V4.1-Flash'
const LONG_NAME = 'DeepSeek model with a very long release and capability description that exceeds the selector width'
const FIXTURE = fileURLToPath(new URL('./fixtures/model-badge/session.v4.jsonl', import.meta.url))
const EXPECTED = fileURLToPath(new URL('./expected/model-badge/models.expected.md', import.meta.url))
const MODE = webSnapshotMode()
/** DeepSeek fixture declarations pass through the production wire decoder before replay. */
function listedModel(id: string, name: string, badge: unknown) {
const entry = parseModelListing({ data: [{ id, name, client_metadata: { 'deepseek-harness': { badge } } }] }).entries[0]!
return { id, name, ...(entry.badge?.kind === 'badge' ? { badge: entry.badge.badge } : {}) }
}
describe.skipIf(MODE === 'record')('web e2e: catalog model badges', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
extraOverlayPath: fileURLToPath(new URL('./fixtures/model-badge/account.overlay.yml', import.meta.url)),
replayFixture: FIXTURE,
replayProvidersOnly: true,
replayProviders: [
{ id: 'deepseek-account', name: 'DeepSeek Account', models: [
listedModel('deepseek-flash', NAME, { text: ' 1017 ', tone: 'info' }),
] },
{ id: 'deepseek-official', name: 'DeepSeek', models: [
listedModel('deepseek-flash', NAME, { text: { en: ' New ', zh: ' 新 ' }, tone: 'success' }),
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
listedModel('long', LONG_NAME, { text: 'Preview', tone: 'future-tone' }),
listedModel('invalid-badge', 'Invalid badge model', { text: { en: 'New', zh: '\u200b' } }),
] },
{ id: 'external', name: 'External', models: [{ id: 'deepseek-flash', name: NAME }] },
],
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 900 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
})
afterAll(async () => {
try { await browser?.close() }
finally { await scaffold?.close() }
})
it('shows each catalog badge in all placements while preserving the chosen provider and model id', async () => {
const trigger = page.getByRole('button', { name: /^选择模型/ })
await trigger.getByText('新', { exact: true }).waitFor()
expect(await trigger.textContent()).toContain(`新 ${NAME}`)
expect(await trigger.getAttribute('aria-label')).toBe(`选择模型,当前 ${NAME}`)
expect(await trigger.getByText('新', { exact: true }).getAttribute('data-tone')).toBe('success')
try {
await trigger.click()
const current = page.getByRole('menuitem', { name: /^模型/ })
expect(await current.getByText('新', { exact: true }).count()).toBe(1)
await current.click()
const menu = page.getByRole('menu', { name: '模型', exact: true })
await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="menu"][aria-label="模型"]', scaffold.workspaceCwd), MODE)
expect(await menu.getByRole('group', { name: 'External', exact: true }).locator('[data-tone]').count()).toBe(0)
expect(await menu.getByRole('menuitemradio', { name: 'Invalid badge model', exact: true }).locator('[data-tone]').count()).toBe(0)
expect(await menu.getByRole('menuitemradio', { name: `${LONG_NAME} Preview`, exact: true }).locator('[data-tone]').getAttribute('data-tone')).toBe('neutral')
const account = menu.getByRole('group', { name: 'DeepSeek 账号', exact: true })
expect(await account.getByText('1017', { exact: true }).getAttribute('data-tone')).toBe('info')
await account.getByRole('menuitemradio', { name: `${NAME} 1017`, exact: true }).click()
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-account', model: 'deepseek-flash' })
await expect.poll(() => trigger.textContent()).toContain(`1017 ${NAME}`)
const catalog = await scaffold.ctx.llm.listModels('deepseek-account')
expect(catalog.map(model => ({ id: model.id, name: model.name }))).toEqual([{ id: 'deepseek-flash', name: NAME }])
} finally {
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
}
})
it('updates and clears badges in an already-open page and model menu', async () => {
const provider = 'live-badge'
class CatalogAdapter extends LlmAdapter {
badge: LlmModelBadge | undefined = { text: 'New', tone: 'info' }
override async listModels(route: string) {
return [{ provider: route, id: 'live', name: 'Live model', ...(this.badge === undefined ? {} : { badge: this.badge }) }]
}
override async *stream(): AsyncIterable<StreamChunk> { throw new Error('catalog-only scenario') }
}
const adapter = new CatalogAdapter()
const unregister = scaffold.ctx.llm.registerAdapter([provider], adapter)
const trigger = page.getByRole('button', { name: /^选择模型/ })
try {
await trigger.click()
await page.getByRole('menuitem', { name: /^模型/ }).click()
await page.getByRole('menuitemradio', { name: 'Live model New', exact: true }).click()
await expect.poll(() => trigger.textContent()).toContain('New Live model')
await trigger.click()
await page.getByRole('menuitem', { name: /^模型/ }).click()
adapter.badge = { text: 'Updated', tone: 'success' }
scaffold.ctx.emit('llm/models-updated', provider)
await expect.poll(() => trigger.textContent()).toContain('Updated Live model')
const updated = page.getByRole('menuitemradio', { name: 'Live model Updated', exact: true })
await updated.waitFor()
expect(await updated.locator('[data-tone]').getAttribute('data-tone')).toBe('success')
adapter.badge = undefined
scaffold.ctx.emit('llm/models-updated', provider)
await expect.poll(() => trigger.textContent()).toBe('Live model')
const cleared = page.getByRole('menuitemradio', { name: 'Live model', exact: true })
await cleared.waitFor()
expect(await cleared.locator('[data-tone]').count()).toBe(0)
} finally {
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await trigger.click()
await page.getByRole('menuitem', { name: /^模型/ }).click()
await page.getByRole('menuitemradio', { name: `${NAME} 1017`, exact: true }).click()
unregister()
}
})
it('ellipsizes long names while keeping badges visible in every placement and both theme palettes', async () => {
const trigger = page.getByRole('button', { name: /^选择模型/ })
try {
await trigger.click()
await page.getByRole('menuitem', { name: /^模型/ }).click()
await page.getByRole('menuitemradio', { name: `${LONG_NAME} Preview`, exact: true }).click()
await expect.poll(() => trigger.textContent()).toBe(`Preview ${LONG_NAME}`)
const triggerBadge = trigger.getByText('Preview', { exact: true })
const triggerBox = (await trigger.boundingBox())!
const triggerBadgeBox = (await triggerBadge.boundingBox())!
expect(triggerBadgeBox.x).toBeGreaterThanOrEqual(triggerBox.x)
expect(triggerBadgeBox.x + triggerBadgeBox.width).toBeLessThanOrEqual(triggerBox.x + triggerBox.width)
const triggerName = trigger.getByText(LONG_NAME, { exact: true })
expect(await triggerName.evaluate(node => node.scrollWidth > node.clientWidth && getComputedStyle(node).textOverflow === 'ellipsis')).toBe(true)
await page.setViewportSize({ width: 390, height: 844 })
await trigger.click()
const current = page.getByRole('menuitem', { name: /^模型/ })
const rootBadgeBox = (await current.getByText('Preview', { exact: true }).boundingBox())!
const rootBox = (await current.boundingBox())!
expect(rootBadgeBox.x).toBeGreaterThanOrEqual(rootBox.x)
expect(rootBadgeBox.x + rootBadgeBox.width).toBeLessThanOrEqual(rootBox.x + rootBox.width)
expect(await current.getByText(LONG_NAME, { exact: true }).evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
await current.click()
const menu = page.getByRole('menu', { name: '模型', exact: true })
const option = menu.getByRole('menuitemradio', { name: `${LONG_NAME} Preview`, exact: true })
const badge = option.getByText('Preview', { exact: true })
const box = await badge.boundingBox()
expect(box).not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(12)
expect(box!.x + box!.width).toBeLessThanOrEqual(378)
expect(await option.getByText(LONG_NAME, { exact: true }).evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
const colors = await badge.evaluate((node) => {
const body = node.ownerDocument.body
const previous = body.getAttribute('data-ds-dark-theme')
try {
return [false, true].map((dark) => {
body.toggleAttribute('data-ds-dark-theme', dark)
const style = getComputedStyle(node)
return { color: style.color, background: style.backgroundColor }
})
} finally {
if (previous === null) body.removeAttribute('data-ds-dark-theme')
else body.setAttribute('data-ds-dark-theme', previous)
}
})
expect(colors[0]).not.toEqual(colors[1])
for (const color of colors) expect(color.color).not.toBe(color.background)
} finally {
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await page.setViewportSize({ width: 1440, height: 900 })
}
expect(tripwire.pageErrors).toEqual([])
})
})
+6
View File
@@ -27,5 +27,11 @@ export async function assertModelInputLayout(page: Page, editor: Locator): Promi
}
} finally {
await page.setViewportSize(original)
// The frame reports its own width to the layout store from a rAF-throttled
// ResizeObserver, so the restored viewport is only in force once the shell
// has left drawer mode; a capture taken in between reads the narrow frame.
if (await page.locator('[data-sidebar-drawer]').count() > 0) {
await expect.poll(async () => page.locator('[data-sidebar-drawer]').count(), { timeout: 5_000 }).toBe(0)
}
}
}
+216 -1
View File
@@ -12,7 +12,10 @@ import { join } from 'node:path'
import type { Browser, Page, Response } from 'playwright'
import { chromium } from 'playwright'
import { strFromU8, unzipSync } from 'fflate'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import { ToolCallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import type SessionToolResults from '@deepseek-ai/dsh-client-ui-trajectory'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import {
@@ -33,6 +36,9 @@ const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', impor
const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md')
const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md')
const COPY_MARKDOWN_EXPECTED = join(CODE_CARD_DIR, 'copy-markdown.expected.md')
const EVICTED_RESULT_EXPECTED = join(CODE_CARD_DIR, 'live-result-evicted.expected.md')
const LIVE_RESULT_EXPECTED = join(CODE_CARD_DIR, 'live-result.expected.md')
const RUNNING_RESULT_EXPECTED = join(CODE_CARD_DIR, 'live-result-running.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl`
@@ -306,6 +312,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.getByRole('tab', { name: 'Result' }).click()
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBeGreaterThan(0)
await page.getByText('Raw results are only available for live tool calls', { exact: true }).waitFor()
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
@@ -624,10 +631,218 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
await assertFixtureInventory(CODE_CARD_DIR, [
'code-card.expected.md', 'command-wrap.expected.md', 'copy-markdown.expected.md',
'live-result-evicted.expected.md', 'live-result.expected.md', 'live-result-running.expected.md',
])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md',
'terminal-card.expected.md', 'timing.expected.md',
])
})
it.skipIf(MODE === 'record')('inspects a live tool value without adding it to the Session journal', async () => {
await ensureSeedOpen(page)
await expect.poll(() => scaffold.ctx.agents.get(SessionId(SEED_ID)) !== undefined).toBe(true)
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))!
const unregister = scaffold.ctx.tools.register(defineTool({
name: 'live_result_probe', description: 'Live result inspection fixture.', parameters: {},
output: { schema: { type: 'json' }, render: () => [{ type: 'text', text: 'LIVE_RESULT_RENDERED' }] },
execute: async () => ({ answer: 42 }),
}))
onTestFinished(unregister)
const callId = ToolCallId('live-result-probe')
let resultEvent: SessionEvent | undefined
const off = scaffold.ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/result' && event.data.message.toolCallId === callId) resultEvent = event
}, { global: true })
onTestFinished(() => { off() })
const batch = await scaffold.ctx.tools.execute({
agent, signal: new AbortController().signal, log: { turn: 2, step: 1 },
calls: [{ type: 'tool-call', id: callId, name: 'live_result_probe', arguments: '{}' }],
})
expect(batch.results[0]?.result.isError).toBe(false)
expect(resultEvent).toBeDefined()
expect(resultEvent?.data).not.toHaveProperty('value')
const abort = new AbortController()
const observed = (scaffold.ctx.get('sessionToolResults') as SessionToolResults).watch(
{ sessionId: agent.id, seq: resultEvent!.seq }, abort.signal,
)[Symbol.asyncIterator]()
try {
expect((await observed.next()).value).toEqual({ available: true, value: { answer: 42 } })
} finally { abort.abort(); await observed.return?.() }
await page.getByRole('tab', { name: 'Trajectory' }).click()
await page.locator('tr[data-kind="tool"]').filter({ hasText: 'live_result_probe' }).click()
const details = page.getByRole('complementary', { name: 'Event details' })
await details.getByRole('tab', { name: 'Result', exact: true }).click()
await details.getByText('LIVE_RESULT_RENDERED', { exact: true }).waitFor()
const rawSection = details.getByRole('region', { name: 'Raw result', exact: true })
const renderedSection = details.getByRole('region', { name: 'Rendered result', exact: true })
await expect.poll(() => rawSection.textContent()).toContain('answer:')
expect(await renderedSection.textContent()).toContain('LIVE_RESULT_RENDERED')
expect(await details.getByRole('button', { name: 'Raw result', exact: true }).count()).toBe(0)
await compareOrRefreshGolden(
LIVE_RESULT_EXPECTED, await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd), MODE,
)
const unregisterJson = scaffold.ctx.tools.register(defineTool({
name: 'json_result_probe', description: 'JSON result inspection fixture.', parameters: {},
output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
execute: async () => Array.from({ length: 80 }, (_, index) => index),
}))
onTestFinished(unregisterJson)
const jsonBatch = await scaffold.ctx.tools.execute({
agent, signal: new AbortController().signal, log: { turn: 2, step: 1 },
calls: [{ type: 'tool-call', id: ToolCallId('json-result-probe'), name: 'json_result_probe', arguments: '{}' }],
})
expect(jsonBatch.results[0]?.result.isError).toBe(false)
await page.locator('tr[data-kind="tool"]').filter({ hasText: 'json_result_probe' }).click()
await rawSection.getByRole('tree').waitFor()
await renderedSection.getByRole('tree').waitFor()
const originalViewport = page.viewportSize()!
try {
for (const width of [originalViewport.width, 390]) {
await page.setViewportSize({ width, height: 600 })
const geometry = await details.getByRole('tabpanel').evaluate((panel) => {
const sections = [...panel.querySelectorAll('section[aria-label]')]
const first = sections[0]!.getBoundingClientRect()
const second = sections[1]!.getBoundingClientRect()
return {
gap: second.top - first.bottom,
headingFonts: sections.map(section => getComputedStyle(section.querySelector('h3')!).fontSize),
overflowingDescendants: [...panel.querySelectorAll('*')].filter(node =>
/auto|scroll/.test(getComputedStyle(node).overflowY) && node.scrollHeight > node.clientHeight + 1).length,
scrolls: panel.scrollHeight > panel.clientHeight,
horizontalOverflow: panel.scrollWidth > panel.clientWidth + 1,
headingsAligned: first.left === second.left,
}
})
expect(geometry).toEqual({
gap: 2, headingFonts: ['13px', '13px'], overflowingDescendants: 0,
scrolls: true, horizontalOverflow: false, headingsAligned: true,
})
await details.getByRole('tabpanel').evaluate((panel) => { panel.scrollTop = panel.scrollHeight })
expect(await details.getByRole('tabpanel').evaluate(panel => panel.scrollTop)).toBeGreaterThan(0)
}
} finally {
await page.setViewportSize(originalViewport)
}
await page.locator('tr[data-kind="tool"]').filter({ hasText: 'live_result_probe' }).click()
await expect.poll(() => rawSection.textContent()).toContain('answer:')
const newer = await scaffold.ctx.tools.execute({
agent, signal: new AbortController().signal,
calls: Array.from({ length: 1000 }, (_, index) => ({
type: 'tool-call' as const, id: ToolCallId(`newer-result-${index}`), name: 'live_result_probe', arguments: '{}',
})),
})
expect(newer.results).toHaveLength(1000)
expect(newer.results.every(entry => !entry.result.isError)).toBe(true)
await details.getByText('Raw results are only available for live tool calls', { exact: true }).waitFor()
// Keep hover and focus tooltips out of the recorded result panel.
await details.getByText('Raw results are only available for live tool calls', { exact: true }).click()
await details.getByRole('tooltip').waitFor({ state: 'hidden' })
await compareOrRefreshGolden(
EVICTED_RESULT_EXPECTED, await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd), MODE,
)
await details.getByText('LIVE_RESULT_RENDERED', { exact: true }).waitFor()
})
it.skipIf(MODE === 'record')('keeps long raw strings and PTC output readable in one result scroller', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-long-tool-results'))
await ensureSeedOpen(page)
await expect.poll(() => scaffold.ctx.agents.get(SessionId(SEED_ID)) !== undefined).toBe(true)
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))!
agent.session.append('turn/start', { turn: 3 })
onTestFinished(() => {
agent.session.append('step/end', { turn: 3, step: 1 })
agent.session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
})
agent.session.append('step/start', { turn: 3, step: 1 })
const callId = ToolCallId('long-ptc-result')
const call = agent.session.append('tool/call', {
turn: 3, step: 1, callId, name: 'run_code',
arguments: JSON.stringify({ code: 'console.log("PTC output")', description: 'Long program output' }),
})
await page.getByRole('tab', { name: 'Trajectory' }).click()
await page.locator('tr[data-kind="tool"]').filter({ hasText: 'Long program output' }).click()
const details = page.getByRole('complementary', { name: 'Event details' })
await details.getByRole('tab', { name: 'Result', exact: true }).click()
const raw = details.getByRole('region', { name: 'Raw result', exact: true })
const rendered = details.getByRole('region', { name: 'Rendered result', exact: true })
await raw.getByText('Running…', { exact: true }).waitFor()
await compareOrRefreshGolden(
RUNNING_RESULT_EXPECTED, await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd), MODE,
)
const ptcOutput = [...Array.from({ length: 100 }, (_, i) => `PTC_LINE_${i}`), 'PTC_END'].join('\n')
agent.session.append('tool/result', {
turn: 3, step: 1,
message: createToolResultMessage({ callId, content: [{ type: 'text', text: ptcOutput }], isError: false }),
}, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
await rendered.getByText(ptcOutput, { exact: true }).waitFor()
const stringValue = 'raw_output_'.repeat(300) + 'RAW_END'
const probes = [
{ name: 'long_string_probe', value: stringValue },
{ name: 'long_json_probe', value: { detail: 'json_output_'.repeat(120) } },
]
for (const probe of probes) {
const unregister = scaffold.ctx.tools.register(defineTool({
name: probe.name, description: 'Long result layout fixture.', parameters: {},
output: {
schema: { type: 'json' },
render: (_args, value) => [{ type: 'text', text: typeof value === 'string' ? 'STRING_RENDERED' : JSON.stringify(value) }],
},
execute: async () => probe.value,
}))
onTestFinished(unregister)
const batch = await scaffold.ctx.tools.execute({
agent, signal: new AbortController().signal, log: { turn: 3, step: 1 },
calls: [{ type: 'tool-call', id: ToolCallId(probe.name), name: probe.name, arguments: '{}' }],
})
expect(batch.results[0]?.result.isError).toBe(false)
}
const viewport = page.viewportSize()!
try {
for (const width of [viewport.width, 390]) {
await page.setViewportSize({ width, height: 600 })
for (const name of ['Long program output', ...probes.map(probe => probe.name)]) {
await details.getByRole('button', { name: 'Close details' }).click()
await page.locator('tr[data-kind="tool"]').filter({ hasText: name }).click()
await details.getByRole('tab', { name: 'Result', exact: true }).click()
if (name === 'long_json_probe') {
await raw.getByRole('treeitem').waitFor()
const heights = await Promise.all([raw, rendered].map(section =>
section.getByRole('treeitem').evaluate(row => row.getBoundingClientRect().height)))
expect(heights[0]).toBeGreaterThan(3 * 16)
expect(heights[0]).toBe(heights[1])
} else {
const body = name === 'Long program output' ? rendered : raw
await expect.poll(() => body.locator('pre').textContent()).toBe(
name === 'Long program output' ? ptcOutput : JSON.stringify(stringValue),
)
}
const panel = details.getByRole('tabpanel')
const geometry = await panel.evaluate((element) => {
element.scrollTop = element.scrollHeight
const bounds = element.getBoundingClientRect()
const pre = element.querySelector('pre')
let tailVisible = true
if (pre?.lastChild !== null && pre?.lastChild !== undefined) {
const range = document.createRange()
range.selectNodeContents(pre)
range.setStart(pre.lastChild, (pre.lastChild.textContent?.length ?? 1) - 1)
const tail = range.getBoundingClientRect()
tailVisible = tail.top >= bounds.top && tail.bottom <= bounds.bottom
}
return {
scrolls: element.scrollTop > 0 && getComputedStyle(element).overflowY === 'auto',
tailVisible,
overflowingDescendants: [...element.querySelectorAll('*')].filter(node =>
/auto|scroll/.test(getComputedStyle(node).overflowY) && node.scrollHeight > node.clientHeight + 1).length,
horizontalOverflow: element.scrollWidth > element.clientWidth + 1,
}
})
expect(geometry).toEqual({ scrolls: true, tailVisible: true, overflowingDescendants: 0, horizontalOverflow: false })
}
}
} finally {
await page.setViewportSize(viewport)
}
})
})

Some files were not shown because too many files have changed in this diff Show More