diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml index f7b369d628..e80e658859 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml @@ -21,8 +21,8 @@ en: 774c3bf80a48ffbe zh: c513245789aa8efd /agent-note-right-sidebar-tab-types-and-navigation/decision/navigation-ctx-sidebarright: - en: b32bad4c3b8c2cc8 - zh: 57ba7b8d3451e478 + en: c52363f57001b7ac + zh: bbcbdbc038cbf316 /agent-note-right-sidebar-tab-types-and-navigation/decision/addresses: en: eeb1e7e7478a2f2c zh: bddbc5bc78245f63 @@ -30,8 +30,8 @@ en: 6386bde2654f9a0a zh: 700439be6a01e162 /agent-note-right-sidebar-tab-types-and-navigation/alternatives-considered: - en: 91fbca006e8de5bc - zh: f48d3ce1dd7b63bf + en: c47eb4e2f3c307f9 + zh: fd4515b8be88506e /agent-note-right-sidebar-tab-types-and-navigation/consequences: en: 3cdb1159ee55d851 zh: dcd068005e678a89 @@ -39,5 +39,5 @@ en: 49449a6c8465c098 zh: 1f476cc1226aa9cd /agent-note-right-sidebar-tab-types-and-navigation/deferred: - en: fa27b7684e38329b - zh: d270e0cdcab73732 + en: 5b2416692028bf89 + zh: 65930102aa253bf2 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md index 599ce36ed4..4e7fcb8cb1 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md @@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar `openResource` accepts the union of every declared resource shape and `openTab` the shape declared for `K`; a body narrows `navigation.params` by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims `file` addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone. -Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The seat publishes its binding — its session, its store's actions, and its surface — while mounted; a command on the public face acts on the mounted session and throws with no mounted session surface. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted. +Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The plugin names the Session on screen — the selected Session while the Conversation fills the main column — from the Session selection and the main panel, before React renders either change, and publishes it as `mounted`; a command on the public face acts on that Session through its adopted store and throws when no Session is on screen or its store has not been minted. The seat reports only what it renders with: the room its docking kit measured and whether its frame width presents the panel fullscreen automatically. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted. ### Addresses @@ -100,6 +100,8 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced **Slot priorities to express an extension taking over a builtin, then registry-minted slot keys.** The first attempt had the overriding type register its body at a lower slot priority through an exported constant — a value import across dynamic plugins, and a second rule system (slot priority) standing in for the registry's. The second attempt had the registry mint a key per registration and return it from `register()`, which made registration a two-step dance whose ordering mattered. Letting the implementation declare its own `id` — required, unique, the same string it registers its seats under — needs no constant, no minting, and no ordering, and gives the registry the identity it needs to reject duplicates. +**A binding the seat publishes from its own effects.** The first form had the mounted seat publish its session, actions, and surface after each commit and release them when it left. React runs a commit's passive effects in tree order, and the frame renders the Conversation column before the right column, so in a commit that switched Sessions or returned from a global panel a Conversation component opening content from its effect ran while no seat was bound — after the departing seat's release and before the arriving seat's bind — and the open threw. The Session selection and the main panel change before React renders, so deriving the on-screen Session from them removes that ordering dependency. The seat still measures pane room, because only its rendered kit can. + ## Consequences - A type is one static object plus one or two keyed seat registrations; its occurrence information is read through injected `useTabInfo()`. The framework grows no per-type API surface, and a type shipped from outside this repository imports only types from the Sidebar package. @@ -116,5 +118,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced ## Deferred - A navigation protocol beyond `sidebar://`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes. -- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session. +- Opening into a session other than the one on screen from the public face, which acts on the on-screen Session only; a tab's own actions already act on their tab's session. - A localized message when an open fails from the conversation; the failure is currently the thrown error's text. diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md index e4e8de5a38..ca10161a62 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md @@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar `openResource` 接受所有已声明资源形状的联合,`openTab` 接受为 `K` 声明的形状;正文按自己所服务的协议或 kind 收窄 `navigation.params`。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 `file` 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。 -除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。座位挂载期间发布其绑定——自己的会话、其 store 的 action 与其面;公开面上的命令作用于已挂载会话,没有已挂载会话面时抛错。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。 +除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。插件依据会话选择与主面板确定屏上会话——Conversation 占据主栏时的选中会话——并在 React 渲染这两者的变化之前将其发布为 `mounted`;公开面上的命令经该会话被收养的 store 作用于它,没有会话在屏上或其 store 尚未铸出时抛错。座位只上报它渲染时依据的事实:停靠套件测得的空间,以及框架宽度是否让面板自动全屏。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。 ### 地址 @@ -100,6 +100,8 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar **用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。** 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 `register()` 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 `id`——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。 +**由座位在自己的 effect 里发布绑定。** 最初的形式由已挂载的座位在每次提交后发布自己的会话、action 与面,离开时释放。React 按树序运行一次提交的被动 effect,而框架先渲染 Conversation 列、后渲染右栏,于是在切换会话或从全局面板返回的那次提交里,从自己 effect 打开内容的 Conversation 组件运行时没有任何座位已绑定——在离开的座位释放之后、到来的座位绑定之前——打开随之抛错。会话选择与主面板都在 React 渲染之前变化,从它们推导屏上会话便消除了这一顺序依赖。分栏空间仍由座位测量,因为只有它渲染出的套件能测。 + ## Consequences - 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 `useTabInfo()` 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。 @@ -116,5 +118,5 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar ## Deferred - `sidebar://` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。 -- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。 +- 从公开面往屏上会话之外的会话里打开;公开面只作用于屏上会话,而 tab 自己的动作已作用于其所在会话。 - 从会话区打开失败时的本地化提示;目前是抛错文本本身。 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index 56a6a1ef1e..683d995312 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml @@ -9,8 +9,8 @@ en: 36bf235f0821ef6a zh: a86bf07af03d54a3 /agent-note-desktop-primary-runtime/decision: - en: b3b104e314762322 - zh: 77478c007893201f + en: 3d2241842f5a2a83 + zh: 8617717f0337d76c /agent-note-desktop-primary-runtime/alternatives-considered: en: 566fd91c30cb0a0a zh: 70758ae2617f6ebb diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md index a1491ca0c4..9c961517b4 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md @@ -16,7 +16,7 @@ The application version, top-level interpreter/package-manager versions, Python The shared Node build entry downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. -macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. +macOS grants `com.apple.security.cs.allow-jit` to the standalone Node executable and LibreOffice helpers. The x64 Node executable additionally requires `com.apple.security.cs.allow-unsigned-executable-memory`: with JIT-only signing, the bundled x64 Node fails during startup under Rosetta in V8 `OS::SetPermissions`. ARM64 Node and LibreOffice helpers retain JIT-only permissions; the additional permission is confined to the interpreter that requires it. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion. diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md index 6447086a69..c2f1a4f105 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md @@ -16,7 +16,7 @@ Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为 共享 Node 构建入口下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 -macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 +macOS 向独立 Node 可执行文件和 LibreOffice 辅助程序授予 `com.apple.security.cs.allow-jit`。x64 Node 可执行文件还需要 `com.apple.security.cs.allow-unsigned-executable-memory`:仅授予 JIT 权限时,内置 x64 Node 在 Rosetta 下启动会在 V8 `OS::SetPermissions` 中失败。ARM64 Node 和 LibreOffice 辅助程序保持仅有 JIT 权限;额外权限仅授予需要它的解释器。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 68731414ce..0fe335a698 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -9,8 +9,8 @@ en: e22ee243b1503319 zh: b53c1a932d547f55 /agent-note-shared-developer-tool-settings/decision: - en: 62f71b49af7b9971 - zh: f0f12da4cb0c7fc0 + en: 6b94e4c856dbd92a + zh: e923b1118dfad7b6 /agent-note-shared-developer-tool-settings/alternatives-considered: en: df1b80e5b8d2a178 zh: 0136d16fc44d57bf diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md index 4d9f0fea89..23cdd7655c 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md @@ -12,9 +12,9 @@ Diagnostic views, preset selection and changed-file summaries add complexity to The settings-owned `ctx.configForms.developerTools` preference uses a shared entry form and exposes one accepted `ui-settings.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. -Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and refuses the Agent-preset cards' selection, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. +Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. -Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, the Agent-preset surfaces' `developerTools`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access. +Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, the new-session preset chip's `developerTools`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md index ac5829ed95..ba405d0f24 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -12,9 +12,9 @@ Status: implemented 设置域内的 `ctx.configForms.developerTools` 偏好对象使用共享条目表单,提供一个已接受的 `ui-settings.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 -关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口并拒绝 Agent 预设卡片的选择操作,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 +关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 -插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、Agent 预设各界面读取的 `developerTools`,以及 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。 +插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、新会话预设入口读取的 `developerTools`,以及 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。 ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml index 10306946dc..bd3c2101c7 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml @@ -9,8 +9,8 @@ en: 5853599207f6b3bb zh: d58b5e8e19d93a4f /agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/decision: - en: 721ea003ebaef8c7 - zh: e17be423bc4c7568 + en: f2f641fe1f271edf + zh: 321df1f4ea9e783d /agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/alternatives-considered: en: 3ad30b2574818a61 zh: d323adddd5065ae4 diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md index 372c8b3110..5cb6e4662a 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md @@ -12,7 +12,7 @@ The Windows coverage run (run 34085536250, job 101628739668) reports two teardow The [ACP test](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) awaits disposal under the execution lane’s test budget, then checks the actual child outcome. Failure cleanup awaits disposal and child completion before removing the private directory. A deferred exit observation proves that disposal cannot finish merely because termination was requested. The production EOF and termination grace periods remain unchanged. -The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted. +The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget, which `vitest.config.ts` applies to every inline project from `DSH_COVERAGE_TEST_TIMEOUT_MS` ([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) owns why a CLI flag cannot carry it). Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted. The [native Windows CI decision](../process/2026-08-08-native-windows-pull-request-ci.md) continues to own lane scheduling and budgets. This change only removes conflicting local deadlines and strengthens resource-lifetime assertions; it does not establish a Windows process-kill or filesystem defect. diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md index 8539664280..66a1cc0924 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md @@ -12,7 +12,7 @@ Windows 覆盖率运行 (run 34085536250, job 101628739668) 为测试和钩子 [ACP 测试](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) 在执行通道的测试预算内等待清理完成,然后检查真实子进程的结果。失败清理先等待释放和子进程完成,再删除私有目录。延迟的退出观察证明,清理不能仅因已请求终止而完成。生产环境的 EOF 与终止宽限期保持不变。 -[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。 +[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算;`vitest.config.ts` 从 `DSH_COVERAGE_TEST_TIMEOUT_MS` 把该预算施加到每个内联 project([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) 负责说明 CLI 标志为何无法承载它)。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。 [原生 Windows CI 决策](../process/2026-08-08-native-windows-pull-request-ci.zh.md) 继续负责通道调度和预算。本次改动仅移除冲突的局部期限并加强资源生命周期断言;它并不证明 Windows 进程终止或文件系统存在缺陷。 diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml index be1357732e..4eb8c76f25 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml @@ -9,8 +9,8 @@ en: 3f8862080b8c3c89 zh: 0e83c485f02c10f1 /agent-note-user-patch-tests-control-filesystem-event-delivery/decision: - en: a49b4a6e76725b7e - zh: 9813f60c40a19f83 + en: 601a3437a4332b69 + zh: 2b6bcc4636314c31 /agent-note-user-patch-tests-control-filesystem-event-delivery/alternatives-considered: en: 5d559ee291e2e15e zh: 828e7def587d71e8 diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md index 32ee3073dc..28fd99621a 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md @@ -12,7 +12,7 @@ The macOS Sandbox run (run 34238200206, job 102101292119) times out while waitin The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block. -The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees. +The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. Before their first write they close a second `fs.watch` directory handle, which on darwin blocks until libuv has rebuilt the FSEvents stream that Chokidar readiness does not wait for, and their polling deadline is the case's effective budget. The refresh test does not establish operating-system delivery guarantees. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md index bcd6f261c0..3a57b6cc88 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md @@ -12,7 +12,7 @@ macOS Sandbox 运行 (run 34238200206, job 102101292119) 在等待首次用户 p [用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。 -独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。 +独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。它们在首次写入前关闭第二个 `fs.watch` 目录句柄,该操作在 darwin 上会阻塞到 libuv 重建 FSEvents 流为止(Chokidar 的就绪信号并不等待该流建立);它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。 ## 考虑过的替代方案 diff --git a/.agents/skills/dsh-create-upgrade-guide/SKILL.md b/.agents/skills/dsh-create-upgrade-guide/SKILL.md new file mode 100644 index 0000000000..9723537e22 --- /dev/null +++ b/.agents/skills/dsh-create-upgrade-guide/SKILL.md @@ -0,0 +1,54 @@ +--- +name: dsh-create-upgrade-guide +description: Use when a deepseek-harness change breaks an externally perceptible surface (CLI, profiles, cordis.yml or settings keys, persisted user data, SDK or wire APIs, published package entry points), or when changing a surface that an unreleased upgrade guide already covers, to write or update docs/upgrade-guide/v//guide.md. +--- + +# DSH Create Upgrade Guide + +An upgrade guide tells a reader who runs release vT what to change to run the next release. Write it in the change that introduces the break, as soon as the break is recognized; do not defer it to release preparation. + +## Scope + +Write a guide only for breaks that someone outside the repository can observe after upgrading: + +- `dsh` commands, flags, and profile names; +- `cordis.yml`, patch, overlay, and settings keys or their accepted values; +- persisted user data: Session logs, SQLite stores, and user data directories; +- TypeScript and Python SDK exports, JSON-RPC, HTTP, and ACP messages; +- published package names and entry points. + +Pre-stable internal APIs whose consumers are all updated in the same change need no guide. A Session-format change still follows [type acknowledgements](../../../docs/cookbook/reviewing-persistence-type-changes.md); its guide states only the reader's actions and links that record. + +## Location + +`docs/upgrade-guide/v//guide.md` + +- `` is the `version` field of the root `package.json` when you write the guide, for example `v0.1.7-rc.2`. The guide describes the upgrade from that release to the next one. +- `` is a kebab-case name of the changed surface, for example `profile-flag-rename`. +- Each item directory holds exactly `guide.md`, its Chinese counterpart `guide.zh.md`, and the pairing record `guide.i18n.yaml`: no index, no attachments. + +## Maintenance + +While the root version is unchanged, the current version directory describes the delta from vT to current `master`. When a later change touches a surface a guide there covers, update that guide in the same change; delete the guide when the break is reverted. After the version bump, guides in older version directories are frozen; a new break goes into the new version directory. Never write a guide spanning more than one release. + +## Format + +Start from the [upgrade-guide template](../dsh-doc/templates/upgrade-guide.md). `verify-upgrade-guides` enforces: + +- frontmatter with exactly `kind: upgrade-guide` and a one-sentence `description` naming what breaks; add a key only together with its gate check, then backfill every guide; +- one `#` title, then exactly the `## Change` and `## Migration` sections in `guide.md`, and `## 变更` and `## 迁移` in `guide.zh.md`, in that order; +- at most 500 words in `guide.md`, frontmatter included. + +`Change` states the old and new behavior of the surface and who is affected. `Migration` lists ordered steps that name exact files, keys, commands, or symbols, and states how to confirm the migration worked. + +When a guide needs more than 500 words, the extra text belongs elsewhere: link source files or symbols instead of restating code, move rationale into an [Agent Note](../../notes/README.md), and turn repeated mechanical steps into a script or `dsh` command that the guide invokes. + +Follow [dsh-prose-standard](../dsh-prose-standard/SKILL.md). Write the Chinese counterpart in the same change under the [bilingual pairing rules](../../../docs/AGENTS.md), then record the pair with `pnpm run verify-translation-pairing --write docs/upgrade-guide/v//guide.md`. + +## Verify + +```sh +pnpm run verify-upgrade-guides +pnpm run verify-translation-pairing docs/upgrade-guide/v//guide.md +pnpm run verify-md-links +``` diff --git a/.agents/skills/dsh-doc/SKILL.md b/.agents/skills/dsh-doc/SKILL.md index 83eee5d526..5e32d4c07e 100644 --- a/.agents/skills/dsh-doc/SKILL.md +++ b/.agents/skills/dsh-doc/SKILL.md @@ -57,6 +57,7 @@ The `kind` frontmatter field selects exactly one README template. Every kind in - `persistence-change` → [templates/persistence-change.md](templates/persistence-change.md): a dated record in `docs/persistence-changes/` — acknowledge a detected type transition with per-root predecessors and generated after schemas. - `persistence-release` → [templates/persistence-release.md](templates/persistence-release.md): a pinned tag in `docs/persistence-changes/releases/` — compare reconstructed historical types without claiming compatibility acknowledgement. - `persistence-format` → [templates/persistence-format.md](templates/persistence-format.md): a historical Session format checkpoint in `docs/persistence-changes/historical-formats/`, with complete schemas; the current writer uses the existing catalog. +- `upgrade-guide` → [templates/upgrade-guide.md](templates/upgrade-guide.md): `docs/upgrade-guide/v//guide.md` and its Chinese sibling — migration steps for one externally perceptible break, owned by [dsh-create-upgrade-guide](../dsh-create-upgrade-guide/SKILL.md). Open the template before writing and follow its skeleton and rules; it states what the kind is, how the page is structured, and the fact checks each section owes. Add a new kind only together with a distinct template file, a documented repository position or declared owner, and a focused check that maps documents to it. @@ -67,7 +68,7 @@ These rules decide what a section may say. They apply to every authored human-fa - **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. In a package Summary, “what it is” means only its reader-visible capability, not its Cordis role, registrations, or internal components. Omit source identifiers unless the reader directly uses them in configuration, a command, or a public API. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does. - **Developer sections explain, never enumerate.** Folded implementation content covers the overall design concept, architecture, and hand-waving dataflow — enough to understand how the package works — and links code for exact detail. No full API catalogs, exhaustive column lists, event-payload enumerations, or JSDoc restatement inside the folds. - **Dev Note is the only slop zone.** Partial ideas, scratches, undecided directions, measured artifacts, and working hypotheses live only in the final Dev Note, marked explicitly non-authoritative. Every other section is polished, current-state prose. -- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. Agent Notes and postmortems retain their own historical scope. +- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. An `upgrade-guide` describes the change from its version directory's release to the next one. Agent Notes and postmortems retain their own historical scope. - **Use controlled technical English.** Give each sentence an explicit actor and one main action when ambiguity can change behavior. Reuse one term per concept, prefer direct verbs, split stacked instructions and conditions, and preserve modality and exceptions. Apply the non-certified, ASD-STE100-inspired discipline in [the page-style reference](references/style.md#controlled-technical-english). Do not force a shorter sentence when precision would fall. ## Quality criteria diff --git a/.agents/skills/dsh-doc/references/metadata-links-i18n.md b/.agents/skills/dsh-doc/references/metadata-links-i18n.md index 19ee5dde40..4ea476085c 100644 --- a/.agents/skills/dsh-doc/references/metadata-links-i18n.md +++ b/.agents/skills/dsh-doc/references/metadata-links-i18n.md @@ -29,7 +29,7 @@ kind: "package-reference" ## The kind system -`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order: +`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Files named `docs/upgrade-guide/v//guide.md` and their `guide.zh.md` siblings use `upgrade-guide`. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order: 1. The README is `packages/README.md` or `packages//README.md` → `package-group`. 2. The package manifest declares `dsh.bundle.patch` → `package-bundle`. @@ -45,6 +45,7 @@ kind: "package-reference" | `persistence-change` | `docs/persistence-changes/YYYY-MM-DD-slug.md` and its Chinese sibling | [persistence-change.md](../templates/persistence-change.md) | Historical acknowledgement: mechanically detected type changes, per-root predecessor references, compatibility decision, and generated after schemas. | | `persistence-release` | `docs/persistence-changes/releases/dsh-v*.md` and its Chinese sibling | [persistence-release.md](../templates/persistence-release.md) | Retrospective observation: pinned tag, adjacent before/after digests, actual version constants, and changed after schemas; no compatibility acknowledgement. | | `persistence-format` | `docs/persistence-changes/historical-formats/vN.md` and its Chinese sibling | [persistence-format.md](../templates/persistence-format.md) | Selected historical format: source evidence, complete root schemas and reachable types; the current writer uses the generated catalog. | +| `upgrade-guide` | `docs/upgrade-guide/v//guide.md` and its Chinese sibling | [upgrade-guide.md](../templates/upgrade-guide.md) | Upgrade path: one externally perceptible break from the directory's release to the next, with `Change` and `Migration` sections in at most 500 English words. | Before assigning `package-library` or `package-bundle`, inspect the facts: read `package.json` for `dsh.bundle.patch` and `src/index.ts` for the entry shape (`apply` export or a default service export is a plugin; a plain module API is a library). `dsh plugin --profile add ` installs any npm dependency, but the profile reconcile activates a layer only for a package that declares `dsh.bundle`; never present that command as an install path for a library or a plain plugin. The documentation check derives the expected kind from these same facts, rejects another value, and rejects `name`, `audience`, `tags`, and README-local `i18n` metadata. Add a new kind only with a distinct template, an unambiguous repository position or declared owner, and a focused check that maps documents to it. diff --git a/.agents/skills/dsh-doc/templates/upgrade-guide.md b/.agents/skills/dsh-doc/templates/upgrade-guide.md new file mode 100644 index 0000000000..d47f7dc4ae --- /dev/null +++ b/.agents/skills/dsh-doc/templates/upgrade-guide.md @@ -0,0 +1,29 @@ +# Template: upgrade-guide + +Use this kind for `docs/upgrade-guide/v//guide.md` and its Chinese sibling `guide.zh.md`, whose sections are `## 变更` and `## 迁移`. The tree has no folder index. [dsh-create-upgrade-guide](../../dsh-create-upgrade-guide/SKILL.md) owns scope, placement, maintenance, and length rules; `pnpm run verify-upgrade-guides` enforces them. + +## Frontmatter + +```yaml +--- +kind: upgrade-guide +description: "The externally perceptible surface that breaks and what replaces it." +--- +``` + +## Skeleton + +````markdown +# + +English | [中文](guide.zh.md) + +## Change + +State the old and new behavior of the surface and who observes it. + +## Migration + +1. Name the exact file, key, command, or symbol to change, with a before/after snippet when it is shorter than prose. +2. State how to confirm the migration worked. +```` diff --git a/AGENTS.md b/AGENTS.md index 37cfd8d280..fc94452a0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ Public APIs are pre-stable; update every consumer. Follow [version/status](docs/ Acknowledge [declared persistence-type changes](docs/cookbook/reviewing-persistence-type-changes.md). +Record each externally perceptible breaking change immediately in an [upgrade guide](.agents/skills/dsh-create-upgrade-guide/SKILL.md). + **Application launch.** Only `dsh` profiles launch supported Node apps; package bins, demos, and public SDK argv escapes are forbidden ([rule](docs/architecture.md#application-launch)). ## Repository layout diff --git a/README.i18n.yaml b/README.i18n.yaml index 7f314d80ef..5ce56b7a61 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -18,8 +18,8 @@ en: 819e3f7c144d0d64 zh: de815f040975dba7 /deepseek-harness/community-and-support: - en: 42848f543aff6e02 - zh: d46a07bdc6f36629 + en: 20acf6451fdc869b + zh: d06246dd019ecef7 /deepseek-harness/contributing: en: cabc2d3e9f8898bd zh: 83b6d238bae29ce3 diff --git a/README.md b/README.md index 423bccd7d9..7941e30f7b 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ pnpm dsh web - Submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions). - Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability. -- Join DeepSeek Harness Discord community. +- Join DeepSeek Harness Discord community. ## Contributing diff --git a/README.zh.md b/README.zh.md index c760f87e4a..3adc57ce89 100644 --- a/README.zh.md +++ b/README.zh.md @@ -48,19 +48,17 @@ pnpm dsh web - 通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。 - 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。 -- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。 +- 欢迎加入 DeepSeek Harness 企微群!扫描下方二维码填写入群问卷,小助手会定期发送入群邀请。 - - diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f9bd7afe4c..b863bd7258 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -139,7 +139,7 @@ External packages installed for runtime use or distributed inside the prebuilt b pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: -- `@earendil-works/pi-ai@0.85.1` — [`patches/@earendil-works__pi-ai@0.85.1.patch`](patches/@earendil-works__pi-ai@0.85.1.patch) +- `@earendil-works/pi-ai@0.87.1` — [`patches/@earendil-works__pi-ai@0.87.1.patch`](patches/@earendil-works__pi-ai@0.87.1.patch) - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) - `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch) - `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-defaults.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-defaults.patch.yml index ec5e74d781..a14bbb4d37 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-defaults.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-defaults.patch.yml @@ -16,7 +16,7 @@ baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL reasoning: low modelOverrides: - deepseek-v4-flash: + deepseek-flash: maxTokens: 1024 streamIdleTimeoutMs: 1000 @@ -25,7 +25,7 @@ agents: - id: main provider: deepseek - model: deepseek-v4-flash + model: deepseek-flash cwd: !!js process.cwd() - id: agent-instructions diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 33752b7812..d464f2863f 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -715,7 +715,7 @@ describe('headless stream-json snapshots', () => { expect(header?.config).toMatchInlineSnapshot(` { "maxTokens": 1024, - "model": "deepseek-v4-flash", + "model": "deepseek-flash", "provider": "deepseek", "reasoningEffort": "low", } diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 20819d01f5..730c1a37d4 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -12,8 +12,8 @@ en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 /deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies: - en: 31cbb7452be8e1ad - zh: 502ec556e2ec301e + en: 320733f93ede61a4 + zh: 33206c3c4753c802 /deepseek-harness-desktop/installation-ownership: en: 863ebc136313b73b zh: 6aa9a9925ceaa80a @@ -39,8 +39,8 @@ en: f0e345fd89868803 zh: fd2c775ad23fa2f0 /deepseek-harness-desktop/package/upload-updates: - en: c64d7fe2fe2534b7 - zh: 2188e1ae99ab4b33 + en: 72073b2e72590067 + zh: 63b1f9790395c37d /deepseek-harness-desktop/package/unsigned-windows-test-installer: en: cfd5780dd26a8e46 zh: f7a16b1ede793b7b diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7d61a39a0d..432a89b096 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -60,7 +60,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. | Decision | Why | Direct consequence | |---|---|---| @@ -235,9 +235,11 @@ When extraction fails, the installer writes a report with the 7-Zip result and i ### Upload updates +Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. + Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. -Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. +Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. `DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 12a7d53fea..1b0afbd7cd 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -62,7 +62,7 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| @@ -237,9 +237,11 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运 ### 上传更新 +固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 + test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 -Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 +Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 `DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8c8211d55a..4b27b70ae1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,10 @@ "package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir", "upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64", "upload:mac:x64": "tsx scripts/upload-target.ts mac-x64", - "upload:win:x64": "tsx scripts/upload-target.ts win-x64" + "upload:win:x64": "tsx scripts/upload-target.ts win-x64", + "upload:latest:mac:arm64": "tsx scripts/upload-target.ts mac-arm64 --latest", + "upload:latest:mac:x64": "tsx scripts/upload-target.ts mac-x64 --latest", + "upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest" }, "dependencies": { "electron-updater": "^6.8.9", diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index e14228cf69..439a4fa6e1 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -38,11 +38,12 @@ export interface DesktopUploadArtifact { readonly contents?: string } -/** A fully validated upload operation with channel metadata ordered last. */ +/** A validated installer or update upload, with any channel metadata ordered last. */ export interface DesktopUploadPlan { readonly environment: 'test' | 'production' readonly target: DesktopPackageTargetName readonly version: string + /** Update feed directory URL, or the single installer URL for a fixed download. */ readonly publicUrl: string readonly bucket: string readonly secretIdEnvName: string @@ -56,6 +57,8 @@ export interface DesktopUploadPlan { /** Filesystem and environment inputs used to validate one upload. */ export interface DesktopUploadPlanOptions { + /** Publish only the installer at its fixed download URL, replacing the previous object. */ + readonly latest?: boolean readonly environment?: NodeJS.ProcessEnv readonly repositoryRoot?: string readonly appRoot?: string @@ -171,7 +174,7 @@ function uploadArtifact( * Validate the completed package record, dsh version, update metadata, hashes, and target files. * @param targetName - Fixed platform and architecture selected by the upload command. * @param options - Optional filesystem roots and environment for tests or release automation. - * @returns An upload plan whose mutable channel metadata is the final entry. + * @returns A fixed installer upload or an update plan with channel metadata ordered last. */ export async function createDesktopUploadPlan( targetName: DesktopPackageTargetName, @@ -237,23 +240,26 @@ export async function createDesktopUploadPlan( const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) const artifacts: DesktopUploadArtifact[] = [] const binaryPrefix = update.binaryKeyPrefix + let installerArtifact: DesktopUploadArtifact if (target.platform === 'darwin') { const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`) const blockmapPath = await requireArtifact(artifactsRoot, `${base}.zip.blockmap`) + installerArtifact = uploadArtifact(dmgPath, binaryPrefix, 'application/x-apple-diskimage') artifacts.push( - uploadArtifact(dmgPath, binaryPrefix, 'application/x-apple-diskimage'), + installerArtifact, uploadArtifact(updaterPath, binaryPrefix, 'application/zip'), uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'), ) } else { const blockmapPath = await requireArtifact(artifactsRoot, `${base}.exe.blockmap`) - artifacts.push(uploadArtifact( + installerArtifact = uploadArtifact( updaterPath, binaryPrefix, 'application/vnd.microsoft.portable-executable', - )) + ) + artifacts.push(installerArtifact) artifacts.push(uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream')) } @@ -272,15 +278,17 @@ export async function createDesktopUploadPlan( const stableFilename = metadataFilename.replace('nightly', 'latest') artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` }) } + const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}` + const latestKey = `desktop/${latestFilename}` return { environment: update.environment, target: targetName, version: buildVersion, - publicUrl: update.publicUrl, + publicUrl: options.latest ? `${update.origin}/${latestKey}` : update.publicUrl, bucket: update.bucket, secretIdEnvName: update.secretIdEnvName, secretKeyEnvName: update.secretKeyEnvName, - artifacts, + artifacts: options.latest ? [{ ...installerArtifact, filename: latestFilename, key: latestKey }] : artifacts, ...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {}, ...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {}, } diff --git a/apps/desktop/scripts/desktop-upload-run.ts b/apps/desktop/scripts/desktop-upload-run.ts index d8361c9224..aa3cd49aa5 100644 --- a/apps/desktop/scripts/desktop-upload-run.ts +++ b/apps/desktop/scripts/desktop-upload-run.ts @@ -1,4 +1,4 @@ -/** Durable, credential-free evidence for test and production release uploads. */ +/** Durable, credential-free evidence for test and production Desktop uploads. */ import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' @@ -52,7 +52,7 @@ function streamedBody(artifact: DesktopUploadArtifact): Readable { } /** - * Upload an already validated release, flushing intent and response evidence around every PUT. + * Upload an already validated installer or update plan, flushing intent and response evidence around every PUT. * * Each object is sent as one streamed PUT with an explicit length and Content-MD5, which is also * what keeps the COS SDK's internal retry path unreachable: it repeats a request only when the diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index b5d35f1672..3d5fabdbed 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -24,11 +24,12 @@ function magic(path: string): string { * @param root - Self-contained production runtime without symlinks. * @param appId - Release application identifier. * @param expected - Required signing identity. - * @param cacheDirectory Optional content-addressed cache; requires the keychain-owned signing probe. + * @param arch - Target runtime architecture, independent of the signing host. + * @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe. * @returns Number of signed native files. */ export async function signMacOSRuntime( - root: string, appId: string, expected: MacOSSigningEnvironment, cacheDirectory?: string, + root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string, ): Promise { const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '') @@ -40,9 +41,12 @@ export async function signMacOSRuntime( const path = files[next++] if (path === undefined) return const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` - const needsJit = path === 'dependencies/node/bin/node' + const isNode = path === 'dependencies/node/bin/node' + const needsJit = isNode || /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path) - const entitlements = needsJit ? join(import.meta.dirname, 'jit-entitlements.plist') : undefined + const entitlementsFile = isNode && arch === 'x64' + ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist' + const entitlements = needsJit ? join(import.meta.dirname, entitlementsFile) : undefined const file = join(root, path) const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file)) if (cacheDirectory !== undefined && policy !== undefined && thin) { diff --git a/apps/desktop/scripts/node-x64-entitlements.plist b/apps/desktop/scripts/node-x64-entitlements.plist new file mode 100644 index 0000000000..74193b83fa --- /dev/null +++ b/apps/desktop/scripts/node-x64-entitlements.plist @@ -0,0 +1,3 @@ + + +com.apple.security.cs.allow-jitcom.apple.security.cs.allow-unsigned-executable-memory diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 377c300efb..442e01757e 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -29,7 +29,7 @@ import { import { signMacOSRuntime, } from './macos-runtime.ts' -import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopTargetPlatform, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts' import { selectOfficeEngine } from '../../../scripts/libreoffice-packages.mjs' @@ -130,7 +130,7 @@ async function main(): Promise { await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])) const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version) const targetName = resolveDesktopBuildTarget() - const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } + const target = { platform: process.platform, arch: desktopTargetPlatform(targetName).arch } const modules = join(BUILD_ROOT, 'node_modules') const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8')) const officeEngine = selectOfficeEngine(officeManifest, target) @@ -154,8 +154,8 @@ async function main(): Promise { throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) } if (process.platform === 'darwin') { - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache'))) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache'))) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) } await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT)) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))) diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts index 055a398419..2285512014 100644 --- a/apps/desktop/scripts/upload-target.ts +++ b/apps/desktop/scripts/upload-target.ts @@ -1,4 +1,4 @@ -/** Upload one validated Desktop release to its Tencent COS update directory. */ +/** Upload validated Desktop update artifacts or a fixed installer to Tencent COS. */ import { resolve } from 'node:path' import { parseArgs } from 'node:util' @@ -58,8 +58,14 @@ export function resolveCredentialUploadEnvironment( } } -async function main(): Promise { - const { positionals, values } = parseArgs({ args: process.argv.slice(2), allowPositionals: true, options: { +/** + * Upload the selected target and record a release tag only for production update uploads. + * @param args Target and upload options from the command line. + * @returns Resolves after upload and any release-tag attempt complete. + */ +export async function uploadDesktopTarget(args: string[]): Promise { + const { positionals, values } = parseArgs({ args, allowPositionals: true, options: { + latest: { type: 'boolean' }, 'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' }, } }) const target = positionals[0] @@ -79,14 +85,14 @@ async function main(): Promise { const environment = launcher ? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name) : fileEnvironment - const plan = await createDesktopUploadPlan(name, { environment }) + const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true }) const cos = createDesktopCos({ secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName), secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName), }) process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`) await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records')) - if (plan.environment === 'production') recordProductionRelease(plan) + if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan) } /** @@ -116,7 +122,7 @@ function recordProductionRelease(plan: DesktopUploadPlan): void { } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { - main().catch(() => { + uploadDesktopTarget(process.argv.slice(2)).catch(() => { process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n') process.exitCode = 1 }) diff --git a/apps/desktop/scripts/upload-with-credentials.ps1 b/apps/desktop/scripts/upload-with-credentials.ps1 index 178880ba47..4f146d59b8 100644 --- a/apps/desktop/scripts/upload-with-credentials.ps1 +++ b/apps/desktop/scripts/upload-with-credentials.ps1 @@ -14,6 +14,8 @@ Deployment that owns the credential pair; never inferred from the filename. Completed Desktop target to upload when Upload is explicitly selected. .PARAMETER Bucket COS bucket for an explicit upload. Not needed for the local credential check. +.PARAMETER Latest +Publish only the installer at its fixed download URL. Requires Upload. .PARAMETER Upload Authorize the existing target upload entry. Omit to check credentials locally. #> @@ -32,7 +34,9 @@ param( [ValidatePattern('^[a-z0-9-]+$')] [string]$Bucket, [Parameter(ParameterSetName = 'Publish', Mandatory = $true)] - [switch]$Upload + [switch]$Upload, + [Parameter(ParameterSetName = 'Publish')] + [switch]$Latest ) $ErrorActionPreference = 'Stop' @@ -87,6 +91,7 @@ try { if ($Upload) { $startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket $startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket" + if ($Latest) { $startInfo.Arguments += " --latest" } Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes." } else { $probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)" diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index bf4d68843c..f1e2097075 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { createRequire } from 'node:module' import { join } from 'node:path' @@ -8,12 +8,15 @@ import { load } from 'js-yaml' import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' import { desktopUpdateMetadataFilename } from '../scripts/desktop-auto-update-environment.mjs' import type { DesktopPackageTargetName } from '../scripts/package-target.ts' +import { createDesktopCos } from '../scripts/desktop-cos.ts' +import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts' +import { startCosLoopback } from './cos-loopback.ts' const temporaryDirectories: string[] = [] const TEST_ORIGIN = 'https://desktop-updates.example.com' -const TEST_BUCKET = 'test-download-bucket' +const TEST_BUCKET = 'test-download-bucket-1250000000' const RELEASE_ID = '0123456789abcdef0123456789abcdef' -const PRODUCTION_BUCKET = 'production-download-bucket' +const PRODUCTION_BUCKET = 'production-download-bucket-1250000000' const require = createRequire(import.meta.url) const { createBlockmap } = require('app-builder-lib/out/targets/differentialUpdateInfoBuilder.js') as { createBlockmap: (file: string, target: object, packager: { info: { emitArtifactBuildCompleted(event: object): Promise } }, @@ -110,6 +113,64 @@ afterEach(async () => { }) describe('desktop upload plan', () => { + it('uploads only the selected latest installer to each deployment using the existing COS transport', async () => { + const published = [] + for (const environment of ['production', 'test'] as const) { + for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) { + const paths = await fixture(target, '1.2.3', environment) + const plan = await createDesktopUploadPlan(target, { ...paths, latest: true }) + const loopback = await startCosLoopback() + try { + const cos = createDesktopCos({ secretId: 'fixture-id', secretKey: 'fixture-secret' }) + loopback.redirect(cos) + const directory = await uploadDesktopRelease(plan, cos, join(paths.appRoot, 'records')) + expect(plan.artifacts).toHaveLength(1) + const artifact = plan.artifacts[0]! + expect(loopback.requests).toHaveLength(1) + const request = loopback.requests[0]! + expect(request.method).toBe('PUT') + expect(request.path).toBe(`/${artifact.key}`) + expect(request.body).toEqual(await readFile(artifact.path)) + expect(JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))).toMatchObject({ + success: true, confirmedPuts: 1, publicReadback: 'not-performed', + }) + published.push({ environment, target, bucket: plan.bucket, publicUrl: plan.publicUrl, + filename: artifact.filename, key: artifact.key, contentType: artifact.contentType, + channelMetadata: artifact.channelMetadata }) + } finally { + await loopback.close() + } + } + } + await expect(`${JSON.stringify(published, null, 2)}\n`).toMatchFileSnapshot('./expected/latest-installer-uploads.json') + }) + + it('allows an explicitly selected production prerelease at the fixed installer URL', async () => { + const paths = await fixture('win-x64', '1.2.3-alpha.4', 'production') + const plan = await createDesktopUploadPlan('win-x64', { ...paths, latest: true }) + expect(plan.version).toBe('1.2.3-alpha.4') + expect(plan.artifacts).toHaveLength(1) + expect(plan.artifacts[0]).toMatchObject({ + path: join(paths.artifactsRoot, 'deepseek-harness-1.2.3-alpha.4-win-x64.exe'), + key: 'desktop/dsh-latest-windows-x64.exe', channelMetadata: false, + }) + }) + + it.each(['completion', 'deployment', 'checksum'] as const)('rejects invalid %s before planning a latest upload', async (failure) => { + const paths = await fixture('win-x64', '1.2.3', 'production') + if (failure === 'completion') await rm(join(paths.artifactsRoot, 'win-x64-release.json')) + if (failure === 'deployment') Object.assign(paths.environment, { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, + }) + if (failure === 'checksum') { + const path = join(paths.artifactsRoot, 'deepseek-harness-1.2.3-win-x64.exe') + await writeFile(path, Buffer.alloc((await readFile(path)).length)) + } + await expect(createDesktopUploadPlan('win-x64', { ...paths, latest: true })) + .rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u) + }) + it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { const paths = await fixture('win-x64', '1.2.3', 'production') const plan = await createDesktopUploadPlan('win-x64', paths) diff --git a/apps/desktop/tests/expected/latest-installer-uploads.json b/apps/desktop/tests/expected/latest-installer-uploads.json new file mode 100644 index 0000000000..b4b6d90b4f --- /dev/null +++ b/apps/desktop/tests/expected/latest-installer-uploads.json @@ -0,0 +1,62 @@ +[ + { + "environment": "production", + "target": "mac-arm64", + "bucket": "production-download-bucket-1250000000", + "publicUrl": "https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg", + "filename": "dsh-latest-macos-arm64.dmg", + "key": "desktop/dsh-latest-macos-arm64.dmg", + "contentType": "application/x-apple-diskimage", + "channelMetadata": false + }, + { + "environment": "production", + "target": "mac-x64", + "bucket": "production-download-bucket-1250000000", + "publicUrl": "https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg", + "filename": "dsh-latest-macos-x64.dmg", + "key": "desktop/dsh-latest-macos-x64.dmg", + "contentType": "application/x-apple-diskimage", + "channelMetadata": false + }, + { + "environment": "production", + "target": "win-x64", + "bucket": "production-download-bucket-1250000000", + "publicUrl": "https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe", + "filename": "dsh-latest-windows-x64.exe", + "key": "desktop/dsh-latest-windows-x64.exe", + "contentType": "application/vnd.microsoft.portable-executable", + "channelMetadata": false + }, + { + "environment": "test", + "target": "mac-arm64", + "bucket": "test-download-bucket-1250000000", + "publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-macos-arm64.dmg", + "filename": "dsh-latest-macos-arm64.dmg", + "key": "desktop/dsh-latest-macos-arm64.dmg", + "contentType": "application/x-apple-diskimage", + "channelMetadata": false + }, + { + "environment": "test", + "target": "mac-x64", + "bucket": "test-download-bucket-1250000000", + "publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-macos-x64.dmg", + "filename": "dsh-latest-macos-x64.dmg", + "key": "desktop/dsh-latest-macos-x64.dmg", + "contentType": "application/x-apple-diskimage", + "channelMetadata": false + }, + { + "environment": "test", + "target": "win-x64", + "bucket": "test-download-bucket-1250000000", + "publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe", + "filename": "dsh-latest-windows-x64.exe", + "key": "desktop/dsh-latest-windows-x64.exe", + "contentType": "application/vnd.microsoft.portable-executable", + "channelMetadata": false + } +] diff --git a/apps/desktop/tests/macos-cache-policy.spec.ts b/apps/desktop/tests/macos-cache-policy.spec.ts index cca57085e2..5344ab200f 100644 --- a/apps/desktop/tests/macos-cache-policy.spec.ts +++ b/apps/desktop/tests/macos-cache-policy.spec.ts @@ -51,7 +51,9 @@ it('includes certificate, identifier and entitlement contents in the cache polic const create = macOSCachePolicy('probe') const original = create('com.example.file', identity).policy expect(create('com.other.file', identity).policy).not.toBe(original) - expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy).not.toBe(original) + const jit = create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy + expect(jit).not.toBe(original) + expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/node-x64-entitlements.plist')).policy).not.toBe(jit) certificate = 'renewed-certificate' expect(macOSCachePolicy('probe')('com.example.file', identity).policy).not.toBe(original) }) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index 5ca9908c28..fdcb61600a 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -21,7 +21,7 @@ it('signs Mach-O files in their final locations and verifies each signature', as const path = root() writeFileSync(join(path, 'addon.node'), Buffer.from('cffaedfe00000000', 'hex')) writeFileSync(join(path, 'source.js'), 'export {}') - await expect(signMacOSRuntime(path, 'com.example.app', identity)).resolves.toBe(1) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).resolves.toBe(1) expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity, undefined) expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) }) @@ -38,7 +38,7 @@ it('awaits other signers before rejecting and permitting output cleanup', async await barrier }) let completed = false - const result = signMacOSRuntime(path, 'com.example.app', identity).catch((error: unknown) => { completed = true; return error }) + const result = signMacOSRuntime(path, 'com.example.app', identity, 'arm64').catch((error: unknown) => { completed = true; return error }) try { await ready expect(completed).toBe(false) @@ -47,16 +47,21 @@ it('awaits other signers before rejecting and permitting output cleanup', async expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity) }) -it('grants JIT only to the standalone Node interpreter and native Office helpers', async () => { +it.each(['arm64', 'x64'] as const)('selects %s Node entitlements and keeps helpers JIT-only', async (arch) => { const path = root() mkdirSync(join(path, 'dependencies/node/bin'), { recursive: true }) const node = join(path, 'dependencies/node/bin/node') const addon = join(path, 'addon.node') - const helpers = ['arm64', 'x64'].map(arch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${arch}`, 'bin/libreoffice-kit')) + const helpers = ['arm64', 'x64'].map(helperArch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${helperArch}`, 'bin/libreoffice-kit')) for (const helper of helpers) mkdirSync(join(helper, '..'), { recursive: true }) for (const file of [node, addon, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) - await signMacOSRuntime(path, 'com.example.app', identity) - for (const file of [node, ...helpers]) { + await signMacOSRuntime(path, 'com.example.app', identity, arch) + const nodePlist = join(import.meta.dirname, '../scripts', arch === 'x64' ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist') + expect(signMacOSRuntimeCode).toHaveBeenCalledWith(node, expect.any(String), identity, nodePlist) + const xml = readFileSync(nodePlist, 'utf8') + expect(xml).toMatch(/com\.apple\.security\.cs\.allow-jit<\/key>\s*/u) + expect(/com\.apple\.security\.cs\.allow-unsigned-executable-memory<\/key>\s*/u.test(xml)).toBe(arch === 'x64') + for (const file of helpers) { expect(signMacOSRuntimeCode).toHaveBeenCalledWith(file, expect.any(String), identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')) } diff --git a/apps/desktop/tests/upload-target.spec.ts b/apps/desktop/tests/upload-target.spec.ts new file mode 100644 index 0000000000..860d7867c2 --- /dev/null +++ b/apps/desktop/tests/upload-target.spec.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDesktopUploadPlan, type DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { tagDesktopRelease } from '../scripts/desktop-release-tag.ts' +import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts' +import { uploadDesktopTarget } from '../scripts/upload-target.ts' + +vi.mock('../scripts/desktop-package-environment.mjs', () => ({ loadDesktopPackageEnvironment: vi.fn() })) +vi.mock('../scripts/desktop-upload-plan.ts', () => ({ createDesktopUploadPlan: vi.fn() })) +vi.mock('../scripts/desktop-cos.ts', () => ({ createDesktopCos: vi.fn() })) +vi.mock('../scripts/desktop-upload-run.ts', () => ({ uploadDesktopRelease: vi.fn() })) +vi.mock('../scripts/desktop-release-tag.ts', async importOriginal => ({ + ...await importOriginal(), + tagDesktopRelease: vi.fn(), +})) + +afterEach(() => { + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe('desktop upload command', () => { + it.each([ + { environment: 'production', latest: true, tagged: false }, + { environment: 'production', latest: false, tagged: true }, + { environment: 'test', latest: true, tagged: false }, + { environment: 'test', latest: false, tagged: false }, + ] as const)('uploads $environment latest=$latest with tagged=$tagged', async ({ environment, latest, tagged }) => { + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const credentials = { DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' } + const plan: DesktopUploadPlan = { + environment, target: 'win-x64', version: '1.2.3-alpha.4', + publicUrl: 'https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe', + bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_TEST_COS_SECRET_ID', + secretKeyEnvName: 'DOWNLOAD_TEST_COS_SECRET_KEY', artifacts: [], commit: '0'.repeat(40), dirty: false, + } + vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(credentials) + vi.mocked(createDesktopUploadPlan).mockResolvedValue(plan) + vi.mocked(uploadDesktopRelease).mockResolvedValue('fixture-records') + vi.mocked(tagDesktopRelease).mockReturnValue({ status: 'created', tag: 'desktop-v1.2.3-alpha.4' }) + + await uploadDesktopTarget(['win-x64', ...latest ? ['--latest'] : []]) + + expect(createDesktopUploadPlan).toHaveBeenCalledWith('win-x64', { environment: credentials, latest }) + expect(uploadDesktopRelease).toHaveBeenCalledOnce() + expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan) + if (tagged) { + expect(tagDesktopRelease).toHaveBeenCalledWith(expect.objectContaining({ version: plan.version, commit: plan.commit })) + expect(vi.mocked(tagDesktopRelease).mock.invocationCallOrder[0]) + .toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!) + } + else { + expect(tagDesktopRelease).not.toHaveBeenCalled() + } + }) +}) diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts index 29688621ca..8951f4a01b 100644 --- a/apps/desktop/tests/upload-with-credentials.spec.ts +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1', const roots: string[] = [] const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` -async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-failure', deployment = 'production') { +async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure', deployment = 'production') { const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-')) roots.push(root) const credentialPath = join(root, 'credential.clixml') let entry = launcher let uploadArguments = '' - if (mode === 'upload' || mode === 'upload-failure') { + if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure') { const scripts = join(root, 'apps/desktop/scripts') await mkdir(scripts, { recursive: true }) entry = join(scripts, 'upload-with-credentials.ps1') @@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined) assert.equal(process.env.NODE_OPTIONS, undefined) assert.equal(process.argv[2], 'win-x64') - assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket']) + assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-latest' ? ", '--latest'" : ''}]) console.log('fixture-id fixture-secret') console.error('private service details fixture-secret') process.exit(${mode === 'upload-failure' ? 17 : 0}) `) - uploadArguments = ' -Upload -Target win-x64 -Bucket fixture-bucket' + uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-latest' ? ' -Latest' : ''}` } const setup = mode === 'missing' ? '' : ` [pscustomobject]@{ @@ -134,9 +134,9 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () = expect(result.output).not.toContain('child environment verified') }) - it.each(['upload', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { + it.each(['upload', 'upload-latest', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { const result = await check(mode) - expect(result.code, result.output).toBe(mode === 'upload' ? 0 : 1) + expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0) expect(result.output).toContain('[REDACTED] [REDACTED]') expect(result.output).not.toContain('fixture-id') expect(result.output).not.toContain('fixture-secret') diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index b1c57c60e7..a47cad63f6 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -115,7 +115,8 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { expect(slashPickerBox).not.toBeNull() expect(composerBox).not.toBeNull() expect(slashPickerBox!.width).toBeGreaterThanOrEqual(220) - expect(composerBox!.width - slashPickerBox!.width).toBeGreaterThan(1) + expect(slashPickerBox!.width).toBeCloseTo(composerBox!.width) + expect(slashPickerBox!.x).toBeCloseTo(composerBox!.x) expect(Math.abs(composerBox!.y - slashPickerBox!.y - slashPickerBox!.height - 4)).toBeLessThan(1) await slashPicker.getByRole('option', { name: 'Auto review EXP' }).click() diff --git a/apps/web/tests/caption-overlays.e2e.ts b/apps/web/tests/caption-overlays.e2e.ts index 9a28468a2f..d0713573c2 100644 --- a/apps/web/tests/caption-overlays.e2e.ts +++ b/apps/web/tests/caption-overlays.e2e.ts @@ -56,8 +56,12 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro for (const fullscreen of [false, true, false]) { await page.evaluate(value => document.documentElement.toggleAttribute('data-fullscreen', value), fullscreen) const margin = fullscreen ? 24 : 60 - await expect.poll(async () => (await plugin.boundingBox())?.y).toBe(margin) - expect((await plugin.boundingBox())?.height).toBe(600 - 2 * margin) + // The dialog is content-sized: it fills the available height only while its content needs it, + // and otherwise stays centered inside the same clearances. + await expect.poll(async () => { + const box = await plugin.boundingBox() + return box !== null && box.y >= margin && box.y + box.height <= 600 - margin + }, { timeout: 5_000 }).toBe(true) expect((await plugin.locator('..').locator(':scope > [aria-hidden="true"]').boundingBox())?.y).toBe(fullscreen ? 0 : 40) expect(await plugin.getByRole('button', { name: '关闭', exact: true }).isVisible()).toBe(true) expect(await plugin.getByRole('button', { name: '安装', exact: true }).isVisible()).toBe(true) diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts index 3331d36416..27619f0bf1 100644 --- a/apps/web/tests/declared-reasoning.e2e.ts +++ b/apps/web/tests/declared-reasoning.e2e.ts @@ -46,6 +46,7 @@ describe.skipIf(MODE === 'record').each([ models: [ { id: 'acme-think', name: 'Acme Think' }, { id: 'acme-swift', name: 'Acme Swift' }, + { id: 'acme-lite', name: 'Acme Lite' }, ].map(model => ({ ...model, reasoningEfforts: { off: null, high: 'high', max: 'ultra' }, @@ -148,8 +149,10 @@ describe.skipIf(MODE === 'record').each([ .evaluate(element => element === document.activeElement)).toBe(true) } else { await page.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ }).click() - await expect.poll(() => page.locator('[role="menuitemradio"][aria-checked="true"]') - .evaluate(element => element === document.activeElement)).toBe(true) + const focused = pane === 'model' + ? page.getByRole('searchbox', { name: '搜索模型…' }) + : page.locator('[role="menuitemradio"][aria-checked="true"]') + await expect.poll(() => focused.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true) } await trigger.click() await menu.waitFor({ state: 'detached' }) @@ -172,22 +175,27 @@ describe.skipIf(MODE === 'record').each([ await page.getByRole('menuitem', { name: /^模型/ }).click() const current = page.getByRole('menuitemradio', { name: 'Acme Think', exact: true }) const target = page.getByRole('menuitemradio', { name: 'Acme Swift', exact: true }) - await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true) + const search = page.getByRole('searchbox', { name: '搜索模型…' }) + await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true) + expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id')) - // Native mousedown must not blur the focused row and unmount the menu before click in WebKit. + // Native mousedown must not blur search and unmount the menu before click in WebKit. await target.getByText('Acme Swift', { exact: true }).hover() await page.mouse.down() try { await expect.poll(() => menu.count()).toBe(1) - await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true) + await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true) await page.getByText('Acme Gateway', { exact: true }).hover() } finally { await page.mouse.up() } // Later selection counts also include any request from this cancelled press. expect(selections).toBe(0) - await page.keyboard.press('ArrowDown') - await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true) + await search.press('ArrowUp') + expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id')) + await search.press('ArrowDown') + expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id')) + await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true) await page.keyboard.press('Escape') await page.keyboard.press('Shift+Tab') await menu.waitFor({ state: 'detached' }) @@ -222,16 +230,20 @@ describe.skipIf(MODE === 'record').each([ }, { times: 1 }) await trigger.click() await page.getByRole('menuitem', { name: /^模型/ }).click() - await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true) + await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true) + expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id')) await current.click() await page.getByRole('alert').waitFor() expect(selections).toBe(3) - await compareOrRefreshGolden(POINTER_EXPECTED, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE) + await compareOrRefreshGolden(POINTER_EXPECTED, + await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), MODE) await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Tab') - await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('ArrowUp') - await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true) + await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true) + expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id')) + await search.press('ArrowUp') + expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id')) + expect(await search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true) await page.keyboard.press('Escape') await page.keyboard.press('Escape') await menu.waitFor({ state: 'detached' }) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 6543428836..52a3fd09a3 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -80,7 +80,13 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).waitFor() await compareOrRefreshGolden(join(EXPECTED, 'picker.expected.md'), - await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode()) + await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), webSnapshotMode()) + await page.keyboard.press('Escape') + await page.getByRole('menuitem', { name: /推理等级/ }).click() + const effortWeights = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row => + getComputedStyle(row.querySelector('span span')!).fontWeight, + )) + expect(new Set(effortWeights)).toEqual(new Set(['400'])) expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index aa17206061..d87bf4ce4d 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -13,7 +13,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' @@ -73,7 +73,7 @@ describe('web e2e: the composer model switch is the default for later sessions', displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1', - models: [{ id: MODEL, name: 'Acme Large' }], + models: [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }], }, }, }) @@ -92,6 +92,223 @@ describe('web e2e: the composer model switch is the default for later sessions', await scaffold?.close() }) + it('keeps command popup search borders transparent in both palettes', async () => { + const composer = page.locator('[data-composer-input]').first() + await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click() + const commandMenuBounds = await page.locator('[data-trigger-menu]').boundingBox() + await page.getByRole('option', { name: /^模型/ }).click() + const search = page.getByRole('textbox', { name: '筛选选项', exact: true }) + await search.waitFor() + try { + const popupBounds = await page.locator('[aria-label="/model 选项"]').boundingBox() + expect(popupBounds).not.toBeNull() + expect(commandMenuBounds).not.toBeNull() + expect(popupBounds!.width).toBeCloseTo(commandMenuBounds!.width) + expect(popupBounds!.x).toBeCloseTo(commandMenuBounds!.x) + expect(await search.getAttribute('placeholder')).toBe('搜索模型…') + await page.getByRole('option').first().waitFor() + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/default-model/command-picker.expected.md', import.meta.url)), + await captureStableAria(page, '[aria-label="/model 选项"]', scaffold.workspaceCwd), + webSnapshotMode(), + ) + await search.fill('no-model-matches') + await page.getByText('没有匹配的模型。', { exact: true }).waitFor() + await search.fill('') + const borders = await search.evaluate((input) => { + const body = input.ownerDocument.body + const previousTheme = body.getAttribute('data-ds-dark-theme') + try { + return [false, true].map((dark) => { + body.toggleAttribute('data-ds-dark-theme', dark) + return getComputedStyle(input).borderColor + }) + } finally { + if (previousTheme === null) body.removeAttribute('data-ds-dark-theme') + else body.setAttribute('data-ds-dark-theme', previousTheme) + } + }) + expect(borders).toEqual(['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)']) + } finally { + await search.press('Escape') + await composer.fill('') + } + }) + + it('shares provider order, fuzzy result order and sticky header material between both model pickers', async () => { + const readGroups = (surface: Locator, role: 'option' | 'menuitemradio') => surface.locator('[data-menu-group]') + .evaluateAll((groups, rowRole) => groups.map(group => ({ + label: group.querySelector('[data-menu-group-heading]')!.textContent, + rows: [...group.querySelectorAll(`[role="${rowRole}"]`)].map(row => row.textContent), + })), role) + const checkSticky = async (surface: Locator, viewport: Locator) => { + // Bound only this test's list viewport so native scrolling can cross a heading. + await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 }) + const heading = surface.locator('[data-menu-group-heading]').first() + await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull() + const clear = await heading.evaluate(node => getComputedStyle(node).backgroundColor) + expect(clear).toBe('rgba(0, 0, 0, 0)') + await viewport.evaluate((node) => { node.scrollTop = 10 }) + await expect.poll(() => heading.getAttribute('data-stuck')).toBe('') + const stuck = await heading.evaluate((node) => { + const style = getComputedStyle(node) + return { fill: style.backgroundColor, radius: style.borderRadius, font: style.fontSize, padding: style.padding } + }) + await viewport.evaluate((node) => { node.scrollTop = 0 }) + await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull() + return stuck + } + try { + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + const menu = page.getByRole('group', { name: '模型与推理等级', exact: true }) + const menuSearch = page.getByRole('searchbox', { name: '搜索模型…' }) + const order = await readGroups(menu, 'menuitemradio') + const sticky = await checkSticky(menu, menu.getByRole('menu', { name: '模型', exact: true })) + await menuSearch.fill(' ACMLG ') + const filtered = await readGroups(menu, 'menuitemradio') + await menuSearch.press('Escape') + await page.keyboard.press('Escape') + await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click() + await page.getByRole('option', { name: /^模型/ }).click() + const popup = page.locator('[aria-label="/model 选项"]') + await popup.getByRole('option').first().waitFor() + expect(await readGroups(popup, 'option')).toEqual(order) + expect(await checkSticky(popup, popup.getByRole('listbox'))).toEqual(sticky) + const popupSearch = page.getByRole('textbox', { name: '筛选选项', exact: true }) + await popupSearch.fill(' ACMLG ') + expect(await readGroups(popup, 'option')).toEqual(filtered) + expect(await popupSearch.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + } finally { + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + await page.locator('[data-composer-input]').first().fill('') + } + }) + + it('hides search only in the button menu when four models remain', async () => { + const trigger = page.getByRole('button', { name: /^选择模型/ }) + const search = page.getByRole('searchbox', { name: '搜索模型…' }) + const setModels = (expanded: boolean) => scaffold.ctx.settings.update('llm-pi-ai', { + providers: { [ROUTE]: { + displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1', + models: expanded ? [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }] + : [{ id: MODEL, name: 'Acme Large' }], + } }, + }) + try { + await setModels(false) + await trigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(4) + expect(await search.count()).toBe(0) + const current = page.getByRole('menuitemradio', { name: 'Origin Large', exact: true }) + await expect.poll(() => current.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + const rows = page.getByRole('menuitemradio') + const currentId = await current.getAttribute('id') + for (let index = 0; index < 4; index++) await page.keyboard.press('ArrowDown') + expect(await page.evaluate(() => document.activeElement?.id)).toBe(currentId) + await page.keyboard.press('Enter') + await expect.poll(() => trigger.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + expect(await trigger.evaluate(node => getComputedStyle(node).boxShadow)).toBe('none') + await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click() + await page.getByRole('option', { name: /^模型/ }).click() + const commandSearch = page.getByRole('textbox', { name: '筛选选项', exact: true }) + await commandSearch.waitFor() + expect(await commandSearch.getAttribute('placeholder')).toBe('搜索模型…') + await commandSearch.press('Escape') + await page.locator('[data-composer-input]').first().fill('') + expect(await rows.count()).toBe(0) + } finally { + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + await setModels(true) + await trigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(5) + await search.waitFor() + await search.press('Escape') + await page.keyboard.press('Escape') + } + }) + + it('paints only pinned provider headings across themes, filtering, and reopening', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-model-sticky-headings')) + const originalViewport = page.viewportSize()! + const attributes = await page.evaluate(() => ({ + platform: document.documentElement.getAttribute('data-platform'), + theme: document.body.getAttribute('data-ds-dark-theme'), + })) + const surface = page.getByRole('group', { name: '模型与推理等级', exact: true }) + const trigger = page.getByRole('button', { name: /^选择模型/ }) + const search = page.getByRole('searchbox', { name: '搜索模型…' }) + try { + await page.setViewportSize({ width: 1680, height: 220 }) + await trigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + const scroller = page.getByRole('menu', { name: '模型', exact: true }) + const headings = surface.locator('section[role="group"] > div') + const readPinned = () => headings.evaluateAll(nodes => nodes.map(node => node.hasAttribute('data-stuck'))) + const resetScroll = async (): Promise => { + await scroller.evaluate((node) => { node.scrollTop = 0 }) + await expect.poll(readPinned).toEqual([false, false, false]) + } + for (const platform of ['web', 'win32', 'darwin']) { + for (const dark of [false, true]) { + await page.evaluate(({ platform, dark }) => { + if (platform === 'web') document.documentElement.removeAttribute('data-platform') + else document.documentElement.setAttribute('data-platform', platform) + document.body.toggleAttribute('data-ds-dark-theme', dark) + }, { platform, dark }) + await resetScroll() + expect(await headings.evaluateAll(nodes => [...new Set(nodes.map(node => getComputedStyle(node).backgroundColor))])) + .toEqual(['rgba(0, 0, 0, 0)']) + const secondTop = await scroller.evaluate((node) => { + const second = node.querySelectorAll('section')[1]! + return Math.ceil(second.getBoundingClientRect().top - node.getBoundingClientRect().top) + 1 + }) + expect(await scroller.evaluate(node => node.scrollHeight - node.clientHeight)).toBeGreaterThanOrEqual(secondTop) + await scroller.evaluate((node) => { node.scrollTop = 10 }) + await expect.poll(readPinned).toEqual([true, false, false]) + expect(await headings.first().evaluate(node => getComputedStyle(node).backgroundColor)) + .toBe(dark ? 'rgba(48, 49, 54, 0.94)' : 'rgba(248, 249, 250, 0.94)') + await scroller.evaluate((node, top) => { node.scrollTop = top }, secondTop) + await expect.poll(readPinned).toEqual([false, true, false]) + await scroller.evaluate((node) => { node.scrollTop = 10 }) + await expect.poll(readPinned).toEqual([true, false, false]) + await resetScroll() + } + } + await scroller.evaluate((node) => { node.scrollTop = 10 }) + await expect.poll(readPinned).toEqual([true, false, false]) + await search.fill('Origin Large') + await expect.poll(() => headings.count()).toBe(1) + await expect.poll(readPinned).toEqual([false]) + await page.getByRole('button', { name: '清除搜索', exact: true }).click() + await expect.poll(readPinned).toEqual([false, false, false]) + await search.fill('zzzz') + await expect.poll(() => surface.locator('[data-stuck]').count()).toBe(0) + await page.getByRole('button', { name: '清除搜索', exact: true }).click() + await expect.poll(readPinned).toEqual([false, false, false]) + await search.press('Escape') + await page.keyboard.press('Escape') + await trigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + expect(await search.inputValue()).toBe('') + await resetScroll() + } finally { + if (await surface.count()) await search.press('Escape') + if (await trigger.getAttribute('aria-expanded') === 'true') await page.keyboard.press('Escape') + await page.setViewportSize(originalViewport) + await page.evaluate(({ platform, theme }) => { + if (platform === null) document.documentElement.removeAttribute('data-platform') + else document.documentElement.setAttribute('data-platform', platform) + if (theme === null) document.body.removeAttribute('data-ds-dark-theme') + else document.body.setAttribute('data-ds-dark-theme', theme) + }, attributes) + } + }) + it('writes the switched model as the default and leaves a logged session alone', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model')) // A session that has already run a turn, spelled as the fact a turn @@ -106,7 +323,109 @@ describe('web e2e: the composer model switch is the default for later sessions', await trigger.waitFor({ timeout: 15_000 }) expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400') await trigger.click() - await page.getByRole('menuitem', { name: /模型/ }).click() + const modelMenuBounds = await page.getByRole('menu', { name: '模型与推理等级', exact: true }).boundingBox() + const composerBounds = await page.locator('[data-composer-card]').first().boundingBox() + expect(modelMenuBounds!.width).toBeLessThan(composerBounds!.width) + const modelCell = page.getByRole('menuitem', { name: /模型/ }) + await trigger.press('ArrowDown') + expect(await modelCell.evaluate(element => element.matches(':focus-visible'))).toBe(true) + expect(await modelCell.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + const focusedBackground = await modelCell.evaluate(element => getComputedStyle(element).backgroundColor) + await trigger.focus() + await modelCell.hover() + expect(await modelCell.evaluate(element => getComputedStyle(element).backgroundColor)).toBe(focusedBackground) + await modelCell.click() + const search = page.getByRole('searchbox', { name: '搜索模型…' }) + const rowIds = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row => row.id)) + const initialHighlight = rowIds.indexOf(await search.getAttribute('aria-activedescendant') ?? '') + expect(initialHighlight).toBeGreaterThanOrEqual(0) + for (let step = 1; step <= rowIds.length + 1; step++) { + await search.press('ArrowDown') + expect(await search.getAttribute('aria-activedescendant')).toBe(rowIds[(initialHighlight + step) % rowIds.length]) + expect(await search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true) + } + await search.press('ArrowUp') + expect(await search.getAttribute('aria-activedescendant')).toBe(rowIds[initialHighlight]) + const searchStyle = await search.evaluate((input) => { + const wrapper = input.parentElement! + const caption = input.ownerDocument.createElement('span') + caption.style.color = 'var(--dsw-alias-label-caption)' + wrapper.append(caption) + try { + return { + placeholderColor: getComputedStyle(input, '::placeholder').color, + captionColor: getComputedStyle(caption).color, + iconCount: wrapper.querySelectorAll('svg').length, + background: getComputedStyle(wrapper).backgroundColor, + radius: getComputedStyle(wrapper).borderRadius, + padding: getComputedStyle(wrapper).padding, + fontSize: getComputedStyle(input).fontSize, + } + } finally { + caption.remove() + } + }) + expect(searchStyle.placeholderColor).toBe(searchStyle.captionColor) + expect(searchStyle.iconCount).toBe(0) + expect(searchStyle.background).toBe('rgba(0, 0, 0, 0)') + expect(searchStyle.radius).toBe('12px') + expect(searchStyle.padding).toBe('5px 7px') + expect(searchStyle.fontSize).toBe('12px') + const modelWeights = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row => + getComputedStyle(row.querySelector('span span')!).fontWeight, + )) + expect(new Set(modelWeights)).toEqual(new Set(['400'])) + const headingPalettes = await page.getByRole('group', { name: '模型与推理等级', exact: true }).evaluate((menu) => { + const html = menu.ownerDocument.documentElement + const body = menu.ownerDocument.body + const previousPlatform = html.getAttribute('data-platform') + const previousTheme = body.getAttribute('data-ds-dark-theme') + try { + return ['web', 'win32', 'darwin'].flatMap(platform => [false, true].map((dark) => { + if (platform === 'web') html.removeAttribute('data-platform') + else html.setAttribute('data-platform', platform) + body.toggleAttribute('data-ds-dark-theme', dark) + const headings = [...menu.querySelectorAll('section[role="group"] > div')] + return { + platform, dark, + headingFills: [...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))], + positions: [...new Set(headings.map(heading => getComputedStyle(heading).position))], + headingRadii: [...new Set(headings.map(heading => getComputedStyle(heading).borderRadius))], + optionRadius: getComputedStyle(menu.querySelector('[role="menuitemradio"]')!).borderRadius, + menuFill: getComputedStyle(menu.querySelector(':scope > [aria-hidden="true"]')!).backgroundColor, + searchBorder: getComputedStyle(menu.querySelector('input')!.parentElement!).borderColor, + } + })) + } finally { + if (previousPlatform === null) html.removeAttribute('data-platform') + else html.setAttribute('data-platform', previousPlatform) + if (previousTheme === null) body.removeAttribute('data-ds-dark-theme') + else body.setAttribute('data-ds-dark-theme', previousTheme) + } + }) + for (const palette of headingPalettes) { + expect(palette.headingFills, `${palette.platform}, dark=${String(palette.dark)}`) + .toEqual(['rgba(0, 0, 0, 0)']) + expect(palette.menuFill).toBe(palette.dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)') + expect(palette.searchBorder).toBe('rgba(0, 0, 0, 0)') + expect(palette.positions).toEqual(['sticky']) + expect(palette.headingRadii).toEqual([palette.platform === 'darwin' ? '0px' : palette.optionRadius]) + } + await search.fill('zzzz') + await page.getByText('没有匹配的模型。', { exact: true }).waitFor() + await page.getByRole('button', { name: '清除搜索', exact: true }).click() + expect(await search.inputValue()).toBe('') + expect(await search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true) + expect(await page.getByRole('menuitemradio').count()).toBe(modelWeights.length) + expect(await page.getByRole('button', { name: '清除搜索', exact: true }).count()).toBe(0) + await search.fill(' ACMLG ') + expect(await page.getByRole('menuitemradio').allTextContents()).toEqual(['Acme Large']) + expect(await page.getByRole('group', { name: 'Origin Gateway', exact: true }).count()).toBe(0) + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/default-model/search.expected.md', import.meta.url)), + await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), + webSnapshotMode(), + ) const entered = Promise.withResolvers() const release = Promise.withResolvers() const blocked = scaffold.ctx.hmr.runExclusive(async () => { @@ -115,9 +434,15 @@ describe('web e2e: the composer model switch is the default for later sessions', }) try { await entered.promise - await page.getByRole('menuitemradio', { name: 'Acme Large' }).click() + await search.press('ArrowDown') + await expect.poll(() => search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true) + expect(await search.getAttribute('aria-activedescendant')) + .toBe(await page.getByRole('menuitemradio', { name: 'Acme Large' }).getAttribute('id')) + await page.keyboard.press('Enter') await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false') await expect.poll(() => trigger.textContent()).toContain('Acme Large') + await expect.poll(() => trigger.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true) + expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).toBe('none') expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL }) const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/background-save.expected.md', import.meta.url)), aria, webSnapshotMode()) @@ -143,6 +468,10 @@ describe('web e2e: the composer model switch is the default for later sessions', .toEqual({ provider: ROUTE, model: MODEL }) // ...while the one holding a logged route keeps deriving from its log. expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL }) + await trigger.press('Tab') + await trigger.focus() + expect(await trigger.evaluate(element => element.matches(':focus-visible'))).toBe(true) + expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).not.toBe('none') expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index 40481128d2..254f62ff2a 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -26,17 +26,14 @@ it('persists developer tools in the Host settings document and restores the acce await page.getByRole('button', { name: 'Agent presets', exact: true }).click() await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1) - // The page owns no selection switch: only the General one gates the choice. + // The page owns no selection switch; the cards and the Creator entry stay usable with Coding Tools off. const section = page.locator('section') .filter({ has: page.getByRole('heading', { name: 'Agent presets', exact: true }) }) - const refused = section.getByRole('button', { name: /^Turn on Coding Tools in General settings to choose a default: / }) + const setDefault = section.getByRole('button', { name: 'Set as new task default: Minimal mode' }) expect(await section.getByRole('switch').count()).toBe(0) - await expect.poll(() => refused.count()).toBeGreaterThan(0) + await expect.poll(() => setDefault.isEnabled()).toBe(true) + expect(await section.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).isEnabled()).toBe(true) await scaffold.ctx.settings.update('ui-settings', { enabled: true }) - await expect.poll(() => section.getByRole('button', { name: 'Set as new task default: Minimal mode' }).count()).toBe(1) - await expect.poll(() => refused.count()).toBe(0) - expect(await section.getByRole('switch').count()).toBe(0) - await scaffold.ctx.settings.update('ui-settings', { enabled: false }) - await expect.poll(() => refused.count()).toBeGreaterThan(0) + await expect.poll(() => setDefault.isEnabled()).toBe(true) expect(await section.getByRole('switch').count()).toBe(0) }) diff --git a/apps/web/tests/expected/declared-reasoning/pointer-menu.expected.md b/apps/web/tests/expected/declared-reasoning/pointer-menu.expected.md index 21a27082cd..2a5271a974 100644 --- a/apps/web/tests/expected/declared-reasoning/pointer-menu.expected.md +++ b/apps/web/tests/expected/declared-reasoning/pointer-menu.expected.md @@ -1,9 +1,12 @@ -- menu "模型与推理等级": - - group "DeepSeek": - - text: DeepSeek - - menuitemradio "DeepSeek-V4-Flash" - - menuitemradio "DeepSeek-V4-Flash-Vision-Exp" - - group "Acme Gateway": - - text: Acme Gateway - - menuitemradio "Acme Think" - - menuitemradio "Acme Swift" [checked] +- group "模型与推理等级": + - searchbox "搜索模型…" + - menu "模型": + - group "DeepSeek": + - text: DeepSeek + - menuitemradio "DeepSeek-V4-Flash" + - menuitemradio "DeepSeek-V4-Flash-Vision-Exp" + - group "Acme Gateway": + - text: Acme Gateway + - menuitemradio "Acme Think" + - menuitemradio "Acme Swift" [checked] + - menuitemradio "Acme Lite" diff --git a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md index 37066e17e2..f256339fcd 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md @@ -1,5 +1,6 @@ -- menu "模型与推理等级": - - group "DeepSeek": - - text: DeepSeek - - menuitemradio "Messages Flash" [checked] - - menuitemradio "DeepSeek-V4-Pro" +- group "模型与推理等级": + - menu "模型": + - group "DeepSeek": + - text: DeepSeek + - menuitemradio "Messages Flash" [checked] + - menuitemradio "DeepSeek-V4-Pro" diff --git a/apps/web/tests/expected/default-model/command-picker.expected.md b/apps/web/tests/expected/default-model/command-picker.expected.md new file mode 100644 index 0000000000..22cfbf980f --- /dev/null +++ b/apps/web/tests/expected/default-model/command-picker.expected.md @@ -0,0 +1,14 @@ +- textbox "筛选选项": + - /placeholder: 搜索模型… +- listbox "/model 匹配项": + - group "DeepSeek": + - text: DeepSeek + - option "DeepSeek-V4-Flash" + - option "DeepSeek-V4-Flash-Vision-Exp" + - group "Origin Gateway": + - text: Origin Gateway + - option "Origin Large" + - group "Acme Gateway": + - text: Acme Gateway + - option "Acme Large" [selected] + - option "Acme Small" diff --git a/apps/web/tests/expected/default-model/search.expected.md b/apps/web/tests/expected/default-model/search.expected.md new file mode 100644 index 0000000000..9b2d48e3c8 --- /dev/null +++ b/apps/web/tests/expected/default-model/search.expected.md @@ -0,0 +1,7 @@ +- group "模型与推理等级": + - searchbox "搜索模型…": ACMLG + - button "清除搜索" + - menu "模型": + - group "Acme Gateway": + - text: Acme Gateway + - menuitemradio "Acme Large" diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index 77b4593b90..9bc10efc51 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -27,8 +27,8 @@ - button "编辑快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 - - switch "在使用官方模型 API 时上传 Session Log" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" + - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 + - switch "在使用官方模型 API 时上传 Session Log" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index c4fd5e5eff..7d839e4a25 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -27,8 +27,8 @@ - button "Edit shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. - - switch "Upload Session Log when using the official model API" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" + - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. + - switch "Upload Session Log when using the official model API" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index 4ddb04a13b..5c2b5c0b72 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -33,6 +33,7 @@ - option "groq" - option "huggingface" - option "kimi-coding" + - option "meta" - option "minimax" - option "minimax-cn" [selected] - option "mistral" @@ -47,6 +48,7 @@ - option "qwen-token-plan" - option "qwen-token-plan-cn" - option "qwen-token-plan-individual" + - option "radius" - option "together" - option "vercel-ai-gateway" - option "xai" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index f955841948..e65df1f213 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -37,6 +37,7 @@ - option "groq" - option "huggingface" - option "kimi-coding" + - option "meta" - option "minimax" - option "minimax-cn" [selected] - option "mistral" @@ -51,6 +52,7 @@ - option "qwen-token-plan" - option "qwen-token-plan-cn" - option "qwen-token-plan-individual" + - option "radius" - option "together" - option "vercel-ai-gateway" - option "xai" diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md index 80e2756b5a..9999ca0af5 100644 --- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -7,5 +7,5 @@ - text: slow-package - button "插件安装引导和示例" - button "安装源 默认安装源" - - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 + - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。 - button "安装" diff --git a/apps/web/tests/expected/plugin-install-github/another-way.expected.md b/apps/web/tests/expected/plugin-install-github/another-way.expected.md index 0932737ec2..66a9943dd0 100644 --- a/apps/web/tests/expected/plugin-install-github/another-way.expected.md +++ b/apps/web/tests/expected/plugin-install-github/another-way.expected.md @@ -7,16 +7,8 @@ - button "安装源 中国大陆镜像源" - list: - listitem: - - text: 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例: + - text: 填入插件 npm 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例: - code: dsh-plugin-whale-pet - button "填入示例 dsh-plugin-whale-pet": 填入示例 - - listitem: - - text: GitHub 仓库地址 插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。 示例: - - code: https://github.com/author/dsh-plugin - - button "填入示例 https://github.com/author/dsh-plugin": 填入示例 - - listitem: - - text: 本地插件目录 本机上插件目录的绝对路径,适用于自行开发或已下载的插件。 示例: - - code: /Users/name/my-plugin - - button "填入示例 /Users/name/my-plugin": 填入示例 - - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 + - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。 - button "安装" [disabled] diff --git a/apps/web/tests/expected/plugin-install-github/mirror.expected.md b/apps/web/tests/expected/plugin-install-github/mirror.expected.md index e414fac6ce..4f672b66d5 100644 --- a/apps/web/tests/expected/plugin-install-github/mirror.expected.md +++ b/apps/web/tests/expected/plugin-install-github/mirror.expected.md @@ -5,5 +5,5 @@ - /placeholder: 例如 dsh-plugin-whale-pet - button "插件安装引导和示例" - button "安装源 中国大陆镜像源" - - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 + - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。 - button "安装" [disabled] diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index ad3348ea5a..0883c044fb 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -27,8 +27,8 @@ - button "Edit shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. - - switch "Upload Session Log when using the official model API" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" + - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. + - switch "Upload Session Log when using the official model API" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index ad3348ea5a..0883c044fb 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -27,8 +27,8 @@ - button "Edit shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. - - switch "Upload Session Log when using the official model API" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" + - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. + - switch "Upload Session Log when using the official model API" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 0f17fa643f..562592ab7d 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -27,8 +27,8 @@ - button "编辑快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 - - switch "在使用官方模型 API 时上传 Session Log" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" + - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 + - switch "在使用官方模型 API 时上传 Session Log" - text: 当前版本:{{version}} diff --git a/apps/web/tests/model-menu-observer.e2e.ts b/apps/web/tests/model-menu-observer.e2e.ts new file mode 100644 index 0000000000..4c58bc9db9 --- /dev/null +++ b/apps/web/tests/model-menu-observer.e2e.ts @@ -0,0 +1,290 @@ +/** Built-profile browser coverage for sticky model groups; counts only their synchronous rect reads, not FPS or latency. */ +import { fileURLToPath } from 'node:url' +import type { Browser, Locator, Page } from 'playwright' +import { chromium, webkit } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +declare global { + interface Window { + /** Test-private count for MenuGroup sections and their direct scroll viewport. */ + __modelMenuRectReads: number + } +} + +const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url)) +const PROVIDERS = [ + { id: 'origin-gateway', name: 'Origin Gateway', prefix: 'Origin' }, + { id: 'amber-gateway', name: 'Amber Gateway', prefix: 'Amber' }, + { id: 'cedar-gateway', name: 'Cedar Gateway', prefix: 'Cedar' }, + { id: 'quartz-gateway', name: 'Quartz Gateway', prefix: 'Quartz' }, +].map((provider, providerIndex) => ({ + ...provider, + models: Array.from({ length: 7 }, (_, index) => providerIndex === 0 && index === 0 + ? { id: 'origin-large', name: 'Origin Large' } + : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }), +})) +const EXPECTED_GROUPS = [ + { label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] }, + ...PROVIDERS.map(provider => ({ label: provider.name, rows: provider.models.map(model => model.name) })), +] + +// Native IO/RO callbacks need rendering opportunities, not elapsed-time sleeps. +async function frames(page: Page): Promise { + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) +} + +function readGroups(viewport: Locator) { + return viewport.locator('[data-menu-group]').evaluateAll(groups => groups.map(group => ({ + label: group.querySelector('[data-menu-group-heading]')!.textContent, + rows: [...group.querySelectorAll('[role="option"], [role="menuitemradio"]')].map(row => row.textContent), + }))) +} + +function readPinned(viewport: Locator) { + return viewport.locator('[data-menu-group-heading]').evaluateAll(headings => + headings.map(heading => heading.hasAttribute('data-stuck'))) +} + +async function assertNoRectReads(page: Page): Promise { + await frames(page) + expect(await page.evaluate(() => window.__modelMenuRectReads)).toBe(0) +} + +async function assertPinned(page: Page, viewport: Locator, index: number, count = 5): Promise { + const expected = Array.from({ length: count }, (_, i) => i === index) + await expect.poll(() => readPinned(viewport)).toEqual(expected) + await assertNoRectReads(page) + expect(await readPinned(viewport)).toEqual(expected) +} + +async function scrollToGroup(viewport: Locator, index: number, inside = 1): Promise { + // Sections share an offset parent. Their normal-flow offsets are test positioning, not rect instrumentation. + await viewport.evaluate((node, target) => new Promise((resolve) => { + requestAnimationFrame(() => { + const groups = node.querySelectorAll(':scope > [data-menu-group]') + node.scrollTop = groups[target.index]!.offsetTop - groups[0]!.offsetTop + target.inside + resolve() + }) + }), { index, inside }) +} + +async function headingMaterial(viewport: Locator) { + return viewport.locator('[data-menu-group-heading]').first().evaluate((heading) => { + const body = heading.ownerDocument.body + const theme = body.getAttribute('data-ds-dark-theme') + try { + return [false, true].map((dark) => { + body.toggleAttribute('data-ds-dark-theme', dark) + const style = getComputedStyle(heading) + return { + fill: style.backgroundColor, radius: style.borderRadius, position: style.position, + font: style.fontSize, weight: style.fontWeight, padding: style.padding, + } + }) + } finally { + if (theme === null) body.removeAttribute('data-ds-dark-theme') + else body.setAttribute('data-ds-dark-theme', theme) + } + }) +} + +describe.skipIf(webSnapshotMode() === 'record').each([ + { name: 'Chromium', engine: chromium }, + { name: 'WebKit', engine: webkit }, +])('web e2e: model menu observers ($name)', ({ engine }) => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let crashed = false + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + // Catalog-only settings traffic: no prompt, selection, replay fixture, or model request. + await scaffold.ctx.settings.update('llm-pi-ai', { + providers: Object.fromEntries(PROVIDERS.map(provider => [provider.id, { + displayName: provider.name, + api: 'openai-completions', + baseURL: `https://${provider.id}.example/v1`, + models: provider.models, + }])), + }) + browser = await engine.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await page.addInitScript(() => { + window.__modelMenuRectReads = 0 + // oxlint-disable-next-line typescript/unbound-method -- The wrapper supplies the original Element receiver below. + const original = Element.prototype.getBoundingClientRect + Element.prototype.getBoundingClientRect = function (): DOMRect { + if (this.hasAttribute('data-menu-group') || [...this.children].some(child => child.hasAttribute('data-menu-group'))) { + window.__modelMenuRectReads++ + } + return original.call(this) + } + }) + page.on('crash', () => { crashed = true; console.error('observer test page crashed') }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + try { + await browser?.close() + } finally { + await scaffold?.close() + } + }) + + // FIXME: WebKit 26.5 crashes while filtering the grouped model rows in this scenario. + it.skipIf(engine === webkit)('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => { + onTestFailed(() => saveFailureShot(page, `web-e2e-model-menu-observer-${engine.name()}`)) + let buttonMaterial: Awaited> | undefined + for (const entry of ['button', 'command'] as const) { + const composer = page.locator('[data-composer-input]').first() + const surface = entry === 'button' + ? page.getByRole('group', { name: '模型与推理等级', exact: true }) + : page.locator('[aria-label="/model 选项"]') + const viewport = entry === 'button' + ? surface.getByRole('menu', { name: '模型', exact: true }) + : surface.getByRole('listbox') + const search = entry === 'button' + ? page.getByRole('searchbox', { name: '搜索模型…', exact: true }) + : page.getByRole('textbox', { name: '筛选选项', exact: true }) + const open = async (): Promise => { + if (entry === 'button') { + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + } else { + await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click() + await page.getByRole('option', { name: /^模型/ }).click() + } + await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS) + await expect.poll(() => search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + await assertNoRectReads(page) + await page.mouse.move(0, 0) + await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 }) + await assertPinned(page, viewport, -1) + } + const close = async (): Promise => { + if (crashed || page.isClosed() || await surface.count() === 0) return + await search.press('Escape') + if (entry === 'button') await page.keyboard.press('Escape') + await surface.waitFor({ state: 'detached' }) + await composer.fill('') + } + + await open() + try { + expect(await viewport.locator('[role="option"], [role="menuitemradio"]').count()).toBe(30) + expect(await viewport.evaluate(node => node.scrollTop)).toBe(0) + expect(await viewport.locator('[data-menu-group-heading]').evaluateAll(headings => + [...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))])) + .toEqual(['rgba(0, 0, 0, 0)']) + await scrollToGroup(viewport, 0) + await assertPinned(page, viewport, 0) + const material = await headingMaterial(viewport) + expect(material.map(style => style.fill)).toEqual(['rgba(248, 249, 250, 0.94)', 'rgba(48, 49, 54, 0.94)']) + for (const style of material) { + expect(style).toMatchObject({ position: 'sticky', font: '11px', weight: '500', padding: '4px 7px 2px' }) + } + if (entry === 'button') buttonMaterial = material + else expect(material).toEqual(buttonMaterial) + + for (const index of [2, 4, 1]) { + await scrollToGroup(viewport, index) + await assertPinned(page, viewport, index) + const height = await viewport.evaluate(node => node.clientHeight) + expect(height).toBeGreaterThan(0) + expect(height).toBeLessThanOrEqual(80) + expect(await viewport.locator('[data-menu-group]').nth(index).evaluate(node => node.clientHeight)) + .toBeGreaterThan(height * 2) + await scrollToGroup(viewport, index, height + 1) + await assertPinned(page, viewport, index) + expect(await viewport.evaluate((node, groupIndex) => { + const headings = node.querySelectorAll('[data-menu-group-heading]') + const firstStart = node.querySelector('[data-menu-group-start]')! + const viewportTop = firstStart.getBoundingClientRect().top + node.scrollTop + return Math.abs(headings[groupIndex]!.getBoundingClientRect().top - viewportTop) + }, index)).toBeLessThan(1) + } + await scrollToGroup(viewport, 0, 0) + await assertPinned(page, viewport, -1) + + for (const [key, steps] of [['ArrowDown', 18], ['ArrowUp', 10]] as const) { + const visited = new Set() + let observedPinnedHighlight = false + for (let step = 0; step < steps; step++) { + const rows = viewport.locator('[role="option"], [role="menuitemradio"]') + const before = await rows.evaluateAll(nodes => nodes.findIndex(node => + node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true')) + await search.press(key) + const expected = (before + (key === 'ArrowDown' ? 1 : -1) + 30) % 30 + await expect.poll(() => rows.evaluateAll(nodes => nodes.findIndex(node => + node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))).toBe(expected) + await frames(page) + await expect.poll(() => viewport.evaluate((node) => { + const groups = [...node.querySelectorAll(':scope > [data-menu-group]')] + const base = groups[0]!.offsetTop + return groups.every((group) => { + const top = group.offsetTop - base + const shouldStick = top < node.scrollTop && top + group.offsetHeight > node.scrollTop + return group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') === shouldStick + }) + })).toBe(true) + const highlight = await viewport.evaluate((node) => { + const groups = [...node.querySelectorAll(':scope > [data-menu-group]')] + const index = groups.findIndex(group => group.querySelector('[data-highlighted], [aria-selected="true"]') !== null) + const group = groups[index]! + return { index, above: group.offsetTop - groups[0]!.offsetTop < node.scrollTop, + stuck: group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') } + }) + visited.add(highlight.index) + if (highlight.above) { + expect(highlight.stuck).toBe(true) + observedPinnedHighlight = true + } + expect(await search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + await assertNoRectReads(page) + } + expect(visited.size).toBeGreaterThan(1) + expect(observedPinnedHighlight).toBe(true) + } + + await scrollToGroup(viewport, 0) + await assertPinned(page, viewport, 0) + await search.fill('Quartz Model 1') + await expect.poll(() => readGroups(viewport)).toEqual([{ label: 'Quartz Gateway', rows: ['Quartz Model 1'] }]) + await assertPinned(page, viewport, -1, 1) + expect(await viewport.evaluate(node => node.scrollTop)).toBe(0) + await search.fill('') + await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS) + await assertPinned(page, viewport, -1) + await close() + await open() + expect(await search.inputValue()).toBe('') + + await scrollToGroup(viewport, 3, 90) + await assertPinned(page, viewport, 3) + await viewport.evaluate((node) => { node.style.maxHeight = '64px' }) + await expect.poll(() => viewport.evaluate(node => node.clientHeight)).toBeLessThanOrEqual(64) + await assertPinned(page, viewport, 3) + await scrollToGroup(viewport, 4, 100) + await assertPinned(page, viewport, 4) + await scrollToGroup(viewport, 1, 100) + await assertPinned(page, viewport, 1) + await scrollToGroup(viewport, 0, 0) + await assertPinned(page, viewport, -1) + } finally { + await close() + } + } + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' }) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 788b6aa871..ea2a30d938 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -16,9 +16,9 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -264,6 +264,130 @@ describe('web e2e: plan review takeover round trip', () => { }) }) +describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar seat changes', () => { + // Two recorded Sessions seeded side by side; opening one in the page gives it + // a live Agent, so a review can be delivered to it while it is off screen. + const OTHER_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let planned: SessionId + let other: SessionId + const crashes: string[] = [] + const row = (session: SessionId) => page.locator(`[data-row-key="session:${session}"]`) + + async function showSessions(): Promise { + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // Expand the Workspace group by its state, not by whether its rows have rendered yet. + const group = page.locator('[data-row-key^="workspace:"]').first() + await group.waitFor({ timeout: 15_000 }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await row(planned).waitFor({ timeout: 15_000 }) + } + + async function open(session: SessionId): Promise { + await row(session).click() + await page.locator(`[data-conversation-session="${session}"]`).waitFor({ timeout: 15_000 }) + await page.locator(`[data-sidebar-right-session="${session}"]:not([hidden])`).first().waitFor({ state: 'attached', timeout: 15_000 }) + // `expect.poll` is test-scoped and this also runs from beforeAll, so poll by hand. + const deadline = Date.now() + 15_000 + while (scaffold.ctx.agents.get(session) === undefined) { + if (Date.now() > deadline) throw new Error(`opening Session ${session} published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + + async function showPlugins(): Promise { + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + await expect.poll(() => page.locator('[data-composer-input]').count(), { timeout: 10_000 }).toBe(0) + } + + /** Deliver a plan review to one Session, run the scenario, and withdraw the review. */ + async function whileReviewing(session: SessionId, detail: string, scenario: () => Promise): Promise { + const agent = scaffold.ctx.agents.get(session) + if (agent === undefined) throw new Error(`Session ${session} has no live Agent`) + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent, signal: controller.signal, + questions: [{ id: 'seat-change', question: 'Approve this plan?', detail, + options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' }, + }], + }) + const outcome = asked.then(value => value, (error: unknown) => ({ error })) + try { + await row(session).locator('[data-state="warning"]').waitFor({ timeout: 10_000 }) + await scenario() + } finally { + controller.abort() + await outcome + } + } + + /** The review opened itself in the Sidebar and kept its manual opener. */ + async function expectOpened(text: string): Promise { + const card = page.locator('[data-plan-review-key]') + await card.waitFor({ timeout: 10_000 }) + const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]') + await preview.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await preview.getByText(text).isVisible()).toBe(true) + expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1) + expect(await page.locator('[data-slot-error]').count()).toBe(0) + expect(crashes).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + planned = await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), 'plan-review-seat-planned') + other = await seedSession(scaffold, await readFile(OTHER_FIXTURE, 'utf8'), 'plan-review-seat-other') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + page.on('console', (message: ConsoleMessage) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text()) + }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await showSessions() + await open(planned) + await open(other) + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('opens a review that arrives while another Session is on screen', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-session-return')) + // Returning mounts the review in the commit that brings its Session back on + // screen and swaps the Sidebar seats; the review's automatic open runs + // before either seat's effects. + await whileReviewing(planned, '# Session review\n\nSubmitted while another Session was on screen.', async () => { + await row(planned).click() + await expectOpened('Submitted while another Session was on screen.') + }) + }) + + it('opens a review for a Session whose Sidebar layout was never saved', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-first-layout')) + // The layout is stored per browser. Without it the arriving seat creates + // one, and that store commit lands in the commit where the review opens. + await open(other) + const warningStart = tripwire.warnings.length + await page.evaluate((session) => { localStorage.removeItem(`dsh.sidebar-right.v1.${session}`) }, planned) + await page.reload({ waitUntil: 'load' }) + await showSessions() + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await showPlugins() + await whileReviewing(planned, '# Layout review\n\nSubmitted to a Session without a saved Sidebar layout.', async () => { + await row(planned).click() + await expectOpened('Submitted to a Session without a saved Sidebar layout.') + }) + }) +}) + describe('web e2e: dismissed plan history', () => { it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => { // Replay supplies a finite continuation; the real question rejection and retained document are the assertions. diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index b89279d173..cb87ceb531 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -835,13 +835,18 @@ describe('web e2e: plugin manager', () => { const install = dialog.getByRole('button', { name: '安装', exact: true }) expect(await install.isDisabled()).toBe(true) expect(await dialog.getByRole('note').textContent()).toContain('请确认插件来源可信') + expect(await dialog.getByRole('note').textContent()).toContain('暂不支持自动更新') await dialog.getByRole('button', { name: '插件安装引导和示例' }).click() + // The guide carries the package-name example only; the former template strings keep their replacement reminder. + await expect.poll(() => dialog.getByRole('listitem').count()).toBe(1) + await dialog.getByRole('button', { name: '填入示例 dsh-plugin-whale-pet' }).click() + expect(await field.inputValue()).toBe('dsh-plugin-whale-pet') + expect(await dialog.getByRole('status').count()).toBe(0) for (const [example, hint] of [ ['https://github.com/author/dsh-plugin', '请替换为实际的 Git 仓库地址'], ['/Users/name/my-plugin', '请替换为本机插件目录的实际路径'], - ]) { - await dialog.getByRole('button', { name: `填入示例 ${example}` }).click() - expect(await field.inputValue()).toBe(example) + ] as const) { + await field.fill(example) expect(await dialog.getByRole('status').textContent()).toBe(hint) } await field.fill('/actual/plugin-directory') diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index cd44f0ec67..c275f73c7a 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -122,6 +122,8 @@ describe('web e2e: requested SVG is explicitly delivered', () => { expect(await page.getByText('产物', { exact: true }).count()).toBe(0) // The scaffold workspace is not a git repository; the write call supplies its single changed file. const changes = page.locator('[data-changed-files]') + // Changed-file cards wait for a separate summary request after the presentation events arrive. + await changes.waitFor({ state: 'visible' }) expect(await changes.count()).toBe(1) expect(await changes.getByText(`已编辑 ${FILE}`, { exact: true }).count()).toBe(1) expect(await changes.getByRole('list').count()).toBe(0) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9608b13863..a9f303c975 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -87,6 +87,7 @@ "tests/deepseek-messages-chat.e2e.ts", "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", + "tests/model-menu-observer.e2e.ts", "tests/default-workspace.e2e.ts", "tests/new-session-refused.e2e.ts", "tests/github-ready-review.e2e.ts", diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 56485c7234..76de4d3632 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -55,7 +55,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,950; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. +Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,960; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. ## The slop checklist diff --git a/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.i18n.yaml b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.i18n.yaml new file mode 100644 index 0000000000..f0046f5fc9 --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md +/: + en: 224d57a22f38ba26 + zh: 1472851dde2bf9c3 +/schedule-moves-into-the-automation-tasks-optional-bundle: + en: 138f715e3a260eb6 + zh: eff22703b2c79915 +/schedule-moves-into-the-automation-tasks-optional-bundle/change: + en: b1b250e677c23b10 + zh: b71c1591ea4f9ce5 +/schedule-moves-into-the-automation-tasks-optional-bundle/migration: + en: 656fea5dc45f8310 + zh: 8dfe16d2fdacf71a diff --git a/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md new file mode 100644 index 0000000000..72ffb9f689 --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "The Web composition no longer carries the Schedule rows; the Automation tasks optional bundle inserts them." +--- + +# Schedule moves into the Automation tasks optional bundle + +English | [中文](guide.zh.md) + +## Change + +In v0.1.7-rc.2, `@deepseek-ai/dsh-web-app` carried the `time-context`, `schedule`, and `ui-schedule` rows with `disabled: true`. Turning Schedule on in the Plugins page, or by hand, wrote id-targeted overrides such as `- id: schedule` with `disabled: false` into `$DSH_HOME/profiles//cordis.patch.yml` or a `--patch` overlay. + +The next release removes the three rows from the Web composition. `@deepseek-ai/dsh-experimental-schedule-bundle`, shown as Automation tasks in the Plugins page's Official group, inserts them. Every installation ships the bundle switched off. + +Profiles that enabled Schedule by id lose it after upgrading: the loader warns `patch: entry schedule not found` (and the same for `time-context` and `ui-schedule`), the `schedule_*` tools and the Automation tasks page disappear, and stored reminders stop being delivered. Stored tasks and delivery records remain on disk. + +## Migration + +1. Open Plugins, find Automation tasks in the Official group, and switch it on. The switch appends `@deepseek-ai/dsh-experimental-schedule-bundle` to `dsh.profile.bundles` in `$DSH_HOME/profiles//package.json`; for a profile edited by hand, add that entry yourself. +2. Keep existing overrides that set other fields on `schedule`, `time-context`, or `ui-schedule`, such as `deliveryHistoryDays`; they apply again once the bundle inserts the rows. Overrides that only set `disabled: false` are redundant and can be deleted. +3. Confirm: restart `dsh web`, check that startup logs no `patch: entry ... not found` warning for the three ids, and that the sidebar shows Automation tasks with the previously stored reminders. diff --git a/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.zh.md b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.zh.md new file mode 100644 index 0000000000..46483a1175 --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.zh.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "Web 组合不再包含 Schedule 的三行;改由“自动化任务”可选 bundle 插入。" +--- + +# Schedule 移入“自动化任务”可选 bundle + +[English](guide.md) | 中文 + +## 变更 + +在 v0.1.7-rc.2 中,`@deepseek-ai/dsh-web-app` 以 `disabled: true` 包含 `time-context`、`schedule` 和 `ui-schedule` 三行。在插件管理页或手动开启 Schedule,会向 `$DSH_HOME/profiles//cordis.patch.yml` 或 `--patch` overlay 写入按 id 定位的覆盖项,例如 `- id: schedule` 加 `disabled: false`。 + +下一版本从 Web 组合中移除这三行。插件管理页“官方”分组中的“自动化任务”,即 `@deepseek-ai/dsh-experimental-schedule-bundle`,负责插入它们。每个安装都自带该 bundle,默认关闭。 + +按 id 开启 Schedule 的 profile 升级后会失去 Schedule:loader 警告 `patch: entry schedule not found`(`time-context` 和 `ui-schedule` 同理),`schedule_*` 工具和自动化任务页面消失,已存储的提醒不再投递。已存储的任务和投递记录仍保留在磁盘上。 + +## 迁移 + +1. 打开插件管理页,在“官方”分组中找到“自动化任务”并开启。开关会把 `@deepseek-ai/dsh-experimental-schedule-bundle` 追加到 `$DSH_HOME/profiles//package.json` 的 `dsh.profile.bundles`;手动维护的 profile 需自行添加该条目。 +2. 保留为 `schedule`、`time-context` 或 `ui-schedule` 设置其他字段的覆盖项,例如 `deliveryHistoryDays`;bundle 插入这些行后它们重新生效。只设置 `disabled: false` 的覆盖项已无作用,可以删除。 +3. 确认:重启 `dsh web`,检查启动日志中这三个 id 不再出现 `patch: entry ... not found` 警告,且侧栏显示“自动化任务”并列出之前存储的提醒。 diff --git a/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.i18n.yaml b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.i18n.yaml new file mode 100644 index 0000000000..89d7925446 --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md +/: + en: 7d7e527ed3c87db1 + zh: 4654dab85b66bcb0 +/legacy-normal-work-details-display-as-detailed: + en: 3c88b0b06094c162 + zh: 9ae8ff49975824a9 +/legacy-normal-work-details-display-as-detailed/change: + en: 0e2287aa768141f3 + zh: 035992a94129fd9e +/legacy-normal-work-details-display-as-detailed/migration: + en: 6078ccce1ceacfd6 + zh: 6a394cf5259c9965 diff --git a/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md new file mode 100644 index 0000000000..cfb2be2793 --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "The legacy ui-chat.transcriptView value normal, and an unset value in non-Desktop Web, now display Detailed instead of Standard." +--- + +# Legacy `normal` work details display as Detailed + +English | [中文](guide.zh.md) + +## Change + +The Host setting `ui-chat.transcriptView` selects Settings → General → Work details. In v0.1.7-rc.2 the client displayed the legacy saved value `normal` as `standard`, and every client used `standard` when the setting was missing, `null`, or invalid. + +From the next release: + +- A saved `normal` displays as `detailed` on Desktop and Web. The saved value on disk is not rewritten. +- A missing, `null`, or invalid value displays as `detailed` in non-Desktop Web (the npm `dsh web` install). Desktop still uses `standard`. +- Saved `compact`, `standard`, `detailed`, and `verbose` values are unchanged. + +Users who relied on the old reading see running Turns with process-group bodies expanded instead of collapsed summaries. + +## Migration + +1. To keep the previous presentation, open Settings → General → Work details and choose Standard. The client saves `standard` to `ui-chat.transcriptView`, which every later default change leaves untouched. +2. Confirm: start a Turn that calls tools and check that its process group shows a collapsed summary with live task detail. diff --git a/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.zh.md b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.zh.md new file mode 100644 index 0000000000..4bcc77d18e --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.zh.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "旧值 ui-chat.transcriptView: normal,以及非 Desktop Web 中未设置的值,改为按“详细”而非“标准”展示。" +--- + +# 旧值 `normal` 的工作步骤展示改为“详细” + +[English](guide.md) | 中文 + +## 变更 + +Host 设置 `ui-chat.transcriptView` 对应设置 → 通用设置 → 工作步骤展示。在 v0.1.7-rc.2 中,客户端把旧保存值 `normal` 按 `standard` 展示;设置缺失、为 `null` 或无效时,所有客户端都使用 `standard`。 + +从下一版本起: + +- 保存的 `normal` 在 Desktop 和 Web 中均按 `detailed` 展示,磁盘中的保存值不会被改写。 +- 设置缺失、为 `null` 或无效时,非 Desktop Web(npm 安装的 `dsh web`)按 `detailed` 展示;Desktop 仍使用 `standard`。 +- 已保存的 `compact`、`standard`、`detailed` 和 `verbose` 保持不变。 + +依赖旧读取方式的用户会看到运行中轮次的过程组正文直接展开,而不是收起的摘要。 + +## 迁移 + +1. 如需保留原有展示,打开设置 → 通用设置 → 工作步骤展示并选择“标准”。客户端会把 `standard` 保存到 `ui-chat.transcriptView`,此后的默认值变化不影响该值。 +2. 确认:发起一个调用工具的轮次,检查其过程组显示收起的摘要和实时任务详情。 diff --git a/package.json b/package.json index 85bdd8c6df..848c0a210f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,9 @@ "upload:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:arm64", "upload:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:x64", "upload:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:win:x64", + "upload:latest:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:arm64", + "upload:latest:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:x64", + "upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready", @@ -118,6 +121,7 @@ "verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-skill-invocation-metadata": "tsx scripts/verify-skill-invocation-metadata.ts", + "verify-upgrade-guides": "tsx scripts/verify-upgrade-guides.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "test:docs": "tsx scripts/run-gates.ts doc-quick", diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 019535b653..545139127a 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 1fa9a72d1ee045cc /deepseek-ai-dsh-api-terminal-controller/use-this-package: - en: 234403af4f95ea67 - zh: d711c2e62d7d37f8 + en: 0148cabb3a856226 + zh: 059a60413474a173 /deepseek-ai-dsh-api-terminal-controller/understand-the-implementation: en: 94dcd1835c55acc2 zh: cbfeb2da8cb60395 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index dd93237a94..f33ce680c0 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -27,7 +27,7 @@ Open the execution environment's default shell in a Session workspace from the W The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. Sandbox policy supplies only the fallback working directory for Sessions without a cwd. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. -Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing. +Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits confirmed lookup misses. It lists each executable name once, ignoring letter case and `.exe`, and keeps the earliest entry, because PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing. | Configuration | Default | Meaning | |---|---|---| diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index b3bda5f404..d4d92f0d3e 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。Sandbox policy 仅为没有 cwd 的 Session 提供默认工作目录。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 -Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。 +Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,并省略确定未找到的候选。同一可执行文件名(不区分大小写,忽略 `.exe`)只列出最早的一项,因为 PATH 查找可能经由其他目录找到默认 shell,例如 merged-`/usr` 系统上 `/bin/bash` 对应的 `/usr/bin/bash`。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。 | 配置 | 默认值 | 含义 | |---|---|---| diff --git a/packages/api/terminal-controller/src/shells.ts b/packages/api/terminal-controller/src/shells.ts index c8010c38c4..7c1f6a4ce2 100644 --- a/packages/api/terminal-controller/src/shells.ts +++ b/packages/api/terminal-controller/src/shells.ts @@ -22,18 +22,26 @@ export async function resolveShell( } function profile(path: string): TerminalShell { - const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) - const kind = name.toLowerCase().replace(/\.exe$/u, '') - return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] } + const kind = shellKind(path) + return { path, name: executableName(path), args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] } +} + +function executableName(path: string): string { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) +} + +function shellKind(path: string): string { + return executableName(path).toLowerCase().replace(/\.exe$/u, '') } /** - * List verified candidates after the configured or environment-default shell. + * List verified candidates after the configured or environment-default shell, one per executable name ignoring case and `.exe`. + * PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. * @param subprocess - target execution provider. * @param configured - optional default profile. * @param candidates - executable names or paths permitted for shell selection. * @param signal - discovery cancellation. - * @returns unique installed shells, with the default first; transport failures reject. + * @returns installed shells with the default first, keeping the earliest entry per name; transport failures reject. */ export async function discoverShells( subprocess: SubprocessRuntime, configured: TerminalShell | undefined, @@ -50,8 +58,8 @@ export async function discoverShells( const shells = new Map() for (const shell of [preferred, ...found]) { if (shell === undefined) continue - const key = shell.path.includes('\\') ? shell.path.toLowerCase() : shell.path - if (!shells.has(key)) shells.set(key, shell) + const kind = shellKind(shell.path) + if (!shells.has(kind)) shells.set(kind, shell) } return [...shells.values()] } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index 3d7f4434d1..a516965228 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -546,6 +546,16 @@ it('deduplicates Windows executable paths regardless of letter case', async () = expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }]) }) +it('lists each executable name once when PATH reaches the default shell through another directory', async () => { + const h = fixture({ shell: undefined, shellCandidates: ['zsh', 'bash'] }) + // Merged /usr: the login shell is /bin/bash, while PATH lists /usr/bin before the /bin link. + h.subprocess.resolveExecutable.mockImplementation(async path => path.startsWith('/') ? path : `/usr/bin/${path}`) + expect(await h.controller.shells(h.agent, signal())).toEqual([ + { path: '/bin/bash', name: 'bash', args: ['-i'] }, + { path: '/usr/bin/zsh', name: 'zsh', args: ['-i'] }, + ]) +}) + it('retains a known terminal without Agent resolution and fences stream admission after explicit close', async () => { const h = fixture() await h.controller.create(h.agent, request, signal()) diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml index c5996ca9af..7e4bce6b3b 100644 --- a/packages/boot/hmr/README.i18n.yaml +++ b/packages/boot/hmr/README.i18n.yaml @@ -39,8 +39,8 @@ en: 89e0624fafd701fb zh: 19d36a33c1d9af9d /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work: - en: 75e861bd7c91e37b - zh: d209c33dd5b16182 + en: 379bc2c72fd9e53e + zh: 1fff1e0e676bb161 /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note: en: 7972e4b2005ec9a4 zh: 754c49ec538c164c diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md index 9e2c62c739..c4405697c6 100644 --- a/packages/boot/hmr/README.md +++ b/packages/boot/hmr/README.md @@ -87,6 +87,7 @@ Reloading a contributing plugin can change later request prefixes; HMR does not - Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process. - Replacing installed package versions still requires a restart through Plugin Manager. The browser Client module graph retains its separate browser-side loading mechanism. +- `watchConfig()` resolves when Chokidar reports readiness. On darwin, libuv starts the FSEvents stream afterwards on its own thread, so a write that lands within milliseconds of registration is not reported until the next event in that directory; edits made after startup are unaffected. ### Dev Note diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md index fa926d5998..6c82c3db8e 100644 --- a/packages/boot/hmr/README.zh.md +++ b/packages/boot/hmr/README.zh.md @@ -87,6 +87,7 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提 - 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。 - 通过插件管理器替换已安装包版本仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。 +- `watchConfig()` 在 Chokidar 报告就绪时 resolve。darwin 上 libuv 随后才在自己的线程启动 FSEvents 流,因此注册后数毫秒内落地的写入要等到该目录的下一个事件才会被报告;启动之后的编辑不受影响。 ### 开发备注 diff --git a/packages/boot/hmr/tests/watch-config.spec.ts b/packages/boot/hmr/tests/watch-config.spec.ts index c206607145..db31c0b654 100644 --- a/packages/boot/hmr/tests/watch-config.spec.ts +++ b/packages/boot/hmr/tests/watch-config.spec.ts @@ -45,8 +45,30 @@ async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): return ctx } -async function eventually(test: () => boolean, message: string): Promise { - const deadline = Date.now() + 10_000 +/** + * Block until this process's native directory watches deliver events. Chokidar + * reports `ready` once `fs.watch()` returns, but libuv on darwin builds the + * per-process FSEvents stream later on its CoreFoundation thread + * (`uv__fsevents_init` only signals that thread), and a write that lands before + * then is never reported. Closing any directory handle runs `uv__fsevents_close`, + * whose `uv_sem_wait` returns only after `uv__fsevents_reschedule` has rebuilt + * and started the stream with the remaining handles (libuv `src/unix/fsevents.c`, + * unchanged across the libuv 1.x releases bundled by Node 22–24), so a watcher + * registered before this call observes the next write. Any existing directory + * works because the stream is per process. Per-file handles use kqueue and leave + * the stream alone, so one call after registration covers the later change and + * unlink steps. Linux inotify and Windows `ReadDirectoryChangesW` are armed + * inside `fs.watch()`, so there this is an immediate open and close. Should a + * libuv release drop the close-time wait, these cases regain the darwin loss + * rate this call removes rather than failing deterministically. + */ +function ensureNativeWatchLive(dir: string): void { + fs.watch(dir).close() +} + +/** Poll `test` every 10 ms under the calling case's effective budget. */ +async function eventually(test: () => boolean, message: string, budget: number): Promise { + const deadline = Date.now() + budget while (!test()) { if (Date.now() >= deadline) throw new Error(message) await new Promise(resolve => setTimeout(resolve, 10)) @@ -109,12 +131,14 @@ describe('HMR exact config paths', () => { } }) - it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async ({ task, onTestFailed }) => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(dir) const observed: string[] = [] + // The case budget fires before eventually() can name the pending step. + onTestFailed(() => { console.error(`refresh observed ${JSON.stringify(observed)}`) }) try { await watchConfig(ctx, filename, {}, () => { try { @@ -124,38 +148,42 @@ describe('HMR exact config paths', () => { observed.push('missing') } }) + ensureNativeWatchLive(dir) writeFileSync(filename, 'one', { flag: 'wx' }) - await eventually(() => observed.includes('one'), 'HMR did not observe config creation') + await eventually(() => observed.includes('one'), 'HMR did not observe config creation', task.timeout) writeFileSync(filename, 'two') - await eventually(() => observed.includes('two'), 'HMR did not observe config change') + await eventually(() => observed.includes('two'), 'HMR did not observe config change', task.timeout) unlinkSync(filename) - await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') + await eventually(() => observed.includes('missing'), 'HMR did not observe config removal', task.timeout) } finally { await ctx.fiber.dispose() } }) - it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async ({ task, onTestFailed }) => { const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) hmrRoots.push(root) const dir = join(root, 'later') const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(root) const observed: string[] = [] + // The case budget fires before eventually() can name the pending step. + onTestFailed(() => { console.error(`refresh observed ${JSON.stringify(observed)}`) }) try { await watchConfig(ctx, filename, {}, () => { observed.push(readFileSync(filename, 'utf8')) }) + ensureNativeWatchLive(root) mkdirSync(dir) writeFileSync(filename, 'created') - await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') + await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent', task.timeout) } finally { await ctx.fiber.dispose() } }) - it('processes native events for a watcher registered during a transaction', async () => { + it('processes native events for a watcher registered during a transaction', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-transaction-watch-')) const filename = join(dir, 'plugins.yml') onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) }) @@ -168,6 +196,7 @@ describe('HMR exact config paths', () => { await hmr.runExclusive(() => hmr.watchConfig(filename, async () => { observed.resolve(readFileSync(filename, 'utf8')) })) + ensureNativeWatchLive(dir) writeFileSync(filename, 'created-after-transaction') expect(await observed.promise).toBe('created-after-transaction') }) diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 7f08d21282..96af0825c3 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: cf381c4f00b42c3f - zh: 1b226687dcbd4875 + en: a50da1a9dfdaf624 + zh: faaffc5fbeec446b /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: en: 7813f0063c15e241 zh: 6eabc242edfc45ec diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index ca7bba393e..cfa73e8bb8 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -27,7 +27,7 @@ Choose Agent presets and the new-task default in Web, read what each mode does a Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. -Coding Tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears and the cards refuse selection, while the saved default keeps composing new tasks. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. +Coding Tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears, while the settings cards still choose the saved default that keeps composing new tasks and Creator stays available. With Coding Tools on, choosing a healthy default also synchronizes the blank session on the current new-task surface; with them off, a reused blank session keeps its preset. Creator starts a new task using the `cordis` preset. Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index d2a3471f45..210ad0e2b5 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 -通用设置中的代码工作工具决定能否选择模式:关闭后新会话选择器消失、卡片拒绝选择,而已保存的默认值继续用于新任务。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。 +通用设置中的代码工作工具决定能否选择模式:关闭后新会话选择器消失,而设置卡片仍可选择继续用于新任务的已保存默认值,Creator 入口也保持可用。开启时,选择健康定义作为默认值也会同步当前新任务页面的空白会话;关闭时,被复用的空白会话保留原预设。Creator 入口开启一个使用 `cordis` preset 的新任务。 已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 5f07356745..32ad216063 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -61,7 +61,7 @@ } -.card:hover:not(.cardActive):not(.cardSelectionDisabled) { +.card:hover:not(.cardActive) { background: var(--dsw-alias-interactive-bg-hover); } @@ -75,14 +75,6 @@ /* Mode selection is off, not the preset itself: mute only the selectable body while the independent view/copy/location actions stay fully usable. */ -.cardSelectionDisabled { - background: var(--dsw-alias-bg-module-platform); -} - -.cardSelectionDisabled .cardMain { - opacity: 0.48; -} - /* A broken preset reads as damaged before anything else: the card cannot be picked, so its border carries the warning the disabled body cannot. */ .cardBroken { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 9d193d94ff..f9a267d653 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { Button, IconBrowseOutlineRegular, IconPlusOutlineRegular, Modal, Tag, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentPresetSectionState } from './section-store.ts' import { isBuiltInPreset, presetDisplayText } from './locales.ts' @@ -15,8 +15,6 @@ import css from './AgentPresetSection.module.css' export interface AgentPresetSectionInjected { hooks: { agentPresetSection: SnapshotStore - /** Shared Developer tools preference; off hides every selection action. */ - developerTools: ObservableSnapshot } /** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */ startCreatorDraft?: () => void @@ -62,10 +60,9 @@ function CardDescription({ text }: { text: string }): ReactNode { */ export function AgentPresetSection({ useAgentPresetSection, load, view, closeView, makeDefault, startCreatorDraft, - close: closeSettings, useDeveloperTools, t, + close: closeSettings, t, }: AgentPresetSectionProps) { const state = useAgentPresetSection(value => value) - const developerTools = useDeveloperTools(enabled => enabled) const [guide, setGuide] = useState<{ content: NonNullable> page: PresetGuidePage @@ -90,8 +87,7 @@ export function AgentPresetSection({ + )} + } {state.status === 'loading' && (
{t('status.loading')}
)} @@ -434,13 +548,19 @@ export function ModelSelect( ))} -
- {groups.map((group) => { - const headingId = `${id}-${group.id}` + - {state.status === 'ready' && choices.length === 0 && ( -
{t('empty.models')}
+ {state.status === 'ready' && filteredGroups.length === 0 && ( +
{t(choices.length === 0 ? 'empty.models' : 'search.empty')}
)} )} diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 7451f225bf..ca30921493 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -28,6 +28,7 @@ import { ModelDirectoryResolver } from './service.ts' import type { ModelSelectInjected } from './slots.ts' import { ModelSelect } from './ModelSelect.tsx' import { en, zh, type ModelKey } from './locales.ts' +import { orderModelProviders } from './provider-order.ts' export { ModelDirectory } from './directory.ts' export type { ModelDirectoryState } from './directory.ts' @@ -47,33 +48,16 @@ function rowId(providerId: string, modelId: string): string { return `${providerId}/${modelId}` } -const BUILTIN_DESCRIPTION_KEYS: Readonly> = { - 'deepseek-account/deepseek-v4-flash': 'option.deepseekV4Flash.description', - 'deepseek-account/deepseek-v4-pro': 'option.deepseekV4Pro.description', - 'deepseek-official/deepseek-v4-flash': 'option.deepseekV4Flash.description', - 'deepseek-official/deepseek-v4-pro': 'option.deepseekV4Pro.description', -} - -function descriptionOf( - providerId: string, - model: ModelDirectoryState['groups'][number]['models'][number], - t: TranslateNS<'model'>, -): string | undefined { - const key = BUILTIN_DESCRIPTION_KEYS[rowId(providerId, model.id)] - return key !== undefined && model.description === en[key] ? t(key) : model.description -} - /** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */ function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] { const rows: SelectOption[] = [] - for (const group of directory.groups) { + for (const group of orderModelProviders(directory.groups)) { const name = group.id === 'deepseek-account' ? t('provider.account') : group.name for (const model of group.models) { - const description = descriptionOf(group.id, model, t) rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: description !== undefined ? `${name} · ${description}` : name, + group: { name: group.id, label: name }, ...(directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id @@ -150,6 +134,12 @@ export function apply(ctx: ClientContext): void { available: session => sessions.subagentAddress(session.sessionId) === undefined, ui: { kind: 'popupSelect', + searchMode: 'fuzzy-label', + searchLabels: () => ({ + placeholder: t('search.placeholder'), + empty: t('empty.models'), + noResults: t('search.empty'), + }), options: async (session) => { if (sessions.subagentAddress(session.sessionId) !== undefined) { throw new Error('model selection is unavailable for addressed subagent sessions') diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index bb61b7e931..a522b55da1 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -14,8 +14,6 @@ export const zh = { 'command.label': '模型', 'command.description': '选择本会话使用的模型', 'option.loadError': '目录加载失败:{message}', - 'option.deepseekV4Flash.description': '快速、高效且经济;适合目标明确、常规或并行任务。', - 'option.deepseekV4Pro.description': '更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。', 'trigger.fallback': '请选择模型', 'trigger.loading': '正在加载模型…', 'trigger.selectAria': '请选择模型', @@ -30,6 +28,9 @@ export const zh = { 'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', + 'search.placeholder': '搜索模型…', + 'search.clear': '清除搜索', + 'search.empty': '没有匹配的模型。', 'empty.models': '没有可用的模型。', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record @@ -43,8 +44,6 @@ export const en = { 'command.label': 'Model', 'command.description': 'Select the model for this conversation', 'option.loadError': 'Catalog failed to load: {message}', - 'option.deepseekV4Flash.description': 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', - 'option.deepseekV4Pro.description': 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', 'trigger.fallback': 'Select model', 'trigger.loading': 'Loading models…', 'trigger.selectAria': 'Select model', @@ -59,6 +58,9 @@ export const en = { 'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', + 'search.placeholder': 'Search models…', + 'search.clear': 'Clear search', + 'search.empty': 'No matching models.', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record diff --git a/packages/client/ui-model-selection/src/client/provider-order.ts b/packages/client/ui-model-selection/src/client/provider-order.ts new file mode 100644 index 0000000000..e17262bc00 --- /dev/null +++ b/packages/client/ui-model-selection/src/client/provider-order.ts @@ -0,0 +1,12 @@ +/** Shared provider display order for the composer and command model pickers. */ + +/** + * Put the account and official providers first, preserving every other relative order. + * @param groups - Provider groups in catalog order. + * @returns a sorted copy; model order within each group is unchanged. + */ +export function orderModelProviders(groups: readonly T[]): T[] { + return groups.toSorted((left, right) => + (left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2) + - (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)) +} diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 1896332c02..b27163fb16 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -225,31 +225,38 @@ describe('ui-model-selection dual entry', () => { expect(b.seat().locale).toBe('model') }) - it('localizes built-in descriptions and preserves external provider descriptions', async () => { - const b = await bench() + it.each(['zh', 'en'] as const)('shows names and providers without catalog descriptions (%s)', async (locale) => { + const b = await bench(locale) b.mint('s1') const options = await b.popup().options(projection('s1'), new AbortController().signal) expect(options.map((o: SelectOption) => o.label)).toEqual([ 'DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'External Flash', ]) - expect(options[0]).toMatchObject({ - active: true, - detail: 'DeepSeek · 快速、高效且经济;适合目标明确、常规或并行任务。', - }) - expect(options[1]?.detail) - .toBe('DeepSeek · 更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。') - expect(options[2]?.detail).toBe('External Provider · Provider-authored description.') + expect(options.map(option => option.group?.label)).toEqual(['DeepSeek', 'DeepSeek', 'External Provider']) + expect(options.every(option => option.detail === undefined)).toBe(true) + expect(b.popup().searchMode).toBe('fuzzy-label') + expect(options[0]?.active).toBe(true) expect(options[1]?.active).toBeUndefined() + expect(b.popup().searchLabels?.()).toEqual(locale === 'zh' + ? { placeholder: '搜索模型…', empty: '没有可用的模型。', noResults: '没有匹配的模型。' } + : { placeholder: 'Search models…', empty: 'No models available.', noResults: 'No matching models.' }) }) - it('keeps built-in descriptions unchanged in English', async () => { - const b = await bench('en') - b.mint('s1') - const options = await b.popup().options(projection('s1'), new AbortController().signal) - expect(options[0]?.detail) - .toBe('DeepSeek · Fast, efficient, and economical; suited to focused, routine, or parallel tasks.') - expect(options[1]?.detail) - .toBe('DeepSeek · Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.') + it('orders popup provider groups account-first while retaining third-party catalog order', async () => { + const b = await bench() + try { + b.setGroups([ + GROUPS[1]!, GROUPS[0]!, { ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }, + { ...GROUPS[1]!, id: 'last-provider', name: 'Last Provider' }, + ]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const options = await b.popup().options(projection('s1'), new AbortController().signal) + expect([...new Set(options.map(option => option.group?.name))]) + .toEqual(['deepseek-account', 'deepseek-official', 'external', 'last-provider']) + } finally { + await b.ctx.fiber.dispose() + } }) it('a seat selection is the current the popup marks active next — one shared state', async () => { @@ -291,14 +298,14 @@ describe('ui-model-selection dual entry', () => { }) }) - it.each(['en', 'zh'] as const)('localizes account provider details in the %s model popup', async (locale) => { + it.each(['en', 'zh'] as const)('localizes account provider headings in the %s model popup', async (locale) => { const b = await bench(locale) try { b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }]) b.remote.emit('llm/adapters-updated', []) b.mint('s1') const options = await b.popup().options(projection('s1'), new AbortController().signal) - expect(options[0]?.detail).toContain(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') + expect(options[0]?.group?.label).toBe(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') } finally { await b.ctx.fiber.dispose() } @@ -311,14 +318,14 @@ describe('ui-model-selection dual entry', () => { b.remote.emit('llm/adapters-updated', []) b.mint('s1') const before = await b.popup().options(projection('s1'), new AbortController().signal) - expect(before.some(option => option.detail?.includes('DeepSeek Account'))).toBe(true) + expect(before.some(option => option.group?.label === 'DeepSeek Account')).toBe(true) b.setGroups(GROUPS) b.remote.emit('credentials/record-updated', ['deepseek-account-platform']) await vi.waitFor(() => { expect(b.ctx.modelDirectories.directoryFor(sid('s1')).store.getSnapshot().groups).toEqual(GROUPS) }) const after = await b.popup().options(projection('s1'), new AbortController().signal) - expect(after.some(option => option.detail?.includes('DeepSeek Account'))).toBe(false) + expect(after.some(option => option.group?.label === 'DeepSeek Account')).toBe(false) expect(after.length).toBeGreaterThan(0) } finally { await b.ctx.fiber.dispose() diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index aafc5b98a9..470c852ec8 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SessionId } from '@deepseek-ai/dsh-session/types' import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' @@ -53,6 +53,27 @@ function state(overrides: Partial = {}): ModelDirectoryStat } } +function modelGroups(count: number): ModelDirectoryState['groups'] { + const group = state().groups[0]! + return [{ ...group, models: [ + ...group.models, + ...Array.from({ length: Math.max(0, count - 1) }, (_, index) => ({ + id: `model-${index + 2}`, name: `Model ${index + 2}`, + })), + ].slice(0, count) }] +} + +const scrollIntoView = vi.fn() +beforeEach(() => { + scrollIntoView.mockClear() + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, writable: true, value: scrollIntoView }) + onTestFinished(() => { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollIntoView') + else Object.defineProperty(Element.prototype, 'scrollIntoView', descriptor) + }) +}) + afterEach(cleanup) describe('ModelSelect reasoning effort', () => { @@ -214,7 +235,9 @@ describe('ModelSelect reasoning effort', () => { const trigger = screen.getByRole('button', { name: /选择模型|当前/ }) fireEvent.click(trigger) fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) - fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ })) + const rejected = screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }) + fireEvent.mouseMove(rejected) + fireEvent.click(rejected) const toast = await screen.findByRole('alert') expect(document.activeElement).toBe(trigger) expect(toast.textContent).toBe(sessionInUse @@ -222,6 +245,9 @@ describe('ModelSelect reasoning effort', () => { : '模型操作失败:session/model-unavailable: session already contains images') // The selection failure does not render the in-menu load strip (no Retry). expect(screen.queryByRole('button', { name: '重试' })).toBeNull() + fireEvent.keyDown(trigger, { key: 'Tab' }) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })) }) it('spins on the trigger and the chosen model row until the selection settles, across pane changes', async () => { @@ -469,15 +495,14 @@ describe('ModelSelect keyboard walk', () => { const trigger = screen.getByRole('button', { name: /选择模型/ }) fireEvent.click(trigger) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) - // No rows to hand the keyboard to: the trigger keeps it, so the card's - // keys still reach the menu. + expect(screen.queryByRole('searchbox')).toBeNull() expect(document.activeElement).toBe(trigger) const retry = screen.getByRole('button', { name: '重试' }) expect(fireEvent.mouseDown(retry)).toBe(false) fireEvent.click(retry) expect(load).toHaveBeenCalledTimes(2) - expect(screen.getByRole('menu')).toBeTruthy() + expect(screen.getByRole('group', { name: '模型与推理等级' })).toBeTruthy() retry.focus() // A control that is not a row keeps the browser's traversal. expect(fireEvent.keyDown(retry, { key: 'Tab' })).toBe(true) @@ -489,11 +514,12 @@ describe('ModelSelect keyboard walk', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('drills into the model list on the selected model', () => { + it('focuses the checked model in a small catalog', () => { mountOpen() fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows[0]!.getAttribute('aria-checked')).toBe('true') + expect(screen.queryByRole('searchbox')).toBeNull() expect(document.activeElement).toBe(rows[0]) }) @@ -520,7 +546,7 @@ describe('ModelSelect keyboard walk', () => { expect(document.activeElement).toBe(cells[0]) }) - it('a pane whose rows mark no current value opens on its first row', () => { + it('focuses the first row when no model is checked in a small catalog', () => { // The session runs a model the catalog no longer lists: no row is checked. render( { fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true) + expect(screen.queryByRole('searchbox')).toBeNull() expect(document.activeElement).toBe(rows[0]) }) }) +describe('ModelSelect catalog size', () => { + it.each([0, 1, 4, 5])('shows search only above four models (%i models)', (count) => { + const groups = modelGroups(count) + const current = { provider: 'deepseek-official', model: count > 1 ? 'model-2' : 'deepseek-v4-flash' } + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const rows = screen.queryAllByRole('menuitemradio') + expect(rows).toHaveLength(count) + if (count > 4) { + const search = screen.getByRole('searchbox') + expect(search).toBeInstanceOf(HTMLInputElement) + expect(document.activeElement).toBe(search) + expect(search.getAttribute('aria-activedescendant')).toBe(rows[1]!.id) + expect(rows.every(row => row.tabIndex === -1)).toBe(true) + } else { + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(count === 0 ? trigger : rows[count > 1 ? 1 : 0]) + expect(rows.every(row => row.tabIndex === 0)).toBe(true) + } + if (count === 0) expect(screen.getByRole('status').textContent).toBe(zh['empty.models']) + }) + + it.each([0, 1, 4, 5])('opens an unselected %i-model catalog on search or its first row', (count) => { + render() + const trigger = screen.getByRole('button', { name: '请选择模型' }) + fireEvent.click(trigger) + const rows = screen.queryAllByRole('menuitemradio') + expect(rows).toHaveLength(count) + expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true) + expect(document.activeElement).toBe(count > 4 ? screen.getByRole('searchbox') : rows[0] ?? trigger) + if (count <= 4) expect(screen.queryByRole('searchbox')).toBeNull() + }) + + it.each([true, false])('clears search and restores focus across 5 → 4 → 5 models (checked: %s)', (checked) => { + const current = { provider: 'deepseek-official', model: checked ? 'model-3' : 'removed' } + const directory = createSnapshotStore(state({ groups: modelGroups(5), current })) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const search = screen.getByRole('searchbox') + fireEvent.change(search, { target: { value: 'Model 5' } }) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(1) + expect(screen.getByRole('searchbox')).toBe(search) + expect(document.activeElement).toBe(search) + + act(() => { directory.set(state({ groups: modelGroups(4), current })) }) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(screen.queryByRole('button', { name: '清除搜索' })).toBeNull() + const rows = screen.getAllByRole('menuitemradio') + expect(rows).toHaveLength(4) + expect(document.activeElement).toBe(rows[checked ? 2 : 0]) + + act(() => { directory.set(state({ groups: modelGroups(5), current })) }) + const restored = screen.getByRole('searchbox') + expect(restored.getAttribute('value')).toBe('') + expect(document.activeElement).toBe(restored) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) + expect(restored.getAttribute('aria-activedescendant')) + .toBe(screen.getAllByRole('menuitemradio')[checked ? 2 : 0]!.id) + }) + + it('moves actual row focus with arrows, leaves Enter native, and selects with Tab in a small catalog', async () => { + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const rows = screen.getAllByRole('menuitemradio') + expect(document.activeElement).toBe(rows[1]) + expect(fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })).toBe(false) + expect(document.activeElement).toBe(rows[2]) + fireEvent.keyDown(rows[2]!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(rows[3]) + fireEvent.keyDown(rows[3]!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(rows[0]) + fireEvent.keyDown(rows[0]!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(rows[3]) + fireEvent.keyDown(rows[3]!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(rows[2]) + expect(rows[2]!.hasAttribute('data-highlighted')).toBe(true) + expect(fireEvent.keyDown(rows[2]!, { key: 'Enter' })).toBe(true) + expect(fireEvent.keyDown(rows[2]!, { key: 'ArrowLeft' })).toBe(true) + expect(document.activeElement).toBe(rows[2]) + expect(select).not.toHaveBeenCalled() + expect(fireEvent.keyDown(rows[2]!, { key: 'Tab' })).toBe(false) + expect(select).toHaveBeenCalledWith({ provider: 'deepseek-official', model: 'model-3' }) + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() + expect(trigger.hasAttribute('data-selection-focus')).toBe(true) + }) + + it('keeps Tab selection available after hovering a small-catalog row', async () => { + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const row = screen.getByRole('menuitemradio', { name: 'Model 3' }) + fireEvent.mouseMove(row) + expect(document.activeElement).toBe(row) + expect(row.hasAttribute('data-highlighted')).toBe(true) + expect(fireEvent.keyDown(row, { key: 'Tab' })).toBe(false) + expect(select).toHaveBeenCalledWith({ provider: 'deepseek-official', model: 'model-3' }) + await waitFor(() => { expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() }) + }) + + it.each(['Escape', 'Tab'])('leaves a small model pane with %s and returns to its root cell', (key) => { + const select = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const row = screen.getAllByRole('menuitemradio')[0]! + expect(fireEvent.keyDown(row, { key, shiftKey: key === 'Tab' })).toBe(false) + const cell = screen.getByRole('menuitem', { name: /^模型/ }) + expect(document.activeElement).toBe(cell) + expect(select).not.toHaveBeenCalled() + fireEvent.keyDown(cell, { key, shiftKey: key === 'Tab' }) + expect(screen.queryByRole('menu')).toBeNull() + }) +}) + +describe('ModelSelect search', () => { + it('clears search when reopening an unselected large catalog', () => { + render() + const trigger = screen.getByRole('button', { name: '请选择模型' }) + fireEvent.click(trigger) + const search = screen.getByRole('searchbox') + expect(document.activeElement).toBe(search) + fireEvent.change(search, { target: { value: 'zzzz' } }) + fireEvent.keyDown(search, { key: 'Escape' }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() + fireEvent.click(trigger) + expect(screen.getByRole('searchbox').getAttribute('value')).toBe('') + expect(document.activeElement).toBe(screen.getByRole('searchbox')) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) + }) + + it('hides empty provider headings and announces an empty catalog', () => { + const directory = createSnapshotStore(state({ groups: [ + ...state().groups, { id: 'empty', name: 'Empty Provider', models: [] }, + ] })) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.queryByRole('group', { name: 'Empty Provider' })).toBeNull() + act(() => { directory.set(state({ groups: [] })) }) + expect(screen.getByRole('status').textContent).toBe(zh['empty.models']) + expect(screen.getByRole('status').closest('[role="menu"]')).toBeNull() + }) + + it.each(['Enter', 'Tab'])('returns focus silently after %s accepts the current model, without suppressing later focus', async (key) => { + const select = vi.fn() + render(<> + + + ) + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + fireEvent.keyDown(screen.getByRole('searchbox'), { key }) + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(true) + expect(select).not.toHaveBeenCalled() + act(() => { screen.getByRole('button', { name: 'Outside' }).focus() }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(false) + act(() => { trigger.focus() }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(false) + }) + + it.each(['Enter', 'Tab'])('keeps typing focus while arrows wrap across groups and %s accepts the highlight', async (key) => { + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + const directory = createSnapshotStore(state({ + current: { provider: 'deepseek-official', model: 'beta' }, + groups: [ + { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }] }, + { id: 'other', name: 'Other', models: [ + { id: 'delta', name: 'Delta' }, { id: 'epsilon', name: 'Epsilon' }, { id: 'gamma', name: 'Gamma' }, + ] }, + ], + })) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + const search = screen.getByRole('searchbox') + expect(search).toBeInstanceOf(HTMLInputElement) + const [alpha, beta, delta, epsilon, gamma] = screen.getAllByRole('menuitemradio') + expect(search.getAttribute('aria-activedescendant')).toBe(beta!.id) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(search.getAttribute('aria-activedescendant')).toBe(delta!.id) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(search.getAttribute('aria-activedescendant')).toBe(epsilon!.id) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(search.getAttribute('aria-activedescendant')).toBe(gamma!.id) + expect(document.activeElement).toBe(search) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(search.getAttribute('aria-activedescendant')).toBe(alpha!.id) + fireEvent.keyDown(search, { key: 'ArrowUp' }) + expect(search.getAttribute('aria-activedescendant')).toBe(gamma!.id) + expect(gamma!.hasAttribute('data-highlighted')).toBe(true) + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }) + expect(scrollIntoView.mock.instances.at(-1)).toBe(gamma) + expect(fireEvent.keyDown(search, { key: 'ArrowLeft' })).toBe(true) + expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true) + fireEvent.keyDown(search, { key: 'ArrowDown', isComposing: true }) + expect(search.getAttribute('aria-activedescendant')).toBe(gamma!.id) + fireEvent.change(search, { target: { value: 'alp' } }) + expect(search.getAttribute('aria-activedescendant')).toBe(screen.getByRole('menuitemradio', { name: 'Alpha' }).id) + fireEvent.change(search, { target: { value: 'zzzz' } }) + expect(search.hasAttribute('aria-activedescendant')).toBe(false) + fireEvent.keyDown(search, { key: 'Enter' }) + expect(select).not.toHaveBeenCalled() + expect(fireEvent.keyDown(search, { key: 'Tab' })).toBe(true) + fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) + const restored = screen.getAllByRole('menuitemradio') + expect(search.getAttribute('aria-activedescendant')).toBe(restored[0]!.id) + fireEvent.mouseMove(restored[4]!) + expect(search.getAttribute('aria-activedescendant')).toBe(restored[4]!.id) + expect(document.activeElement).toBe(search) + fireEvent.keyDown(search, { key }) + expect(select).toHaveBeenCalledWith({ provider: 'other', model: 'gamma' }) + await waitFor(() => { expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() }) + const trigger = screen.getByRole('button', { name: /选择模型/ }) + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(true) + }) + + it('filters model names fuzzily, hides empty groups, clears on reopening, and selects a result', async () => { + const directory = createSnapshotStore(state({ groups: [ + ...modelGroups(4), + { id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] }, + ] })) + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + const search = screen.getByRole('searchbox') + expect(document.activeElement).toBe(search) + expect(search.closest('[role="menu"]')).toBeNull() + expect(fireEvent.keyDown(search, { key: 'ArrowDown', isComposing: true })).toBe(true) + expect(document.activeElement).toBe(search) + fireEvent.change(search, { target: { value: ' GMFL ' } }) + expect(screen.getAllByRole('menuitemradio').map(row => row.textContent)).toEqual(['Gemini Flash']) + expect(screen.getByRole('searchbox')).toBe(search) + expect(document.activeElement).toBe(search) + expect(screen.queryByRole('group', { name: 'DeepSeek' })).toBeNull() + expect(trigger.textContent).toContain('DeepSeek-V4-Flash') + fireEvent.change(search, { target: { value: 'zzzz' } }) + const status = screen.getByRole('status') + expect(status.textContent).toBe('没有匹配的模型。') + expect(status.closest('[role="menu"]')).toBeNull() + expect(screen.queryByRole('menu')).toBeNull() + expect(fireEvent.keyDown(search, { key: 'ArrowDown' })).toBe(false) + expect(document.activeElement).toBe(search) + fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) + expect(search.getAttribute('value')).toBe('') + expect(document.activeElement).toBe(search) + expect(screen.queryByRole('button', { name: '清除搜索' })).toBeNull() + expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) + fireEvent.change(search, { target: { value: 'gmfl' } }) + fireEvent.keyDown(search, { key: 'Escape' }) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.getByRole('searchbox').getAttribute('value')).toBe('') + const reopened = screen.getByRole('searchbox') + fireEvent.change(reopened, { target: { value: 'gmfl' } }) + const row = screen.getByRole('menuitemradio', { name: 'Gemini Flash' }) + expect(screen.getByRole('menu', { name: '模型' }).contains(row)).toBe(true) + fireEvent.keyDown(reopened, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(reopened) + expect(reopened.getAttribute('aria-activedescendant')).toBe(row.id) + fireEvent.keyDown(reopened, { key: 'Tab' }) + await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() }) + expect(select).toHaveBeenCalledWith({ provider: 'other', model: 'gemini' }) + }) +}) + it('shows the unselected model control with the inherited effort', async () => { const directory = createSnapshotStore(state({ current: null, routable: false, retainedEffort: 'High' })) render() @@ -547,10 +861,15 @@ it('shows the unselected model control with the inherited effort', async () => { expect(trigger.textContent).toContain('High') fireEvent.click(trigger) expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull() - const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) - expect(document.activeElement).toBe(model) - fireEvent.keyDown(model, { key: 'Escape' }) - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() + expect(screen.queryByRole('searchbox')).toBeNull() + const row = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) + expect(document.activeElement).toBe(row) + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() + fireEvent.click(trigger) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })) }) diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index aec8a85468..29501d3d4e 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -27,8 +27,8 @@ en: a13078ade4ef3b9d zh: 2c8b3e8b62ad5652 /deepseek-ai-dsh-client-ui-plan/understand-the-implementation: - en: 1360fc232aee29e7 - zh: 0beca76c9214a079 + en: 9b663a9a30286847 + zh: 9dca5298fce6a6dd /deepseek-ai-dsh-client-ui-plan/further-exploration: en: 59f62ef6269abf82 zh: c8b4ee607be1e432 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index e9e38251ff..e02091c34b 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. -Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a seat is on screen: a review arriving while a global panel is active mounts ahead of the returning seat in the same commit, before that seat binds. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. +Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a Session is on screen: the Sidebar names the returning Session before the commit that mounts the review renders, so a review that arrived while a global panel or another Session was on screen opens from that commit's effect. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. The framework-bound `usePlans(turn)` exposes only submitted-plan data for that Turn. Chat indexes membership on Node updates and orders this collection when read; card rendering neither scans the transcript nor subscribes to other Turns or Node kinds. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 91fe232d27..5bb898b582 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。 -计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在座位出现在屏幕上后才执行:全局面板打开期间到达的审批,会在返回时与座位在同一次提交里先挂载、早于座位完成绑定。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在有会话出现在屏幕上后才执行:Sidebar 在挂载审批的那次提交渲染之前就已给出返回的会话,因此在全局面板或其他会话占据屏幕期间到达的审批,会在那次提交的 effect 里打开。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 框架绑定的 `usePlans(turn)` 只提供该回合的已提交计划数据。Chat 随节点更新维护成员索引,并在读取时排列该集合;卡片渲染不扫描整段对话,也不订阅其他回合或节点类型。 diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index b78454d0e7..e629b024c3 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -30,7 +30,7 @@ export interface PlanReviewOpenInjected { /** Open the logged plan, or the request's temporary document when no invocation exists. */ openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => void readonly hooks: { - /** The session whose right Sidebar seat is mounted; `undefined` while none is on screen. */ + /** The Session on screen, whose right Sidebar the opens land in; `undefined` while none is. */ readonly sidebarMounted: HostObservable } } @@ -62,10 +62,10 @@ export function PlanCards({ turn, usePlans, openPlan, t }: PropsRuntime<'convers /** * Open each pending plan automatically and retain a manual opener without answering it. * - * The automatic open waits for a mounted Sidebar seat: a review that arrives - * while the Conversation is off screen mounts in the same commit as the seat, - * ahead of it, and the seat binds from its own effect. Reading the bound - * session through the hook opens once that binding exists. + * The automatic open waits for a Session on screen. `ctx.sidebarRight` names + * the Session before the commit that brings its Conversation back renders, so + * a review that arrived while the Conversation was off screen opens from the + * effect of the commit that mounts it. * @param props - Review identity, Session store, localized copy, and navigation. * @returns an opener for either logged or temporary plan text. */ diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index 6567c45cb1..266ddeda6a 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -109,14 +109,14 @@ describe('ui-plan browser apply', () => { const tabs = new SidebarRightTabRegistry(b.ctx) b.registerType.mockImplementation(definition => tabs.register(definition)) const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'Guide' })).create() - const { controller, adopt } = createSidebarRightController(tabs, vi.fn()) + const { controller, adopt, show } = createSidebarRightController(tabs, vi.fn(), { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) const release = adopt(parent, store) store.actions.open(parent) - const unbind = controller.bind({ - sessionId: parent, actions: store.actions, surfaces: store.getSnapshot().bySession, canSplitPane: () => true, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - }) + show(parent) b.openResource.mockImplementation(controller.openResource.bind(controller)) b.openResourceIn.mockImplementation(controller.openResourceIn.bind(controller)) b.subagentAddress.mockReturnValue({ parentSessionId: parent, childSessionId: child, mode: 'continuable' }) @@ -137,7 +137,7 @@ describe('ui-plan browser apply', () => { expect(store.getSnapshot().bySession[child]).toBeUndefined() } finally { await fiber.dispose() - unbind() + show(undefined) release() controller.tabDomain.dispose() } @@ -220,7 +220,7 @@ describe('ui-plan browser apply', () => { const review = b.slots.entries('conversation.plan-review.actions')[0]! expect(review.component).toBe(PlanReviewOpen) const reviewInjected = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(SID) - // The automatic open reads the service's mounted-seat source, not a copy. + // The automatic open reads the service's on-screen Session source, not a copy. expect(reviewInjected.hooks.sidebarMounted).toBe(b.mounted) const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } } reviewInjected.openReview(pending, 'question:1') diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx index 73e08d4b2d..1a4fa9aac9 100644 --- a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -29,13 +29,13 @@ function planHook(plans: readonly typeof plan[]): Parameters[0 const source = createSnapshotStore(plans) return keyedObservableHook(() => source) as Parameters[0]['usePlans'] } -/** The opener's props share; each test supplies the seat hook beside it. */ +/** The opener's props share; each test supplies the on-screen Session hook beside it. */ function reviewProps(review: { plan: string; callId?: typeof plan.callId }, openReview: Mock, store: ReturnType['create']>) { return { review, requestKey: 'question:1', t, openReview, actions: store.actions, useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), } as unknown as Parameters[0] } -/** A bound `useSidebarMounted` reading one observable seat value. */ +/** A bound `useSidebarMounted` reading one on-screen Session value. */ function seatHook(read: () => SessionId | undefined) { return (select: (session: SessionId | undefined) => S): S => select(read()) } @@ -257,9 +257,8 @@ describe('plan entry points and document', () => { } as Parameters[0]} />) expect(openReview).toHaveBeenCalledTimes(4) }) - it('waits for a mounted sidebar seat before opening automatically, then opens once', () => { - // The review and the seat mount in one commit, the review first: its effect - // runs while no seat is bound, and the seat's own effect binds afterwards. + it('waits for a Session on screen before opening automatically, then opens once', () => { + // A retained Sidebar can hold the review while no Session is on screen. const openReview = vi.fn() const store = createPlanReviewStore().create() const mounted = createSnapshotStore(undefined) @@ -268,7 +267,7 @@ describe('plan entry points and document', () => { const view = render() expect(openReview).not.toHaveBeenCalled() expect(store.getSnapshot().opened).toEqual({}) - // The manual opener stays available without a seat; the service decides what to do. + // The manual opener stays available without a Session on screen; the service decides what to do. fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' })) expect(openReview).toHaveBeenCalledTimes(1) mounted.set(target.session.sessionId) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 29acda2d0f..6467056546 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -18,8 +18,8 @@ en: b7d2396da72ecac4 zh: 4ec8336478dd39c7 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: 20e7310ab6867389 - zh: eed2957381abc0bd + en: 7f4438da14f803bc + zh: 1728231acbd25d49 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: abc90531efa889d5 zh: 0562570f85e69c35 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index c075e53cf0..63565bd596 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -41,7 +41,7 @@ On first use, when pnpm uses the unconfigured official npm registry and npmmirro The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. -**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. Git and local-path examples require the actual address or path; a hint appears while either example remains in the field. A trust warning stays above **Install**, including when the guide is collapsed. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the package-name example; **Use example** drops it into the field. Typing or pasting the Git or local-path template verbatim still raises the reminder asking for the actual value. A trust box above **Install** stays visible including when the guide is collapsed, and adds that installed plugins do not update automatically: upgrading means uninstalling and installing the new version, which later releases will keep improving. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 0d8e0f5afd..383c2c19f1 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 -**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。Git 地址和本地路径示例需要替换为实际值;输入框中保留示例时会显示提醒。可信来源提示始终显示在**安装**按钮上方,引导收起后也可见。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出包名示例;**填入示例**把它填进输入框。输入框中若原样保留 Git 地址或本地路径的模板文本,仍会显示要求替换为实际值的提醒。**安装**按钮上方的提示框在引导收起后也可见,并说明插件安装后暂不支持自动更新:升级需先卸载再安装新版,后续版本会持续改善升级体验。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index f2818ba19a..6af4a623fe 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -535,7 +535,7 @@ box-shadow: inset 0 0 0 0.5px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); } -/* The guide under the field: opened by a text button, it says what an ID is and shows the three forms with examples. */ +/* The guide under the field: opened by a text button, it names the npm package name and shows its example. */ .guideToggle { display: inline-flex; align-items: center; @@ -567,17 +567,17 @@ display: flex; flex-direction: column; gap: 10px; - padding: 8px 14px 14px; + padding: 8px 14px; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-lg); background: var(--dsw-alias-bg-layer-1); } .guideHint { - margin: 0; + margin: 6px 0 0; font-size: 12px; line-height: 18px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); } /* Trust guidance stays beside the install action, including while examples are collapsed. */ @@ -594,6 +594,13 @@ color: var(--dsw-alias-label-secondary); } +.installSafetyText { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + .installSafety > svg { flex: none; margin-top: 2px; @@ -620,21 +627,6 @@ align-self: center; } -.guideIndex { - display: inline-flex; - flex: none; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 999px; - corner-shape: round; - background: var(--dsw-alias-bg-module-platform); - font-size: 11px; - font-weight: 500; - color: var(--dsw-alias-label-secondary); -} - .guideMain { display: flex; flex: 1; @@ -653,7 +645,7 @@ .guideExample { font-size: 12px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 92d06906f0..26ba779529 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -668,11 +668,9 @@ interface GuideExample { readonly hintKey: PluginManagerLocaleKey } -/** The spec forms the install guide shows, each with an example the person can drop into the field. */ +/** The spec form the install guide shows, with an example the person can drop into the field. */ const GUIDE_EXAMPLES = [ { key: 'id', titleKey: 'installGuideIdTitle', exampleKey: 'installGuideIdExample', hintKey: 'installGuideIdHint' }, - { key: 'git', titleKey: 'installGuideGitTitle', exampleKey: 'installGuideGitExample', hintKey: 'installGuideGitHint' }, - { key: 'path', titleKey: 'installGuidePathTitle', exampleKey: 'installGuidePathExample', hintKey: 'installGuidePathHint' }, ] as const satisfies readonly GuideExample[] /** The one-line reading of a classified pnpm failure. */ @@ -850,7 +848,7 @@ function InstallDialog({ variant="primary" autoFocus onClick={() => { - // The mirror is already asked, so the form opens with the guide to the other kinds of spec. + // The mirror is already asked, so the form opens with the package-name guide. if (anotherWay) setGuideOpen(true) onUseGithubMirror() }} @@ -892,7 +890,10 @@ function InstallDialog({

+ + +
, + ) + const viewport = screen.getByTestId('viewport') + const groups = screen.getAllByRole('group').map(section => ({ + section, heading: directChild(section, 'data-menu-group-heading'), start: directChild(section, 'data-menu-group-start'), + })) + return { ...view, viewport, groups } +} + +function entry( + target: Element, top: number, height: number, time: number, rootHeight = 1, + rootBounds: DOMRectReadOnly | null = new DOMRect(0, 100, 200, rootHeight), +): IntersectionObserverEntry { + const bottom = top + height + const intersectionTop = Math.max(top, 100) + const intersectionBottom = Math.min(bottom, 100 + rootHeight) + const intersectionHeight = Math.max(0, intersectionBottom - intersectionTop) + return { + target, time, boundingClientRect: new DOMRect(0, top, 200, height), + rootBounds, + intersectionRect: new DOMRect(0, intersectionTop, 200, intersectionHeight), + intersectionRatio: intersectionHeight / height, isIntersecting: bottom >= 100 && top <= 100 + rootHeight, + } +} + +function startEntry(target: Element, top: number, time: number): IntersectionObserverEntry { + return entry(target, top, 1, time, 200) +} + +function startObserving(viewport: HTMLElement) { + const dispose = observe(viewport) + const start = intersections[0]! + const size = sizes[0]! + size.notify(viewport) + const strip = intersections[1]! + return { dispose, start, size, strip } +} + +describe('MenuGroup', () => { + it('names each section with a unique direct heading and keeps the sentinel out of the accessible tree', () => { + const { groups: [first, second] } = fixture() + expect(first!.section.tagName).toBe('SECTION') + expect(first!.section.hasAttribute('data-menu-group')).toBe(true) + expect(first!.section.getAttribute('aria-labelledby')).toBe(first!.heading.id) + expect(second!.section.getAttribute('aria-labelledby')).toBe(second!.heading.id) + expect(first!.heading.id).not.toBe('') + expect(first!.heading.id).not.toBe(second!.heading.id) + expect(first!.start.getAttribute('aria-hidden')).toBe('true') + expect(isInaccessible(first!.start)).toBe(true) + expect(first!.start.textContent).toBe('') + expect(first!.start.hasAttribute('tabindex')).toBe(false) + expect(first!.heading.nextElementSibling).toBe(within(first!.section).getByRole('button', { name: 'Model A' })) + }) + + it('renders a named group without rows', () => { + render() + const group = screen.getByRole('group', { name: 'Empty provider' }) + expect(group.children).toHaveLength(2) + expect(group.getAttribute('aria-labelledby')).toBe(within(group).getByText('Empty provider').id) + }) +}) + +describe('observeStickyMenuGroups', () => { + it('does not synchronously read layout when observing a 30-model menu', () => { + render(
{Array.from({ length: 6 }, (_, index) => + + {Array.from({ length: 5 }, (_, row) => )} + )}
) + const viewport = screen.getByTestId('large-viewport') + const groups = screen.getAllByRole('group') + const read = vi.spyOn(Element.prototype, 'getBoundingClientRect') + const clientRects = vi.spyOn(Element.prototype, 'getClientRects') + const computedStyle = vi.spyOn(window, 'getComputedStyle') + const offsetTop = vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get') + const offsetHeight = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + const clientHeight = vi.spyOn(Element.prototype, 'clientHeight', 'get') + const addScroll = vi.spyOn(viewport, 'addEventListener') + const addResize = vi.spyOn(window, 'addEventListener') + const dispose = observe(viewport) + expect(read).not.toHaveBeenCalled() + expect(intersections).toHaveLength(1) + expect(sizes).toHaveLength(1) + const start = intersections[0]! + const size = sizes[0]! + expect(start.observe).toHaveBeenCalledTimes(6) + expect(size.observe.mock.calls).toEqual([[viewport]]) + size.notify(viewport) + const strip = intersections[1]! + expect(strip.observe.mock.calls).toEqual(groups.map(group => [group])) + strip.notify(...groups.map((group, index) => entry(group, 100 + index * 203, 200, 1))) + start.notify(...groups.map((group, index) => startEntry(directChild(group, 'data-menu-group-start'), 100 + index * 203, 1))) + expect(directChild(groups[0]!, 'data-menu-group-heading').hasAttribute('data-stuck')).toBe(false) + viewport.scrollTop = 0.25 + fireEvent.scroll(viewport) + start.notify(startEntry(directChild(groups[0]!, 'data-menu-group-start'), 99.75, 2)) + expect(directChild(groups[0]!, 'data-menu-group-heading').hasAttribute('data-stuck')).toBe(true) + fireEvent.resize(window) + size.notify(viewport, 180) + intersections[2]!.notify(entry(groups[0]!, 99.75, 200, 3)) + dispose() + for (const spy of [read, clientRects, computedStyle, offsetTop, offsetHeight, clientHeight]) { + expect(spy).not.toHaveBeenCalled() + } + expect(addScroll).not.toHaveBeenCalled() + expect(addResize).not.toHaveBeenCalled() + }) + + it('waits for asynchronous size and intersection delivery, including an already-scrolled viewport', () => { + const { viewport, groups: [first] } = fixture() + viewport.scrollTop = 20 + observe(viewport) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(intersections).toHaveLength(1) + intersections[0]!.notify(startEntry(first!.start, 80, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + sizes[0]!.notify(viewport) + intersections[1]!.notify(entry(first!.section, 80, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + }) + + it('keeps the initial top transparent, handles fractional crossing, and clears on upward return to zero', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + expect(start.root).toBe(viewport) + expect(start.thresholds).toEqual([0, 1]) + expect(strip.root).toBe(viewport) + expect(strip.rootMargin).toBe('0px 0px -199px 0px') + expect(strip.thresholds).toEqual([0]) + const write = vi.spyOn(first!.heading, 'toggleAttribute') + strip.notify(entry(first!.section, 100, 100, 1)) + start.notify(startEntry(first!.start, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(write).not.toHaveBeenCalled() + // The section still intersects the strip, so only the sentinel crosses a threshold. + start.notify(startEntry(first!.start, 99.75, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + start.notify(startEntry(first!.start, 99, 3)) + expect(write).toHaveBeenCalledTimes(1) + start.notify(startEntry(first!.start, 100, 4)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(write).toHaveBeenCalledTimes(2) + }) + + it('clears at the section tail and hands off only after the following group crosses the top', () => { + const { viewport, groups: [first, second] } = fixture() + const { start, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1), entry(second!.section, 183, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + expect(second!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, 0, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, -3, 100, 3), entry(second!.section, 100, 100, 3)) + expect(second!.heading.hasAttribute('data-stuck')).toBe(false) + start.notify(startEntry(second!.start, 99.75, 4)) + expect(second!.heading.hasAttribute('data-stuck')).toBe(true) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + }) + + it('finds the middle group after a fast jump even when its sentinel reports no new intersection', () => { + const { viewport, groups: [first, second, third] } = fixture() + const { start, strip } = startObserving(viewport) + start.notify(startEntry(first!.start, 100, 1), startEntry(second!.start, 503, 1), startEntry(third!.start, 906, 1)) + strip.notify(entry(first!.section, 100, 400, 1), entry(second!.section, 503, 400, 1), entry(third!.section, 906, 400, 1)) + strip.notify(entry(first!.section, -450, 400, 2), entry(second!.section, -47, 400, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(second!.heading.hasAttribute('data-stuck')).toBe(true) + expect(third!.heading.hasAttribute('data-stuck')).toBe(false) + }) + + it('orders samples from both observers by entry time rather than callback delivery', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + start.notify(startEntry(first!.start, 99.75, 3)) + strip.notify(entry(first!.section, 100, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + start.notify(startEntry(first!.start, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + strip.notify(entry(first!.section, -1, 100, 5)) + strip.notify(entry(first!.section, 80, 100, 4)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, 80, 100, 6)) + start.notify(startEntry(first!.start, 100, 5)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + }) + + it('rebuilds only the strip on viewport height changes and invalidates its old callback', () => { + const { viewport, groups: [first] } = fixture() + const { size, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + size.notify(viewport) + size.notify(first!.section, 400) + expect(intersections).toHaveLength(2) + size.notify(viewport, 400) + expect(strip.disconnect).toHaveBeenCalledOnce() + const replacement = intersections[2]! + expect(replacement.rootMargin).toBe('0px 0px -399px 0px') + replacement.notify(entry(first!.section, 100, 100, 2)) + const write = vi.spyOn(first!.heading, 'toggleAttribute') + strip.notify(entry(first!.section, 80, 100, 100)) + expect(write).not.toHaveBeenCalled() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + size.notify(viewport, 0.5) + expect(intersections[3]!.rootMargin).toBe('0px 0px 0px 0px') + size.notify(viewport, 0) + expect(intersections[4]!.rootMargin).toBe('0px 0px 0px 0px') + }) + + it('ignores unavailable root bounds and unrelated intersection targets', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + strip.notify(entry(first!.section, 100, 100, 2, 1, null), entry(viewport, 100, 100, 2)) + start.notify(entry(first!.start, 100, 1, 2, 200, null), startEntry(viewport, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + }) + + it('disconnects every observer, clears only stuck headings, and prevents all late writes', () => { + const { viewport, groups: [first] } = fixture() + const { dispose, start, size, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + const remove = vi.spyOn(first!.heading, 'removeAttribute') + dispose() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(remove).toHaveBeenCalledOnce() + const write = vi.spyOn(first!.heading, 'toggleAttribute') + start.notify(startEntry(first!.start, 80, 2)) + strip.notify(entry(first!.section, 80, 100, 2)) + size.notify(viewport, 400) + dispose() + expect(intersections).toHaveLength(2) + expect(write).not.toHaveBeenCalled() + expect(remove).toHaveBeenCalledOnce() + for (const observer of [start, size, strip]) expect(observer.disconnect).toHaveBeenCalledOnce() + }) + + it('can dispose before the first size delivery without constructing a strip observer', () => { + const { viewport } = fixture() + const dispose = observe(viewport) + dispose() + sizes[0]!.notify(viewport) + expect(intersections).toHaveLength(1) + expect(sizes[0]!.disconnect).toHaveBeenCalledOnce() + }) + + it('recaptures filtered groups and rejects callbacks from the previous setup', () => { + const { viewport, rerender, groups: [first] } = fixture() + const previous = startObserving(viewport) + previous.strip.notify(entry(first!.section, 80, 100, 1)) + previous.dispose() + rerender(
) + const group = screen.getByRole('group', { name: 'Filtered' }) + const heading = directChild(group, 'data-menu-group-heading') + observe(viewport) + sizes[1]!.notify(viewport) + const current = intersections[3]! + expect(current.observe.mock.calls).toEqual([[group]]) + current.notify(entry(group, 80, 100, 2)) + expect(heading.hasAttribute('data-stuck')).toBe(true) + const write = vi.spyOn(heading, 'toggleAttribute') + previous.start.notify(startEntry(first!.start, 100, 3)) + previous.strip.notify(entry(first!.section, 100, 100, 3)) + previous.size.notify(viewport, 400) + expect(write).not.toHaveBeenCalled() + expect(heading.hasAttribute('data-stuck')).toBe(true) + expect(intersections).toHaveLength(4) + }) + + it('observes only direct groups with both direct headings and start sentinels', () => { + render(
+ +
Indirect
+
No start
+
Unmarked
+
) + const viewport = screen.getByTestId('viewport') + const group = screen.getByRole('group', { name: 'Direct' }) + const { start, strip } = startObserving(viewport) + expect(start.observe.mock.calls).toEqual([[directChild(group, 'data-menu-group-start')]]) + expect(strip.observe.mock.calls).toEqual([[group]]) + strip.notify(entry(group, 80, 100, 1)) + expect(screen.getByText('Direct').hasAttribute('data-stuck')).toBe(true) + for (const label of ['Nested', 'Indirect', 'No start', 'Unmarked']) { + expect(screen.getByText(label).hasAttribute('data-stuck')).toBe(false) + } + }) + + it('acquires no observers or listeners without valid direct groups', () => { + render(
) + const viewport = screen.getByTestId('viewport') + const addScroll = vi.spyOn(viewport, 'addEventListener') + const addResize = vi.spyOn(window, 'addEventListener') + const dispose = observe(viewport) + expect(intersections).toHaveLength(0) + expect(sizes).toHaveLength(0) + expect(addScroll).not.toHaveBeenCalled() + expect(addResize).not.toHaveBeenCalled() + expect(dispose).not.toThrow() + }) + + it.each(['IntersectionObserver', 'ResizeObserver'])('keeps CSS sticky headings transparent without %s', (api) => { + vi.stubGlobal(api, undefined) + const { viewport, groups: [first] } = fixture() + const read = vi.spyOn(Element.prototype, 'getBoundingClientRect') + const dispose = observe(viewport) + viewport.scrollTop = 20 + fireEvent.scroll(viewport) + fireEvent.resize(window) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(intersections).toHaveLength(0) + expect(sizes).toHaveLength(0) + expect(read).not.toHaveBeenCalled() + expect(dispose).not.toThrow() + }) +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 5aba3a01d9..1b597f4d27 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -24,8 +24,8 @@ en: 6b3ad3b6860100cf zh: aeced4fb37642f15 /deepseek-ai-dsh-client-ui-settings-account/further-exploration: - en: c785923662681191 - zh: f24389c25e954035 + en: a28acdef21b1643a + zh: 5e2e357a6b716274 /deepseek-ai-dsh-client-ui-settings-account/model-experience: en: ee1df7ca3467ee3f zh: 84904a5148da3862 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 75f37f6789..18604436f7 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -77,7 +77,7 @@ Account login uses a dismissible dialog before the model onboarding credential e A terminal account-state stream failure appears in the sign-in dialog or Account settings. Plugin unload suppresses late failure reports. -The balance card shows recharge funds and bonus credit in separate rows. The bonus row is present while signed in; without positive bonus credit it states that no bonus is available. A failed wallet read shows the existing unavailable copy as a link to Platform — the same destination and behavior as the Usage action, so the embedded page opens on Desktop and a new tab elsewhere — while the loading and empty states stay plain text. Currencies retain their own amounts, and a notice date is rendered only inside the server’s own message. +The balance card shows recharge funds and bonus credit in separate rows. While signed in, the bonus row appears during loading, after a failed wallet read, and when a wallet holds positive bonus credit; a successful read without positive bonus credit omits the row. A failed wallet read shows the existing unavailable copy as a link to Platform — the same destination and behavior as the Usage action, so the embedded page opens on Desktop and a new tab elsewhere — while the loading state stays plain text. Currencies retain their own amounts, and a notice date is rendered only inside the server’s own message. A successful login selects the first available `deepseek-account` model as the default when no other provider has a configured API key, even if a previous default was saved. Configured keys preserve the existing default even when their provider has no available models. Session-specific selections remain unchanged. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 1b7b184587..ccc6daaeae 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -77,7 +77,7 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 账户状态流的终止错误会显示在登录对话框或账户设置中。插件卸载后不再报告延迟到达的错误。 -余额卡片分行展示充值余额与赠金余额。赠金行在登录态下常驻;没有正额赠金时显示暂无可用赠金。余额或赠金读取失败时,沿用既有的不可用文案并改为指向 Platform 的链接,目标与行为同“查询用量”一致:Desktop 打开内嵌页面,其它环境新开标签页;加载中与暂无赠金仍为纯文本。不同币种分别显示;日期只在服务端自己的消息中出现。 +余额卡片分行展示充值余额与赠金余额。登录态下,赠金行在加载中、余额读取失败以及存在正额赠金时显示;读取成功但没有正额赠金时不显示该行。余额或赠金读取失败时,沿用既有的不可用文案并改为指向 Platform 的链接,目标与行为同“查询用量”一致:Desktop 打开内嵌页面,其它环境新开标签页;加载中仍为纯文本。不同币种分别显示;日期只在服务端自己的消息中出现。 登录成功时,若其他提供方均未配置 API key,则将 `deepseek-account` 的第一个可用模型设为默认值,即使此前保存过默认选择。其他提供方只要配置了 key,就保留原默认值,即使其没有可用模型。会话内的显式选择保持不变。 diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index d59b45a976..6d020ad29a 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -93,6 +93,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla const wallets = details?.balance?.status === 'ready' ? details.balance.value : undefined const bonusWallets = details?.balance?.status === 'ready' ? details.balance.bonusWallets.filter(wallet => new Big(wallet.balance).gt(0)) : [] + const showBonusRow = details?.balance?.status !== 'ready' || bonusWallets.length > 0 const attempt = state?.attempt const active = attempt !== null && attempt !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(attempt.phase) @@ -183,7 +184,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla ? {t(!signedIn ? 'balanceSignedOut' : 'loading')} : platformLink(t('balanceUnavailable'), css.unavailableLink)}
- {signedIn && <> + {signedIn && showBonusRow && <>
{t('bonusBalance')} @@ -194,9 +195,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla )} : details?.balance === undefined ? {t('loading')} - : details.balance.status === 'failed' - ? platformLink(t('balanceUnavailable'), css.unavailableLink) - : {t('bonusEmpty')}} + : platformLink(t('balanceUnavailable'), css.unavailableLink)}
} diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index 9b6178bf38..e6f743a4a5 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -33,7 +33,6 @@ export const en = { quotaDescription: 'DeepSeek Harness cannot start a new task with this account when no balance is available. Would you like to top up? You can also top up later in Settings → Account.', quotaTopUp: 'Top up', bonusNoticeTitle: 'Bonus credited', - bonusEmpty: 'No bonus available', } as const /** Account locale keys. */ export type AccountKey = keyof typeof en @@ -69,5 +68,4 @@ export const zh: Record = { quotaDescription: '没有可用额度时,DeepSeek Harness 无法开始新的任务,是否前往充值?或者可以稍后前往 设置 → 账号与余额 进行充值。', quotaTopUp: '去充值', bonusNoticeTitle: '赠金已到账', - bonusEmpty: '暂无可用赠金', } diff --git a/packages/client/ui-settings-account/src/client/locales/onboarding.ts b/packages/client/ui-settings-account/src/client/locales/onboarding.ts index 5e3b71cf20..509986446e 100644 --- a/packages/client/ui-settings-account/src/client/locales/onboarding.ts +++ b/packages/client/ui-settings-account/src/client/locales/onboarding.ts @@ -30,10 +30,10 @@ export const onboardingCopy = { onboardingBack: '上一步', onboardingSkip: '跳过', onboardingSkipTitle: '跳过设置?', - onboardingSkipDescription: '你可以随时前往 个人中心 → 通用设置 中调整工作过程、性能与用量展示方式或开启代码工作工具。', + onboardingSkipDescription: '你可以随时前往 设置 → 通用设置 中调整工作过程、性能与用量展示方式或开启代码工作工具。', onboardingKeepSetting: '继续设置', onboardingNoCreditTitle: '暂时跳过充值?', - onboardingNoCreditDescription: '没有可用额度时,DeepSeek Harness 将无法开始新的任务。你可以稍后前往 个人中心 → 账号与余额 进行充值。', + onboardingNoCreditDescription: '没有可用额度时,DeepSeek Harness 将无法开始新的任务。你可以稍后前往 设置 → 账号与余额 进行充值。', onboardingUnderstood: '我知道了', onboardingGoTopUp: '前往充值', onboardingSaveFailed: '未能保存设置,请重试。', @@ -73,10 +73,10 @@ export const onboardingEnglishCopy: Record onboardingBack: 'Back', onboardingSkip: 'Skip', onboardingSkipTitle: 'Skip setup?', - onboardingSkipDescription: 'You can change how progress, performance, and usage are displayed, or enable Coding Tools, anytime in Profile → General Settings.', + onboardingSkipDescription: 'You can change how progress, performance, and usage are displayed, or enable Coding Tools, anytime in Settings → General.', onboardingKeepSetting: 'Continue setup', onboardingNoCreditTitle: 'Skip adding credits?', - onboardingNoCreditDescription: 'DeepSeek Harness can’t start new tasks without credits. You can add them later in Profile → Accounts.', + onboardingNoCreditDescription: 'DeepSeek Harness can’t start new tasks without credits. You can add them later in Settings → Account.', onboardingUnderstood: 'Got it', onboardingGoTopUp: 'Add credits', onboardingSaveFailed: 'Could not save settings. Try again.', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 5294b9be22..cae2d7fac0 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -13,6 +13,7 @@ import type { BonusNotice } from '../src/client/bonus-notices.ts' import type { AccountMenuProps } from '../src/client/AccountMenu.tsx' import type {} from '../src/client/index.ts' import { en, zh, type AccountKey } from '../src/client/locales.ts' +import css from '../src/client/AccountSection.module.css' afterEach(() => { cleanup(); vi.unstubAllGlobals() }) @@ -441,18 +442,19 @@ it.each([en, zh])('renders positive bonus wallets separately from recharge balan }) expect(screen.getByText(copy.balance).parentElement!.textContent).toBe(`${copy.balance}¥209.00$20.07`) expect(screen.getByText(copy.bonusBalance).parentElement!.textContent).toBe(`${copy.bonusBalance}¥5.00<$0.01`) + expect(screen.getByRole('region').querySelectorAll(`.${css.divider}`)).toHaveLength(2) await expect(`${screen.getByRole('region').textContent}\n`) .toMatchFileSnapshot(`./expected/bonus-${copy === en ? 'en' : 'zh'}.txt`) }) it.each([[], [{ currency: 'CNY' as const, balance: '0.00' }, { currency: 'USD' as const, balance: '-1.00' }]].map(bonusWallets => ({ bonusWallets })))( - 'keeps the bonus row without inventing credit for zero or negative wallets', ({ bonusWallets }) => { + 'omits the bonus row for zero or negative wallets', ({ bonusWallets }) => { mount({ status: 'credential-stored', attempt: null }, en, { balance: { status: 'ready', value: [{ currency: 'CNY', balance: '0' }], bonusWallets }, }) - // The row itself stays and states the absence. - expect(screen.getByText(en.bonusBalance)).toBeTruthy() - expect(screen.getByText(en.bonusEmpty)).toBeTruthy() + expect(screen.queryByText(en.bonusBalance)).toBeNull() + // The divider above the bonus row goes with it. + expect(screen.getByRole('region').querySelectorAll(`.${css.divider}`)).toHaveLength(1) expect(screen.getByText('¥0.00')).toBeTruthy() expect(screen.queryByText('¥-1.00')).toBeNull() }, diff --git a/packages/client/ui-settings-account/tests/expected/details-en.txt b/packages/client/ui-settings-account/tests/expected/details-en.txt index da68efdf2b..08cdd9dbe9 100644 --- a/packages/client/ui-settings-account/tests/expected/details-en.txt +++ b/packages/client/ui-settings-account/tests/expected/details-en.txt @@ -1 +1 @@ -Harness Mock (TEST ONLY)138****0000More account informationTopped-up balance¥1,234.56<$0.01Granted balanceNo bonus availableMoreView usageTop up +Harness Mock (TEST ONLY)138****0000More account informationTopped-up balance¥1,234.56<$0.01MoreView usageTop up diff --git a/packages/client/ui-settings-account/tests/expected/details-zh.txt b/packages/client/ui-settings-account/tests/expected/details-zh.txt index 75bc72e8f8..725935948e 100644 --- a/packages/client/ui-settings-account/tests/expected/details-zh.txt +++ b/packages/client/ui-settings-account/tests/expected/details-zh.txt @@ -1 +1 @@ -Harness Mock (TEST ONLY)138****0000更多账号信息充值余额¥1,234.56<$0.01赠金余额暂无可用赠金更多查询用量充值 +Harness Mock (TEST ONLY)138****0000更多账号信息充值余额¥1,234.56<$0.01更多查询用量充值 diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index 3cfb4fe189..da67649ce5 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -40,7 +40,7 @@ async function boot() { const rt = await SlotTestRuntime.create() runtime = rt rt.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never) - rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) + rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: rt.panelInfo } as never) rt.ctx.provide('shortcuts', { register: () => () => {}, catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} } } as never) const locale = new LocaleRuntime(rt.ctx) rt.ctx.provide('locale', locale) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index a101c8c61b..dfd1b69803 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6d7f145ecb1fd140 zh: 666d142ef249f6cb /deepseek-ai-dsh-client-ui-sidebar-right/the-expand-button: - en: 28b0bc3d4050b0e0 - zh: 733b44ad9309f49a + en: 2614d3f179290606 + zh: 88c08ee260ba57b7 /deepseek-ai-dsh-client-ui-sidebar-right/state: en: 2b15cdf5f1383291 zh: 57018036d77363ba @@ -30,8 +30,8 @@ en: ef2af36136c4a919 zh: 48d992e2c923a130 /deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright: - en: 8cffef22360aa177 - zh: a09eef5bb7fdbd61 + en: 4519c4633d160c2a + zh: e4019a62532f885a /deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain: en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 @@ -51,5 +51,5 @@ en: 76a763b4dd71babd zh: fdc966dd427a82fb /deepseek-ai-dsh-client-ui-sidebar-right/known-limitations-and-deferred-work/dev-note: - en: 33274d681fac9a21 - zh: e024c1c4766b289f + en: 9fcbcccf09b43c5d + zh: 224b8a0881ab6890 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 846557709f..980b192ce0 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -58,7 +58,7 @@ While the panel is hidden, one button in the conversation header's corner seat ( The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it. -The root-scoped `rightbar` entry renders the selected Session and background Sessions with initialized `keepMounted` bodies through separate `rightbar.session` subtrees. Each View owns its Session reference. Only the foreground Conversation reports a frame track and binds public navigation; other subtrees stay hidden without deleting tab state. +The root-scoped `rightbar` entry renders the selected Session and background Sessions with initialized `keepMounted` bodies through separate `rightbar.session` subtrees. Each View owns its Session reference. Only the foreground subtree reports a frame track; other subtrees stay hidden without deleting tab state. ## State @@ -94,9 +94,9 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. -`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. `mounted` is the observable of the session whose seat is on screen, `undefined` while a global panel replaces the Conversation or no session is selected. A component that opens content from its own mount effect reads it through a bound hook and acts once it is defined: the frame mounts the Conversation column ahead of this seat, and the seat publishes its binding from a passive effect of the same commit, so an effect that assumed a bound seat would run first and throw. The plan review's automatic open reads it this way. +`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook. -`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the mounted Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. +`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the on-screen Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. ## The Tab domain @@ -156,4 +156,4 @@ None. -**Runtime invariant:** No companion is published. The two services (`sidebarRight`, `sidebarRightTabs`) are provided through `ctx.reflect.provide` inside one effect and torn down with it; the seat's binding and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them. +**Runtime invariant:** No companion is published. The two services (`sidebarRight`, `sidebarRightTabs`) are provided through `ctx.reflect.provide` inside one effect and torn down with it; the on-screen Session and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 3f0a64c7ee..96f8e284d9 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" 面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。 -root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲染选中的 Session,以及拥有已初始化 `keepMounted` 正文的后台 Session。每个 View 拥有自身的 Session reference。只有前台 Conversation 上报框架列宽并绑定公共导航;其他子树保持隐藏,不删除 tab 状态。 +root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲染选中的 Session,以及拥有已初始化 `keepMounted` 正文的后台 Session。每个 View 拥有自身的 Session reference。只有前台子树上报框架列宽;其他子树保持隐藏,不删除 tab 状态。 ## 状态 @@ -94,9 +94,9 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 -`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。`mounted` 是「席位正在屏幕上的那个会话」的可观察值,全局面板替代 Conversation 或没有选中会话时为 `undefined`。要在自己的挂载 effect 里打开内容的组件通过绑定的 hook 读取它,并在它有值后再行动:框架把 Conversation 列挂载在这个席位之前,而席位在同一次提交的 passive effect 里才发布绑定,一个假定席位已绑定的 effect 会先运行并 throw。计划审阅的自动打开就是这样读取它的。 +`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 -`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用已挂载会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 +`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用屏幕上会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 ## Tab 域 @@ -156,4 +156,4 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止 -**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;席位绑定与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。 +**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;屏幕上的会话与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。 diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 2c0613698a..5974b81723 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -36,6 +36,7 @@ import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' import { GuideTitle } from './tabs/guide/GuideTitle.tsx' import { ExpandButton } from './shell/ExpandButton.tsx' import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' +import { closeWithPaneFocus, openWithPaneFocus } from './shell/close-focus.ts' import { RightbarRoot, type RightbarRootInjected } from './shell/RightbarRoot.tsx' import { SidebarSessionViews } from './session-views.ts' import { createSidebarRightController, type SidebarRightController } from './service.ts' @@ -53,7 +54,7 @@ export type { GuideBodyProps, GuideInjected } from './tabs/guide/GuideBody.tsx' export type { ExpandButtonProps } from './shell/ExpandButton.tsx' export type { SidebarRightState, SurfaceState } from './stores.ts' export type { - ISidebarRight, SidebarRightBinding, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, + ISidebarRight, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, SidebarRightPlacement, SidebarRightCloseHandler, SurfaceActions, } from './service.ts' export type { @@ -115,10 +116,37 @@ export function apply(ctx: ClientContext): void { sync() return () => { unsubscribe(); views.dispose() } }, 'ui-sidebar-right: retained Session views') - const { controller, adopt, forget } = createSidebarRightController( + const layout: ILayout = ctx.layout + // The automatic fullscreen rule as the seats last rendered it: the frame + // hands them its width as owner props, and every seat reports the same rule. + let autoFullscreen = false + const { controller, adopt, forget, show, measure } = createSidebarRightController( tabs, (address, signal) => { ctx.resources.pin(address, signal) }, + { + autoFullscreen: () => autoFullscreen, + openWithFocus: (sessionId, open) => { openWithPaneFocus(document, sessionId, open) }, + closeWithFocus: (sessionId, paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) }, + }, ) + // The Session on screen: the selected one while the Conversation fills the + // main column. Both sources notify before React renders their change, so the + // service names the arriving Session before any component of that commit + // reads it, and its seat mints the Session's store in the same render. + ctx.effect(() => { + const sync = (): void => { + const selected = views.source.getSnapshot().find(view => view.selected) + show(layout.panelInfo.getSnapshot().activePanelId === null ? selected?.sessionId : undefined) + } + const unsubscribeViews = views.source.subscribe(sync) + const unsubscribePanel = layout.panelInfo.subscribe(sync) + sync() + return () => { + unsubscribeViews() + unsubscribePanel() + show(undefined) + } + }, 'ui-sidebar-right: on-screen Session') const disposeRegistry = ctx.reflect.provide('sidebarRightTabs', tabs) const disposeService = ctx.reflect.provide('sidebarRight', controller) // Registered first, so it tears down last: the faces outlive every seat and @@ -157,13 +185,12 @@ export function apply(ctx: ClientContext): void { } } }, } - const layout: ILayout = ctx.layout - const injected: Omit = { + const injected: Omit = { syncPresentation({ shown, track, fullscreen }) { if (shown) layout.openRightbar(track, fullscreen) else layout.closeRightbar() }, - bindService: binding => controller.bind(binding), + reportAutoFullscreen: (value) => { autoFullscreen = value }, splitPane: (paneId) => { controller.split(paneId) }, toggleFullscreen: () => { const target = controller.commandTarget(); if (target !== undefined) controller.toggleFullscreen(target) }, openTab: (kind, options) => { controller.openTab(kind, options) }, @@ -194,6 +221,7 @@ export function apply(ctx: ClientContext): void { store, inject: (sessionId): SidebarRightInjected => ({ ...injected, + measureRoom: (canSplitPane) => { measure(sessionId, canSplitPane) }, closeTab: (tabId) => { try { controller.closeIn(sessionId, tabId) } catch (error) { console.error('Sidebar tab close failed:', error) } diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 193d40873b..c4bfc1252b 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -3,19 +3,22 @@ * * The surface is per session and its state lives in that session's store * instance, which the slot runtime mints per session and a root service cannot - * reach on its own. Two paths lead in. The mounted seat publishes its binding — - * session id, bound actions, its surface — for exactly as long as it is mounted, - * and every command on the public face goes through that binding; a command - * arriving with no seat mounted has no session to act on and fails loudly rather - * than writing into a surface nobody is drawing. `mounted` publishes that - * binding's session as an observable, so a consumer that wants to open content - * as soon as a seat is on screen subscribes to it instead of assuming one is - * bound when its own effect runs. And the plugin adopts each - * session's store instance as the runtime mints it, so the controller reaches - * any session's store by id and syncs the Tab domain from that store's commits. + * reach on its own. The plugin adopts each session's store instance as the + * runtime mints it, so the controller reaches any session's store by id and + * syncs the Tab domain from that store's commits, on screen or not. + * + * The plugin also names the Session on screen — the selected Session while the + * Conversation fills the main column — from the selection and the main panel, + * before React renders either change, and publishes it as `mounted`. Every + * command on the public face acts on that Session through its adopted store; a + * command with no Session on screen, or with one whose store the runtime has not + * minted, has nothing to act on and fails loudly rather than writing into a + * surface nobody is drawing. The seats never publish which Session they draw: + * they report only what they render with, the room their docking kit measured + * and the automatic fullscreen rule of their frame width. * * A tab's own actions (`tabActions`) aim at the session the tab is in, not at - * the mounted one: they run through that session's adopted store, so a callback + * the on-screen one: they run through that session's adopted store, so a callback * fired after the user switched sessions still lands where its tab is, and they * do nothing for a session whose store was never minted. * @@ -27,8 +30,8 @@ * hand the store one settled intent and record the navigation in the Tab * domain. Placement is the caller's option, never a type's property. * - * The registration adopts Session stores and injects the mounted seat binding; - * callers use the service's navigation methods. + * The registration adopts Session stores, names the on-screen Session, and + * forwards the seats' reports; callers use the service's navigation methods. */ import { sidebarTargetFromElement, type SidebarRightTarget } from './focus.ts' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' @@ -61,24 +64,49 @@ interface Adoption { readonly unsubscribe: () => void } +/** The room rule's verdict for a docked pane, as a seat's docking kit last measured it: whether two working halves would fit. */ +type RoomRule = (paneId: PaneId) => boolean + +/** Plugin-owned Session state the controller reads; the plugin writes it through the factory's callbacks. */ +interface SidebarRightSessions { + /** Each Session's latest adopted store. */ + readonly adopted: ReadonlyMap + /** Each Session's room rule as its seat last reported it; a Session without one has room in every pane. */ + readonly rooms: ReadonlyMap + /** The Session the plugin names as on screen. */ + readonly onScreen: ObservableSnapshot + /** Open tab metadata across saved and adopted Sessions. */ + readonly openTabs: SidebarTabInventory['source'] +} + /** - * Create the public controller and the plugin-private store adoption callback. + * Create the public controller and the plugin-private Session callbacks. * Adoption reconciles restored records before any seat renders, then follows commits. * @param tabs - registered tab types. * @param pin - resource retention for an occurrence's lifetime. - * @returns the controller and plugin-owned adoption and scope-removal callbacks. + * @param host - the viewport rule and focus continuity the commands use. + * @returns the controller; store adoption and scope removal; naming the on-screen Session; and recording a seat's room rule. */ -export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource): { +export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource, host: SidebarRightHost): { controller: SidebarRightController adopt: (sessionId: SessionId, store: SidebarRightSurfaceStore) => () => void forget: (sessionId: SessionId) => void + show: (sessionId: SessionId | undefined) => void + measure: (sessionId: SessionId, canSplitPane: RoomRule) => void } { const adopted = new Map() + const rooms = new Map() + const onScreen = createSnapshotStore(undefined) const inventory = new SidebarTabInventory() - const controller = new SidebarRightController(tabs, pin, adopted, inventory.source) + const controller = new SidebarRightController(tabs, pin, host, { adopted, rooms, onScreen, openTabs: inventory.source }) return { controller, - forget: (sessionId) => { inventory.remove(sessionId) }, + forget: (sessionId) => { + inventory.remove(sessionId) + rooms.delete(sessionId) + }, + show: (sessionId) => { if (onScreen.getSnapshot() !== sessionId) onScreen.set(sessionId) }, + measure: (sessionId, canSplitPane) => { rooms.set(sessionId, canSplitPane) }, adopt(sessionId, store) { adopted.get(sessionId)?.unsubscribe() const sync = (): void => { @@ -97,29 +125,26 @@ export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: } } -/** Everything a command needs, as the mounted seat sees it. */ -export interface SidebarRightBinding { - /** The session the mounted seat is drawing. */ - readonly sessionId: SessionId - /** The seat's store's bound actions; every action names the session it acts on. */ - readonly actions: SurfaceActions +/** What the controller needs from the page around it: the viewport rule and focus continuity. */ +export interface SidebarRightHost { /** - * The seat's store surfaces as last committed, keyed by session id; the - * mounted session's is `surfaces[sessionId]`, absent before the seat's first - * open. The runtime mints one store per session, so this holds that session. + * Whether the viewport is narrow enough that an expanded panel is presented fullscreen. + * @returns the rule's verdict for the frame width the seats last rendered at. */ - readonly surfaces: Readonly> + readonly autoFullscreen: () => boolean /** - * The room rule's verdict for a docked pane, as the kit last measured it: - * whether two working halves would fit. Unmeasured panes fit. + * Commit a page operation and focus the pane it selects. + * @param sessionId - the Session whose page is opening. + * @param open - the synchronous operation; returns the selected pane, or `undefined` when unchanged. */ - readonly canSplitPane: (paneId: PaneId) => boolean - /** Commit a keyboard/menu close and retain focus on a surviving visible pane. */ - readonly closeWithFocus: (paneId: PaneId, close: () => void) => void - /** Commit a page operation and focus the pane it selects. */ - readonly openWithFocus: (open: () => PaneId | undefined) => void - /** Narrow viewports present an expanded panel fullscreen. */ - readonly autoFullscreen?: boolean + readonly openWithFocus: (sessionId: SessionId, open: () => PaneId | undefined) => void + /** + * Commit a keyboard/menu close and retain focus on a surviving visible pane. + * @param sessionId - the Session whose page is closing. + * @param paneId - the pane whose page is closing. + * @param close - the synchronous cleanup and removal. + */ + readonly closeWithFocus: (sessionId: SessionId, paneId: PaneId, close: () => void) => void } /** Where an open lands; every field is optional and the defaults are the common case. */ @@ -160,12 +185,13 @@ export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => /** The outward right-Sidebar face (`ctx.sidebarRight`). */ export interface ISidebarRight { /** - * The session whose seat is mounted, or `undefined` while no seat is on - * screen (a global panel is active, or no session is selected). Moves when a - * seat binds or releases, so a component that opens content from its own - * mount effect reads it through a bound hook and acts once it is defined: - * the frame mounts the Conversation column ahead of the seat, and the seat - * publishes its binding from a passive effect of the same commit. + * The Session on screen, or `undefined` while none is (a global panel fills + * the main column, or no Session is selected). It changes before React + * renders the selection or panel change that causes it, so every component + * rendered in that commit reads the arriving Session, and the commands act on + * it from that commit's effects: its seat mints its store in the same render. + * Moves only when the on-screen Session changes; the Session's own store + * commits are silent. */ readonly mounted: ObservableSnapshot /** @@ -189,13 +215,13 @@ export interface ISidebarRight { */ openTab(kind: K, options?: SidebarRightOpenTabOptions): void /** - * Close one tab of the mounted session; the sole docked guide remains open. + * Close one tab of the on-screen Session; the sole docked guide remains open. * @param tabId - the tab to close. */ close(tabId: TabId): void /** * The active tab of the active pane. - * @returns the record, or `undefined` when no seat is mounted. + * @returns the record, or `undefined` with no Session on screen. */ active(): TabRecord | undefined /** @@ -236,10 +262,10 @@ export interface ISidebarRight { export class SidebarRightController implements ISidebarRight { /** Open tab metadata across saved and adopted Sessions, independent of visible seats. */ readonly openTabs: SidebarTabInventory['source'] - private readonly mountedSession = createSnapshotStore(undefined) - /** The mounted seat's session; see {@link ISidebarRight.mounted}. */ - readonly mounted: ObservableSnapshot = this.mountedSession - private binding: SidebarRightBinding | undefined + /** The on-screen Session; see {@link ISidebarRight.mounted}. */ + readonly mounted: ObservableSnapshot + private readonly adopted: ReadonlyMap + private readonly rooms: ReadonlyMap private readonly closeHandlers = new Map() /** @@ -263,16 +289,19 @@ export class SidebarRightController implements ISidebarRight { /** * @param tabs - the tab-type registry consulted to claim an address. * @param pin - `ctx.resources.pin`, which the Tab domain holds addresses with. - * @param adopted - plugin-owned session stores used by occurrence actions. - * @param openTabs - plugin-owned metadata source across saved and adopted layouts. + * @param host - the viewport rule and focus continuity the commands use. + * @param sessions - plugin-owned adopted stores, room rules, on-screen Session, and open tab metadata. */ constructor( private readonly tabs: SidebarRightTabRegistry, pin: PinResource, - private readonly adopted = new Map(), - openTabs: SidebarTabInventory['source'] = new SidebarTabInventory().source, + private readonly host: SidebarRightHost, + sessions: SidebarRightSessions, ) { - this.openTabs = openTabs + this.adopted = sessions.adopted + this.rooms = sessions.rooms + this.mounted = sessions.onScreen + this.openTabs = sessions.openTabs this.tabDomain = new TabDomain(this, pin) } @@ -285,31 +314,6 @@ export class SidebarRightController implements ISidebarRight { return Object.values(this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout.tabs ?? {}) } - /** - * Adopt the mounted seat's binding, replacing any previous one. - * - * Called from the seat while it is mounted, and released when it leaves. - * @param binding - the mounted seat's session, actions, and the store's surfaces. - * @returns a release callback that clears exactly this binding. - */ - bind(binding: SidebarRightBinding): () => void { - this.binding = binding - this.publishMounted() - return () => { - // A newer seat may already have taken over; only the binding that is - // still ours may be cleared. - if (this.binding !== binding) return - this.binding = undefined - this.publishMounted() - } - } - - /** Publish the mounted session only when it changes; a republished binding for the same session is silent. */ - private publishMounted(): void { - const next = this.binding?.sessionId - if (this.mountedSession.getSnapshot() !== next) this.mountedSession.set(next) - } - /** * Open a resource: claim it, place it, reveal the column, record the navigation. * @param address - a `dsh-resource:///…` address. @@ -413,7 +417,6 @@ export class SidebarRightController implements ISidebarRight { params: SidebarRightNavigationParams, ): void { const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] - ?? (this.binding?.sessionId === sessionId ? this.binding.surfaces[sessionId] : undefined) const targetPane = surface === undefined ? undefined : placement.paneId ?? activeDockPaneId(surface.layout) const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane] const preferNewPane = placement.preferNewPane === true @@ -424,8 +427,8 @@ export class SidebarRightController implements ISidebarRight { && target.tabs.length > 0 && canSplit(surface.layout) && dockPaneIds(surface.layout).length < 2 - && this.binding?.sessionId === sessionId - && this.binding.canSplitPane(target.id) + && this.mounted.getSnapshot() === sessionId + && this.canSplitPane(sessionId, target.id) const commit = (): void => { actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, @@ -444,18 +447,16 @@ export class SidebarRightController implements ISidebarRight { } /** - * Close one tab of the mounted session; the sole docked guide remains open. + * Close one tab of the on-screen Session; the sole docked guide remains open. * @param tabId - the tab to close. */ close(tabId: TabId): void { - const { sessionId, actions } = this.require() - if (this.adopted.has(sessionId)) { this.closeIn(sessionId, tabId); return } - actions.closeTab(sessionId, tabId) + this.closeIn(this.require().sessionId, tabId) } /** * The active tab of the active pane. - * @returns the record, or `undefined` with no mounted surface. + * @returns the record, or `undefined` without an on-screen surface. */ active(): TabRecord | undefined { const layout = this.mountedSurface()?.layout @@ -466,7 +467,7 @@ export class SidebarRightController implements ISidebarRight { /** * Whether the column is currently showing its panel. - * @returns `true` while expanded; `false` while collapsed or with no mounted surface. + * @returns `true` while expanded; `false` while collapsed or without an on-screen surface. */ isExpanded(): boolean { return this.mountedSurface()?.layout.expanded ?? false @@ -474,8 +475,8 @@ export class SidebarRightController implements ISidebarRight { /** Collapse the column, or expand it and focus its active dock pane after rendering. */ toggleExpanded(): void { - const { sessionId, actions, openWithFocus } = this.require() - openWithFocus(() => { + const { sessionId, actions } = this.require() + this.host.openWithFocus(sessionId, () => { actions.toggleExpanded(sessionId) const layout = this.mountedSurface()?.layout return layout?.expanded ? activeDockPaneId(layout) : undefined @@ -498,28 +499,27 @@ export class SidebarRightController implements ISidebarRight { * @returns current focused page identity, or undefined outside a visible sidebar page. */ focusedTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined { - const binding = this.binding - const surface = this.mountedSurface() - return binding === undefined || surface === undefined ? undefined - : sidebarTargetFromElement(element, binding.sessionId, surface.layout, - tabId => this.tabDomain.occurrence(binding.sessionId, { id: tabId })) + const screen = this.screen() + return screen === undefined ? undefined + : sidebarTargetFromElement(element, screen.sessionId, screen.surface.layout, + tabId => this.tabDomain.occurrence(screen.sessionId, { id: tabId })) } /** - * Choose a focused sidebar pane, or the mounted Session's active dock pane for an outside open. + * Choose a focused sidebar pane, or the on-screen Session's active dock pane for an outside open. * @param element - live command input target; stale sidebar markup never falls back to another pane. - * @returns captured target, or undefined without a mounted Session. + * @returns captured target, or undefined without an on-screen surface. */ commandTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined { const focused = this.focusedTarget(element) if (focused !== undefined || element?.closest('[data-sidebar-right-session]')) return focused - const binding = this.binding - const layout = this.mountedSurface()?.layout - if (binding === undefined || layout === undefined) return undefined + const screen = this.screen() + if (screen === undefined) return undefined + const { sessionId, surface: { layout } } = screen const pane = getPane(layout, activeDockPaneId(layout)) const tabId = pane.activeTabId - const held = tabId === undefined ? undefined : this.tabDomain.occurrence(binding.sessionId, { id: tabId }) - return { sessionId: binding.sessionId, paneId: pane.id, host: pane.host, tabId, + const held = tabId === undefined ? undefined : this.tabDomain.occurrence(sessionId, { id: tabId }) + return { sessionId, paneId: pane.id, host: pane.host, tabId, occurrence: held, navigationRevision: held?.navigation.getSnapshot().revision } } @@ -530,10 +530,11 @@ export class SidebarRightController implements ISidebarRight { */ openTabFromTarget(kind: string, target: SidebarRightTarget): void { if (!this.isTargetCurrent(target)) return - this.require().openWithFocus(() => { + const { sessionId, actions } = this.require() + this.host.openWithFocus(sessionId, () => { const tab = target.tabId === undefined ? undefined : this.mountedSurface()?.layout.tabs[target.tabId] if (target.host === 'float' && tab?.kind === kind && this.tabs.get(kind)?.multiple !== true) { - this.require().actions.setExpanded(target.sessionId, true) + actions.setExpanded(sessionId, true) this.focus(tab.id) } else { this.openTab(kind, { @@ -565,10 +566,10 @@ export class SidebarRightController implements ISidebarRight { const surface = this.mountedSurface() if (surface === undefined || target.tabId === undefined) return 'unavailable' const tabId = target.tabId - const binding = this.require() - binding.closeWithFocus(target.paneId, () => { + const { sessionId, actions } = this.require() + this.host.closeWithFocus(sessionId, target.paneId, () => { if (canCloseTab(surface, tabId)) this.close(tabId) - else binding.actions.setExpanded(target.sessionId, false) + else actions.setExpanded(sessionId, false) }) return 'closed' } @@ -576,10 +577,10 @@ export class SidebarRightController implements ISidebarRight { /** * Check a captured target before acting, including reopened records and intervening navigation. * @param target - identity captured while resolving the input. - * @returns whether the mounted Session, pane, tab lifetime and navigation still match. + * @returns whether the on-screen Session, pane, tab lifetime and navigation still match. */ isTargetCurrent(target: SidebarRightTarget): boolean { - if (this.binding?.sessionId !== target.sessionId) return false + if (this.mounted.getSnapshot() !== target.sessionId) return false const layout = this.mountedSurface()?.layout const pane = layout?.nodes[target.paneId] if (pane?.kind !== 'pane' || pane.host !== target.host) return false @@ -603,7 +604,7 @@ export class SidebarRightController implements ISidebarRight { if (!layout.expanded) return 'collapsed' if (getPane(layout, target.paneId).tabs.length === 0) return 'empty' if (!canSplit(layout) || dockPaneIds(layout).length >= 2) return 'budget' - if (!this.require().canSplitPane(target.paneId)) return 'width' + if (!this.canSplitPane(target.sessionId, target.paneId)) return 'width' return undefined } @@ -613,9 +614,10 @@ export class SidebarRightController implements ISidebarRight { */ toggleFullscreen(target: SidebarRightTarget): void { if (!this.isTargetCurrent(target) || target.host === 'float' || !this.isExpanded()) return - const { sessionId, actions, autoFullscreen } = this.require() - const fullscreen = autoFullscreen === true || this.mountedSurface()?.layout.mode === 'fullscreen' - if (fullscreen && autoFullscreen === true) actions.setExpanded(sessionId, false) + const { sessionId, actions } = this.require() + const autoFullscreen = this.host.autoFullscreen() + const fullscreen = autoFullscreen || this.mountedSurface()?.layout.mode === 'fullscreen' + if (fullscreen && autoFullscreen) actions.setExpanded(sessionId, false) actions.setMode(sessionId, fullscreen ? 'push' : 'fullscreen') } @@ -625,15 +627,15 @@ export class SidebarRightController implements ISidebarRight { * @returns the new pane's id, or `undefined` when nothing was split. */ split(paneId?: PaneId): PaneId | undefined { - const { sessionId, actions, canSplitPane } = this.require() + const { sessionId, actions } = this.require() const layout = this.mountedSurface()?.layout if (layout === undefined) return undefined const target = paneId ?? activeDockPaneId(layout) const node = layout.nodes[target] if (node === undefined || node.kind !== 'pane' || node.host !== 'dock') return undefined - if (!canSplit(layout) || dockPaneIds(layout).length >= 2 || !canSplitPane(target)) return undefined + if (!canSplit(layout) || dockPaneIds(layout).length >= 2 || !this.canSplitPane(sessionId, target)) return undefined let created: PaneId | undefined - this.require().openWithFocus(() => { + this.host.openWithFocus(sessionId, () => { actions.splitPane(sessionId, target, (id) => { created = id }) return created }) @@ -665,7 +667,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Step the mounted session's surface back one intent. + * Step the on-screen Session's surface back one intent. * * @internal Not part of the product: the sequence is an architectural fact * with no user-facing control yet. Kept reachable for tests. @@ -676,7 +678,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Step the mounted session's surface forward one intent. + * Step the on-screen Session's surface forward one intent. * * @internal See `_undo`. */ @@ -685,11 +687,25 @@ export class SidebarRightController implements ISidebarRight { actions.redo(sessionId) } - /** The mounted session's surface; `undefined` without a seat or before its first open. */ + /** + * The on-screen Session and its committed surface; `undefined` with no Session + * on screen, before the runtime mints its store, or before its first open. + */ + private screen(): { readonly sessionId: SessionId; readonly surface: SurfaceState } | undefined { + const sessionId = this.mounted.getSnapshot() + if (sessionId === undefined) return undefined + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] + return surface === undefined ? undefined : { sessionId, surface } + } + + /** The on-screen Session's surface; see {@link SidebarRightController.screen}. */ private mountedSurface(): SurfaceState | undefined { - const { binding } = this - return binding === undefined ? undefined - : this.adopted.get(binding.sessionId)?.store.getSnapshot().bySession[binding.sessionId] ?? binding.surfaces[binding.sessionId] + return this.screen()?.surface + } + + /** Whether a Session's docked pane has room for two working halves, as its seat last measured; unmeasured panes do. */ + private canSplitPane(sessionId: SessionId, paneId: PaneId): boolean { + return this.rooms.get(sessionId)?.(paneId) ?? true } /** @@ -701,13 +717,15 @@ export class SidebarRightController implements ISidebarRight { return this.adopted.get(sessionId)?.store.actions } - private require(): SidebarRightBinding { + private require(): { readonly sessionId: SessionId; readonly actions: SurfaceActions } { // Reads answer for the no-session case (there is nothing expanded), but a // write has no session to write to. Callers are UI gestures and tool // results, both of which belong to a session that is on screen. - if (this.binding === undefined) { + const sessionId = this.mounted.getSnapshot() + const actions = sessionId === undefined ? undefined : this.actionsFor(sessionId) + if (sessionId === undefined || actions === undefined) { throw new Error('sidebarRight: no session surface is mounted') } - return this.binding + return { sessionId, actions } } } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 3b12fed68a..39fc5f6778 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -49,7 +49,6 @@ import type { TabOccurrence } from '../tab-domain.ts' import type { SidebarRightTabNavigation } from '../contract/slots.ts' import type { TabHookContext } from '../tab-info.ts' import css from './SidebarRight.module.css' -import { closeWithPaneFocus, openWithPaneFocus } from './close-focus.ts' /** The store share the seat receives. */ type Store = PropsStore> @@ -78,27 +77,21 @@ export interface SidebarRightInjected { */ readonly syncPresentation: (presentation: SidebarRightPresentation) => void /** - * Publish this seat's session, actions, and the store's surfaces to `ctx.sidebarRight`. + * Record this seat's room rule for `ctx.sidebarRight`'s splits, replacing its previous one. * - * The service is root-scoped and cannot read a per-entry store, so the only - * honest source is the mounted seat. Held for as long as the seat is mounted; - * the service publishes the bound session through `ctx.sidebarRight.mounted`. - * @param binding - what a command needs to act on this session, and what a tab's own action needs to act on its. - * @returns a release callback. + * The docking kit measures its panes after it renders; the seat forwards each + * reading, and the service applies it when it splits this seat's Session. + * @param canSplitPane - whether two working halves of a docked pane would fit; unmeasured panes fit. */ - readonly bindService: (binding: { - sessionId: SessionId - actions: Store['actions'] - /** Every session's surface as last committed; the mounted one is `surfaces[sessionId]`. */ - surfaces: Readonly> - /** The room rule's verdict for a docked pane, as the kit last measured it. */ - canSplitPane: (paneId: PaneId) => boolean - /** Commit a keyboard/menu close and retain focus on a surviving visible pane. */ - closeWithFocus: (paneId: PaneId, close: () => void) => void - /** Commit a page operation and focus the pane it selects. */ - openWithFocus: (open: () => PaneId | undefined) => void - autoFullscreen?: boolean - }) => () => void + readonly measureRoom: (canSplitPane: (paneId: PaneId) => boolean) => void + /** + * Record whether the frame width this seat renders at presents an expanded panel fullscreen automatically. + * + * The fullscreen command reads the latest report: while it holds, the command + * closes the panel instead of switching its recorded mode. + * @param autoFullscreen - whether the frame is narrower than the automatic fullscreen width. + */ + readonly reportAutoFullscreen: (autoFullscreen: boolean) => void /** * The navigation face's `openTab`, for the strip's add control: a new tab is * the guide opened by kind, through the same path as every other open. @@ -353,29 +346,26 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject entries) - const surfaces = useStore(state => state.bySession) - const surface = surfaces[sessionId] + const surface = useStore(state => state.bySession[sessionId]) const shown = active && surface !== undefined && surface.layout.expanded const autoFullscreen = viewportWidth < 768 const fullscreen = autoFullscreen || surface?.layout.mode === 'fullscreen' const panelRef = useRef(null) - // The kit's room-rule readings, kept in a ref: the service reads them at - // call time through the binding, and a reading never re-renders anything. - const room = useRef>(new Map()) - const reportRoom = useCallback((fits: ReadonlyMap): void => { room.current = fits }, []) + // A reading never re-renders anything: the service applies it when it splits. + const reportRoom = useCallback((fits: ReadonlyMap): void => { + measureRoom(paneId => fits.get(paneId)?.row !== false) + }, [measureRoom]) const track = shown && !autoFullscreen useEffect(() => { @@ -386,6 +376,9 @@ export function RightbarSeat({ if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false) }, [actions, sessionId, shown, fullscreen, canShow]) + // Read only when the fullscreen command runs, after this commit has settled. + useLayoutEffect(() => { reportAutoFullscreen(autoFullscreen) }, [reportAutoFullscreen, autoFullscreen]) + // The open/close slide needs no pulse of its own: the shell's window drag // watcher (ui-web) measures the marked rows every frame the surface moves and // sets the recall mark itself (electron#32341). @@ -419,17 +412,6 @@ export function RightbarSeat({ ? () => { syncPresentation({ shown: false, track: false, fullscreen: false }) } : undefined, [syncPresentation, active]) - // Republished on every committed change: the service's readers answer from the - // last commit, and its commands act on the session actually on screen. - useEffect( - () => active - ? bindService({ sessionId, actions, surfaces, autoFullscreen, - closeWithFocus: (paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) }, - openWithFocus: (open) => { openWithPaneFocus(document, sessionId, open) }, - canSplitPane: paneId => room.current.get(paneId)?.row !== false }) - : undefined, - [bindService, sessionId, actions, surfaces, autoFullscreen, active], - ) // The Tab domain is not synced here: the controller adopted this session's // store as the runtime minted it and reconciles on the store's own commits, // on screen or not. diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 42e27709bd..fa83737536 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -5,7 +5,7 @@ * means what those faces do; the slot, locale, frame, and resource faces are * recorders, because what matters here is what was handed to them — two seats * over one store, the guide's body under its own id, the frame reports, the - * service binding — and that every registration is gone after dispose, which + * on-screen Session — and that every registration is gone after dispose, which * is what makes a reload safe. The seats' components have their own specs. */ import { describe, expect, it, onTestFinished, vi } from 'vitest' @@ -63,7 +63,11 @@ async function boot(shortcuts: Partial = {}) { return () => { dictionaries.delete(ns) } }), } - const layout = { openRightbar: vi.fn(), closeRightbar: vi.fn() } + const layout = { + openRightbar: vi.fn(), closeRightbar: vi.fn(), + panelInfo: createSnapshotStore<{ activePanelId: string | null }>({ activePanelId: null }), + } + const current = createSnapshotStore<{ key: SessionId | undefined }>({ key: undefined }) const resources = { pin: vi.fn<(address: string, signal: AbortSignal) => void>() } ctx.provide('slots', slots as never) ctx.provide('locale', locale as never) @@ -71,8 +75,8 @@ async function boot(shortcuts: Partial = {}) { catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} }, ...shortcuts } as never) ctx.provide('layout', layout as never) ctx.provide('resources', resources as never) - ctx.provide('sessions', { retain: vi.fn() } as never) - ctx.provide('uiSession', { adapter: { current: createSnapshotStore({ key: undefined }) } } as never) + ctx.provide('sessions', { retain: vi.fn(() => ({ ready: Promise.resolve(), release: vi.fn() })) } as never) + ctx.provide('uiSession', { adapter: { current } } as never) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const seat = (name: string): Recorded => { @@ -84,7 +88,7 @@ async function boot(shortcuts: Partial = {}) { if (entry.inject === undefined) throw new Error(`expected ${entry.name} to inject`) return entry.inject(SESSION) } - return { ctx, registered, dictionaries, layout, resources, fiber, seat, injectedOf } + return { ctx, registered, dictionaries, layout, current, resources, fiber, seat, injectedOf } } describe('ui-sidebar-right apply', () => { @@ -143,8 +147,21 @@ describe('ui-sidebar-right apply', () => { expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar.session').store) }) - it('hands the panel seat the frame report, the service binding, the opens, the observable registry, and the Tab domain', async () => { - const { ctx, layout, resources, seat, injectedOf } = await boot() + it('names the selected Session as on screen while the Conversation fills the main column', async () => { + const { ctx, layout, current } = await boot() + expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined() + current.set({ key: SESSION }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBe(SESSION) + layout.panelInfo.set({ activePanelId: 'plugins' }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined() + layout.panelInfo.set({ activePanelId: null }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBe(SESSION) + current.set({ key: undefined }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined() + }) + + it('hands the panel seat the frame report, the room rule, the opens, the observable registry, and the Tab domain', async () => { + const { ctx, layout, current, resources, seat, injectedOf } = await boot() const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected // The frame learns the composition of expanded and presentation, nothing else. injected.syncPresentation({ shown: true, track: true, fullscreen: false }) @@ -162,31 +179,42 @@ describe('ui-sidebar-right apply', () => { ctx.sidebarRightTabs.register({ id: 'spec/text', kind: 'text', patterns: ['dsh-resource://file/**'], title: () => 'text' }) expect(seen).toHaveBeenCalledOnce() unsubscribe() - // The binding makes the service act on this seat's session; the seat's - // store instance is minted here from the handle the registration declared. + // The runtime mints each Session's store from the handle the registration + // declared; selecting the Session puts it on screen, and the service acts on + // its adopted store. const handle = seat('rightbar.session').store as ReturnType - const instance = handle.create() - instance.clearPersisted() - vi.stubGlobal('document', { activeElement: null }) + handle.create().clearPersisted() + const instance = handle.create(SESSION) + vi.stubGlobal('document', { activeElement: null, querySelectorAll: () => [] }) onTestFinished(() => { vi.unstubAllGlobals() }) injected.toggleFullscreen() expect(injectedOf(seat('conversation.session.header.corner'))).toHaveProperty('hooks.shortcuts') - const release = injected.bindService({ - sessionId: SESSION, actions: instance.actions, surfaces: {}, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => true, - }) + current.set({ key: SESSION }) injected.openTab('guide', { revealIfOpened: false }) const surface = instance.getSnapshot().bySession[SESSION] expect(surface?.layout.expanded).toBe(true) expect(Object.values(surface?.layout.tabs ?? {}).map(tab => tab.kind)).toEqual(['guide']) // Holding a record pins its address through the resource model. if (surface === undefined) throw new Error('expected a surface') - ctx.sidebarRight.tabDomain.sync(SESSION, surface.layout) expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal)) + // The seat reports the room rule its kit measured; a narrow pane is not split. + const panes = () => Object.values(instance.getSnapshot().bySession[SESSION]?.layout.nodes ?? {}).filter(node => node.kind === 'pane') + injected.measureRoom(() => false) injected.splitPane(surface.layout.activePaneId) - release() + expect(panes()).toHaveLength(1) + injected.measureRoom(() => true) + injected.splitPane(surface.layout.activePaneId) + expect(panes()).toHaveLength(2) + // The fullscreen command follows the automatic fullscreen rule the seats report. + const layoutOf = () => instance.getSnapshot().bySession[SESSION]?.layout + injected.toggleFullscreen() + expect(layoutOf()?.mode).toBe('fullscreen') + injected.reportAutoFullscreen(true) + injected.toggleFullscreen() + expect(layoutOf()?.expanded).toBe(false) + expect(layoutOf()?.mode).toBe('push') + // A global panel takes the Conversation's place: no Session is on screen. + layout.panelInfo.set({ activePanelId: 'plugins' }) expect(() => { ctx.sidebarRight.toggleExpanded() }).toThrow('no session surface is mounted') }) @@ -274,17 +302,12 @@ describe('ui-sidebar-right apply', () => { }) it('takes every registration and both faces back when disposed, aborting the open records, so a reload registers again', async () => { - const { ctx, registered, dictionaries, fiber, seat, injectedOf } = await boot() + const { ctx, registered, dictionaries, current, fiber, seat, injectedOf } = await boot() const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected const handle = seat('rightbar.session').store as ReturnType // Minted under the session key, so the instance is adopted and the teardown releases it. const instance = handle.create(SESSION) - injected.bindService({ - sessionId: SESSION, actions: instance.actions, surfaces: {}, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => true, - }) + current.set({ key: SESSION }) injected.openTab('guide') const surface = instance.getSnapshot().bySession[SESSION] const guide = Object.values(surface?.layout.tabs ?? {})[0] diff --git a/packages/client/ui-sidebar-right/tests/focus.client.spec.ts b/packages/client/ui-sidebar-right/tests/focus.client.spec.ts index fd88240f6e..b949dd5e53 100644 --- a/packages/client/ui-sidebar-right/tests/focus.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/focus.client.spec.ts @@ -26,17 +26,22 @@ function harness() { const ctx = new Context() const tabs = new SidebarRightTabRegistry(ctx) for (const kind of ['guide', 'files', 'terminal']) tabs.register({ id: kind, kind, title: () => kind, multiple: kind === 'terminal' }) - const { controller, adopt } = createSidebarRightController(tabs, vi.fn()) + const room = { allowed: true, autoFullscreen: false } + const { controller, adopt, show: showSession, measure } = createSidebarRightController(tabs, vi.fn(), { + autoFullscreen: () => room.autoFullscreen, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'guide' })).create() releases.push(adopt(SESSION, store), () => { controller.tabDomain.dispose() }) store.actions.setExpanded(SESSION, true) - const room = { allowed: true, autoFullscreen: false } - const bind = (sessionId = SESSION) => controller.bind({ sessionId, actions: store.actions, - surfaces: store.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => room.allowed, autoFullscreen: room.autoFullscreen }) - releases.push(bind()) + /** Put a Session on screen the way the plugin does, with the room rule its seat reports. */ + const show = (sessionId = SESSION) => { + showSession(sessionId) + measure(sessionId, () => room.allowed) + return () => { if (controller.mounted.getSnapshot() === sessionId) showSession(undefined) } + } + releases.push(show()) const layout = () => store.getSnapshot().bySession[SESSION]!.layout const owner = document.createElement('section') owner.dataset.sidebarRightSession = SESSION @@ -57,7 +62,7 @@ function harness() { parent.append(element) return element } - return { controller, store, layout, paneElement, tabElement, room, bind } + return { controller, store, layout, paneElement, tabElement, room, show } } describe('sidebar focus targets', () => { @@ -121,10 +126,10 @@ describe('sidebar focus targets', () => { expect(h.controller.focusedTarget(chip)).toBeUndefined() expect(h.controller.commandTarget(chip)).toBeUndefined() h.store.actions.setExpanded(SESSION, true) - releases.push(h.bind('another-session' as SessionId)) + releases.push(h.show('another-session' as SessionId)) expect(h.controller.isTargetCurrent(target)).toBe(false) expect(h.controller.focusedTarget(chip)).toBeUndefined() - releases.push(h.bind()) + releases.push(h.show()) h.controller.float(files) expect(h.controller.isTargetCurrent(target)).toBe(false) h.controller.dock(findTabPane(h.layout(), files).id) @@ -190,7 +195,7 @@ describe('sidebar focus targets', () => { h.controller.toggleFullscreen(floated) expect(h.layout().mode).toBe('push') h.room.autoFullscreen = true - releases.push(h.bind()) + releases.push(h.show()) h.controller.toggleFullscreen(h.controller.commandTarget(null)!) expect(h.layout().expanded).toBe(false) }) @@ -233,7 +238,7 @@ describe('sidebar focus targets', () => { expect(findTabPane(h.layout(), floatingFile).id).toBe(floating.paneId) expect(h.layout().expanded).toBe(true) expect(h.controller.splitBlock(floating)).toBe('float') - releases.push(h.bind('missing-session' as SessionId)) + releases.push(h.show('missing-session' as SessionId)) expect(h.controller.commandTarget(null)).toBeUndefined() expect(h.controller.splitBlock(floating)).toBe('stale') }) @@ -462,7 +467,7 @@ describe('sidebar keyboard commands', () => { const pendingSplit = split.resolve(context) const pendingFullscreen = fullscreen.resolve(context) const pendingToggle = toggle.resolve(context) - releases.push(h.bind('missing-session' as SessionId)) + releases.push(h.show('missing-session' as SessionId)) for (const pending of [pendingSplit, pendingFullscreen, pendingToggle]) { expect(pending.status).toBe('handled') if (pending.status === 'handled') pending.run() @@ -470,7 +475,7 @@ describe('sidebar keyboard commands', () => { expect(h.layout().mode).toBe('push') expect(h.layout().expanded).toBe(true) expect(toggle.resolve(context).status).toBe('blocked') - releases.push(h.bind()) + releases.push(h.show()) const splitNow = split.resolve(context) if (splitNow.status === 'handled') splitNow.run() expect(h.controller.splitBlock(h.controller.commandTarget(null)!)).toBe('budget') diff --git a/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts b/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts index 7a4678e1a9..4b6aea9bc9 100644 --- a/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts @@ -63,7 +63,11 @@ it('pins restored records during adoption before the first render or store mutat first.actions.openContent(sessionId, { kind: 'text', contentId: address, title: 'a' }, (id) => { file = id }) const ctx = new Context() const pin = vi.fn() - const { controller, adopt } = createSidebarRightController(new SidebarRightTabRegistry(ctx), pin) + const { controller, adopt } = createSidebarRightController(new SidebarRightTabRegistry(ctx), pin, { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) expect(controller.tabsIn(sessionId)).toEqual([]) const restored = createSidebarRightStore(seed).create(sessionId) const release = adopt(sessionId, restored) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 8c692290c9..b6e7c85fca 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent } from '@testing-library/react' import { useEffect, useState, useSyncExternalStore } from 'react' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, type SlotView } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ShortcutCatalogEntry, ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' @@ -71,7 +71,7 @@ function transition(property = 'transform') { async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, opener = false, keepMounted = false) { const runtime = await SlotTestRuntime.create() runtimes.push(runtime) - const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn() } + const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: runtime.panelInfo } const pin = vi.fn<(address: string, signal: AbortSignal) => void>() runtime.ctx.provide('layout', frame as never) runtime.ctx.provide('resources', { pin } as never) @@ -110,10 +110,12 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o return armed && visible ? : null } let arm = (_armed: boolean): void => { throw new Error('mountSeat(opener = true) renders the opener') } + let openerView: SlotView<'sidebar-right.test.opener'> | undefined if (opener) { await act(async () => { runtime.slots.register({ name: 'sidebar-right.test.opener' }, Opener) }) - const openerView = runtime.renderSlot('sidebar-right.test.opener', { armed: false }, { session: reference }) - arm = (armed) => { openerView.update({ armed }) } + const view = runtime.renderSlot('sidebar-right.test.opener', { armed: false }, { session: reference }) + openerView = view + arm = (armed) => { view.update({ armed }) } } const bodies = new Map() const titles = new Map() @@ -162,6 +164,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o return { runtime, feature, controller, instance, actions: instance.actions, layout, open, selectSession, frame, pin, bodies, titles, hooks, view, arm, opened, registerPage, catalog, + openerView: () => openerView!, } } @@ -321,19 +324,20 @@ describe('RightbarSeat presentation', () => { expect(h.layout()).toBe(retained) }) - it('publishes the mounted seat so a Conversation opener acts in the commit that returns from a global panel', async () => { + it('names the on-screen Session before a Conversation opener acts in the commit that returns from a global panel', async () => { const h = await mountSeat(1440, true, 0, true) const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(h.controller.mounted.getSnapshot()).toBe(SESSION) - // Armed while the seat is already bound: the ordinary case. + // Armed while the Session is already on screen: the ordinary case. act(() => { h.arm(true) }) expect(h.opened).toHaveLength(1) expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toEqual(h.opened) - // A global panel hides the Sidebar and releases its public navigation binding. + // A global panel takes the Conversation's place and hides the Sidebar. act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) }) expect(element(h.view.container, '[data-sidebar-right-session]').hidden).toBe(true) expect(h.controller.mounted.getSnapshot()).toBeUndefined() - // The opener waits until the foreground seat republishes its navigation binding. + // Returning names the Session before the Conversation renders, so the + // opener's effect in that commit opens into the Session's store. act(() => { h.runtime.panelInfo.set({ activePanelId: null }) }) expect(h.controller.mounted.getSnapshot()).toBe(SESSION) expect(h.opened).toHaveLength(2) @@ -343,6 +347,47 @@ describe('RightbarSeat presentation', () => { expect(document.querySelector('[data-slot-error]')).toBeNull() }) + it('keeps the on-screen Session through its own store commits', async () => { + const h = await mountSeat(1440, true, 0, true) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const published: (SessionId | undefined)[] = [] + const stop = h.controller.mounted.subscribe(() => { published.push(h.controller.mounted.getSnapshot()) }) + try { + // The opener mounts in the commit that also carries a store commit of the + // seat, and its effect runs before the seat's own effects. + act(() => { + h.actions.toggleExpanded(SESSION) + h.arm(true) + }) + expect(published).toEqual([]) + expect(h.opened).toHaveLength(1) + expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toContain(h.opened[0]) + expect(errors.mock.calls.map(call => String(call[0])).filter(text => text.includes('slot entry crashed'))).toEqual([]) + } finally { stop() } + }) + + it('names the arriving Session before a Conversation opener acts in the commit that switches Sessions', async () => { + const h = await mountSeat(1440, true, 0, true) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + // The opener holds its own reference, as the Conversation does for its Session. + const conversation = h.runtime.sessions.retainFor(h.runtime.ctx, SESSION) + act(() => { h.openerView().update({ armed: false }, { session: conversation }) }) + await act(async () => { await h.runtime.sessions.add({ id: OTHER }) }) + await act(async () => { h.selectSession(OTHER) }) + expect(h.controller.mounted.getSnapshot()).toBe(OTHER) + // Switching back swaps the seats in the commit that mounts the opener: the + // departing seat leaves, and the arriving one renders after the opener. + await act(async () => { + h.selectSession(SESSION) + h.openerView().update({ armed: true }, { session: conversation }) + }) + expect(h.controller.mounted.getSnapshot()).toBe(SESSION) + expect(h.opened).toHaveLength(1) + expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toContain(h.opened[0]) + expect(errors.mock.calls.map(call => String(call[0])).filter(text => text.includes('slot entry crashed'))).toEqual([]) + conversation.release() + }) + it.each([0, 1, 2])('selects the default from %i guide entries and protects only a sole guide', async (entryCount) => { const h = await mountSeat(1440, true, entryCount) act(() => { h.controller.toggleExpanded() }) @@ -465,6 +510,22 @@ describe('RightbarSeat presentation', () => { expect(h.layout().expanded).toBe(false) }) + it('applies the automatic fullscreen rule of the width it renders at to the fullscreen command', async () => { + const h = await mountSeat() + h.open() + // Narrowed below the automatic fullscreen width, the command closes the panel. + h.view.update({ width: 420, viewportWidth: 700, canShow: false }) + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(h.layout().expanded).toBe(false) + expect(h.layout().mode).toBe('push') + // Widened again, the command switches the recorded mode. + h.view.update({ width: 420, viewportWidth: 1440, canShow: true }) + act(() => { h.controller.toggleExpanded() }) + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(h.layout().expanded).toBe(true) + expect(h.layout().mode).toBe('fullscreen') + }) + it('preserves manual fullscreen through narrow and wide viewport changes', async () => { const h = await mountSeat() h.open() diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 65b520ce97..9dd8979cea 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -2,8 +2,8 @@ * What `ctx.sidebarRight` promises other plugins. * * The service is root-scoped and the surface is per session, so every command - * depends on a binding the mounted seat publishes; the interesting cases are all - * about that seam. Opening is asserted against a real store instance, because + * acts on the Session the plugin names as on screen, through the store the + * runtime minted for it; the interesting cases are all about that seam. Opening is asserted against a real store instance, because * "already open" and "in that tab's place" mean whatever the planner means by * them and nothing else, and against the Tab domain, because an open is not * complete until the tab knows how it was navigated to. @@ -31,7 +31,7 @@ const SESSION = 's-test' as SessionId /** Key-echoing translate: this file asserts behaviour, not copy. */ const t = ((key: string) => key) as Parameters[0] -/** A controller over a real registry and a real store, bound as a mounted seat would be. */ +/** A controller over a real registry and a real store, adopted as the runtime mints it. */ function harness() { const ctx = new Context() const tabs = new SidebarRightTabRegistry(ctx) @@ -47,25 +47,26 @@ function harness() { title: address => address.slice(address.lastIndexOf('/') + 1), }) const pin = vi.fn<(address: string, signal: AbortSignal) => void>() - const { controller, adopt } = createSidebarRightController(tabs, pin) + const { controller, adopt, show, measure } = createSidebarRightController(tabs, pin, { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create() + // The runtime mints the Session's store before any seat renders, and the plugin adopts it. + const unadopt = adopt(SESSION, instance) const layout = (): LayoutState => { const surface = instance.getSnapshot().bySession[SESSION] if (surface === undefined) throw new Error('expected a surface') return surface.layout } - /** Republish the way the seat does after each commit, and sync the domain the way it does too. */ /** The room rule's verdict the seat would report; a spec flips it to model a narrow pane. */ const room = { allowed: true } + /** Put the Session on screen the way the plugin does from the selection, with the room rule its seat reports. */ const publish = (): (() => void) => { - const surface = instance.getSnapshot().bySession[SESSION] - if (surface !== undefined) controller.tabDomain.sync(SESSION, surface.layout) - return controller.bind({ - sessionId: SESSION, actions: instance.actions, surfaces: instance.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => room.allowed, - }) + show(SESSION) + measure(SESSION, () => room.allowed) + return () => { show(undefined) } } const titles = (): string[] => { const surface = instance.getSnapshot().bySession[SESSION] @@ -79,7 +80,7 @@ function harness() { const entries = (): number => instance.getSnapshot().bySession[SESSION]?.history.entries.length ?? 0 // A surface starts empty; expanding seeds the default guide ('seed'). const expand = (): void => { instance.actions.setExpanded(SESSION, true) } - return { controller, adopt, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand } + return { controller, adopt, unadopt, show, measure, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand } } describe('SidebarRightController — opening', () => { @@ -102,7 +103,7 @@ describe('SidebarRightController — opening', () => { } finally { release() } }) - it('refuses every write while no seat is mounted', () => { + it('refuses every write while no Session is on screen', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted') @@ -114,21 +115,19 @@ describe('SidebarRightController — opening', () => { expect(() => { controller.dock('pane1' as PaneId) }).toThrow('no session surface is mounted') }) - it('publishes the mounted session only on real transitions of the seat binding', () => { + it('publishes the on-screen Session only when it changes', () => { const h = harness() const seen: (string | undefined)[] = [] const unsubscribe = h.controller.mounted.subscribe(() => { seen.push(h.controller.mounted.getSnapshot()) }) try { expect(h.controller.mounted.getSnapshot()).toBeUndefined() - const first = h.publish() + h.show(SESSION) expect(h.controller.mounted.getSnapshot()).toBe(SESSION) - // The seat republishes on every store commit; the same session is silent. - const second = h.publish() + // Naming the same Session again, and the Session's own store commits, are silent. + h.show(SESSION) + h.expand() expect(seen).toEqual([SESSION]) - // A stale release — the first seat's, after a newer one took over — changes nothing. - first() - expect(h.controller.mounted.getSnapshot()).toBe(SESSION) - second() + h.show(undefined) expect(h.controller.mounted.getSnapshot()).toBeUndefined() expect(seen).toEqual([SESSION, undefined]) } finally { unsubscribe() } @@ -519,25 +518,14 @@ describe('SidebarRightController — a tab\'s own actions', () => { const B_TXT = 'dsh-resource://file/session/s-test/b.txt' it('opens into an adopted store before that store has created its session surface', () => { - const { controller, adopt, instance, titles } = harness() - const releaseOwn = adopt(SESSION, instance) - const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) - const releaseBinding = controller.bind({ - sessionId: OTHER, - actions: other.actions, - surfaces: other.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, canSplitPane: () => true, - }) + const { controller, show, titles } = harness() + show(OTHER) controller.openResourceIn(SESSION, A_TXT) expect(titles()).toContain('a.txt') - releaseBinding() - releaseOwn() }) - it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => { - const { controller, adopt, instance, publish, layout, expand } = harness() - const releaseOwn = adopt(SESSION, instance) + it('land in the session the tab is in through its own adopted store, after another session came on screen', () => { + const { controller, adopt, show, publish, layout, expand } = harness() expand() publish() controller.openResource(A_TXT) @@ -546,7 +534,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { if (own === undefined || guide === undefined) throw new Error('expected the opened tab and the seeded guide') const fromOwn = controller.tabDomain.occurrence(SESSION, own).tabActions const guideOccurrence = controller.tabDomain.occurrence(SESSION, guide) - // The user switches sessions: the other seat binds with the other session's + // The user switches sessions: the other Session comes on screen with its // own instance, whose store knows nothing of this session. const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) const releaseOther = adopt(OTHER, other) @@ -554,12 +542,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { other.actions.setExpanded(OTHER, true) const otherSurface = other.getSnapshot().bySession[OTHER] if (otherSurface === undefined) throw new Error('expected the other surface') - controller.bind({ - sessionId: OTHER, actions: other.actions, surfaces: other.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => true, - }) + show(OTHER) fromOwn.openResource(B_TXT) fromOwn.openTab('guide', { revealIfOpened: false }) expect(Object.values(layout().tabs).map(tab => tab.title)).toContain('b.txt') @@ -574,17 +557,17 @@ describe('SidebarRightController — a tab\'s own actions', () => { expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface) expect(controller.active()?.kind).toBe('guide') releaseOther() - releaseOwn() }) it('do nothing for a session whose store is not adopted, and again once its adoption is released', () => { - const { controller, adopt, instance, publish, layout, titles } = harness() + const { controller, adopt, unadopt, instance, publish, layout, titles } = harness() publish() controller.openResource(A_TXT) - publish() const own = Object.values(layout().tabs).find(tab => tab.title === 'a.txt') if (own === undefined) throw new Error('expected the opened tab') const { tabActions } = controller.tabDomain.occurrence(SESSION, own) + // The runtime disposed the Session's store generation. + unadopt() const before = instance.getSnapshot().bySession tabActions.openResource(B_TXT) tabActions.openTab('guide') @@ -661,17 +644,16 @@ describe('SidebarRightController — the readable slice', () => { }) }) -describe('SidebarRightController — binding lifetime', () => { - it('acts on the newest binding when a seat republishes', () => { +describe('SidebarRightController — the on-screen Session', () => { + it('acts on the on-screen Session through its adopted store', () => { const { controller, publish, layout } = harness() publish() controller.toggleExpanded() - publish() expect(controller.isExpanded()).toBe(true) expect(layout().expanded).toBe(true) }) - it('goes back to refusing writes once the seat releases', () => { + it('goes back to refusing writes once no Session is on screen', () => { const { controller, publish } = harness() const release = publish() release() @@ -679,12 +661,20 @@ describe('SidebarRightController — binding lifetime', () => { expect(controller.isExpanded()).toBe(false) }) - it('a stale release does not clear a newer binding', () => { - const { controller, publish } = harness() - const stale = publish() - publish() - stale() - expect(() => { controller.toggleExpanded() }).not.toThrow() + it('refuses writes for an on-screen Session whose store the runtime has not minted', () => { + const { controller, show } = harness() + show('s-unminted' as SessionId) + expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') + expect(controller.active()).toBeUndefined() + }) + + it('treats panes its seat has not measured as having room', () => { + const { controller, show, instance, layout, expand } = harness() + instance.actions.open(SESSION) + expand() + show(SESSION) + expect(controller.split()).toBeDefined() + expect(dockPaneIds(layout())).toHaveLength(2) }) }) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index f9b699e924..c3ab1c7cdc 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -27,8 +27,8 @@ en: 54296471349d9f77 zh: 8ca162b7b57b822e /deepseek-ai-dsh-client-ui-theme/understand-the-implementation: - en: 8fbfdb050e880f9b - zh: dd201f049036c5c4 + en: 687ff0a8bccc9c0c + zh: 463d6b7afd4bd4a4 /deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets: en: 4bbf1beae9e25e9c zh: 6494ac4d34b1fabb diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 931f24ea2c..38f1ac3372 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -44,7 +44,7 @@ When the host composition includes an HTTP server, the host half embeds the regi ## Understand the implementation -Shared menus use `--dsw-menu-surface-fill` and blur through `MenuSurface`; platform code must preserve those token values. Other overlays consume `--dsw-specific-menu`, which keeps a nearly opaque macOS fill without a menu backing. The enforced source rules are defined in the [styling reference](../../../docs/web-styling.md#component-rules). Modal masks retain their dark translucent fill without background blur. +Shared menus use `--dsw-menu-surface-fill` and blur through `MenuSurface`; platform code must preserve those token values. Sticky menu group headings can use `--dsw-alias-menu-group-header-fill`, a 94%-opaque light or dark fill, independently of the menu material. Other overlays consume `--dsw-specific-menu`, which keeps a nearly opaque macOS fill without a menu backing. The enforced source rules are defined in the [styling reference](../../../docs/web-styling.md#component-rules). Modal masks retain their dark translucent fill without background blur.
Implementation internals — click to expand diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index f21c6cc5d2..5a345a4e8e 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -44,7 +44,7 @@ kind: "package-reference" ## 理解实现 -公共菜单通过 `MenuSurface` 共享 `--dsw-menu-surface-fill` 和模糊,平台代码须保留这些 token 值。其他浮层使用 `--dsw-specific-menu`,在没有菜单底层时保留 macOS 上接近不透明的填充。源码约束见[样式参考](../../../docs/web-styling.zh.md#component-rules)。 模态遮罩保留黑色半透明填充,不模糊背景。 +公共菜单通过 `MenuSurface` 共享 `--dsw-menu-surface-fill` 和模糊,平台代码须保留这些 token 值。菜单吸顶分组标题可使用 `--dsw-alias-menu-group-header-fill`,其浅/深色填充均为 94% 不透明度,独立于菜单材质。其他浮层使用 `--dsw-specific-menu`,在没有菜单底层时保留 macOS 上接近不透明的填充。源码约束见[样式参考](../../../docs/web-styling.zh.md#component-rules)。 模态遮罩保留黑色半透明填充,不模糊背景。
实现细节——点击展开 diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 834b269295..eb67869c92 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -269,6 +269,7 @@ body { --dsw-specific-input-major: var(--dsw-static-neutral-bluish-00); --dsw-specific-login-input: var(--dsw-static-neutral-bluish-50); --dsw-menu-surface-fill: rgba(248, 249, 250, 0.58); + --dsw-alias-menu-group-header-fill: rgba(248, 249, 250, 0.94); --dsw-specific-menu: var(--dsw-menu-surface-fill); --dsw-specific-selector: var(--dsw-static-neutral-bluish-60); --dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-50); @@ -386,6 +387,7 @@ body[data-ds-dark-theme] { --dsw-specific-input-major: var(--dsw-static-neutral-bluish-850); --dsw-specific-login-input: var(--dsw-static-neutral-bluish-900); --dsw-menu-surface-fill: rgba(67, 69, 74, 0.45); + --dsw-alias-menu-group-header-fill: rgba(48, 49, 54, 0.94); --dsw-specific-menu: var(--dsw-menu-surface-fill); --dsw-specific-selector: var(--dsw-static-neutral-bluish-800); --dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-900); diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 8afec3d794..19311117b6 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -54,8 +54,8 @@ en: 4da379c9be65c18a zh: fd305d4bd3dfa452 /deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/replay-and-vocabulary: - en: ad7f22c0eba06eb0 - zh: 451ec5836425d7a1 + en: 36e0c59a0f9f5980 + zh: 90a62c4a9cd23429 /deepseek-ai-dsh-llm-pi-ai/further-exploration: en: a2a2ccdeae59d408 zh: c47ee091aa90ad91 @@ -87,8 +87,8 @@ en: b6bd570c0159ba08 zh: 7d2d91eea6a8d830 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work: - en: b72e581a744cf2ab - zh: d3b0bc7c8ec716fe + en: bb240ee5dd3f81c7 + zh: 884506a36f4aac62 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work/dev-note: en: be67a4547256efbf zh: 8c32799683ba292c diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 7b1708b957..0e8a801b53 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -156,7 +156,7 @@ The plugin declares every installed catalog provider it can authenticate in the ### Replay and vocabulary -Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them — response-level facts plus one per-block entry per streamed block. At request time, `LlmRuntime` passes replay state only when the same adapter instance owns both routes; the adapter validates it and restores native response ids, provider signatures, and optional `providerThinkingLevel` effort metadata, keeping absent effort metadata absent. Replay validates the requested model identity against the assistant source and separately restores an Anthropic response model when the provider resolved an alias or fallback. Absent or unusable replay state degrades to provider-neutral content while preserving the assistant source’s required provider and model. pi-ai tool-call arguments are parsed objects, so the adapter parses input and re-stringifies output to the harness raw-JSON convention; pi-ai in-stream error events map to terminal `finish` chunks. +Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them — response-level facts plus one per-block entry per streamed block. At request time, `LlmRuntime` passes replay state only when the same adapter instance owns both routes; the adapter validates it and restores native response ids, provider signatures, and optional `providerThinkingLevel` effort metadata, keeping absent effort metadata absent. Replay validates the requested model identity against the assistant source and uses it to retain same-model signatures; provider-reported response models remain diagnostic metadata. Absent or unusable replay state degrades to provider-neutral content while preserving the assistant source’s required provider and model. pi-ai tool-call arguments are parsed objects, so the adapter parses input and re-stringifies output to the harness raw-JSON convention; pi-ai in-stream error events map to terminal `finish` chunks.
@@ -226,10 +226,10 @@ These limits define where the adapter stops and future work begins. They are cur - **A modality declaration is not verified** — a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again; switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that request. - **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers. -- **Only a leading in-history `system` message becomes pi-ai's `systemPrompt`** — pi-ai has one system slot, so a later `system` message, or a leading one when `GenerateOptions.system` is also set, folds into a `user` message at its position; provider-specific placement of the prompt follows pi-ai rather than a harness-owned wire override. Images in system or assistant history, including the leading system message, fail with `UNSUPPORTED_CONTENT` on both conversion paths. +- **Only a leading in-history `system` message becomes pi-ai's `systemPrompt`** — this adapter uses pi-ai's single `systemPrompt` input, so a later `system` message, or a leading one when `GenerateOptions.system` is also set, folds into a `user` message at its position; provider-specific placement of the prompt follows pi-ai rather than a harness-owned wire override. Images in system or assistant history, including the leading system message, fail with `UNSUPPORTED_CONTENT` on both conversion paths. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers. - **Retry policy is provider-owned, not an SDK retry** — pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. -- **Streamed tool-call arguments are parsed once, when the call ends** — the installed pi-ai carries [`patches/@earendil-works__pi-ai@0.85.1.patch`](../../../patches/@earendil-works__pi-ai@0.85.1.patch), which removes the per-delta re-parse of the whole accumulated argument JSON in every stream adapter (upstream [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265)); unpatched, a multi-megabyte argument stream costs O(n²) CPU on the event loop and stalls every session in the process. Until `toolcall_end`, a pi-ai partial's tool-call `arguments` stays `{}`; this adapter reads only the delta strings and the finalized arguments. Re-apply or retire the patch on every pi-ai upgrade. +- **Streamed tool-call arguments are parsed once, when the call ends** — the installed pi-ai carries [`patches/@earendil-works__pi-ai@0.87.1.patch`](../../../patches/@earendil-works__pi-ai@0.87.1.patch), which removes the per-delta re-parse of the whole accumulated argument JSON in every stream adapter (upstream [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265)); unpatched, a multi-megabyte argument stream costs O(n²) CPU on the event loop and stalls every session in the process. Until `toolcall_end`, a pi-ai partial's tool-call `arguments` stays `{}`; this adapter reads only the delta strings and the finalized arguments. Re-apply or retire the patch on every pi-ai upgrade. ### Dev Note diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index ed780ad235..5126fe25ed 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -156,7 +156,7 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的 ### 回放与词汇 -成功 assistant 响应会存储带版本的、无损 JSON 回放状态,与产生它们的提供方和模型放在一起——响应级事实加每个流式块一条逐块条目。请求时,`LlmRuntime` 仅当同一适配器实例拥有两条路由时才传递回放状态;适配器校验它并恢复原生响应 id、提供方签名与可选的 `providerThinkingLevel` effort 元数据,缺失的 effort 元数据仍保持缺失。回放会对照 assistant 来源校验请求模型身份,并在提供方解析别名或回退时单独恢复 Anthropic 响应模型。回放状态缺失或无法使用时会降级为提供方无关内容,并保留 assistant 来源中必需的提供方和模型。pi-ai 工具调用参数是解析后的对象,因此适配器解析输入并重新字符串化输出,以符合 harness 原始 JSON 约定;pi-ai 流内错误事件映射为终止 `finish` 分片。 +成功 assistant 响应会存储带版本的、无损 JSON 回放状态,与产生它们的提供方和模型放在一起——响应级事实加每个流式块一条逐块条目。请求时,`LlmRuntime` 仅当同一适配器实例拥有两条路由时才传递回放状态;适配器校验它并恢复原生响应 id、提供方签名与可选的 `providerThinkingLevel` effort 元数据,缺失的 effort 元数据仍保持缺失。回放会对照 assistant 来源校验请求模型身份,并用它保留同模型签名;提供方报告的响应模型仅作为诊断元数据。回放状态缺失或无法使用时会降级为提供方无关内容,并保留 assistant 来源中必需的提供方和模型。pi-ai 工具调用参数是解析后的对象,因此适配器解析输入并重新字符串化输出,以符合 harness 原始 JSON 约定;pi-ai 流内错误事件映射为终止 `finish` 分片。
@@ -226,10 +226,10 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish - **模态声明不受校验**——声明 `image` 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。 - **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。 - **不支持 `GenerateOptions.stop`**——pi-ai 的通用流式选项无法跨提供方保证停止序列行为。 -- **只有历史中首条 `system` 消息会成为 pi-ai 的 `systemPrompt`**——pi-ai 只有一个系统槽位,因此后续的 `system` 消息,或在同时设置了 `GenerateOptions.system` 时的首条消息,会在原位置折叠为 `user` 消息;系统提示词的提供方专属放置遵循 pi-ai,而非 harness 自有的协议覆盖。system 或 assistant 历史中的图片(包括首条系统消息中的图片)在两条转换路径上都会以 `UNSUPPORTED_CONTENT` 失败。 +- **只有历史中首条 `system` 消息会成为 pi-ai 的 `systemPrompt`**——本适配器使用 pi-ai 的单一 `systemPrompt` 输入,因此后续的 `system` 消息,或在同时设置了 `GenerateOptions.system` 时的首条消息,会在原位置折叠为 `user` 消息;系统提示词的提供方专属放置遵循 pi-ai,而非 harness 自有的协议覆盖。system 或 assistant 历史中的图片(包括首条系统消息中的图片)在两条转换路径上都会以 `UNSUPPORTED_CONTENT` 失败。 - **提供方 HTTP 状态不可用**——pi-ai 错误事件不跨提供方暴露稳定 HTTP 状态。 - **重试策略由提供方自有,而非 SDK 重试**——pi-ai SDK 重试保持禁用,因此持久 agent(智能体)步骤与 `llm/retry` 事件拥有每个可见尝试,直接 `ctx.llm.stream()` 调用仍是单次尝试。 -- **流式工具调用参数只在调用结束时解析一次**——安装的 pi-ai 带有 [`patches/@earendil-works__pi-ai@0.85.1.patch`](../../../patches/@earendil-works__pi-ai@0.85.1.patch),它移除了每个流适配器中对整段累计参数 JSON 的逐 delta 重新解析(上游 [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265));未打补丁时,数 MB 的参数流会在事件循环上消耗 O(n²) CPU,并使进程内所有会话停滞。在 `toolcall_end` 之前,pi-ai partial 的工具调用 `arguments` 保持为 `{}`;本适配器只读取 delta 字符串与最终参数。每次升级 pi-ai 时都要重新应用或撤销该补丁。 +- **流式工具调用参数只在调用结束时解析一次**——安装的 pi-ai 带有 [`patches/@earendil-works__pi-ai@0.87.1.patch`](../../../patches/@earendil-works__pi-ai@0.87.1.patch),它移除了每个流适配器中对整段累计参数 JSON 的逐 delta 重新解析(上游 [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265));未打补丁时,数 MB 的参数流会在事件循环上消耗 O(n²) CPU,并使进程内所有会话停滞。在 `toolcall_end` 之前,pi-ai partial 的工具调用 `arguments` 保持为 `{}`;本适配器只读取 delta 字符串与最终参数。每次升级 pi-ai 时都要重新应用或撤销该补丁。 ### 开发备注 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index e186f0c3b9..129d3e744e 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -41,7 +41,7 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "@earendil-works/pi-ai": "^0.85.1" + "@earendil-works/pi-ai": "^0.87.1" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 191c77c0d2..a257feedba 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -20,6 +20,7 @@ import type { BedrockCompat, ChatTemplateKwargValue, KnownApi, + MistralConversationsCompat, Model, ModelCost, ModelThinkingLevel, @@ -253,7 +254,8 @@ const COMPLETIONS_COMPAT_GATE = { zaiToolStream: 'withhold', supportsOpenAIGrammarTools: 'withhold', sendSessionAffinityHeaders: 'withhold', - deferredToolsMode: 'withhold', + supportsMidConvoSystemMessages: 'withhold', + supportsMidConvoToolAdditions: 'withhold', sessionAffinityFormat: 'withhold', } as const satisfies Record @@ -268,6 +270,7 @@ const RESPONSES_COMPAT_GATE = { supportsAdditionalTools: 'withhold', supportsToolSearch: 'withhold', supportsExplicitPromptCacheMode: 'withhold', + supportsMidConvoSystemMessages: 'withhold', } as const satisfies Record /** Disposition of every `AnthropicMessagesCompat` field; a drift gate like the one above. */ @@ -280,7 +283,9 @@ const ANTHROPIC_COMPAT_GATE = { allowEmptySignature: 'offer', supportsStrictTools: 'offer', sendSessionAffinityHeaders: 'withhold', - supportsToolReferences: 'withhold', + sessionAffinityFormat: 'withhold', + supportsMidConvoSystemMessages: 'withhold', + supportsMidConvoToolChanges: 'withhold', supportsMidConvoEffort: 'withhold', allowedFallbackModels: 'withhold', } as const satisfies Record @@ -290,6 +295,11 @@ const BEDROCK_COMPAT_GATE = { supportsStrictMode: 'offer', } as const satisfies Record +/** Disposition of every `MistralConversationsCompat` field. */ +const MISTRAL_COMPAT_GATE = { + supportsMidConvoSystemMessages: 'withhold', +} as const satisfies Record + /** * Every wire protocol pi-ai gives a compat type. Derived from `Model.compat`'s * own conditional rather than listed by hand, so a pi-ai release that gives a @@ -309,6 +319,7 @@ type ApiWithCompat = { [K in KnownApi]: NonNullable['compat']> extends * models declare. */ const COMPAT_GATES: Readonly>>> = { + 'mistral-conversations': MISTRAL_COMPAT_GATE, 'openai-completions': COMPLETIONS_COMPAT_GATE, 'openai-responses': RESPONSES_COMPAT_GATE, 'azure-openai-responses': RESPONSES_COMPAT_GATE, @@ -337,6 +348,8 @@ type OfferedCompatField = | OfferedIn | OfferedIn | OfferedIn + // oxlint-disable-next-line typescript/no-redundant-type-constituents -- Include gates whose current offering is empty. + | OfferedIn /** * pi-ai wire-compatibility switches, set on the route (its models' default) or @@ -450,7 +463,7 @@ export type EveryOfferedFieldIsDocumented = AssertNever = T /** Every compat type a gate classifies, merged so one `Pick` reaches all offered fields. */ -type UpstreamCompat = OpenAICompletionsCompat & OpenAIResponsesCompat & AnthropicMessagesCompat & BedrockCompat +type UpstreamCompat = OpenAICompletionsCompat & OpenAIResponsesCompat & AnthropicMessagesCompat & BedrockCompat & MistralConversationsCompat /** * Proof that each documented field carries its upstream type, not a hand-copied @@ -744,7 +757,7 @@ function resolveModelReasoning( } /** The compat block a materialized model carries, whichever protocol it speaks. */ -type ModelCompat = OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat | BedrockCompat +type ModelCompat = OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat | BedrockCompat | MistralConversationsCompat /** * Resolve one model's compat block from the profile's switches. diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts index 7df034522c..9012c56fca 100644 --- a/packages/llm/llm-pi-ai/src/replay.ts +++ b/packages/llm/llm-pi-ai/src/replay.ts @@ -10,7 +10,7 @@ import { LlmError } from '@deepseek-ai/dsh-llm' import type { AssistantMessage as HarnessAssistantMessage, ModelMessageSource, ReplayEnvelope } from '@deepseek-ai/dsh-llm' -import type { Api, AssistantMessage, Usage as PiUsage } from '@earendil-works/pi-ai' +import type { Api, AssistantMessage, ToolCall, Usage as PiUsage } from '@earendil-works/pi-ai' /** Per-block half of the pi-ai replay envelope, one entry per content block. */ export type PiAiReplayBlock = @@ -26,7 +26,7 @@ export interface PiAiReplayResponse { provider: string /** Requested model identity, matching the durable assistant source. */ model: string - /** Provider-reported model; only Anthropic replays it as the native model (reported in `message.model`, not `message.responseModel`). */ + /** Provider-reported model; replay retains the requested model for signature matching. */ responseModel?: string responseId?: string /** Provider-native effort for historical replay; absence is preserved. */ @@ -41,11 +41,11 @@ interface PiAiReplayState { } /** Parse tool-call argument JSON; tolerate model malformations with {}. */ -function parseArguments(raw: string): Record { +function parseArguments(raw: string): ToolCall['arguments'] { try { const parsed: unknown = JSON.parse(raw) if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - return parsed as Record + return parsed as ToolCall['arguments'] } } catch { // fall through @@ -75,15 +75,13 @@ function emptyPiUsage(): PiUsage { * @returns the versioned lossless-JSON replay projection. */ export function toPiReplayState(message: AssistantMessage, requestedModel = message.model): ReplayEnvelope { - const responseModel = message.api === 'anthropic-messages' && message.model !== requestedModel - ? message.model : message.responseModel const response: PiAiReplayResponse = { kind: 'pi-ai', version: 2, api: message.api, provider: message.provider, model: requestedModel, - ...responseModel === undefined ? {} : { responseModel }, + ...message.responseModel === undefined ? {} : { responseModel: message.responseModel }, ...message.responseId === undefined ? {} : { responseId: message.responseId }, ...message.providerThinkingLevel === undefined ? {} : { providerThinkingLevel: message.providerThinkingLevel }, stopReason: message.stopReason, @@ -221,9 +219,7 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag content, api: state.response.api, provider: state.response.provider, - // Anthropic reports aliases and fallbacks as model, unlike Completions' informational responseModel. - model: state.response.api === 'anthropic-messages' - ? state.response.responseModel ?? state.response.model : state.response.model, + model: state.response.model, ...state.response.responseModel === undefined ? {} : { responseModel: state.response.responseModel }, ...state.response.responseId === undefined ? {} : { responseId: state.response.responseId }, ...state.response.providerThinkingLevel === undefined ? {} : { providerThinkingLevel: state.response.providerThinkingLevel }, diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index b9152f00aa..edb9a13165 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -13,7 +13,7 @@ import { assemble, type AssembledResult } from './assemble.ts' * and exercises a replayed tool follow-up. Key-gated. */ -const FLASH = 'deepseek-v4-flash' +const FLASH = 'deepseek-flash' const contexts: Context[] = [] async function harness(_model: string, config: Partial = {}) { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 58b8d6afec..ba7bac9085 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -77,7 +77,7 @@ describe('PiAiAdapter provider routing', () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) const result = await assemble(ctx, { - model: 'deepseek-v4-flash', + model: 'deepseek-flash', messages: [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, @@ -103,7 +103,7 @@ describe('PiAiAdapter provider routing', () => { auth: memoryAuth(), })) - const prepared = await ctx.llm.prepareCall({ provider: 'deepseek', model: 'deepseek-v4-flash' }) + const prepared = await ctx.llm.prepareCall({ provider: 'deepseek', model: 'deepseek-flash' }) providers = { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: second.url } } const chunks: unknown[] = [] for await (const chunk of prepared.stream({ ...prepared.config, messages: [] })) chunks.push(chunk) @@ -118,7 +118,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = await harness(server.url, { headers: { 'x-company': 'private', 'User-Agent': 'wrong' }, }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(server.headers[0]?.['x-company']).toBe('private') expect(server.headers[0]?.['user-agent']).toBe(userAgent()) }) @@ -157,14 +157,14 @@ describe('PiAiAdapter provider routing', () => { const ctx = await harness(server.url, { reasoning: 'max' }) await assemble(ctx, { - model: 'deepseek-v4-flash', + model: 'deepseek-flash', reasoningEffort: ReasoningEffortId('high'), messages: [], }) expect(server.requests[0]).toMatchObject({ reasoning_effort: 'high' }) await assemble(ctx, { - model: 'deepseek-v4-flash', + model: 'deepseek-flash', reasoningEffort: ReasoningEffortId('off'), messages: [], }) @@ -172,7 +172,7 @@ describe('PiAiAdapter provider routing', () => { expect(server.requests[1]).not.toHaveProperty('reasoning_effort') const unsupported = await assemble(ctx, { - model: 'deepseek-v4-flash', + model: 'deepseek-flash', reasoningEffort: ReasoningEffortId('xhigh'), messages: [], }) @@ -191,7 +191,7 @@ describe('PiAiAdapter provider routing', () => { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: server.url }, })) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + const result = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) }) @@ -215,7 +215,7 @@ describe('PiAiAdapter provider routing', () => { it('reports unsupported stop sequences rather than silently ignoring them', async () => { const server = await mockServer([]) const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], stop: ['END'] }) + const result = await assemble(ctx, { model: 'deepseek-flash', messages: [], stop: ['END'] }) expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNSUPPORTED_OPTION' } }) expect(server.requests).toEqual([]) }) @@ -383,14 +383,14 @@ describe('PiAiAdapter provider routing', () => { ] as const)('maps HTTP %s failures to %s', async (status, code) => { const server = await mockServer([{ status, body: JSON.stringify({ error: { message: `provider ${status}` } }) }]) const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + const result = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(result.finish).toMatchObject({ kind: 'error', failure: { code } }) expect(server.paths).toEqual(['/chat/completions']) }) it('uses the resolved catalog context window for usage-based overflow detection', async () => { - const model = getBuiltinModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash') - if (model === undefined) throw new Error('deepseek-v4-flash missing from pi-ai test catalog') + const model = getBuiltinModels('deepseek').find(candidate => candidate.id === 'deepseek-flash') + if (model === undefined) throw new Error('deepseek-flash missing from pi-ai test catalog') const events = [ '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', JSON.stringify({ @@ -417,7 +417,7 @@ describe('PiAiAdapter provider routing', () => { const server = await mockServer([{ events: textEvents, delayMs: 200 }]) const ctx = await harness(server.url, { streamIdleTimeoutMs: 20 }) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + const result = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'TIMEOUT' } }) await Promise.race([ server.responseClosed, @@ -497,7 +497,7 @@ describe('provider profile lifecycle', () => { providers: { deepseek: {}, openai: {} }, }) - await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) + await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-flash')) .resolves.toMatchObject({ reasoning: { efforts: [ @@ -530,7 +530,7 @@ describe('provider profile lifecycle', () => { await supported.plugin(LlmPiAi, { providers: { deepseek: { reasoning: 'max' } }, }) - await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) + await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } }) // A profile level this model cannot take DESCRIBES as no default rather @@ -543,11 +543,11 @@ describe('provider profile lifecycle', () => { await unsupported.plugin(LlmPiAi, { providers: { deepseek: { reasoning: 'medium' } }, }) - const described = await unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash') + const described = await unsupported.llm.resolveModelInfo('deepseek', 'deepseek-flash') expect(described.reasoning?.defaultEffort).toBeUndefined() expect(described.reasoning?.efforts.length).toBeGreaterThan(0) await expect(assemble(unsupported, { - provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], + provider: 'deepseek', model: 'deepseek-flash', messages: [], })).resolves.toMatchObject({ finish: { kind: 'error', failure: { code: 'UNSUPPORTED_REASONING_EFFORT' } }, }) @@ -557,7 +557,7 @@ describe('provider profile lifecycle', () => { await disabled.plugin(LlmPiAi, { providers: { deepseek: { reasoning: 'off' } }, }) - await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) + await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) }) @@ -786,7 +786,7 @@ describe('provider profile lifecycle', () => { // A profile that names no reference at all is the one case that defers to // pi-ai's own provider-native discovery. const ctx = await harness(server.url, { apiKeyEnv: undefined }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') }) @@ -794,7 +794,7 @@ describe('provider profile lifecycle', () => { vi.stubEnv('PI_CUSTOM_REF_KEY', 'custom-ref-key') const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key') }) @@ -806,9 +806,9 @@ describe('provider profile lifecycle', () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) - const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + const second = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(second.finish.kind).toBe('error') if (second.finish.kind !== 'error') throw new Error('expected an error finish') expect(second.finish.failure.message).toMatch(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s) @@ -911,12 +911,15 @@ describe('provider profile lifecycle', () => { await expect(drain({ provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-v4-pro', messages: [createUserMessage({ content: [{ type: 'image', attachment: IMAGE_REF }], source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })], - })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + })).rejects.toMatchObject({ + code: 'UNSUPPORTED_CONTENT', + message: 'pi-ai model "deepseek-v4-pro" does not support image input', + }) await expect(drain({ provider: 'openai', model: 'gpt-4.1', @@ -956,7 +959,7 @@ describe('abort wiring', () => { const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', messages: [message as never], })) { /* drain */ } } @@ -977,7 +980,7 @@ describe('abort wiring', () => { const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', messages: [message as never], signal: controller.signal, })) { /* drain */ } @@ -993,7 +996,7 @@ describe('abort wiring', () => { const chunks = [] for await (const chunk of adapter.stream({ provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', messages: [], signal: controller.signal, })) chunks.push(chunk) @@ -1008,7 +1011,7 @@ describe('abort wiring', () => { const ctx = await harness(server.url) const controller = new AbortController() controller.abort('already stopped') - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal }) + const result = await assemble(ctx, { model: 'deepseek-flash', messages: [], signal: controller.signal }) expect(result.finish.kind).toBe('aborted') }) @@ -1017,7 +1020,7 @@ describe('abort wiring', () => { const ctx = await harness(server.url) const controller = new AbortController() const resultPromise = assemble(ctx, { - model: 'deepseek-v4-flash', messages: [], signal: controller.signal, + model: 'deepseek-flash', messages: [], signal: controller.signal, }) setTimeout(() => { controller.abort('stopped during stream') }, 10) const result = await resultPromise @@ -1027,7 +1030,7 @@ describe('abort wiring', () => { it('aborts upstream when a consumer stops early', async () => { const server = await mockServer([{ events: textEvents, delayMs: 30 }]) const ctx = await harness(server.url) - for await (const chunk of ctx.llm.stream({ provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) { + for await (const chunk of ctx.llm.stream({ provider: 'deepseek', model: 'deepseek-flash', messages: [] })) { if (chunk.type === 'block-start') break } await new Promise(resolve => setTimeout(resolve, 20)) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index e574461018..e6f5330e9b 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -9,6 +9,7 @@ import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { AssistantMessageEventStream } from '@earendil-works/pi-ai/utils/event-stream' +import { normalizeContext } from '@earendil-works/pi-ai/utils/transcript' import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai' import { resolveProfiles } from '../src/config.ts' import { createModels, createProvider, getSupportedThinkingLevels } from '../src/models.ts' @@ -362,7 +363,7 @@ describe('hand-declared providers', () => { auth: { apiKey: { name: 'Local', resolve: () => Promise.resolve({ auth: {}, source: 'Local' }) } }, api: { stream, streamSimple }, }) - const context = { messages: [] } + const context = normalizeContext({ messages: [] }) expect(provider.stream(model, context)).toBe(direct) expect(provider.streamSimple(model, context)).toBe(simple) @@ -545,7 +546,7 @@ describe('catalog routes with per-model configuration', () => { if (built === undefined) throw new Error('the deepseek route built no provider') const [model] = built.getModels() if (model === undefined) throw new Error('the deepseek route resolved no models') - const context = { messages: [{ role: 'user' as const, content: 'hi', timestamp: 0 }] } + const context = normalizeContext({ messages: [{ role: 'user', content: 'hi', timestamp: 0 }] }) // `stream` is interface-required and unused by the harness adapter, which // only calls `streamSimple`; both must still reach the catalog provider. @@ -1069,9 +1070,9 @@ describe('compat switches', () => { it('refuses a valueless compat key on a model entry too', () => { expect(() => resolveProfiles({ deepseek: { - modelOverrides: { 'deepseek-v4-flash': { compat: { requiresReasoningContentOnAssistantMessages: null } } as never }, + modelOverrides: { 'deepseek-flash': { compat: { requiresReasoningContentOnAssistantMessages: null } } as never }, }, - })).toThrow(/model "deepseek-v4-flash" sets compat "requiresReasoningContentOnAssistantMessages" with no value/) + })).toThrow(/model "deepseek-flash" sets compat "requiresReasoningContentOnAssistantMessages" with no value/) }) it('serves the Responses compat type on every protocol pi-ai gives it to', () => { @@ -1134,7 +1135,7 @@ describe('resolution snapshots', () => { const inFlight = (async () => { for await (const chunk of adapter.stream({ provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', messages: [], })) chunks.push(chunk) })() @@ -1163,7 +1164,7 @@ describe('resolution snapshots', () => { }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ - provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], + provider: 'deepseek', model: 'deepseek-flash', messages: [], })) { /* drain */ } } diff --git a/packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts b/packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts index 1dd72645cf..73fca353de 100644 --- a/packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts +++ b/packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts @@ -50,11 +50,21 @@ describe('pi-ai gateway compatibility declarations', () => { expect(() => resolved(compat, 'anthropic-messages')).toThrow(/compat/) }) - it.each(['supportsMidConvoEffort', 'allowedFallbackModels'])('withholds catalog-owned %s', (field) => { + it.each([ + 'supportsMidConvoEffort', 'allowedFallbackModels', 'supportsMidConvoSystemMessages', + 'supportsMidConvoToolAdditions', 'supportsMidConvoToolChanges', 'sessionAffinityFormat', + ])('withholds catalog-owned %s', (field) => { expect(() => resolved({ [field]: true }, 'anthropic-messages')) .toThrow(/which is not configurable here/) }) + it('withholds Mistral conversation capabilities', () => { + expect(() => resolveProfiles({ + mistral: { compat: { supportsMidConvoSystemMessages: true } as never }, + })) + .toThrow(/which is not configurable here/) + }) + it('keeps generic additions absent unless configured', () => { expect(resolved({})).toBeUndefined() }) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 4ca36d762a..005c12596c 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -443,6 +443,33 @@ describe('toPiContext', () => { expect(onDegrade).not.toHaveBeenCalled() }) + it('replays saved v2 Anthropic alias metadata with the requested model and signed thinking', () => { + const requestedModel = 'claude-haiku-4-5' + const responseModel = 'claude-haiku-4-5-20251001' + const replayState = { + response: { + kind: 'pi-ai', version: 2, api: 'anthropic-messages', provider: 'anthropic', + model: requestedModel, responseModel, stopReason: 'stop', + }, + blocks: [{ type: 'reasoning', thinkingSignature: 'saved-signature' }], + } + const onDegrade = vi.fn() + const context = toPiContext({ + provider: 'anthropic', model: requestedModel, + messages: [createMessage({ + role: 'assistant', content: [{ type: 'reasoning', text: 'saved reasoning' }], + source: { kind: 'model', provider: 'anthropic', model: requestedModel, replayState }, + })], + }, undefined, onDegrade) + const model = getBuiltinModels('anthropic').find(candidate => candidate.id === requestedModel) + if (model === undefined) throw new Error('missing Anthropic catalog model') + expect(onDegrade).not.toHaveBeenCalled() + expect(context.messages[0]).toMatchObject({ model: requestedModel, responseModel }) + expect(transformMessages(context.messages, model)[0]).toMatchObject({ + content: [{ type: 'thinking', thinking: 'saved reasoning', thinkingSignature: 'saved-signature' }], + }) + }) + it('replays all native block kinds when optional metadata is absent', () => { const state = toPiReplayState(assistant({ content: [ @@ -660,11 +687,12 @@ describe('toPiContext', () => { describe('toStreamChunks', () => { it.each([ ['claude-haiku-4-5', 'claude-haiku-4-5-20251001'], - ['claude-fable-5', 'claude-opus-5'], - ['claude-opus-5', 'claude-opus-5'], - ])('replays Anthropic request %s with native response model %s', async (requestedModel, returnedModel) => { + ['claude-opus-5', 'kimi-for-coding'], + ['claude-opus-5', undefined], + ])('retains signed thinking for Anthropic request %s reported as %s', async (requestedModel, reportedModel) => { const native = assistant({ - api: 'anthropic-messages', provider: 'anthropic', model: returnedModel, + api: 'anthropic-messages', provider: 'anthropic', model: requestedModel, + ...reportedModel === undefined ? {} : { responseModel: reportedModel }, providerThinkingLevel: 'high', content: [{ type: 'thinking', thinking: 'reason', thinkingSignature: 'signed' }], }) @@ -674,8 +702,10 @@ describe('toStreamChunks', () => { const finish = chunks.find(chunk => chunk.type === 'finish') const replayState: unknown = JSON.parse(JSON.stringify(finish?.replayState)) expect(replayState).toMatchObject({ response: { model: requestedModel } }) - if (requestedModel !== returnedModel) { - expect(replayState).toMatchObject({ response: { responseModel: returnedModel } }) + if (reportedModel === undefined) { + expect(finish?.replayState?.response).not.toHaveProperty('responseModel') + } else { + expect(replayState).toMatchObject({ response: { responseModel: reportedModel } }) } const onDegrade = vi.fn() const context = toPiContext({ @@ -687,16 +717,14 @@ describe('toStreamChunks', () => { }, undefined, onDegrade) expect(onDegrade).not.toHaveBeenCalled() expect(context.messages[0]).toMatchObject({ - api: 'anthropic-messages', model: returnedModel, providerThinkingLevel: 'high', + api: 'anthropic-messages', model: requestedModel, providerThinkingLevel: 'high', content: native.content, }) - const catalog = getBuiltinModels('anthropic') - const requested = catalog.find(model => model.id === requestedModel) - const returned = catalog.find(model => model.id === returnedModel) - if (requested === undefined || returned === undefined) throw new Error('missing Anthropic catalog model') - expect(transformMessages(context.messages, returned)[0]).toMatchObject({ content: native.content }) - expect(transformMessages(context.messages, requested)[0]).toMatchObject({ - content: requestedModel === returnedModel ? native.content : [{ type: 'text', text: 'reason' }], + const requested = getBuiltinModels('anthropic').find(model => model.id === requestedModel) + if (requested === undefined) throw new Error('missing Anthropic catalog model') + expect(transformMessages(context.messages, requested)[0]).toMatchObject({ content: native.content }) + expect(transformMessages(context.messages, { ...requested, id: 'other-model' })[0]).toMatchObject({ + content: [{ type: 'text', text: 'reason' }], }) }) diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index ee0faca3ca..ae99592020 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -170,7 +170,7 @@ describe('request-level dynamic profiles', () => { expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0) - const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-flash', messages: [] }) expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings') @@ -197,7 +197,7 @@ describe('request-level dynamic profiles', () => { }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) - const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-flash', messages: [] }) expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) expect(server.headers[0]?.authorization).toBe('Bearer live-key') @@ -205,7 +205,7 @@ describe('request-level dynamic profiles', () => { // composition route stays. await configurations.get(ctx)!.replace(initialConfigs.get(ctx)!) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) - const removed = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + const removed = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-flash', messages: [] }) expect(removed.finish).toMatchObject({ kind: 'error', failure: { code: 'NO_ADAPTER' } }) }) @@ -218,11 +218,11 @@ describe('request-level dynamic profiles', () => { providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, }) - await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-flash', messages: [] }) expect(server.headers[0]?.authorization).toBe('Bearer pk-one') await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two') - await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-flash', messages: [] }) expect(server.headers[1]?.authorization).toBe('Bearer pk-two') }) diff --git a/packages/llm/llm-pi-ai/tests/egress.spec.ts b/packages/llm/llm-pi-ai/tests/egress.spec.ts index ba9c38c651..fa845610dc 100644 --- a/packages/llm/llm-pi-ai/tests/egress.spec.ts +++ b/packages/llm/llm-pi-ai/tests/egress.spec.ts @@ -46,7 +46,7 @@ async function streamOnce(): Promise { await ctx.plugin(LlmPiAi, { providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: 'http://pi-stream-probe.invalid' } }, }) - for await (const _chunk of ctx.llm.stream({ provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) { + for await (const _chunk of ctx.llm.stream({ provider: 'deepseek', model: 'deepseek-flash', messages: [] })) { // The endpoint never answers; the proxy record is the assertion. } } finally { diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index e9d1699c34..6beedfb3fb 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -105,7 +105,7 @@ describe('llm-pi-ai real dormant composition', () => { expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) }, { timeout: 5000 }) - const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-flash', messages: [] }) expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) expect(server.headers[0]?.authorization).toBe('Bearer key-from-store') }) @@ -169,7 +169,7 @@ describe('llm-pi-ai real dormant composition', () => { const truncated = await assemble(ctx, { provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', messages: [], }) expect(truncated.finish).toEqual({ kind: 'max-tokens' }) @@ -177,14 +177,14 @@ describe('llm-pi-ai real dormant composition', () => { expect(truncated.message.source).toEqual({ kind: 'model', provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', replayState: { response: { kind: 'pi-ai', version: 2, api: 'openai-completions', provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', stopReason: 'length', }, blocks: [{ type: 'text' }], @@ -193,7 +193,7 @@ describe('llm-pi-ai real dormant composition', () => { const continued = await assemble(ctx, { provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', messages: [ truncated.message, createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }), @@ -237,13 +237,13 @@ describe('llm-pi-ai real dormant composition', () => { kind: 'model', ...{ provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', replayState: { kind: 'pi-ai', version: 1, api: 'openai-completions', provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', stopReason: 'length', blocks: [{ type: 'text' }, { type: 'tool-call' }], }, @@ -252,7 +252,7 @@ describe('llm-pi-ai real dormant composition', () => { }) const continued = await assemble(ctx, { provider: 'deepseek', - model: 'deepseek-v4-flash', + model: 'deepseek-flash', messages: [ poisoned, createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }), diff --git a/packages/llm/llm-pi-ai/tests/tool-argument-streaming.spec.ts b/packages/llm/llm-pi-ai/tests/tool-argument-streaming.spec.ts index 14a4e09a9b..42d9e28a8b 100644 --- a/packages/llm/llm-pi-ai/tests/tool-argument-streaming.spec.ts +++ b/packages/llm/llm-pi-ai/tests/tool-argument-streaming.spec.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { AssistantMessageEvent, Model } from '@earendil-works/pi-ai' import { stream as streamCompletions } from '@earendil-works/pi-ai/api/openai-completions' import { stream as streamResponses } from '@earendil-works/pi-ai/api/openai-responses' +import { normalizeContext } from '@earendil-works/pi-ai/utils/transcript' import { closeMockServers, mockServer } from './mock-server.ts' afterEach(closeMockServers) @@ -35,7 +36,7 @@ async function collect(events: AsyncIterable): Promise<{ return { partials, final } } -const context = { messages: [{ role: 'user' as const, content: 'hi', timestamp: 0 }] } +const context = normalizeContext({ messages: [{ role: 'user', content: 'hi', timestamp: 0 }] }) describe('streamed tool-call arguments (patched pi-ai)', () => { it('openai-completions parses arguments once, at the end of the call', async () => { diff --git a/patches/@earendil-works__pi-ai@0.85.1.patch b/patches/@earendil-works__pi-ai@0.87.1.patch similarity index 100% rename from patches/@earendil-works__pi-ai@0.85.1.patch rename to patches/@earendil-works__pi-ai@0.87.1.patch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8b600d794..496f6e8e4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ overrides: '@deepseek-ai/schemastery': link:vendor/schemastery patchedDependencies: - '@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f + '@earendil-works/pi-ai@0.87.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 '@fortune-sheet/core@1.0.4': 8791a230763dbf853fd65d627c68c31de37209992851377855e16cf08ea70532 '@fortune-sheet/react@1.0.4': 2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37 @@ -9676,8 +9676,8 @@ importers: specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@earendil-works/pi-ai': - specifier: ^0.85.1 - version: 0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + specifier: ^0.87.1 + version: 0.87.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -14796,8 +14796,8 @@ packages: '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.123.0': - resolution: {integrity: sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==} + '@anthropic-ai/sdk@0.124.0': + resolution: {integrity: sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -14829,105 +14829,84 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - - '@aws-sdk/client-bedrock-runtime@3.1048.0': - resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + '@aws-sdk/client-bedrock-runtime@3.1127.0': + resolution: {integrity: sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.20': - resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} + '@aws-sdk/core@3.978.1': + resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.46': - resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} + '@aws-sdk/credential-provider-env@3.972.72': + resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.48': - resolution: {integrity: sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==} + '@aws-sdk/credential-provider-http@3.972.74': + resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.53': - resolution: {integrity: sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==} + '@aws-sdk/credential-provider-ini@3.973.17': + resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.52': - resolution: {integrity: sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==} + '@aws-sdk/credential-provider-login@3.972.79': + resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.55': - resolution: {integrity: sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==} + '@aws-sdk/credential-provider-node@3.972.84': + resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.46': - resolution: {integrity: sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==} + '@aws-sdk/credential-provider-process@3.972.72': + resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.52': - resolution: {integrity: sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==} + '@aws-sdk/credential-provider-sso@3.973.16': + resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.52': - resolution: {integrity: sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==} + '@aws-sdk/credential-provider-web-identity@3.972.78': + resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.21': - resolution: {integrity: sha512-mVC0hOmwGJmNFezZ+wM8Sqfap/LjsMavEf2Evl0YWrLAcrdZOEdjnY8nRvgakVViWJSGm2eJxLuPVHGdeV06kA==} + '@aws-sdk/eventstream-handler-node@3.972.35': + resolution: {integrity: sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.17': - resolution: {integrity: sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA==} + '@aws-sdk/middleware-eventstream@3.972.30': + resolution: {integrity: sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.28': - resolution: {integrity: sha512-SCW06Zjugn86pq7+dxGnFcyWJuEWHT753HTU/Vj/OzVxP+NoShwdAr4ynxAcvWL883OgRVbSqW3ohnjIxwXjjw==} + '@aws-sdk/middleware-websocket@3.972.54': + resolution: {integrity: sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.997.20': - resolution: {integrity: sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==} + '@aws-sdk/nested-clients@3.997.46': + resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.34': - resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} + '@aws-sdk/signature-v4-multi-region@3.996.47': + resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1048.0': - resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + '@aws-sdk/token-providers@3.1127.0': + resolution: {integrity: sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1066.0': - resolution: {integrity: sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==} + '@aws-sdk/token-providers@3.1138.0': + resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.12': - resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} + '@aws-sdk/types@3.974.6': + resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.7': - resolution: {integrity: sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==} + '@aws-sdk/xml-builder@3.972.41': + resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.29': - resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} - engines: {node: '>=20.0.0'} - - '@aws/lambda-invoke-store@0.2.4': - resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} '@babel/code-frame@7.29.7': @@ -15155,13 +15134,13 @@ packages: search-insights: optional: true - '@earendil-works/pi-ai@0.85.1': - resolution: {integrity: sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==} + '@earendil-works/pi-ai@0.87.1': + resolution: {integrity: sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-telemetry@0.85.1': - resolution: {integrity: sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==} + '@earendil-works/pi-telemetry@0.87.1': + resolution: {integrity: sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ==} engines: {node: '>=22.19.0'} '@electron-internal/extract-zip@1.0.5': @@ -15740,8 +15719,8 @@ packages: react: '>= 18.2' react-dom: '>= 18.2' - '@google/genai@1.52.0': - resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + '@google/genai@2.21.0': + resolution: {integrity: sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -16297,9 +16276,6 @@ packages: resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} - '@nodable/entities@2.2.0': - resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} - '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} @@ -17174,46 +17150,30 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/core@3.24.7': - resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} + '@smithy/core@3.35.0': + resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.9': - resolution: {integrity: sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==} + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.4.7': - resolution: {integrity: sha512-NslaM2ir0N2hisDmzXLstPaVINZheh8SokyOC++kzFPloZucL2R7Y7bS57mSzx/1Fc/fqmn7twjkeezTTrV0EA==} + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/node-http-handler@4.7.3': - resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.7.8': - resolution: {integrity: sha512-f+DbsWUwSbtMu1a/j8Y93KiU1SRg9nyzfjereqn1BJ33QOTUXxdlYvVXMhAYl1vuR1Kmna5aIJe09KSIfyFNYw==} + '@smithy/signature-v4@5.7.4': + resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.4.7': - resolution: {integrity: sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==} + '@smithy/types@4.19.0': + resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} engines: {node: '>=18.0.0'} - '@smithy/types@4.14.4': - resolution: {integrity: sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -17920,6 +17880,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -17969,9 +17933,6 @@ packages: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} - anynum@1.0.0: - resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} - anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} @@ -19095,10 +19056,6 @@ packages: resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==} hasBin: true - fast-xml-parser@5.7.3: - resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} - hasBin: true - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -19420,6 +19377,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} + engines: {node: '>= 20'} + http-proxy@1.18.1: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} @@ -19445,6 +19406,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} + engines: {node: '>= 20'} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -20755,6 +20720,15 @@ packages: resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} @@ -21267,9 +21241,6 @@ packages: strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} - strnum@2.4.0: - resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} - strnum@2.4.2: resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} @@ -21497,8 +21468,8 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typebox@1.3.7: - resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==} + typebox@1.3.27: + resolution: {integrity: sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==} typescript-language-server@5.3.0: resolution: {integrity: sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==} @@ -22217,7 +22188,7 @@ snapshots: '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.263 '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.263 - '@anthropic-ai/sdk@0.123.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.124.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.1.1 @@ -22250,228 +22221,187 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@aws-crypto/crc32@5.2.0': + '@aws-sdk/client-bedrock-runtime@3.1127.0': dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/eventstream-handler-node': 3.972.35 + '@aws-sdk/middleware-eventstream': 3.972.30 + '@aws-sdk/middleware-websocket': 3.972.54 + '@aws-sdk/token-providers': 3.1127.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-crypto/sha256-browser@5.2.0': + '@aws-sdk/core@3.978.1': dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - '@aws-sdk/util-locate-window': 3.965.7 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-js@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - tslib: 2.8.1 - - '@aws-crypto/supports-web-crypto@5.2.0': - dependencies: - tslib: 2.8.1 - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-sdk/client-bedrock-runtime@3.1048.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-node': 3.972.55 - '@aws-sdk/eventstream-handler-node': 3.972.21 - '@aws-sdk/middleware-eventstream': 3.972.17 - '@aws-sdk/middleware-websocket': 3.972.28 - '@aws-sdk/token-providers': 3.1048.0 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@aws-sdk/core@3.974.20': - dependencies: - '@aws-sdk/types': 3.973.12 - '@aws-sdk/xml-builder': 3.972.29 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/core': 3.24.7 - '@smithy/signature-v4': 5.4.7 - '@smithy/types': 4.14.4 + '@aws-sdk/types': 3.974.6 + '@aws-sdk/xml-builder': 3.972.41 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.35.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.46': + '@aws-sdk/credential-provider-env@3.972.72': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.48': + '@aws-sdk/credential-provider-http@3.972.74': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.53': + '@aws-sdk/credential-provider-ini@3.973.17': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-env': 3.972.46 - '@aws-sdk/credential-provider-http': 3.972.48 - '@aws-sdk/credential-provider-login': 3.972.52 - '@aws-sdk/credential-provider-process': 3.972.46 - '@aws-sdk/credential-provider-sso': 3.972.52 - '@aws-sdk/credential-provider-web-identity': 3.972.52 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/credential-provider-imds': 4.3.9 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-login': 3.972.79 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.52': + '@aws-sdk/credential-provider-login@3.972.79': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.55': + '@aws-sdk/credential-provider-node@3.972.84': dependencies: - '@aws-sdk/credential-provider-env': 3.972.46 - '@aws-sdk/credential-provider-http': 3.972.48 - '@aws-sdk/credential-provider-ini': 3.972.53 - '@aws-sdk/credential-provider-process': 3.972.46 - '@aws-sdk/credential-provider-sso': 3.972.52 - '@aws-sdk/credential-provider-web-identity': 3.972.52 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/credential-provider-imds': 4.3.9 - '@smithy/types': 4.14.4 + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-ini': 3.973.17 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.46': + '@aws-sdk/credential-provider-process@3.972.72': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.52': + '@aws-sdk/credential-provider-sso@3.973.16': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/token-providers': 3.1066.0 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/token-providers': 3.1138.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.52': + '@aws-sdk/credential-provider-web-identity@3.972.78': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/eventstream-handler-node@3.972.21': + '@aws-sdk/eventstream-handler-node@3.972.35': dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.17': + '@aws-sdk/middleware-eventstream@3.972.30': dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.28': + '@aws-sdk/middleware-websocket@3.972.54': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/signature-v4': 5.4.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.20': + '@aws-sdk/nested-clients@3.997.46': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.34': + '@aws-sdk/signature-v4-multi-region@3.996.47': dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/signature-v4': 5.4.7 - '@smithy/types': 4.14.4 + '@aws-sdk/types': 3.974.6 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1048.0': + '@aws-sdk/token-providers@3.1127.0': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1066.0': + '@aws-sdk/token-providers@3.1138.0': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/types@3.973.12': + '@aws-sdk/types@3.974.6': dependencies: - '@smithy/types': 4.14.4 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.7': + '@aws-sdk/xml-builder@3.972.41': dependencies: + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.29': - dependencies: - '@smithy/types': 4.14.4 - fast-xml-parser: 5.7.3 - tslib: 2.8.1 - - '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} '@babel/code-frame@7.29.7': dependencies: @@ -22722,27 +22652,28 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.87.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.123.0(zod@4.4.3) - '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@earendil-works/pi-telemetry': 0.85.1 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) - '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + '@anthropic-ai/sdk': 0.124.0(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1127.0 + '@earendil-works/pi-telemetry': 0.87.1 + '@google/genai': 2.21.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@smithy/node-http-handler': 4.12.1 + http-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0 openai: 6.40.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 - typebox: 1.3.7 + typebox: 1.3.27 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil + - kerberos - supports-color - utf-8-validate - ws - zod - '@earendil-works/pi-telemetry@0.85.1': {} + '@earendil-works/pi-telemetry@0.87.1': {} '@electron-internal/extract-zip@1.0.5': {} @@ -23182,7 +23113,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) regenerator-runtime: 0.14.1 - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': + '@google/genai@2.21.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 @@ -23689,8 +23620,6 @@ snapshots: '@noble/hashes@2.3.0': {} - '@nodable/entities@2.2.0': {} - '@nodable/entities@3.0.0': {} '@octokit/openapi-types@28.0.0': {} @@ -24279,58 +24208,37 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/core@3.24.7': + '@smithy/core@3.35.0': dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.4 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.3.9': + '@smithy/credential-provider-imds@4.5.2': dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.4.7': + '@smithy/fetch-http-handler@5.8.0': dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/is-array-buffer@2.2.0': + '@smithy/node-http-handler@4.12.1': dependencies: + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.7.3': + '@smithy/signature-v4@5.7.4': dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.7.8': + '@smithy/types@4.19.0': dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@smithy/signature-v4@5.4.7': - dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@smithy/types@4.14.4': - dependencies: - tslib: 2.8.1 - - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-utf8@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 '@stablelib/base64@1.0.1': {} @@ -25122,6 +25030,8 @@ snapshots: agent-base@7.1.4: {} + agent-base@9.0.0: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -25177,8 +25087,6 @@ snapshots: ansis@4.3.1: {} - anynum@1.0.0: {} - anynum@1.0.1: {} app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): @@ -26530,13 +26438,6 @@ snapshots: strnum: 2.4.2 xml-naming: 0.3.0 - fast-xml-parser@5.7.3: - dependencies: - '@nodable/entities': 2.2.0 - fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.4.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -26942,6 +26843,15 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3(supports-color@9.4.0) + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 @@ -26992,6 +26902,15 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3(supports-color@9.4.0) + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + human-signals@8.0.1: {} humanize-ms@1.2.1: @@ -28378,6 +28297,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent-negotiate@1.1.0: {} + psl@1.15.0: dependencies: punycode: 2.3.1 @@ -29005,10 +28926,6 @@ snapshots: strnum@1.1.2: {} - strnum@2.4.0: - dependencies: - anynum: 1.0.0 - strnum@2.4.2: dependencies: anynum: 1.0.1 @@ -29240,7 +29157,7 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typebox@1.3.7: {} + typebox@1.3.27: {} typescript-language-server@5.3.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f3c6b2baf9..b9cf05ab45 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -64,8 +64,8 @@ minimumReleaseAgeExclude: - '@deepseek-ai/libreoffice-kit-wasm@0.1.1' # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - - '@earendil-works/pi-ai@0.85.1' - - '@earendil-works/pi-telemetry@0.85.1' + - '@earendil-works/pi-ai@0.87.1' + - '@earendil-works/pi-telemetry@0.87.1' - node-addon-native-custom-loader@0.1.6 - node-addon-require-builtin-darwin-arm64@0.1.6 - node-addon-require-builtin-darwin-x64@0.1.6 @@ -98,7 +98,7 @@ minimumReleaseAgeExclude: - '@trycua/cua-driver-win32-x64-msvc@0.28.0' - '@trycua/cua-driver@0.28.0' patchedDependencies: - '@earendil-works/pi-ai@0.85.1': patches/@earendil-works__pi-ai@0.85.1.patch + '@earendil-works/pi-ai@0.87.1': patches/@earendil-works__pi-ai@0.87.1.patch '@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch '@fortune-sheet/core@1.0.4': patches/@fortune-sheet__core@1.0.4.patch '@fortune-sheet/react@1.0.4': patches/@fortune-sheet__react@1.0.4.patch diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index d37ce19871..86179be885 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -631,6 +631,16 @@ describe('CI workflow', () => { expect(config).not.toContain("pool: process.platform === 'win32' ? 'threads' : 'forks'") expect(config.match(/pool: 'forks'/g)).toHaveLength(2) }) + + it('applies the lane test budget inside every Vitest project', () => { + // Each inline project spreads coverageTestTimeoutOptions, the only route + // for DSH_COVERAGE_TEST_TIMEOUT_MS into projects; the behavior itself is + // pinned by scripts/lane-test-budget.spec.ts. + const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8') + + expect(config).toContain('const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV])') + expect(config.match(/^ {10}\.\.\.laneTestBudget,$/gm)).toHaveLength(2) + }) }) describe('Runtime and LLM e2e Blacksmith routing', () => { diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts index 8d3219b81f..2060608248 100644 --- a/scripts/coverage-partitions.spec.ts +++ b/scripts/coverage-partitions.spec.ts @@ -11,9 +11,10 @@ import { CoveragePartitionCoordinator, assignWeightedPartitions, collectPartitionDurations, - coverageTestTimeoutArgs, + coverageTestTimeoutOptions, forwardedCoverageArgs, parseCoveragePartitionCount, + parseCoverageTestTimeout, parseListOutput, readFileDurations, writeFileDurations, @@ -102,21 +103,24 @@ describe('coverage partition count', () => { }) }) -describe('coverage partition timeout', () => { - it('applies one configured timeout to tests, polling, and hooks', () => { - expect(coverageTestTimeoutArgs('30000')).toEqual([ - '--testTimeout=30000', - '--expect.poll.timeout=30000', - '--hookTimeout=30000', - ]) +describe('lane test budget', () => { + it('applies one configured budget to tests, hooks, and expect.poll', () => { + expect(coverageTestTimeoutOptions('30000')).toEqual({ + testTimeout: 30000, + hookTimeout: 30000, + expect: { poll: { timeout: 30000 } }, + }) }) - it('keeps Vitest defaults when the timeout is absent', () => { - expect(coverageTestTimeoutArgs(undefined)).toEqual([]) + it.each([undefined, ''])('keeps Vitest defaults when the budget is %j', (raw) => { + expect(parseCoverageTestTimeout(raw)).toBeUndefined() + expect(coverageTestTimeoutOptions(raw)).toEqual({}) }) - it('rejects invalid timeout input', () => { - expect(() => coverageTestTimeoutArgs('0')) + it.each(['0', '-1', '2.5', '090', '90000ms', 'many'])('rejects %j', (raw) => { + expect(() => parseCoverageTestTimeout(raw)) + .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) + expect(() => coverageTestTimeoutOptions(raw)) .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`) }) }) diff --git a/scripts/coverage-partitions.ts b/scripts/coverage-partitions.ts index 1dbc89390a..d67bc5b533 100644 --- a/scripts/coverage-partitions.ts +++ b/scripts/coverage-partitions.ts @@ -3,6 +3,7 @@ import { spawn } from 'node:child_process' import { globSync, readFileSync, writeFileSync } from 'node:fs' import { lstat, mkdir, readdir, rm, unlink, writeFile } from 'node:fs/promises' import { join, relative, sep } from 'node:path' +import type { TestUserConfig } from 'vitest/config' import { coverageExemptHeavySuites } from './coverage-exempt.ts' import { pnpmInvocation } from './pnpm-invocation.ts' @@ -12,7 +13,7 @@ export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS' /** Internal marker that suppresses reports and thresholds inside a partition process. */ export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE' -/** Environment variable overriding instrumented test, polling, and hook timeouts. */ +/** Environment variable carrying a lane's per-test, hook, and `expect.poll` budget in milliseconds. */ export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS' /** @@ -85,22 +86,44 @@ export function parseCoveragePartitionCount(raw: string | undefined): number | u } /** - * Resolve the paired Vitest timeout arguments used by coverage partitions. - * `--hookTimeout` travels with the test budget because setup and teardown pay - * the same host contention the raised test budget accounts for: fixtures that - * await child exit or retry Windows handle release spend that cost in - * `afterEach`, where Vitest's separate 10 s default would otherwise fail a - * suite whose cases all passed. + * Parse the lane test budget carried by `DSH_COVERAGE_TEST_TIMEOUT_MS`. * @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults. - * @returns the Vitest arguments applying that budget, empty when unset. + * @returns the budget in milliseconds, or undefined when unset. + * @throws when the value is set and is not a positive integer. */ -export function coverageTestTimeoutArgs(raw: string | undefined): string[] { - if (raw === undefined || raw === '') return [] +export function parseCoverageTestTimeout(raw: string | undefined): number | undefined { + if (raw === undefined || raw === '') return undefined const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { throw new Error(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) } - return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`, `--hookTimeout=${raw}`] + return parsed +} + +/** The Vitest `test` options one lane budget sets in each inline project. */ +export type CoverageTestTimeoutOptions = Pick + +/** + * Resolve the Vitest test options that apply one lane budget to the per-test, + * hook, and `expect.poll` defaults together. The hook budget travels with the + * test budget because setup and teardown pay the same host contention the + * raised test budget accounts for: fixtures that await child exit or retry + * Windows handle release spend that cost in `afterEach`, where Vitest's + * separate 10 s default would otherwise fail a suite whose cases all passed. + * The root `vitest.config.ts` spreads these into each inline project, so the + * budget governs every Vitest run of that config in an environment carrying + * the variable, whichever gate or script starts it. The config, not a CLI + * flag, is the only route into inline projects: Vitest forwards a fixed list + * of CLI overrides into projects, and `--hookTimeout` and + * `--expect.poll.timeout` are not on it (only `--testTimeout` is). Explicit + * `describe`, case, and fixture timeouts remain authoritative. + * @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults. + * @returns the options to spread into a project's `test` block, empty when unset. + */ +export function coverageTestTimeoutOptions(raw: string | undefined): CoverageTestTimeoutOptions { + const timeout = parseCoverageTestTimeout(raw) + if (timeout === undefined) return {} + return { testTimeout: timeout, hookTimeout: timeout, expect: { poll: { timeout } } } } /** Remove pnpm's package-script separator before forwarding Vitest arguments. */ diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index d06682d7fd..2eba3f4c17 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1950, + "AGENTS.md": 1960, "docs/AGENTS.md": 1320, "docs/architecture.md": 2410, "docs/cordis-primer.md": 600, diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index e8ed611e60..84dd6e1515 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -45,6 +45,7 @@ const KIND_TEMPLATES: Readonly> = { 'persistence-change': '.agents/skills/dsh-doc/templates/persistence-change.md', 'persistence-release': '.agents/skills/dsh-doc/templates/persistence-release.md', 'persistence-format': '.agents/skills/dsh-doc/templates/persistence-format.md', + 'upgrade-guide': '.agents/skills/dsh-doc/templates/upgrade-guide.md', } /** diff --git a/scripts/fixtures/lane-test-budget.fixture.ts b/scripts/fixtures/lane-test-budget.fixture.ts new file mode 100644 index 0000000000..0f9bc64a85 --- /dev/null +++ b/scripts/fixtures/lane-test-budget.fixture.ts @@ -0,0 +1,32 @@ +/** + * Waits that end only through the lane budget in force. Runs only inside the + * Vitest child that scripts/lane-test-budget.spec.ts spawns. + */ +import { beforeEach, describe, expect, it } from 'vitest' + +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) + +describe('lane test budget fixture', () => { + it('per-test budget', async () => { + await sleep(600) + }) + + // The predicate counts attempts instead of reading a clock, so a host stall + // cannot turn a budget-ended poll into a pass: a 10 ms interval fits at most + // ~20 attempts into a 200 ms budget and ~100 into Vitest's 1000 ms default, + // both far below the target, and the case's own budget stays above both so + // the poll budget is what ends the assertion. The reported duration is then + // the poll budget (or longer under load), which the spawning spec reads. + it('expect.poll budget', { timeout: 5_000 }, async () => { + let attempts = 0 + await expect.poll(() => { attempts += 1; return attempts }, { interval: 10 }).toBeGreaterThan(10_000) + }) + + describe('hook budget', () => { + beforeEach(async () => { + await sleep(600) + }) + + it('runs after the hook', () => {}) + }) +}) diff --git a/scripts/lane-test-budget.spec.ts b/scripts/lane-test-budget.spec.ts new file mode 100644 index 0000000000..b6250a50de --- /dev/null +++ b/scripts/lane-test-budget.spec.ts @@ -0,0 +1,122 @@ +/** + * DSH_COVERAGE_TEST_TIMEOUT_MS reaches every inline Vitest project through the + * root config (coverageTestTimeoutOptions owns the rule and why a CLI flag + * cannot carry it). A budget below the fixture's waits must end all three + * inside both projects; unset must keep Vitest's defaults, under which the + * fixture's per-test and hook waits pass and its poll ends at the 1000 ms + * default; a malformed value must fail at config load. + */ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { COVERAGE_TEST_TIMEOUT_ENV } from './coverage-partitions.ts' + +const root = resolve(import.meta.dirname, '..') +const vitestCli = fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)) +const fixture = 'scripts/fixtures/lane-test-budget.fixture.ts' +const fixtureLine = /lane-test-budget\.fixture\.ts \((\d+) tests(?: \| (\d+) failed)?\)/g + +/** One spawned Vitest run takes a few seconds; a hung child must not hold the worker. */ +const CHILD_TIMEOUT_MS = 60_000 + +let temporaryRoot: string | undefined +let configPath = '' + +// The root config with every project's inventory narrowed to the fixture, so +// the fixture never joins the ordinary inventory and still runs once per +// project. Absolute import in POSIX spelling because the temporary directory +// is outside the repository; the sibling package.json keeps Vite bundling the +// config as ESM, which the repository root's "type" otherwise supplies. +beforeAll(() => { + temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-lane-test-budget-')) + configPath = join(temporaryRoot, 'vitest.config.ts') + writeFileSync(join(temporaryRoot, 'package.json'), '{ "type": "module" }\n', 'utf8') + writeFileSync(configPath, [ + `import base from ${JSON.stringify(resolve(root, 'vitest.config.ts').split('\\').join('/'))}`, + 'export default {', + ' ...base,', + ' test: {', + ' ...base.test,', + ' projects: (base.test.projects ?? []).map(project => ({', + ' ...project,', + ` test: { ...project.test, include: [${JSON.stringify(fixture)}] },`, + ' })),', + ' },', + '}', + '', + ].join('\n'), 'utf8') +}) +afterAll(() => { + if (temporaryRoot !== undefined) rmSync(temporaryRoot, { recursive: true, force: true }) +}) + +function runFixture(budget: string | undefined): { status: number | null; output: string } { + const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' } + for (const name of Object.keys(env)) { + // The parent worker's Vitest state and the coverage coordinator's own + // variables describe this process, not the child; the Actions reporter + // would otherwise annotate the parent job with the fixture's failures. + if (name.startsWith('VITEST') || name.startsWith('DSH_COVERAGE_') || name === 'GITHUB_ACTIONS') Reflect.deleteProperty(env, name) + } + if (budget !== undefined) env[COVERAGE_TEST_TIMEOUT_ENV] = budget + const child = spawnSync(process.execPath, [vitestCli, 'run', '--config', configPath], { + cwd: root, + env, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + timeout: CHILD_TIMEOUT_MS, + killSignal: 'SIGKILL', + }) + if (child.error !== undefined) throw child.error + expect(child.signal, 'the child Vitest run ended through a signal').toBeNull() + return { status: child.status, output: `${child.stdout}\n${child.stderr}` } +} + +/** Per-project fixture summary lines, `[total, failed]` each, in output order. */ +function fixtureSummaries(output: string): Array<[number, number]> { + return [...output.matchAll(fixtureLine)].map(([, total, failed]) => [Number(total), Number(failed ?? 0)]) +} + +describe('lane test budget', () => { + it('ends per-test, hook, and expect.poll waits in both inline projects', { timeout: 90_000 }, () => { + const { status, output } = runFixture('200') + + expect(output).toMatch(/\|thread-safe\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/) + expect(output).toMatch(/\|process-bound\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/) + expect(fixtureSummaries(output)).toEqual([[3, 3], [3, 3]]) + expect(output.match(/Test timed out in 200ms\./g)).toHaveLength(2) + expect(output.match(/Hook timed out in 200ms\./g)).toHaveLength(2) + expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2) + // At most ~20 attempts fit into the 200 ms budget; the target is 10 000. + const polled = [...output.matchAll(/expected (\d+) to be greater than 10000/g)].map(([, count]) => Number(count)) + expect(polled).toHaveLength(2) + for (const count of polled) expect(count).toBeLessThan(100) + expect(status).toBe(1) + }) + + it('keeps Vitest defaults when the budget is unset', { timeout: 90_000 }, () => { + const { status, output } = runFixture(undefined) + + // 600 ms waits pass under the 5000 ms per-test and 10 000 ms hook defaults; + // the poll case ends at the 1000 ms default and can only take longer + // under load, never less. + expect(fixtureSummaries(output)).toEqual([[3, 1], [3, 1]]) + expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2) + expect(output).not.toMatch(/Test timed out in|Hook timed out in/) + const pollDurations = [...output.matchAll(/× expect\.poll budget (\d+)ms/g)].map(([, ms]) => Number(ms)) + expect(pollDurations).toHaveLength(2) + for (const ms of pollDurations) expect(ms).toBeGreaterThanOrEqual(1000) + expect(status).toBe(1) + }) + + it('refuses a malformed budget at config load before any test runs', { timeout: 90_000 }, () => { + const { status, output } = runFixture('90000ms') + + expect(output).toContain(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got "90000ms".`) + expect(fixtureSummaries(output)).toEqual([]) + expect(status).toBe(1) + }) +}) diff --git a/scripts/run-coverage-partitions.ts b/scripts/run-coverage-partitions.ts index 8626665b96..4b2293c148 100644 --- a/scripts/run-coverage-partitions.ts +++ b/scripts/run-coverage-partitions.ts @@ -2,9 +2,7 @@ import { resolve } from 'node:path' import { COVERAGE_PARTITIONS_ENV, - COVERAGE_TEST_TIMEOUT_ENV, CoveragePartitionCoordinator, - coverageTestTimeoutArgs, forwardedCoverageArgs, parseCoveragePartitionCount, } from './coverage-partitions.ts' @@ -22,9 +20,6 @@ const coordinator = new CoveragePartitionCoordinator({ root: resolve(import.meta.dirname, '..'), partitions, pnpmEntrypoint, - vitestArgs: [ - ...coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]), - ...forwardedCoverageArgs(process.argv.slice(2)), - ], + vitestArgs: forwardedCoverageArgs(process.argv.slice(2)), }) process.exitCode = await coordinator.run() diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 05113f87e0..4aa6e78d26 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -533,64 +533,49 @@ describe('gate graph validation', () => { expect(results.some(result => result.status === 'skipped')).toBe(false) }) - it('applies one configured test, polling, and hook timeout to both coverage gates', () => { - const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () => - withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) - - for (const id of ['coverage', 'coverage-exempt-heavy']) { - expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([ - '--testTimeout=15000', - '--expect.poll.timeout=15000', - '--hookTimeout=15000', - ])) + it('leaves the lane test budget to the inherited environment on both coverage gates', () => { + // vitest.config.ts reads DSH_COVERAGE_TEST_TIMEOUT_MS per inline project + // (coverageTestTimeoutOptions); a gate argument would add a second owner. + for (const budget of ['15000', undefined]) { + const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) + for (const id of ['coverage', 'coverage-exempt-heavy']) { + const gate = gates.find(subject => subject.id === id) + if (gate === undefined) throw new Error(`ci-windows-complete must define the ${id} gate`) + expect(gate.args).not.toEqual(expect.arrayContaining([ + expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/), + ])) + expect(gate.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS') + } } }) - it('keeps Vitest timeout defaults when the coverage override is absent', () => { - const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () => - withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) - - for (const id of ['coverage', 'coverage-exempt-heavy']) { - expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([ - expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/), - ])) - } - }) - - it('rejects an invalid coverage timeout before starting a gate', () => { - expect(() => withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '0', () => - withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))) - .toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer') - }) - - it('runs the plain unit inventory after the native build under the same configured budget', () => { + it('runs the plain unit inventory after the native build and leaves its budget to the inherited environment', () => { const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { scripts: Record } expect(scripts['check:ci:unit']).toBe('tsx scripts/run-gates.ts ci-unit') - const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () => - withPnpmEntrypoint(() => gatesForMode('ci-unit'))) - expect(gates.map(gate => gate.id)).toEqual(['native-system', 'unit']) // The aggregate is the `test` package script's two segments, in order, - // so a step added to one cannot silently leave the other. + // so a step added to one cannot silently leave the other. The lane sets + // DSH_COVERAGE_TEST_TIMEOUT_MS; vitest.config.ts reads it per inline + // project (coverageTestTimeoutOptions), so the gate passes no budget flag. const [nativeBuild, unitRun, ...rest] = (scripts.test ?? '').split(' && ') expect(rest).toEqual([]) - expect(gates[0]).toMatchObject({ displayCommand: nativeBuild }) - expect(gates[1]?.displayCommand).toBe(`pnpm exec ${unitRun} --testTimeout=15000 --expect.poll.timeout=15000 --hookTimeout=15000`) - expect(gates[1]).toMatchObject({ - label: 'test', - needs: ['native-system'], - streamOutput: true, - args: ['/private/pnpm.cjs', 'exec', 'vitest', 'run', '--testTimeout=15000', '--expect.poll.timeout=15000', '--hookTimeout=15000'], - }) - expect(gates[1]?.args).not.toContain('--coverage') - }) - - it('keeps Vitest timeout defaults for the plain unit inventory when the override is absent', () => { - const unit = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () => - withPnpmEntrypoint(() => gatesForMode('ci-unit').find(gate => gate.id === 'unit'))) - expect(unit?.args).toEqual(['/private/pnpm.cjs', 'exec', 'vitest', 'run']) + for (const budget of ['15000', undefined]) { + const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () => + withPnpmEntrypoint(() => gatesForMode('ci-unit'))) + expect(gates.map(gate => gate.id)).toEqual(['native-system', 'unit']) + expect(gates[0]).toMatchObject({ displayCommand: nativeBuild }) + expect(gates[1]?.displayCommand).toBe(`pnpm exec ${unitRun}`) + expect(gates[1]).toMatchObject({ + label: 'test', + needs: ['native-system'], + streamOutput: true, + args: ['/private/pnpm.cjs', 'exec', 'vitest', 'run'], + }) + expect(gates[1]?.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS') + } }) it('selects partitioned coverage only when explicitly configured', () => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 39738dee9b..3f8011175b 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -12,12 +12,7 @@ import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { CLIENT_BUILD_PROFILE_SELECTOR } from './client-build-environment.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts' -import { - COVERAGE_PARTITIONS_ENV, - COVERAGE_TEST_TIMEOUT_ENV, - coverageTestTimeoutArgs, - parseCoveragePartitionCount, -} from './coverage-partitions.ts' +import { COVERAGE_PARTITIONS_ENV, parseCoveragePartitionCount } from './coverage-partitions.ts' import { pnpmInvocation } from './pnpm-invocation.ts' /** A named aggregate exposed by the gate runner. */ @@ -662,9 +657,8 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // small share. A budget of 1 gives each gate 1 worker; lanes that need a strict // total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1, // which keeps the gates from overlapping at all. -// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test, expect.poll, and hook -// defaults together for lanes whose scheduling overhead exceeds those -// defaults. Explicit fixture timeouts remain authoritative. +// DSH_COVERAGE_TEST_TIMEOUT_MS is not a gate argument: vitest.config.ts reads +// it from the environment every gate inherits (coverageTestTimeoutOptions). function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers') if (flag === undefined) return { instrumented: [], exempt: [] } @@ -679,7 +673,6 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { function coverageGates(): Gate[] { const workers = coverageWorkerArgs() - const timeouts = coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]) const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV]) const instrumented = partitions === undefined ? pnpmExec('coverage', [ @@ -687,7 +680,6 @@ function coverageGates(): Gate[] { 'run', '--coverage', ...workers.instrumented, - ...timeouts, ], { label: 'test:coverage', env: { [COVERAGE_EXEMPT_ENV]: '1' }, @@ -706,7 +698,6 @@ function coverageGates(): Gate[] { 'run', ...coverageExemptHeavySuites.map(suite => suite.filter), ...workers.exempt, - ...timeouts, ], { label: 'test:coverage-exempt-heavy', needs: ['native-system'], @@ -715,19 +706,15 @@ function coverageGates(): Gate[] { } // The uninstrumented unit inventory for a whole-inventory reference lane -// (the Sandbox workflow's darwin parity job). It is `pnpm run test` with the -// same DSH_COVERAGE_TEST_TIMEOUT_MS budget the coverage gates take: a shared -// hosted runner delays cases that inherit Vitest's defaults past them. The -// package script itself has no environment hook. Output streams so the job -// log keeps per-file timestamps for a 15–30 minute run. +// (the Sandbox workflow's darwin parity job). It is `pnpm run test`; the lane +// sets DSH_COVERAGE_TEST_TIMEOUT_MS, which vitest.config.ts reads from the +// inherited environment, because a shared hosted runner delays cases that +// inherit Vitest's defaults past them. Output streams so the job log keeps +// per-file timestamps for a 15–30 minute run. function ciUnitGates(): Gate[] { return [ pnpmScript('native-system', 'build:native-system'), - pnpmExec('unit', [ - 'vitest', - 'run', - ...coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]), - ], { + pnpmExec('unit', ['vitest', 'run'], { label: 'test', needs: ['native-system'], streamOutput: true, @@ -850,6 +837,7 @@ function docSyncLeafGates(options: { pnpmScript('skill-invocation-metadata', 'verify-skill-invocation-metadata', { label: 'skill invocation metadata', quick: true }), pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt', quick: true }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets', quick: true }), + pnpmScript('upgrade-guides', 'verify-upgrade-guides', { label: 'upgrade guides', quick: true }), pnpmExec('doc-standard-tests', ['vitest', 'run', 'scripts/doc-standard.spec.ts'], { label: 'documentation standard tests', quick: true, diff --git a/scripts/verify-upgrade-guides.spec.ts b/scripts/verify-upgrade-guides.spec.ts new file mode 100644 index 0000000000..6d92157e8a --- /dev/null +++ b/scripts/verify-upgrade-guides.spec.ts @@ -0,0 +1,130 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectUpgradeGuideViolations } from './verify-upgrade-guides.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const VALID = [ + '---', + 'kind: upgrade-guide', + 'description: "The `--preset` flag is removed."', + '---', + '', + '# `--profile` replaces `--preset`', + '', + '## Change', + '', + 'Old and new behavior.', + '', + '### Detail', + '', + '```md', + '# not a heading', + '```', + '', + '## Migration', + '', + '1. Rename the flag.', + '', +].join('\n') + +const VALID_ZH = VALID + .replace('description: "The `--preset` flag is removed."', 'description: "移除 `--preset` 参数。"') + .replace('## Change', '## 变更') + .replace('## Migration', '## 迁移') + +function fixtureRoot(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-upgrade-guides-')) + roots.push(root) + for (const [file, source] of Object.entries(files)) { + const path = join(root, 'docs/upgrade-guide', file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, source) + } + return root +} + +describe('upgrade guide gate', () => { + it('accepts an absent tree and conforming guide pairs, including prerelease versions and CRLF files', () => { + expect(collectUpgradeGuideViolations(fixtureRoot({}))).toEqual([]) + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7-rc.2/profile-flag/guide.md': VALID, + 'v0.1.7-rc.2/profile-flag/guide.zh.md': VALID_ZH, + 'v0.1.7-rc.2/profile-flag/guide.i18n.yaml': 'not parsed by this gate\n', + 'v1.0.0/crlf-item/guide.md': VALID.replaceAll('\n', '\r\n'), + }))).toEqual([]) + }) + + it('rejects files outside the version/item/guide pair layout', () => { + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'README.md': VALID, + '0.1.7/item/guide.md': VALID, + 'v0.1/item/guide.md': VALID, + 'v0.1.7/Item_Name/guide.md': VALID, + 'v0.1.7/item/notes.md': VALID, + 'v0.1.7/item/extra/guide.md': VALID, + }))).toEqual([ + 'docs/upgrade-guide/0.1.7/item/guide.md', + 'docs/upgrade-guide/README.md', + 'docs/upgrade-guide/v0.1.7/Item_Name/guide.md', + 'docs/upgrade-guide/v0.1.7/item/extra/guide.md', + 'docs/upgrade-guide/v0.1.7/item/notes.md', + 'docs/upgrade-guide/v0.1/item/guide.md', + ].map(path => `${path}: only v//guide.{md,zh.md,i18n.yaml} files belong in docs/upgrade-guide`)) + }) + + it('rejects missing, extra, or invalid frontmatter fields', () => { + const body = VALID.slice(VALID.indexOf('# ')) + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7/none/guide.md': body, + 'v0.1.7/list/guide.md': `---\n- a\n---\n${body}`, + 'v0.1.7/fields/guide.md': `---\nkind: persistence-change\ndescription: ""\nversion: v0.1.7\n---\n${body}`, + }))).toEqual([ + 'docs/upgrade-guide/v0.1.7/fields/guide.md: frontmatter keys must be exactly description, kind; got description, kind, version', + 'docs/upgrade-guide/v0.1.7/fields/guide.md: kind must be upgrade-guide', + 'docs/upgrade-guide/v0.1.7/fields/guide.md: description must be a non-empty string', + 'docs/upgrade-guide/v0.1.7/list/guide.md: frontmatter must be a YAML object', + 'docs/upgrade-guide/v0.1.7/none/guide.md: must start with YAML frontmatter', + ]) + }) + + it('rejects a missing title and missing, renamed, extra, or reordered sections', () => { + const cases = { + 'no-title': VALID.replace('# `--profile` replaces `--preset`\n', ''), + 'two-titles': VALID.replace('## Migration', '# Migration'), + 'renamed': VALID.replace('## Change', '## Changes'), + 'extra': `${VALID}\n## References\n`, + 'reordered': VALID.replace('## Change', '## Placeholder').replace('## Migration', '## Change').replace('## Placeholder', '## Migration'), + } + const violations = collectUpgradeGuideViolations(fixtureRoot(Object.fromEntries( + Object.entries(cases).map(([item, source]) => [`v0.1.7/${item}/guide.md`, source]), + ))) + expect(violations.map(violation => violation.split('/')[3])).toEqual(['extra', 'no-title', 'renamed', 'reordered', 'two-titles']) + expect(violations[0]).toBe('docs/upgrade-guide/v0.1.7/extra/guide.md: headings must be one "#" title followed by "## Change", "## Migration"; got "# `--profile` replaces `--preset`", "## Change", "## Migration", "## References"') + }) + + it('checks the Chinese sibling against its own section names and metadata', () => { + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7/english-headings/guide.zh.md': VALID, + 'v0.1.7/wrong-kind/guide.zh.md': VALID_ZH.replace('kind: upgrade-guide', 'kind: guide'), + }))).toEqual([ + 'docs/upgrade-guide/v0.1.7/english-headings/guide.zh.md: headings must be one "#" title followed by "## 变更", "## 迁移"; got "# `--profile` replaces `--preset`", "## Change", "## Migration"', + 'docs/upgrade-guide/v0.1.7/wrong-kind/guide.zh.md: kind must be upgrade-guide', + ]) + }) + + it('rejects an English guide over 500 words, counting frontmatter, and leaves the Chinese sibling uncounted', () => { + const words = VALID.split(/\s+/u).filter(Boolean).length + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7/at-limit/guide.md': `${VALID}${'word '.repeat(500 - words)}\n`, + 'v0.1.7/over-limit/guide.md': `${VALID}${'word '.repeat(501 - words)}\n`, + 'v0.1.7/over-limit/guide.zh.md': `${VALID_ZH}${'词 '.repeat(600)}\n`, + }))).toEqual(['docs/upgrade-guide/v0.1.7/over-limit/guide.md: 501 words exceeds the 500-word ceiling']) + }) +}) diff --git a/scripts/verify-upgrade-guides.ts b/scripts/verify-upgrade-guides.ts new file mode 100644 index 0000000000..bb067cb41f --- /dev/null +++ b/scripts/verify-upgrade-guides.ts @@ -0,0 +1,85 @@ +/** + * Enforce the `docs/upgrade-guide/v//guide.md` layout, metadata, + * sections, and word ceiling defined by the `dsh-create-upgrade-guide` skill. + * `verify-translation-pairing` owns the Chinese sibling's pairing record. + * @module scripts/verify-upgrade-guides + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { JSON_SCHEMA, load } from 'js-yaml' +import { fromMarkdown } from 'mdast-util-from-markdown' + +const ROOT = resolve(import.meta.dirname, '..') +const GUIDE_ROOT = 'docs/upgrade-guide' +const FRONTMATTER_KEYS = ['description', 'kind'] +/** Required `##` sections for each guide language; the word ceiling applies to the English source. */ +const LANGUAGES = { + 'guide.md': { sections: ['Change', 'Migration'], maxWords: 500 }, + 'guide.zh.md': { sections: ['变更', '迁移'], maxWords: undefined }, +} as const +const SEMVER = String.raw`(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?` +const GUIDE_PATH = new RegExp(`^v${SEMVER}/[a-z0-9]+(?:-[a-z0-9]+)*/(guide\\.md|guide\\.zh\\.md|guide\\.i18n\\.yaml)$`, 'u') + +/** Return one guide's frontmatter and structure violations. */ +function guideViolations(source: string, language: typeof LANGUAGES[keyof typeof LANGUAGES]): string[] { + const violations: string[] = [] + const words = source.split(/\s+/u).filter(Boolean).length + if (language.maxWords !== undefined && words > language.maxWords) violations.push(`${String(words)} words exceeds the ${String(language.maxWords)}-word ceiling`) + + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/u.exec(source) + if (frontmatter === null) return [...violations, 'must start with YAML frontmatter'] + const metadata = load(frontmatter[1] as string, { schema: JSON_SCHEMA }) + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) return [...violations, 'frontmatter must be a YAML object'] + const fields = metadata as Record + const keys = Object.keys(fields).sort() + if (keys.join() !== FRONTMATTER_KEYS.join()) violations.push(`frontmatter keys must be exactly ${FRONTMATTER_KEYS.join(', ')}; got ${keys.join(', ') || 'none'}`) + if (fields.kind !== 'upgrade-guide') violations.push('kind must be upgrade-guide') + if (typeof fields.description !== 'string' || fields.description.trim() === '') violations.push('description must be a non-empty string') + + const body = source.slice(frontmatter[0].length) + const outline = fromMarkdown(body).children + .flatMap(node => node.type === 'heading' && node.depth <= 2 ? [body.slice(node.position?.start.offset, node.position?.end.offset)] : []) + const expected = language.sections.map(section => `## ${section}`) + if (outline.length !== expected.length + 1 || !outline[0]?.startsWith('# ') || outline.slice(1).join('\n') !== expected.join('\n')) { + violations.push(`headings must be one "#" title followed by ${expected.map(heading => `"${heading}"`).join(', ')}; got ${outline.map(heading => `"${heading}"`).join(', ') || 'none'}`) + } + return violations +} + +/** + * Report upgrade-guide tree violations. + * @param root - Repository root containing `docs/upgrade-guide`. + * @returns diagnostics prefixed by the repository-relative file path; empty when the tree is absent. + */ +export function collectUpgradeGuideViolations(root: string): string[] { + const directory = join(root, GUIDE_ROOT) + if (!existsSync(directory)) return [] + const violations: string[] = [] + const files = readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter(entry => !entry.isDirectory()) + .map(entry => join(entry.parentPath, entry.name).slice(directory.length + 1).replaceAll('\\', '/')) + .sort() + for (const file of files) { + const path = `${GUIDE_ROOT}/${file}` + const name = GUIDE_PATH.exec(file)?.[1] + if (name === undefined) { + violations.push(`${path}: only v//guide.{md,zh.md,i18n.yaml} files belong in ${GUIDE_ROOT}`) + continue + } + if (name === 'guide.md' || name === 'guide.zh.md') { + for (const violation of guideViolations(readFileSync(join(directory, file), 'utf8'), LANGUAGES[name])) violations.push(`${path}: ${violation}`) + } + } + return violations +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + const violations = collectUpgradeGuideViolations(ROOT) + if (violations.length > 0) { + process.stderr.write('verify-upgrade-guides: violations found (see .agents/skills/dsh-create-upgrade-guide/SKILL.md):\n') + for (const violation of violations) process.stderr.write(` ${violation}\n`) + process.exit(1) + } + process.stdout.write('verify-upgrade-guides: upgrade guides conform.\n') +} diff --git a/tsconfig.host.json b/tsconfig.host.json index d821be41cf..8bffd7941f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -31,6 +31,7 @@ "apps/web/tests/auto-review-denial.e2e.ts", "apps/web/tests/scaffold-generation.spec.ts", "apps/web/tests/default-model.e2e.ts", + "apps/web/tests/model-menu-observer.e2e.ts", "apps/web/tests/github-ready-review.e2e.ts", "apps/web/tests/streaming-fence-highlight.e2e.ts", "apps/web/tests/declared-reasoning.e2e.ts", diff --git a/vitest.config.ts b/vitest.config.ts index 6c0a46e4f3..d0fd3c31f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ import { resolvePwshPath } from './packages/shell/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' -import { COVERAGE_PARTITION_MODE_ENV } from './scripts/coverage-partitions.ts' +import { COVERAGE_PARTITION_MODE_ENV, COVERAGE_TEST_TIMEOUT_ENV, coverageTestTimeoutOptions } from './scripts/coverage-partitions.ts' // Prints exact `path:line:col` records for every uncovered statement, branch // path, and function when a file misses the per-file 100% gate — the built-in @@ -144,6 +144,12 @@ if (coveragePartitionRaw !== undefined && coveragePartitionRaw !== '' && coverag } const coveragePartitionMode = coveragePartitionRaw === '1' +// Lanes on shared hosts raise the per-test, hook, and expect.poll defaults +// together through DSH_COVERAGE_TEST_TIMEOUT_MS; it lands in each inline +// project below because CLI flags do not reach them (coverageTestTimeoutOptions +// owns the rule and its reach). +const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV]) + // These suites exercise process-global state, process APIs, or timing-sensitive process I/O // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. @@ -177,6 +183,7 @@ export default defineConfig({ // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS, // Linux, and Windows. Forked workers avoid that shared thread path. pool: 'forks', + ...laneTestBudget, setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'], include: testIncludes, exclude: [ @@ -192,6 +199,7 @@ export default defineConfig({ name: 'process-bound', execArgv: vitestExecArgv, pool: 'forks', + ...laneTestBudget, setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'], include: processBoundTests, exclude: [
企微小助手 入群问卷 微信公众号
DeepSeek Harness 企微小助手二维码 DeepSeek Harness 入群问卷二维码 DeepSeek Harness 团队微信公众号二维码