Merge remote-tracking branch 'origin/master' into worktree/jobs-api-review-3052
# Conflicts: # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/persistence-catalog.i18n.yaml # docs/persistence-catalog.md # docs/persistence-catalog.zh.md # docs/persistence-schema.json
This commit is contained in:
commit
3c394440ab
466 files changed
+12878
-7014
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: 620102f9f5e7fd76f38bf0031b856b26c2a7840d
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 3f00658c672908ba92627416ba5d5db381c0d9cd
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: 0f003edc4a53e7a04ea4f5613abe42c2d3926566
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1bae16cfaa1c54eebc6c8709ac6db5323b68d222
|
||||
+2
@@ -95,6 +95,8 @@ Slot scope is the closed set `root | session-maybe | session`:
|
||||
|
||||
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
|
||||
|
||||
Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn.
|
||||
|
||||
- The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing.
|
||||
- Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
|
||||
- Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`).
|
||||
|
||||
+2
@@ -95,6 +95,8 @@ slot scope 是闭集 `root | session-maybe | session`:
|
||||
|
||||
`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无会话 → blank 会话的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
|
||||
|
||||
blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。
|
||||
|
||||
- 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。
|
||||
- Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
|
||||
- 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
|
||||
2026-09-14-current-profile-plugin-management.md: a59a79022bce88dcee7b1629ec35c9759489e34a
|
||||
2026-09-14-current-profile-plugin-management.zh.md: 7c3e8d470fcf87fb32cda8d9f68dd2ffe4b7fe15
|
||||
2026-09-14-current-profile-plugin-management.md: 8d4d4c40f033e490f09ebf8d367e1fb621037a6d
|
||||
2026-09-14-current-profile-plugin-management.zh.md: a8dce606d00321bd96e643a9ba842581c961d6c1
|
||||
+1
-1
@@ -16,7 +16,7 @@ Configuration watches use Chokidar write stabilization by default. Its ordinary
|
||||
|
||||
Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted.
|
||||
|
||||
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
|
||||
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Creator mode and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
|
||||
|
||||
CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate.
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
|
||||
|
||||
profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。
|
||||
|
||||
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
|
||||
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
|
||||
|
||||
CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
|
||||
2026-09-15-desktop-native-fatal-recovery.md: b2a0dfff1a630d1eedaa898e4a4c8144ff7ae776
|
||||
2026-09-15-desktop-native-fatal-recovery.zh.md: 0fee729e3be791d10966050d21482c5132b14fe6
|
||||
2026-09-15-desktop-native-fatal-recovery.md: 63ec81991f3e396075fe4ed09a8ddaa87c865862
|
||||
2026-09-15-desktop-native-fatal-recovery.zh.md: 05dbad80064706d384eeac6537da073a8f365e7e
|
||||
@@ -12,7 +12,7 @@ A recovery document depends on the renderer and preload whose failure can preven
|
||||
|
||||
Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
|
||||
|
||||
The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Disabling writes activation metadata under the existing profile transaction lock after Host shutdown, without requiring runtime initialization or deleting installed files. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
|
||||
The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
|
||||
|
||||
The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
|
||||
|
||||
@@ -22,6 +22,8 @@ This supersedes recovery-page and reset behavior in the [immediate-window decisi
|
||||
|
||||
A Web modal depends on client initialization, while a second recovery document adds renderer resources and preload recovery paths. Native dialogs remain usable when those components fail. Automatically resetting configuration or restarting on every report can delete user configuration or create restart loops; explicit actions preserve user control.
|
||||
|
||||
**Disable bundles while retaining the active profile patch.** A malformed patch or a patch that inserts a broken plugin can still prevent startup. Renaming preserves the user’s exact bytes for manual repair while removing that layer from startup; unique backup names preserve earlier recovery attempts.
|
||||
|
||||
## Consequences
|
||||
|
||||
Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.
|
||||
Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. A malformed home-level patch still blocks startup after profile recovery and requires manual repair. Backups accumulate in the profile directory without automatic pruning; users remove them when no longer needed. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.
|
||||
+4
-2
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
|
||||
|
||||
首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
|
||||
首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
|
||||
|
||||
Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
|
||||
|
||||
@@ -22,6 +22,8 @@ Web 文档保留在原位。宿主回调负责启动失败展示,共享启动
|
||||
|
||||
Web 模态框依赖客户端初始化,而第二份恢复文档会增加渲染器资源和 preload 恢复路径。原生对话框在这些组件失败时仍然可用。自动重置配置或每次报告都重启可能删除用户配置或形成重启循环;显式操作保留用户控制权。
|
||||
|
||||
**禁用 bundle,但保留生效的 profile patch。** 损坏的 patch 或插入故障插件的 patch 仍可阻止启动。重命名保留用户原始内容供手动修复,同时将该层移出启动过程;唯一备份名保留此前恢复操作的备份。
|
||||
|
||||
## Consequences
|
||||
|
||||
恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。
|
||||
恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。损坏的 home 级 patch 在 profile 恢复后仍会阻止启动,需要手动修复。备份在 profile 目录中累积,不会自动清理;用户在不再需要时删除。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md
|
||||
2026-09-16-creator-persistent-plugin-management.md: 04ccbb83fe6d2f5c460f53100a9087cce119ea4d
|
||||
2026-09-16-creator-persistent-plugin-management.zh.md: 91afa00cd3f0adb8b21a52ee958bac61bc0912fc
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Creator mode installs persistent plugin bundles
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-creator-persistent-plugin-management.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Agents need to install capabilities and use them during the same conversation. Generated-code tools create a second plugin lifecycle alongside ordinary installed bundles.
|
||||
|
||||
## Decision
|
||||
|
||||
Creator mode enables the existing `plugin_manager` tool. Agents author packages and Loader YAML patches as workspace files, then install them through `install_bundle`. An MCP connection is a configuration-only bundle inserting the installed `dsh-mcp-client`; a UI bundle includes a Host entry and a Client artifact. Profile locking, package installation, enablement and HMR remain owned by the existing manager. The entire management tool requires `danger-full-access` or approval for one call: profile changes can load code with host permissions and affect other sessions. Each execution, including inventory reads, uses the shared sandbox escalation helper and approval service before accessing the manager. Under lower sandbox modes, `ask` requests approval and `never` denies; a full-access session needs no additional approval. A grant does not change session permissions, but its profile changes persist. This keeps shell-equivalent Host access explicit without requiring a permanent session-wide elevation. Human-operated Web and CLI controls retain their existing behavior.
|
||||
|
||||
The model sees two read-only Cordis inspection tools. Generated-code define/run/stop/undefine and dynamic self-inspection tool APIs are absent. Existing runtime and Client consumers retain their services; historical session cards remain readable. This supersedes only the model-facing mutation workflow in the [self-referential toolset decision](../feature/2026-07-08-self-referential-cordis-toolset.md); its runtime ownership and sandbox rationale remain independently relevant. The [profile transaction decision](2026-09-14-current-profile-plugin-management.md) continues to govern locking, package installation, and partial failures.
|
||||
|
||||
Visual creation requests default to an installed Client plugin rendered in the current Web page unless the user names another destination. The development skill supplies a minimal package and effect-owned Client registration. Discovery ends when the required APIs are known; a working first version is installed before optional visual refinement. Verification uses the connected page where available. Browser authentication or operating-system setup is not a prerequisite for plugin installation, and a mock preview cannot establish an in-app result.
|
||||
|
||||
Desktop supplies its bundled pnpm entry and Electron Node invocation through launcher-owned profile facts. Plugin Manager uses that invocation for installation, removal, and registry inspection, retaining the ordinary profile transaction and activation behavior. Its environment applies only to package subprocesses; ordinary Host subprocesses do not inherit the private Node launcher path. CLI profiles retain their configured PATH command.
|
||||
|
||||
Historical Cordis sessions declare `retired-tools` coverage at their exact format version, including when it equals the current writer. They are immutable replay inputs; their tool call/result data is compared after persistence and their cards render without registering retired tools. This coverage does not count toward migration coverage. The retained runner write APIs remain for programmatic and browser consumers; removing them requires replacing those consumers together.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Generic entry CRUD and an MCP-specific management API duplicate operations expressible as bundle files plus existing installation and enablement. They are unnecessary for prompt-driven installation. Moving generated-code versioning into Plugin Manager retains two lifecycles without providing ordinary package persistence.
|
||||
|
||||
## Consequences
|
||||
|
||||
Selected bundles affect all sessions in the profile and survive restart. HMR activates new bundles on live profiles; installed package replacement requires restart. The agent reports saved state separately from activation and verifies the requested capability. Side effects belong to the plugin lifecycle, including stylesheet cleanup.
|
||||
|
||||
A built Web profile test installs an MCP bundle, checks existing and new Creator sessions, restarts the process, and verifies tool disposal after bundle removal. A recorded session replays manager enablement of a configured MCP entry followed by an actual local MCP request without model credentials. Historical-card tests retain the removed tools' saved presentation.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 创造模式安装持久化插件组合包
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-creator-persistent-plugin-management.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
agent 需要安装能力并在同一段对话中使用。生成代码工具在普通已安装组合包之外引入了第二套插件生命周期。
|
||||
|
||||
## Decision
|
||||
|
||||
创造模式启用现有的 `plugin_manager` 工具。agent 将包和 Loader YAML patch 写入工作区文件,再通过 `install_bundle` 安装。MCP 连接是插入已安装 `dsh-mcp-client` 的纯配置组合包;UI 组合包包含 Host 入口和 Client 产物。profile 锁、包安装、启停和 HMR 继续由现有管理器负责。 整个管理工具要求 `danger-full-access` 或单次调用的批准:profile 变更能以宿主权限加载代码,并影响其他会话。每次执行(包括查询列表)都会先使用共享沙箱提权函数和审批服务,再访问管理器。在较低沙箱模式下,`ask` 请求审批,`never` 拒绝;完整权限会话无需额外审批。批准不改变会话权限,但本次操作的 profile 变更会持久化。这让与 shell 等价的宿主访问需要明确授权,同时不必永久提升整个会话的权限。由用户直接操作的 Web 和 CLI 控件保持原有行为。
|
||||
|
||||
模型可见两个只读 Cordis 检查工具,不再提供生成代码的 define/run/stop/undefine 和动态自省工具 API。现有运行时和 Client 消费者保留其服务;历史会话卡片仍然可读。这仅取代[自引用工具集决策](../feature/2026-07-08-self-referential-cordis-toolset.zh.md)中的模型侧变更流程;其运行时所有权和沙箱依据仍有独立价值。[profile 事务决策](2026-09-14-current-profile-plugin-management.zh.md)继续规定锁、包安装和部分失败行为。
|
||||
|
||||
除非用户指定其他目标,视觉创建请求默认通过已安装的 Client 插件显示在当前 Web 页面。开发 skill 提供最小包和由 effect 管理的 Client 注册示例。已知所需 API 后结束探查,在可选视觉优化之前先安装能工作的初版。有条件时使用已连接页面验证。浏览器认证或操作系统设置不是安装插件的前提,mock 预览不能证明应用内结果。
|
||||
|
||||
Desktop 通过启动器提供的 profile 信息传入内置 pnpm 入口与 Electron Node 调用方式。Plugin Manager 使用该调用完成安装、移除和 registry 检查,保留通常的 profile 事务与激活行为。其环境仅应用于包管理子进程;普通 Host 子进程不继承私有 Node 启动器路径。CLI profile 保留其配置的 PATH 命令。
|
||||
|
||||
历史 Cordis 会话以准确的格式版本声明 `retired-tools` 覆盖,即使该版本等于当前 writer 也如此。它们是不可改写的重放输入;持久化后的工具调用及结果数据会进行比较,卡片渲染不注册已移除工具。此覆盖不计入迁移覆盖。保留的 runner 写入 API 供程序和浏览器消费者使用;移除时必须一起替换这些消费者。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
通用条目增删改查和 MCP 专用管理 API 重复了组合包文件及现有安装、启停操作能够表达的能力,提示驱动的安装不需要这些接口。将生成代码的版本管理搬入 Plugin Manager 会保留两套生命周期,且无法提供普通包的持久化方式。
|
||||
|
||||
## Consequences
|
||||
|
||||
已选择的组合包影响 profile 中的所有会话,并在重启后保留。HMR 在实时 profile 中激活新组合包;替换已安装的包需要重启。agent 分别报告保存状态和激活结果,并验证所需能力。副作用归属于插件生命周期,包括样式表清理。
|
||||
|
||||
构建后的 Web profile 测试安装 MCP 组合包,检查现有和新建创造模式会话,重启进程,并验证移除组合包后的工具释放。录制会话无需模型凭据即可回放管理器启用已配置 MCP 条目及后续真实本地 MCP 请求。历史卡片测试保留已移除工具的已保存展示。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md
|
||||
2026-09-16-session-writer-held-feedback.md: 922987996c4abd32857cd0f54cec4394566cf97f
|
||||
2026-09-16-session-writer-held-feedback.zh.md: 6a1aa7993a8f36c50cb5fed0180d6523af44c177
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Session writer contention feedback
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-session-writer-held-feedback.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A Session can retain its write handle while its Agent is idle. Another Host cannot resume that Session, but a generic internal-error toast gives the user no recovery guidance. The holder can also belong to the same process, so contention alone does not identify another running application.
|
||||
|
||||
## Decision
|
||||
|
||||
Session Controller reports `session/writer-held` with `{ sessionId }` when resume fails with `SessionAlreadyOwnedError` and no reusable Agent exists. Clients discriminate by code, following the [Remote failure vocabulary](../architecture/2026-08-28-ctx-remote-failure-vocabulary.md). The controller recognizes the persistence error by its Error name, matching Session Query's optional-dependency handling; loading the controller requires no persistence implementation or error-class identity.
|
||||
|
||||
Send and model-selection failures show localized guidance that another DSH instance may hold the Session and suggest quitting other instances before retrying. Model selection returns the original Remote result to both UI entries; error classification does not depend on a later read of shared directory state. The [write-lease decision](../feature/2026-08-31-cross-process-session-write-lease.md) continues to own locking and release semantics; this feedback neither takes ownership nor retries writes automatically.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A free-text reason under `session/agent-busy`.** A second string discriminator loses the code-to-details type relationship and mixes write contention with prompt admission failures.
|
||||
|
||||
**A separate directory flag or a required persistence peer.** A flag duplicates the operation's failure and can become stale after a catalog refresh. Making persistence mandatory solely to identify its error removes support for deployments without persistence.
|
||||
|
||||
## Consequences
|
||||
|
||||
The wire gains one typed failure code without changing stored Session data. Recovery guidance cannot identify which process owns the handle. Host tests cover the error name and ordinary failures with and without persistence; Client tests cover both locales and operation-local model failures. The keyless `queue-actions` Web scenario pins the writer-held toast and preserved draft through the shipped composition. `verify-optional-dependency-imports` guards module loading against optional value imports.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: 会话写句柄占用反馈
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-session-writer-held-feedback.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Agent 空闲时,Session 仍可能持有写句柄。另一个 Host 无法恢复该会话,但通用内部错误 toast 没有提供恢复指引。持有者也可能属于同一个进程,因此仅凭占用不能确定是另一个正在运行的应用。
|
||||
|
||||
## Decision
|
||||
|
||||
当恢复因 `SessionAlreadyOwnedError` 失败且不存在可复用 Agent 时,Session Controller 返回 `session/writer-held`,携带 `{ sessionId }`。Client 遵循 [Remote 失败词汇](../architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md),按 code 判别。Controller 按 Error 名称识别持久化错误,与 Session Query 的可选依赖处理一致;加载 Controller 不需要持久化实现或错误类身份相同。
|
||||
|
||||
发送和模型选择失败显示本地化指引,说明可能有其他 DSH 实例占用 Session,并建议退出其他实例后重试。模型选择将原始 Remote 结果返回给两个 UI 入口;错误分类不依赖稍后读取共享目录状态。[写租约决策](../feature/2026-08-31-cross-process-session-write-lease.zh.md) 继续拥有锁定和释放语义;此反馈既不抢占写权限,也不自动重试写入。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 `session/agent-busy` 下使用自由文本 reason。** 第二个字符串判别值失去 code 与 details 的类型关系,并将写占用与提示词接纳失败混在一起。
|
||||
|
||||
**单独的目录标志或必需的持久化 peer。** 标志重复记录操作失败,且可能在目录刷新后过时。仅为识别错误而强制依赖持久化,会移除对无持久化部署的支持。
|
||||
|
||||
## Consequences
|
||||
|
||||
Wire 新增一个有类型的失败码,不改变已存储的 Session 数据。恢复指引无法确定哪个进程持有句柄。Host 测试覆盖错误名称,以及有、无持久化时的普通失败;Client 测试覆盖两种语言和随模型操作返回的失败。无密钥的 `queue-actions` Web 场景通过随附组合固定写占用 toast 和保留草稿的输出。`verify-optional-dependency-imports` 防止可选依赖的值导入破坏模块加载。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.md
|
||||
2026-09-16-windows-subprocess-console-visibility.md: 281525e960b049d5871cde9f5da5c8f928654440
|
||||
2026-09-16-windows-subprocess-console-visibility.zh.md: 04abf7154b4b384ca521b9d232c7dfb398f5ed99
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Hide Windows subprocess console windows at creation
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-windows-subprocess-console-visibility.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
PTC runtime and shell calls share the Windows subprocess provider. Its ordinary Job runner omits window hiding, and native targets supply standard handles without a startup visibility flag. Desktop execution can therefore flash console windows for short-lived commands.
|
||||
|
||||
## Decision
|
||||
|
||||
The private Node Job runner uses `windowsHide: true`. Native ordinary and restricted-token process creation supplies `STARTF_USESHOWWINDOW` and `SW_HIDE` alongside standard handles before target code runs. Console inheritance, Job assignment before resume, and pipe ownership stay intact. No operation hides an existing parent console or promises to suppress windows explicitly opened by the command.
|
||||
|
||||
The [ACL sandbox decision](../feature/2026-08-08-windows-acl-restricted-token-sandbox.md) still owns restricted-token policy and console-isolation limits. Initial window visibility does not require adding `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` to restricted creation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Hide only the outer runner.** Native target creation is independent of Node's launch options, so its initial visibility also needs an explicit setting.
|
||||
|
||||
**Remove consoles from every process.** Restricted-token creation with console-isolation flags has a recorded DLL initialization failure. Startup visibility preserves the existing console attachment rules instead.
|
||||
|
||||
**Hide the window after PowerShell starts.** A window can become visible before the script executes; creation-time settings avoid that interval.
|
||||
|
||||
## Consequences
|
||||
|
||||
Ordinary subprocess startup suppresses incidental console windows without changing tool output or process cleanup. Native Windows tests inspect console visibility in a descendant and in both ACL modes, alongside existing stream, control-pipe, and Job-lifetime tests. A missing console is valid; the tests do not require one to exist. Startup-parameter tests pin the creation-time guarantee that a final visibility observation alone cannot establish.
|
||||
|
||||
Session recordings cannot observe native console windows and their transcripts are unchanged. Windows native tests own this regression; browser screenshots cannot establish the absence of desktop windows.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 在创建时隐藏 Windows 子进程控制台窗口
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-windows-subprocess-console-visibility.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
PTC 运行时和 shell 调用共用 Windows 子进程提供方。其普通 Job runner 未设置窗口隐藏,原生目标只提供标准句柄而没有启动可见性标志。因此,Desktop 执行短命令时可能闪现控制台窗口。
|
||||
|
||||
## 决策
|
||||
|
||||
私有 Node Job runner 使用 `windowsHide: true`。普通和受限令牌原生进程创建在目标代码运行前,将 `STARTF_USESHOWWINDOW` 和 `SW_HIDE` 与标准句柄一起传入。控制台继承、恢复线程前分配 Job 和管道归属保持不变。任何操作都不隐藏已有父控制台,也不承诺抑制命令显式打开的窗口。
|
||||
|
||||
[ACL 沙箱决策](../feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md) 仍负责受限令牌策略和控制台隔离限制。设置初始窗口可见性不需要向受限进程创建添加 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE`。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**仅隐藏外层 runner。** 原生目标创建独立于 Node 启动选项,因此也需要明确设置初始可见性。
|
||||
|
||||
**移除所有进程的控制台。** 受限令牌配合控制台隔离标志存在已记录的 DLL 初始化失败。启动可见性设置保留现有控制台附着规则。
|
||||
|
||||
**PowerShell 启动后再隐藏窗口。** 窗口可能在脚本执行前已经可见;创建时设置可避免这一间隔。
|
||||
|
||||
## 后果
|
||||
|
||||
普通子进程启动抑制附带控制台窗口,不改变工具输出或进程清理。原生 Windows 测试检查后代进程和两种 ACL 模式的控制台可见性,并配合现有流、控制管道和 Job 生命周期测试。没有控制台也是有效状态;测试不要求控制台必须存在。启动参数测试固定创建时的保证,单独观察最终可见性无法确认这一点。
|
||||
|
||||
会话录制无法观察原生控制台窗口,转录内容也没有变化。此回归由 Windows 原生测试负责;浏览器截图无法确认桌面窗口不存在。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md
|
||||
2026-07-08-self-referential-cordis-toolset.md: d8ae7d26666964c24245021249473d4160b35220
|
||||
2026-07-08-self-referential-cordis-toolset.zh.md: d6fac5b59d1d057ea11b607cba115ceb1c37664a
|
||||
2026-07-08-self-referential-cordis-toolset.md: 278c71051de1fb897c8735b39dff0b66c43a5626
|
||||
2026-07-08-self-referential-cordis-toolset.zh.md: 8a8e197e197fb6b53b52426d5ad9a33fd09e78c9
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: The self-referential cordis toolset
|
||||
# Agent Note: Cordis runtime inspection and runner isolation
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,79 +6,24 @@ English | [中文](2026-07-08-self-referential-cordis-toolset.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Everything in this harness is a cordis plugin, but the agent running inside that plugin runtime cannot see or touch it: it cannot enumerate the services and events around it, cannot extend itself with a new tool mid-session, and cannot compose capabilities it invents. Handing the model that power is worth exploring — a self-referential agent that inspects and modifies its own runtime — but it raises three correctness problems at once, and the design is about answering them rather than the raw "let the model run code" mechanic.
|
||||
|
||||
First, model-written registration must be validated where it happens: a malformed tool schema has to fail at registration, not when a later request tries to assemble it into a prompt. Second, model-written code has to call service APIs whose source it has never seen — guessed method signatures and, worse, guessed return-value shapes cost many steps of blind probing. Third, everything the model mounts must be fully disposable, by the model on demand and by the ordinary plugin lifecycle when the host plugin reloads, or a long session accretes orphaned listeners and tools.
|
||||
Runtime API discovery must describe the APIs a plugin can actually call. Process-local generated definitions also need registration validation and complete effect disposal; isolating JavaScript globals alone does not constrain the authority of injected services.
|
||||
|
||||
## Decision
|
||||
|
||||
The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.md), with its runnable overlay and usage in the [runtime Cordis guide](../../../../docs/user/develop/practice/dynamic-cordis.md). It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence.
|
||||
`cordis_inspect_list` and `cordis_inspect_query` expose read-only runtime discovery. Generated catalogs retain source-owned declarations and JSDoc, intersected with live providers. The catalog generator rejects freshness drift; detailed queries avoid charging every request for complete API declarations.
|
||||
|
||||
The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a temporary Plugin can call `ctx.shell` with the host executor's privileges and reach the real filesystem and web services. It runs in the shared DSH runtime and may affect other sessions in that process. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default.
|
||||
The Host and Client runners retain their programmatic lifecycle and browser consumers. A Host definition evaluates in a fresh vm realm and receives a context façade: service access requires declared injection, framework internals are hidden, and registrations belong to the definition's fiber. Tool output normalization crosses back into the Host realm before validation. Disposal awaits the fiber's owned effects. The vm prevents accidental global pollution; injected filesystem, shell, and network services still have real authority, so it is not a security boundary.
|
||||
|
||||
### The three tools
|
||||
|
||||
| Tool | Contract |
|
||||
|---|---|
|
||||
| `cordis_inspect` | Read-only report over the live current-process runtime, one Markdown section per `what` value (omit `what` for all sections). `plugins` lists every live fiber; `temporary` lists only the temporary Plugins created by `cordis_mount`. An exact `name` with `what: "api"` or `what: "events"` narrows to one source-documented target. |
|
||||
| `cordis_mount` | Evaluates `code` now as an async JavaScript-function body in a `node:vm` sandbox and saves it nowhere. The returned Plugin is mounted under the internal `cordis-dynamic` group and tracked under a fresh process-local id (`dyn-1`, `dyn-2`, …). |
|
||||
| `cordis_unmount` | Unmounts one `cordis_mount` temporary Plugin by id and returns only after every owned tool, listener, service, timer, and effect reaches quiescence. It cannot remove Loader, configured, or installed Plugins. |
|
||||
|
||||
`cordis_inspect` sections are `services` (every provided ctx service and owning fiber), `plugins` (every live plugin fiber), `tools` (what the model can call), `temporary` (the `cordis_mount` subset with id, running/pending state, provided and awaited services, and lifetime), `api` (live service signatures and referenced types), and `events` (harness events with dispatch mode and signature). Temporary Plugins remain active across later turns and disappear after `cordis_unmount`, toolset unload, or DSH restart; they are never restored automatically. Broad `api` and `events` reports omit full JSDoc to stay compact; an exact `name` returns one service or event with its original method/declaration JSDoc. A name is invalid with other sections, unknown targets fail, and an API target must be live. The model-facing tool descriptions carry the operational rules needed at call time; [the generated tool catalog](../../../../docs/tool-catalog.md) is their exhaustive rendering.
|
||||
|
||||
### Sandbox semantics
|
||||
|
||||
Mount code runs as an async-function body in a fresh vm realm. Its documented API steers file, network, process, and timer access through Cordis services so mounts remain inspectable and disposable. Host-realm helpers still make Node escape possible, consistent with the trusted posture. `vmTimeoutMs` bounds only synchronous evaluation.
|
||||
|
||||
Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:<id>] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor.
|
||||
|
||||
Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` rebuilds the output schema/projectors in the host realm, snapshots the body value as host-owned JSON, and lets the registry enforce the [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) before observation. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRuntime.execute` by calling a definition directly.
|
||||
|
||||
The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec`, preserving `integer`, raw object openness, and required arrays. Direct DSL object nodes must declare `additionalProperties`; invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals.
|
||||
|
||||
### The internal group and temporary-Plugin lifecycle
|
||||
|
||||
Every temporary Plugin is a child of one internal `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles toolset reload and unload. `cordis_mount` awaits settlement; startup failure disposes the fiber before returning an error. A settled pending Plugin remains visible with its missing injections. `cordis_unmount` awaits the Plugin fiber's disposal.
|
||||
|
||||
Temporary Plugins exist only in process memory. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and have no automatic save, promote, or install path. Keeping an experiment means asking the Agent to implement a normal project Plugin or installable profile bundle through the regular development workflow.
|
||||
|
||||
### Cross-mount composition via provide/inject
|
||||
|
||||
Mounts relate to each other through ordinary cordis service semantics, with their ids as the lifecycle handles: mount A calls `ctx.provide('foo', value)`, mount B declares `inject: ['foo']` and activates the moment `foo` exists; mounted first, B stays pending and names the missing service; unmounting A sends B back to pending (its registrations unwound) and a later re-provide re-runs B's `apply` through a fresh sandbox façade; a duplicate provide fails loud with the owning fiber named. One realm caveat: a service value provided by a mount is a vm-realm object — method calls on it work from anywhere, but consumers must not assume host prototypes on it.
|
||||
|
||||
### The generated API catalog
|
||||
|
||||
`cordis_inspect` serves API and event data from a generated catalog rather than a duplicated table. The generator reuses the Cordis catalog AST scan and emits service summaries, signatures, original service-method and event JSDoc, event modes, referenced type declarations, and the inherited context API. Ambiguous type names are omitted and oversized declarations are marked as truncated.
|
||||
|
||||
Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` (in `doc-sync`) regenerates in memory and fails on any diff, so a JSDoc or public-signature edit cannot ship without regenerating the catalog the model reads. At runtime the inspect tool intersects the catalog with the live runtime rather than dumping it: broad reports render live catalogued services as summary + signatures, live services without a catalog entry (mount-provided ones) as name + owning fiber, catalogued services with no live provider tersely, and then the referenced type shapes. Exact-name reports render one live service or event with the original JSDoc immediately before each signature; keeping that detail opt-in avoids charging its token cost on exploratory listings.
|
||||
|
||||
### Configuration, rendering, and observability
|
||||
|
||||
The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_mount`, and `cordis_unmount`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, mount is `execute` carrying code as `rawInput`, and unmount is `delete`. Web conversation rows preserve those generic mechanics while giving the tools the action titles `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` plus one shared Cordis accent; the mount row retains the shared JavaScript expansion and syntax highlighting.
|
||||
|
||||
Model-visible ⟺ logged holds with no new session event type: mount and unmount are visible through their logged `tool/call` / `tool/result` pairs, and any changed tool set is logged by the full changed request header emitted when schemas change between steps. Temporary Plugins are process memory, not session state: session resume rehydrates conversation history but never recreates them.
|
||||
Definitions remain process-local. Restart and session resume do not recreate them from historical calls. The [Creator persistent plugin decision](../architecture/2026-09-16-creator-persistent-plugin-management.md) owns agent-authored installation, approval, and profile persistence. Shipped model tools do not create or mutate runner definitions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A structured per-capability registration tool instead of `cordis_mount`.** The most tempting alternative is a `cordis_register_tool` with explicit `name` / `description` / `parameters` / `code` fields (and siblings `cordis_register_listener`, `cordis_register_service`, …) rather than a single "mount a plugin" primitive. It was rejected because its one real win — no plugin boilerplate for the single commonest case — does not pay for its costs, while a single mount primitive answers every capability at once.
|
||||
**Hand-maintained API tables.** Rejected because they drift independently from service declarations; generated catalogs and freshness checks share one source.
|
||||
|
||||
| Dimension | Structured per-capability tools | Single `cordis_mount` |
|
||||
|---|---|---|
|
||||
| Schema correctness | `parameters` is still model-written JSON needing unified-schema validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors |
|
||||
| The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration |
|
||||
| Capability coverage | Tools only; listeners, services, `inject` relations each need another structured tool — an API that grows without bound | One vocabulary (a cordis plugin) covers every effect, present and future |
|
||||
| Cross-mount composition | Not expressible in a tool-registration payload | Native `provide`/`inject`, ordinary cordis semantics |
|
||||
| Inspectability | Registers something the plugin list cannot show as a plugin | What the model mounts is exactly what `cordis_inspect` renders |
|
||||
| Model ergonomics | Wins for the single most common case (no plugin boilerplate) | Mitigated by the canonical recipe in the mount description plus boundary errors that teach the fix |
|
||||
**Treat the vm as a security sandbox.** Rejected because services exposed through the façade can reach the Host's real resources. Restricted globals improve lifecycle correctness, not permission enforcement.
|
||||
|
||||
The correctness investment therefore goes where it pays for every capability at once: the generated API catalog surfaced through `cordis_inspect`, and sandbox-boundary validation whose error messages teach the correct call. A structured registration tool remains addable later as sugar that synthesizes mount code; nothing here forecloses it.
|
||||
|
||||
**A hand-maintained service/event reference in the tool.** The first cut of the inspect tool carried a hand-written table of service method signatures. It was replaced by the generated `api-catalog.ts` because a hand table drifts from the JSDoc the moment a signature changes and nothing gates the drift, whereas the generated artifact is freshness-checked against the same AST the docs use.
|
||||
|
||||
**A new `cordis/mount` session event.** A durable event recording each mount's source and name has clear precedent (`hook/invoked`, `compaction/start`). Rejected: mount and unmount are already visible as `tool/call` / `tool/result` pairs and the tool-set change is already logged as a full changed request header, so a dedicated event would only duplicate the record. It remains addable if an audit use case needs the mount source and name outside the tool call.
|
||||
|
||||
**A hardened / capability-restricted sandbox.** Trapping Node built-ins and handing mount code a whitelist façade rather than the raw context might suggest an intent to sandbox for safety. It is explicitly not that: the traps and the façade narrow the *API* mount code sees — steering it onto cordis services and away from leak-prone Node built-ins and framework internals — for correctness and to close the unguarded-context escape, but the capabilities the façade exposes (`ctx.shell`, `ctx.fs`, `ctx.web`) reach the real runtime, so it is not a security boundary. A real one (separate process, permission prompts) was out of scope for a dev/opt-in toolset and would fight the entire point — handing the model the live runtime.
|
||||
**Recreate definitions from session logs.** Rejected because replaying source would execute historical side effects. Historical cards display persisted source and outcomes without restoring a live definition.
|
||||
|
||||
## Consequences
|
||||
|
||||
The toolset is a deliberate opt-in with a fully-privileged `ctx`, so a deployment adopts it as consciously as a bash tool. Several facts follow that the tool descriptions warn the model about directly: a waterfall listener (e.g. `tools/pre-execute`) that returns without calling `next()` short-circuits the chain, so a mounted listener can stop the agent's own tool dispatch ([waterfall semantics](../../../../docs/cordis-primer.md#cordis-waterfall-semantics)); mount code runs inside a tool call of the current turn, so awaiting anything that resolves only after the turn deadlocks; `vmTimeoutMs` bounds synchronous evaluation only; and mounts do not survive session resume.
|
||||
Inspection remains usable without Creator or Plugin Manager. Runner lifecycle tests cover guarded registration, startup failure cleanup, and awaited disposal. Existing browser consumers retain their lifecycle APIs; removing their write-side registry and activation machinery requires a coordinated replacement of those consumers, rather than deleting historical rendering or assuming session data recreates live state.
|
||||
+13
-68
@@ -1,84 +1,29 @@
|
||||
# Agent Note: 自引用 cordis 工具集
|
||||
# Agent Note:Cordis 运行时检查与 runner 隔离
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-08-self-referential-cordis-toolset.md) | 中文
|
||||
|
||||
## 问题
|
||||
## Problem
|
||||
|
||||
本 harness 中的一切都是 cordis 插件,但运行在该插件运行时内部的 agent(智能体)既看不到也碰不到它:它无法枚举周围的服务和事件,无法在会话中途为自己添加新工具,也无法组合自己发明的能力。赋予模型这种能力值得探索——一个能审视并修改自身运行时的自引用 agent——但这同时引发三个正确性问题,本设计的核心正是回答这些问题,而非单纯的「让模型执行代码」机制。
|
||||
运行时 API 发现必须描述插件实际能调用的 API。进程内生成定义还需要注册校验和完整的 effect 释放;仅隔离 JavaScript 全局变量并不能限制注入服务的权限。
|
||||
|
||||
第一,模型编写的注册必须在注册发生时就完成校验:格式错误的工具 schema 必须在注册时失败,而不是等到后续请求尝试将其组装进提示词时才报错。第二,模型编写的代码需要调用它从未见过源码的服务 API——靠猜测方法签名、更糟糕的是猜测返回值结构,会消耗大量盲目试探的步骤。第三,模型挂载的一切都必须完全可释放:模型可以按需释放,普通的插件生命周期在宿主插件重载时也会释放,否则长会话会积累遗留的监听器和工具。
|
||||
## Decision
|
||||
|
||||
## 决策
|
||||
`cordis_inspect_list` 和 `cordis_inspect_query` 提供只读运行时发现。生成目录保留源码中的声明和 JSDoc,并按存活 provider 筛选。目录生成器拒绝过期产物;精确查询避免每次请求都承担完整 API 声明的 token 成本。
|
||||
|
||||
该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.zh.md) 发布,其可运行 overlay 与用法位于[运行时 Cordis 指南](../../../../docs/user/develop/practice/dynamic-cordis.zh.md)。它为模型提供三个工具,用于操作当前 DSH 进程中的活跃 Cordis 运行时:检查该运行时、挂载一个仅存于内存的临时插件,再将该插件卸载至完全停稳。
|
||||
Host 和 Client runner 保留程序侧生命周期和浏览器消费者。Host 定义在新的 vm realm 中求值,并接收 context façade:服务访问需要声明注入,框架内部结构不可见,注册归定义的 fiber 所有。工具输出先回到 Host realm 进行归一化,再接受校验。释放会等待 fiber 所有的 effect。vm 防止意外污染全局变量;注入的文件系统、shell 和网络服务仍有真实权限,因此它不是安全边界。
|
||||
|
||||
vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:临时插件可以调用 `ctx.shell` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。它运行在共享 DSH 运行时中,可能影响同一进程的其他会话。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。
|
||||
定义仅存在于进程中。重启和会话恢复不会从历史调用重建它们。[Creator 持久化插件决策](../architecture/2026-09-16-creator-persistent-plugin-management.zh.md)负责 agent 安装、审批和 profile 持久化。内置模型工具不创建或修改 runner 定义。
|
||||
|
||||
### 三个工具
|
||||
## Alternatives considered
|
||||
|
||||
| 工具 | 约定 |
|
||||
|---|---|
|
||||
| `cordis_inspect` | 当前进程活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 小节(省略 `what` 则输出全部小节)。`plugins` 列出全部存活 fiber,`temporary` 只列 `cordis_mount` 创建的临时插件。精确 `name` 搭配 `what: "api"` 或 `what: "events"` 可收窄到一个带源码文档的目标。 |
|
||||
| `cordis_mount` | 立即在 `node:vm` 沙箱中把 `code` 作为异步 JavaScript 函数体求值,且不保存到任何位置。返回的插件挂在内部 `cordis-dynamic` 分组下,并用新的进程内 id(`dyn-1`、`dyn-2`……)跟踪。 |
|
||||
| `cordis_unmount` | 按 id 卸载一个 `cordis_mount` 临时插件,并只在其自有工具、监听器、服务、定时器和其他 effect 完全停稳后返回。它不能删除 Loader、已配置或已安装的插件。 |
|
||||
**手写 API 表。** 拒绝,因为它会独立于服务声明漂移;生成目录与新鲜度检查共享同一源码。
|
||||
|
||||
`cordis_inspect` 的小节是 `services`(每个已提供的 ctx 服务及所属 fiber)、`plugins`(全部存活插件 fiber)、`tools`(模型可调用的工具)、`temporary`(`cordis_mount` 子集,包含 id、running/pending 状态、提供与等待的服务和生命周期)、`api`(活跃服务签名及其引用类型)和 `events`(harness 事件及分发模式和签名)。临时插件可跨后续轮次保持活跃,并在 `cordis_unmount`、工具集卸载或 DSH 重启后消失;系统绝不会自动恢复它们。宽泛的 `api` 和 `events` 报告省略完整 JSDoc;精确 `name` 返回一个服务或事件及其原始 JSDoc。其他小节不能搭配 name,未知目标会失败,而 API 目标必须处于活跃状态。面向模型的工具描述包含调用时所需的操作规则;[生成的工具目录](../../../../docs/tool-catalog.zh.md)是这些规则的完整呈现。
|
||||
**将 vm 视为安全沙箱。** 拒绝,因为 façade 暴露的服务可以访问 Host 的真实资源。受限全局变量改善生命周期正确性,不执行权限控制。
|
||||
|
||||
### 沙箱语义
|
||||
**从会话日志重建定义。** 拒绝,因为重放源码会执行历史副作用。历史卡片展示持久化源码和结果,不恢复运行中的定义。
|
||||
|
||||
挂载代码以异步函数体的形式在一个新的 vm realm 中运行。其文档化的 API 将文件、网络、进程和定时器访问引导至 Cordis 服务,使挂载保持可审视和可释放。宿主 realm 的辅助手段仍然使 Node 逃逸成为可能,这与信任姿态一致。`vmTimeoutMs` 仅约束同步执行部分。
|
||||
## Consequences
|
||||
|
||||
沙箱全局变量刻意精简:一个带标签的直写 `console`(在宿主 stdout/stderr 上输出 `[cordis:<id>] …`,这样在挂载调用之后很久才触发的监听器输出仍能落到用户可见的地方)、`harness.defineTool`/`harness.registerTool` 注册对、新 vm 上下文缺少的编码原语(`btoa`/`atob` 作为基于 `Buffer` 的宿主闭包——这是一个明确允许的例外,`Buffer` 本身从不暴露——加上 `TextEncoder`/`TextDecoder`),以及对未暴露的 Node API 设置的可调用陷阱(`require`、`setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`、`fetch`),这些陷阱会抛出一条重定向消息指明 cordis 替代方案。只有函数形态的全局变量才设陷阱;`process` 和 `Buffer` 保持 `undefined`,这样 `typeof` 特性探测仍然无害,而不会触发会抛出异常的访问器。
|
||||
|
||||
挂载代码通过三道控制跨越 vm 边界。双 realm `instanceof` 同时识别宿主和 vm 对象。`harness.defineTool` 在宿主 realm 中重建输出 schema/投影器,将工具体返回值快照为宿主自有的 JSON,并让注册表在观测前强制执行[规范工具输出约定](../architecture/2026-07-20-canonical-tool-output-contract.zh.md)。挂载的插件接收的是一个白名单上下文门面,而非原始或透传的 `Context`;框架内部机制和以上下文为值的返回会被拒绝。服务读取需要声明 `inject`,保留 Cordis 的激活与卸载语义。`ctx.tools.get` 仅暴露 schema 视图,因此挂载代码无法绕过 `ToolRuntime.execute` 直接调用定义。
|
||||
|
||||
边界将无歧义的 JSON Schema 形式规范化为 `ParameterSchemaSpec`,同时保留 `integer`、原始对象开放性和 required 数组。直接使用 DSL 的对象节点必须声明 `additionalProperties`;无效词汇会报错并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 误用和重复工具名等错误信息包含相关源码行或纠正性约定,不叙述实现内部细节。
|
||||
|
||||
### 内部分组与临时插件生命周期
|
||||
|
||||
每个临时插件都是工具插件下方内部 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理工具集重载和卸载。`cordis_mount` 会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的插件仍然可见,并列出其缺失的注入。`cordis_unmount` 等待插件 fiber 的释放完成。
|
||||
|
||||
临时 Plugin 只存在于进程内存中。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不存在自动保存、转正式或安装路径。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的项目 Plugin 或可安装的 profile 组合包。
|
||||
|
||||
### 通过 provide/inject 实现跨挂载组合
|
||||
|
||||
挂载之间通过普通的 cordis 服务语义相互关联,以各自的 id 作为生命周期句柄:挂载 A 调用 `ctx.provide('foo', value)`,挂载 B 声明 `inject: ['foo']` 并在 `foo` 存在的瞬间激活;如果 B 先挂载,它保持 pending 状态并列出缺失的服务;卸载 A 使 B 回到 pending(其注册被撤销),之后重新 provide 会通过一个新的沙箱门面重新运行 B 的 `apply`;重复 provide 会明确报错并指出拥有该服务的 fiber。一个 realm 注意事项:由挂载 provide 的服务值是 vm realm 对象——从任何地方调用其方法都能工作,但消费方不得假设它具有宿主原型。
|
||||
|
||||
### 生成的 API 目录
|
||||
|
||||
`cordis_inspect` 从生成的目录提供 API 和事件数据,而非维护一份重复的表格。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、原始服务方法与事件 JSDoc、事件模式、引用的类型声明以及继承的上下文 API。有歧义的类型名被省略,过大的声明被标记为截断。
|
||||
|
||||
新鲜度像所有生成产物一样受门禁约束:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此 JSDoc 或公开签名变更如果不重新生成模型读取的目录就无法合入。运行时 inspect 工具将目录与活跃运行时取交集而非直接转储:宽泛报告把有目录条目的活跃服务渲染为摘要 + 签名,把没有目录条目的活跃服务(挂载提供的)渲染为名称 + 所属 fiber,简要列出有目录条目但无活跃提供方的服务,再附上引用的类型结构。精确名称报告渲染一个活跃服务或事件,并把原始 JSDoc 紧靠在每个签名之前;让该细节按需出现,避免探索性列表承担其 token 成本。
|
||||
|
||||
### 配置、渲染与可观测性
|
||||
|
||||
该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.zh.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_mount` 和 `cordis_unmount`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.zh.md)渲染为 `generic` 卡片:inspect 为 `read`,mount 为携带代码 `rawInput` 的 `execute`,unmount 为 `delete`。Web 对话行保留这些通用机制,同时为各工具设置操作标题 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin` 以及统一的 Cordis 强调色;mount 行仍使用共用的 JavaScript 展开视图和语法高亮。
|
||||
|
||||
「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:mount 与 unmount 通过已记录的 `tool/call`/`tool/result` 对可见,当步骤之间的 schema 发生变化时,系统发出的完整 request header 会记录工具集的任何变化。临时插件属于进程内存,而非会话状态:恢复持久化会话只会重建对话历史,绝不会重新创建它们。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**用结构化的逐能力注册工具替代 `cordis_mount`。** 最具吸引力的替代方案是一个带有显式 `name`/`description`/`parameters`/`code` 字段的 `cordis_register_tool`(以及配套工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。
|
||||
|
||||
| 维度 | 结构化逐能力工具 | 单一 `cordis_mount` |
|
||||
|---|---|---|
|
||||
| schema 正确性 | `parameters` 仍然是模型编写的 JSON,需要统一 schema 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误信息 |
|
||||
| 代码字段 | `execute` 函数体仍然是 vm 中模型编写的 JS;realm 和服务调用的正确性问题不变 | 一个沙箱、一条规范化路径、一处受保护的注册 |
|
||||
| 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——API 无限增长 | 一套词汇(cordis 插件)覆盖当前和未来的所有效果 |
|
||||
| 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通的 cordis 语义 |
|
||||
| 可审视性 | 注册的东西无法在插件列表中显示为插件 | 模型挂载的正是 `cordis_inspect` 渲染的 |
|
||||
| 模型易用性 | 对最常见的单一场景有优势(无插件样板) | 通过 mount 描述中的规范示例加边界错误信息教会正确调用来缓解 |
|
||||
|
||||
因此正确性投入放在能一次性为所有能力带来回报的地方:通过 `cordis_inspect` 呈现的生成 API 目录,以及沙箱边界校验(其错误信息教会正确的调用方式)。结构化注册工具日后仍可作为语法糖添加,由它合成 mount 代码;本设计不排斥这一可能。
|
||||
|
||||
**在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一份手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节且没有门禁约束这种漂移,而生成产物的新鲜度由文档使用的同一套 AST 检查。
|
||||
|
||||
**新增 `cordis/mount` 会话事件。**一个持久事件记录每次挂载的源码和名称,有明确先例(`hook/invoked`、`compaction/start`)。不予采纳:挂载和卸载已经作为 `tool/call`/`tool/result` 对可见,工具集变化已经作为完整的变更 request header 被记录,因此专用事件只会重复记录。如果审计用例需要在工具调用之外取得挂载的源码和名称,日后仍可添加。
|
||||
|
||||
**加固的/能力受限的沙箱。** 对 Node 内置模块设陷阱并向挂载代码提供白名单门面而非原始上下文,可能暗示意图是为安全而沙箱化。这里明确不是:陷阱和门面收窄的是挂载代码所见的 *API*——将其引导至 cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未受保护的上下文逃逸,但门面暴露的能力(`ctx.shell`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)超出了一个开发/显式启用工具集的范围,且会与其核心目的——将活跃运行时交给模型——相冲突。
|
||||
|
||||
## 后果
|
||||
|
||||
该工具集是刻意的显式启用设计,具有完整权限的 `ctx`,因此部署方采用它的意识程度应与 bash 工具相当。以下几个事实由工具描述直接告知模型:一个 waterfall(瀑布式事件)监听器(如 `tools/pre-execute`)如果不调用 `next()` 就返回,会短路整条链,因此一个挂载的监听器可以阻止 agent 自身的工具分发([waterfall 语义](../../../../docs/cordis-primer.zh.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会导致死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。
|
||||
检查能力不依赖 Creator 或 Plugin Manager。Runner 生命周期测试覆盖受保护注册、启动失败清理和等待释放。现有浏览器消费者保留生命周期 API;移除其写入注册表与激活机制,需要协调替换这些消费者,不能删除历史渲染或假定会话数据会重建运行状态。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.md: a70fd8839c371c11b6660229146306f7c5aa642a
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 892eabf17c63b7ceaa9bb8b21ac8c6e71a2f6112
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.md: 9834e9b559f0a8045b0d36dd2dcfba864238996b
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.zh.md: d349b504ca6a1054237d5ca6aa61547c2a0dcdad
|
||||
+1
-1
@@ -32,7 +32,7 @@ The landstrip evaluation was rejected before implementation (not battle-tested;
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
|
||||
Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher
|
||||
|
||||
## 后果
|
||||
|
||||
所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
|
||||
所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
|
||||
2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc
|
||||
2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6
|
||||
2026-09-04-web-clickable-link-styles.md: fc383387cede1517981358fd31a08f47c2d76c02
|
||||
2026-09-04-web-clickable-link-styles.zh.md: 7336a740e2ba93fe67a504745dcf770cab3c803d
|
||||
@@ -13,7 +13,7 @@ Clickable artifact links in the chat transcript wore four different costumes: ma
|
||||
One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links:
|
||||
|
||||
- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus.
|
||||
- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
|
||||
- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
|
||||
- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show.
|
||||
- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style).
|
||||
- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border.
|
||||
@@ -23,7 +23,7 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl
|
||||
## Alternatives considered
|
||||
|
||||
- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead.
|
||||
- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category.
|
||||
- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site marks live behind the same `url` category ([known-site link marks](2026-09-16-known-site-link-marks.md)).
|
||||
- **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line.
|
||||
- **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Status: implemented
|
||||
会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言:
|
||||
|
||||
- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。
|
||||
- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
|
||||
- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
|
||||
- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。
|
||||
- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。
|
||||
- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。
|
||||
@@ -23,7 +23,7 @@ Status: implemented
|
||||
## 备选方案
|
||||
|
||||
- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。
|
||||
- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。
|
||||
- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份;按站点标记位于同一 `url` 类别之下([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md))。
|
||||
- **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。
|
||||
- **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
|
||||
2026-09-08-present-workspace-source-files.md: 650c16fe5a614ce1ccf118744a5f0e8cb5d19f4b
|
||||
2026-09-08-present-workspace-source-files.zh.md: 61bca82c83ff42600a7017039a6be4c3a4605769
|
||||
2026-09-08-present-workspace-source-files.md: e1bc2bbc56d423741ca53550af2879fed3f01ecf
|
||||
2026-09-08-present-workspace-source-files.zh.md: e4f21172b36a5fef50e44a79adca71011ee90a3b
|
||||
@@ -10,13 +10,13 @@ Users need to open and edit the files produced in their workspace, including she
|
||||
|
||||
## Decision
|
||||
|
||||
The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
|
||||
The [present tool](../../../../packages/deliverables/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
|
||||
|
||||
The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
|
||||
|
||||
The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results.
|
||||
|
||||
An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery.
|
||||
An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The [changed-files card](2026-09-11-turn-changed-files-card.md) lists the turn's recorded changes beside the delivery cards; neither surface implies final delivery.
|
||||
|
||||
File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and verify the Session filesystem and Host path; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response.
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
|
||||
[present 工具](../../../../packages/deliverables/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
|
||||
|
||||
工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
|
||||
|
||||
工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
|
||||
|
||||
经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。
|
||||
经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。[改动文件卡片](2026-09-11-turn-changed-files-card.zh.md)在交付卡片旁列出本轮记录的改动;两个表面均不表示最终交付。
|
||||
|
||||
文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并验证 Session 文件系统与 Host 路径;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md
|
||||
2026-09-14-model-image-input-settings.md: ef328400332aa58c02a450ead0195eff124c5fdf
|
||||
2026-09-14-model-image-input-settings.zh.md: 70ec427b138026124cad2ffdb1fc5f60597abe8a
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
|
||||
2026-09-11-turn-changed-files-card.md: 0c41783c6d5d02fecd0c85298aed1f07f5c8a65f
|
||||
2026-09-11-turn-changed-files-card.zh.md: f417b31b899529b664664926c832c28f4b1be6ba
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Note: Turn changed-files card
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-11-turn-changed-files-card.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
After a turn, users want to see which files the model changed and by how much. The Web turn tail listed only the paths of successful `write`, `edit`, and `str_replace_editor` calls, without line counts, and missed every file a shell command changed; the per-call diff cards in the message flow answered the question one call at a time.
|
||||
|
||||
## Decision
|
||||
|
||||
The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) plugin summarizes each top-level turn's changed files, announces the summary with a `workspace/changes` Session event that carries only the turn number, and serves the summary through its `workspaceChanges` service until the Session is disposed; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) reads the served summary and renders it as the changed-files card in place of the mutation-call row. The event is log-only and never model-visible.
|
||||
|
||||
The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a temporary object directory owned by the Session while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects; keeping every captured byte outside the workspace follows the workspace change journal POC (#2973) and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it.
|
||||
|
||||
Git is the default executable on `PATH`; no environment plugin is consulted. Outside any repository, or without git, no snapshot is taken and the summary lists the file-tool edits alone, with the working directory as the workspace, so the card still appears but misses shell edits. Nested repositories and submodules are gitlinks and are not descended into.
|
||||
|
||||
Changes outside snapshot coverage come from whole-file captures: before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the named file into the Session's temporary directory, once per path per turn, and copies it again at turn end; ignored files, files outside the work tree, and every file-tool edit without a snapshot are listed from a line comparison of the two copies ([comparison decision](2026-09-15-changed-file-diff-preview.md)). Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation.
|
||||
|
||||
The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. Each row opens the turn's review tab in the right Sidebar on that file and the header opens it on the first file ([review decision](2026-09-15-changed-file-diff-preview.md)); with a Host desktop the review tab offers the default-application open.
|
||||
|
||||
The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered. The Client keeps the latest announcement per turn and reads its summary once through the authenticated summary route.
|
||||
|
||||
The log deliberately carries nothing but the turn number. Summaries, snapshot trees, and captured copies live only as long as the Session in the Host process: the summaries in the recorder, the objects and copies in a temporary directory removed on disposal. A conversation reopened after a Host restart has no card for its earlier turns. The product decision is that a card whose content the Host can no longer open should not appear at all, so the card's lifetime equals the content's lifetime rather than the log's. Content is not kept across restarts ([comparison decision](2026-09-15-changed-file-diff-preview.md)).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Extending the mutation-call row with hunk counts** kept a second, weaker implementation beside the per-call diff cards and still missed shell edits; git sees every write regardless of the tool.
|
||||
|
||||
**Diffing against `HEAD` at turn end** needs one command but attributes the user's uncommitted work to the turn.
|
||||
|
||||
**A git tag or `stash create` per turn** leaves refs in the user's repository or omits untracked files; a tree written through a private index does neither.
|
||||
|
||||
**A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; until git exists the card lists file-tool edits only.
|
||||
|
||||
**Summing the hunks the file tools persist** for uncovered files was the first implementation; it counted a repeatedly edited line more than once and could not show a whole-file comparison, so the recorder now copies the whole file at first touch instead ([comparison decision](2026-09-15-changed-file-diff-preview.md)).
|
||||
|
||||
**Recording the file list and counts in the event** was the first implementation: the card would then render from the log forever, while the content it opens would not survive. It was replaced by the announcement-only event so that the card and its content share one lifetime.
|
||||
|
||||
**A snapshot object store under the Harness home with a byte bound** was the first implementation's placement: one store per repository, shared by every Session, discarded when it outgrew the bound. It survived Host restarts that the summaries no longer do, needed two configuration fields, and made a Session's snapshot fail when another Session discarded the store; a per-Session temporary directory removes all three.
|
||||
|
||||
**An environment-provider seam for locating git** was raised by the team but not settled; the plugin uses `PATH` and keeps its lookup in one place.
|
||||
|
||||
**A shadow repository for working directories outside any repository** — a git directory under the Harness home with the work tree pointing at the working directory — would add shell edits to those users' card without adding a `.git`, but a shadow repository has no `.gitignore`, and a configured exclude list cannot reliably keep build outputs, caches, and dependency trees out of every project layout. It is deferred until that exclude policy is settled; the recorder already treats the repository as an input, so adding the tier changes only where the snapshot goes.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the Session's temporary directory, which disposal removes. Edits the user makes during a turn are attributed to it. Every file-tool edit also copies its whole file into that directory once per turn.
|
||||
|
||||
The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card whenever a turn changes a file; one dedicated scenario seeds a git repository so the card also carries a shell edit, while the others list their file-tool writes alone. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries.
|
||||
|
||||
Focused tests cover repositories with real git, directories outside any repository, coverage classification of captured paths, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file route, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Note: 本轮改动文件卡片
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-11-turn-changed-files-card.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
一轮结束后,用户想看到模型改了哪些文件、改了多少。Web 的轮尾只列出成功的 `write`、`edit` 与 `str_replace_editor` 调用的路径,没有行数,也漏掉所有 shell 命令改动的文件;消息流中逐调用的 diff 卡片只能一次回答一个调用。
|
||||
|
||||
## Decision
|
||||
|
||||
Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 插件汇总每个顶层轮次改动的文件,用只带轮号的 `workspace/changes` Session 事件宣告,并通过 `workspaceChanges` 服务提供摘要直到 Session 释放;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)读取提供的摘要并渲染为改动文件卡片,取代修改调用行。该事件仅写日志,模型永远看不到。
|
||||
|
||||
记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两者都写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 挂接,因此用户仓库不会多出任何对象;把捕获的每个字节都放在工作区外学自工作区变更日志 POC(#2973),而且因为 stat 缓存在 index 里,它对耗时没有可测量的影响。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、对象、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。
|
||||
|
||||
git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。不在任何仓库内或没有 git 时,不做快照,摘要只列文件工具的编辑,并以工作目录作为工作区,因此卡片仍会出现,但缺少 shell 的改动。嵌套仓库和 submodule 是 gitlink,不会深入。
|
||||
|
||||
快照覆盖之外的改动来自整文件捕获:在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把所指文件复制到 Session 的临时目录,每轮每个路径一次,轮次结束时再复制一次;被忽略的文件、工作树之外的文件,以及没有快照时的每一次文件工具编辑,都由两份副本的逐行对比列出([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
|
||||
|
||||
列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。每一行在右侧 Sidebar 打开本轮的 review tab 并选中该文件,标题则在第一个文件上打开它([review 决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,review tab 提供用默认应用打开。
|
||||
|
||||
记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。
|
||||
|
||||
日志有意只携带轮号。摘要、快照树与捕获的副本只在 Host 进程内随 Session 存活:摘要在记录器里,对象和副本在释放时删除的临时目录里。Host 重启后重新打开的对话,先前轮次没有卡片。产品上的决定是,Host 已经打不开内容的卡片根本不该出现,所以卡片的寿命等于内容的寿命,而不是日志的寿命。内容不跨重启保留([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**给修改调用行补上 hunk 计数**会在逐调用 diff 卡片旁保留第二个更弱的实现,且仍然漏掉 shell 编辑;git 不管工具是什么都能看到每次写入。
|
||||
|
||||
**轮末直接与 `HEAD` 比较**只需一条命令,但会把用户未提交的工作算到本轮。
|
||||
|
||||
**每轮打一个 git tag 或 `stash create`**会在用户仓库里留下 ref,或者遗漏未跟踪文件;通过私有 index 写出的树两者都不会发生。
|
||||
|
||||
**为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片只列文件工具的编辑。
|
||||
|
||||
**累加文件工具持久化的 hunk** 是未覆盖文件的第一版实现;它会把反复编辑的行计算多次,也给不出整文件对比,因此记录器改为在首次触碰时复制整个文件([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。
|
||||
|
||||
**把文件列表和行数记进事件**是第一版实现:卡片从此可以永远从日志渲染,而它打开的内容却活不了那么久。它被只做宣告的事件取代,让卡片和内容共用一个寿命。
|
||||
|
||||
**Harness home 下带字节上限的快照对象库**是第一版实现的放置方式:每个仓库一个、所有 Session 共用、超过上限就丢弃。它能活过 Host 重启,而摘要已经不再需要这一点;它还需要两个配置字段,并且一个 Session 丢弃对象库会让另一个 Session 的快照失败。每个 Session 一个临时目录把这三点一起去掉。
|
||||
|
||||
**用于定位 git 的环境提供者接缝**由团队提出但尚未定案;插件使用 `PATH` 并把查找收敛在一处。
|
||||
|
||||
**为不在任何仓库内的工作目录建影子仓库**,即把 git 目录放在 Harness home 下、工作树指向工作目录,可以让这些用户在不多出 `.git` 的情况下也看到 shell 改动,但影子仓库没有 `.gitignore`,一份配置的排除列表无法在所有项目布局里可靠地挡住构建产物、缓存和依赖目录。它暂缓到排除策略定下来为止;记录器已经把仓库当作输入,加这一档只改变快照写到哪里。
|
||||
|
||||
## Consequences
|
||||
|
||||
每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入 Session 的临时目录,释放时一并删除。用户在轮次进行中的编辑会被算到该轮。每次文件工具编辑还会把整个文件复制进该目录,每轮一次。
|
||||
|
||||
只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只要某一轮改动了文件就会新增该事件与卡片;一个专门的场景种入 git 仓库,让卡片也带上 shell 的改动,其余场景只列它们的文件工具写入。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
|
||||
|
||||
聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、对已捕获路径的覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。
|
||||
@@ -1,27 +0,0 @@
|
||||
# Agent Note: Model image-input settings
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-14-model-image-input-settings.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Models settings can edit a model id without exposing the input capabilities that determine whether image attachments are accepted. A custom vision model can therefore appear in the picker while retaining a text-only declaration.
|
||||
|
||||
## Decision
|
||||
|
||||
Each model row exposes image input under Model options. Supported declares text and image; Not supported declares text only; Default removes the model's explicit input field. DeepSeek writes `inputModalities`, whose absent value means text only. Pi-ai writes `input`, whose absent or empty value inherits the installed model catalog or provider default. Opening a row preserves that inheritance without materializing an override.
|
||||
|
||||
The shared field replaces one drafted row and preserves unrelated metadata. Selecting text only or default for DeepSeek also removes its image request limits, because the adapter rejects those limits without image input. Saving uses the existing catalog-array settings mutation and adapter validation. Configuration declares an upstream capability; it does not add image processing to a text-only model.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `input` editable only in the settings document.** The [earlier pi-ai modality decision](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md) kept this field outside the model-list editor. That leaves users who add custom vision models through the UI unable to enable their image input there. Per-row editing supplies that configuration while the default choice preserves catalog inheritance.
|
||||
|
||||
**A two-state switch.** Treating an absent pi-ai declaration as disabled would misrepresent inherited vision support and encourage overwriting catalog defaults. The explicit Default choice preserves the adapter's existing resolution rules.
|
||||
|
||||
**Keep image limits when disabling DeepSeek images.** This leaves a configuration that the adapter refuses to save. Clearing the image-specific limits makes the selected text-only state valid while preserving unrelated model fields.
|
||||
|
||||
## Consequences
|
||||
|
||||
Users can configure image input for DeepSeek and custom pi-ai model rows through the same control. Restoring defaults can change effective capabilities when the installed catalog or provider defaults change. DeepSeek image limits must be configured again after disabling images. Provider routing and the [catalog recovery rules](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md) remain owned by their existing decisions.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Agent Note:模型图片输入设置
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-14-model-image-input-settings.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
模型设置可以编辑模型 ID,却没有展示决定图片附件是否被接受的输入能力。因此,自定义视觉模型虽然出现在选择器中,仍可能保留仅文本的声明。
|
||||
|
||||
## 决策
|
||||
|
||||
每个模型行在「模型选项」下提供图片输入设置。「支持」声明文本和图片;「不支持」声明仅文本;「默认」移除模型的显式输入字段。DeepSeek 写入 `inputModalities`,缺省时表示仅文本。Pi-ai 写入 `input`,缺省或空数组时继承已安装模型目录或提供方默认值。打开模型行保留这种继承,不会生成覆盖值。
|
||||
|
||||
共享字段替换一个草稿模型行并保留无关元数据。DeepSeek 选择仅文本或默认时,还会移除图片请求限制,因为适配器在没有图片输入时拒绝这些限制。保存使用现有的模型目录数组设置变更和适配器校验。配置声明上游能力,不会为仅文本模型增加图片处理能力。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**仅允许在设置文档中编辑 `input`。** [早期 pi-ai 输入模态决策](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md)将该字段留在模型列表编辑器之外。这使通过 UI 添加自定义视觉模型的用户无法在同一界面启用图片输入。逐行编辑提供了该配置,而默认选项保留模型目录继承。
|
||||
|
||||
**两态开关。** 将缺省的 pi-ai 声明视为禁用,会错误表达继承的视觉能力,并促使用户覆盖模型目录的默认值。显式「默认」选项保留适配器现有的解析规则。
|
||||
|
||||
**禁用 DeepSeek 图片时保留图片限制。** 这会留下适配器拒绝保存的配置。清除图片专属限制,使选择的仅文本状态有效,同时保留无关模型字段。
|
||||
|
||||
## 影响
|
||||
|
||||
用户可以通过相同控件配置 DeepSeek 和自定义 pi-ai 模型行的图片输入。恢复默认值后,有效能力可能随已安装模型目录或提供方默认值变化。禁用图片后,DeepSeek 图片限制需要重新配置。提供方路由和[模型目录恢复规则](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md)仍由现有决策负责。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md
|
||||
2026-09-15-changed-file-diff-preview.md: e265c1f4528f2de3a95ce0fab3e20cab1c83a3f6
|
||||
2026-09-15-changed-file-diff-preview.zh.md: 48571cd9866cb01e31609c90500d26b19802bb8e
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Turn review tab
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-15-changed-file-diff-preview.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [changed-files card](2026-09-11-turn-changed-files-card.md) tells the user which files a turn changed and by how many lines, but not what changed. A row opened the file's current content in the Sidebar, or in a desktop application, which shows neither the turn's edit nor the state before it. Files git does not cover — ignored files, files outside the repository, and every file-tool edit in a working directory without a repository — had only the hunks the file tools persist with their results: partial context, counts summed over repeated edits, and no whole-file comparison at all.
|
||||
|
||||
## Decision
|
||||
|
||||
Each card row opens the turn's `changes-review` tab in the right Sidebar on that file, and the card's header opens it on the first file: one tab per turn, addressed by the viewed Session and the announcing event's sequence, with a file selector in its header that lists every recorded file and shows one file's turn-start and turn-end comparison at a time. The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) recorder serves each comparison through `workspaceChanges.diff(sessionId, seq, index, signal)`; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) registers the tab type, passes the row's file index as a navigation parameter, and reads the summary and the comparisons through authenticated routes. The header's tools switch between the unified view and a side-by-side view, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop, open it in the default application; view and wrap choices are kept per tab. Rows and the header open the review with and without a desktop, so the earlier row-level native open and the header's folder open move into the tab and the card keeps one behavior.
|
||||
|
||||
Files git does not cover are compared the way Codex's turn diff tracker compares its `apply_patch` edits: from whole-file copies, not from hunks. Before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder's `tools/pre-execute` step, which already waits for the baseline snapshot, copies the named file into the Session's temporary directory beside the snapshot objects, once per path per turn; at turn end it copies the path again. The copies are named by the SHA-1 of their bytes, so identical content is stored once, and they need no git. Paths the snapshots cover keep their git counts; every other captured path is listed from a line comparison of its two copies, which counts a repeatedly edited line once and includes a shell edit made after the file-tool edit. The persisted hunks and the argument-derived hunks are no longer read by the recorder.
|
||||
|
||||
Two bounds keep the copies and the comparisons small. `maxFileBytes` caps a copy and a snapshot blob read for a comparison; a larger file is listed with `oversized`, without counts, and its comparison is refused, which is what makes whole-file copies affordable. `diffTimeoutMs` bounds the line comparison, the same 100 ms Codex uses; past it the comparison degrades to one hunk that replaces every line, marked `coarse`, so a pathological file never stalls the turn's record or the tab. Both are Config fields.
|
||||
|
||||
The comparison is computed when asked for, on the Host, from the two content sources kept beside the served summary: a path in a snapshot tree, read with `ls-tree -l` and `cat-file blob` under the byte cap, or a copy read from disk. Snapshot sides git reported as binary and copies holding a NUL byte serve no lines. The tab renders the hunks with old and new line numbers and no syntax highlighting; the side-by-side view pairs each run of deletions with the additions that follow it row by row. A comparison the Host no longer serves, a failed read, a binary file, and an oversized file each show one line.
|
||||
|
||||
Content still lives only as long as the Session in this Host process, as the card decision settled; the comparison shares the card's lifetime, so a conversation reopened after a Host restart has neither.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Whole-file before and after text in the file tools' result metadata**, as Codex's `apply_patch` returns, would put every edited file twice into the Session log. The recorder captures the file itself instead, so the log keeps carrying only the turn number.
|
||||
|
||||
**Storing the copies in memory**, as Codex does for its per-turn tracker, grows with every turn because the records live for the Session; the copies go to the Session's temporary directory beside the snapshot objects and are read only when a comparison is asked for.
|
||||
|
||||
**Storing the copies as git blobs in the private object store** would have shared one read path with the snapshots but made the file-tool path depend on git, which the working directories without a repository or without git cannot provide; plain files under the same directory keep that path git-free.
|
||||
|
||||
**A shadow repository for working directories outside any repository** remains deferred; the copies cover the file-tool edits there, which is the part a user can act on.
|
||||
|
||||
**One tab per file**, titled by the file name, was the first implementation; reviewing a turn meant one tab per row. One tab per turn with a file selector keeps the turn's changes together and lets the row still land on its file.
|
||||
|
||||
**Syntax highlighting** in the tab is deferred until the plain view proves insufficient.
|
||||
|
||||
**Keeping rows opening the current file** would have left the comparison one click further away; the current file still opens from the prose links and the Files tab.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every file-tool edit reads and writes its whole file once per turn on the Host, bounded by `maxFileBytes`, even for paths the snapshots also cover; a Session's temporary directory holds the copies until disposal. Comparison line counts for uncovered files are first-to-last, not sums, and two sides that both exceed the cap list the file as `oversized` rather than dropping it, since unread content is never known to be unchanged. A comparison costs at most two `ls-tree` and two `cat-file` commands plus one bounded line comparison; a comparison that timed out carries every line of both sides, up to twice `maxFileBytes`, and the tab draws at most 5,000 lines of it. The comparison route serves a listed file's complete text to the browser wherever the Host recorded it — ignored files, repository files above the working directory, and files outside the workspace — where the summary route served only paths and counts and the Sidebar's file previews stay under the workspace root; the user authorized those edits, so this is accepted and recorded in both READMEs.
|
||||
|
||||
The `WorkspaceChangedFile` type gains `oversized`, `WorkspaceChanges` gains `diff`, and two served types, `WorkspaceDiffHunk` and `WorkspaceFileDiff`, join the subsystem page. The Session log is unchanged. The changed-files card's rows are labeled as opening the file's changes in both languages, so the recorded Web scenario's golden changed; the same scenario now opens a shell-appended file's comparison from the snapshots and an ignored file's from its copies.
|
||||
|
||||
Focused tests cover whole-file capture classification, the timeout degradation, comparisons served from snapshot trees, renames, deleted files, oversized blobs, and copies, the comparison route, the tab's addresses, store, and states, and the card's row wiring.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: 轮次 review tab
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-15-changed-file-diff-preview.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[改动文件卡片](2026-09-11-turn-changed-files-card.zh.md)告诉用户一轮改了哪些文件、改了多少行,但没说改了什么。点一行打开的是文件当前内容,在 Sidebar 里或在桌面应用里,既看不到本轮的编辑,也看不到编辑前的状态。git 覆盖不到的文件——被忽略的文件、仓库之外的文件,以及没有仓库的工作目录里的每一次文件工具编辑——只有文件工具随结果持久化的 hunk:上下文不完整,行数按重复编辑累加,完全没有整文件对比。
|
||||
|
||||
## Decision
|
||||
|
||||
卡片的每一行在右侧 Sidebar 打开本轮的 `changes-review` tab 并选中该文件,卡片头部则在第一个文件上打开它:每轮一个 tab,由当前查看的 Session 和宣告事件的序号定址,头部有一个列出所有记录文件的文件选择器,一次显示一个文件在轮次开始与结束时的对比。Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 记录器通过 `workspaceChanges.diff(sessionId, seq, index, signal)` 提供每个对比;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)注册该 tab 类型,把行的文件下标作为导航参数传入,并通过经过认证的路由读取摘要和对比。头部的工具在单栏和左右视图之间切换、切换自动换行、在 Sidebar 中打开当前整个文件,有 Host 桌面时用默认应用打开它;视图与换行的选择按 tab 保留。有没有桌面,行和头部都打开 review,先前行级别的原生打开和头部的打开文件夹都移进 tab,卡片只保留一种行为。
|
||||
|
||||
git 覆盖不到的文件按 Codex 的 turn diff tracker 对比其 `apply_patch` 编辑的方式对比:用整文件副本,不用 hunk。在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器本来就要等待基线快照的 `tools/pre-execute` 步骤把所指文件复制到 Session 临时目录里快照对象旁边,每轮每个路径一次;轮次结束时再复制一次该路径。副本按其字节的 SHA-1 命名,相同内容只存一份,且不需要 git。快照覆盖到的路径保留 git 的行数;其余每个被捕获的路径由两份副本的逐行对比列出,反复编辑的行只计一次,文件工具编辑之后的 shell 改动也包含在内。记录器不再读取持久化的 hunk 和由参数推出的 hunk。
|
||||
|
||||
两个上限让副本和对比保持小。`maxFileBytes` 限制副本和为对比而读出的快照 blob;更大的文件列出时带 `oversized`,没有行数,其对比被拒绝,整文件副本因此负担得起。`diffTimeoutMs` 限制逐行对比,与 Codex 一样是 100 毫秒;超时后退化为一个替换全部行的 hunk,标记 `coarse`,因此病态的文件从不会拖住本轮记录或 tab。两者都是 Config 字段。
|
||||
|
||||
对比在被请求时在 Host 上计算,来源是保存在所提供摘要旁边的两侧内容来源:快照树中的路径,用 `ls-tree -l` 和 `cat-file blob` 在字节上限之内读出;或者从磁盘读取的副本。git 报告为二进制的快照一侧和含 NUL 字节的副本不提供行。tab 渲染带旧新行号的 hunk,没有语法高亮;左右视图把每一段删除与紧随其后的新增逐行配对。Host 已不再提供的对比、读取失败、二进制文件和过大的文件各显示一行。
|
||||
|
||||
内容仍然只在本 Host 进程内随 Session 存活,这是卡片决定已经定下的;对比与卡片同寿命,因此 Host 重启后重新打开的对话两者都没有。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把整文件的前后文本放进文件工具的结果元数据**,像 Codex 的 `apply_patch` 返回的那样,会把每个被编辑的文件两次写进 Session 日志。记录器改为自己捕获文件,日志继续只携带轮号。
|
||||
|
||||
**把副本存在内存里**,像 Codex 的每轮 tracker 那样,会随每一轮增长,因为记录要活到 Session 结束;副本放到 Session 临时目录里快照对象旁边,只在请求对比时读取。
|
||||
|
||||
**把副本存为私有对象库里的 git blob** 能和快照共用一条读取路径,但会让文件工具这条路径依赖 git,而没有仓库或没有 git 的工作目录给不了它;同一目录下的普通文件让这条路径与 git 无关。
|
||||
|
||||
**给仓库之外的工作目录建影子仓库**仍然推迟;副本已经覆盖那里的文件工具编辑,这是用户能采取行动的部分。
|
||||
|
||||
**每个文件一个 tab**、以文件名作标题,是第一版实现;审阅一轮意味着每行一个 tab。每轮一个 tab 加文件选择器把一轮的改动放在一起,行仍然能落到自己的文件上。
|
||||
|
||||
**tab 里的语法高亮**推迟到纯文本视图被证明不够用时再做。
|
||||
|
||||
**保留行打开当前文件**会让对比多一次点击才能到达;当前文件仍可从正文链接和文件 tab 打开。
|
||||
|
||||
## Consequences
|
||||
|
||||
每次文件工具编辑在 Host 上每轮读写一次整个文件,受 `maxFileBytes` 限制,即使快照也覆盖该路径;Session 的临时目录保存副本直到释放。未覆盖文件的对比行数是首尾对比,不是累加;两侧都超过上限的文件列为 `oversized` 而不是丢掉,因为没读过的内容永远不能认定为没有改动。一次对比最多花费两次 `ls-tree`、两次 `cat-file` 和一次有界的逐行对比;超时的对比携带两侧的全部行,最多两倍 `maxFileBytes`,tab 最多绘制其中 5000 行。对比路由会把所列文件在 Host 上记录时的完整文本送到浏览器,包括被忽略的文件、工作目录之上的仓库文件和工作区外的文件,而摘要路由只送路径和行数,Sidebar 的文件预览也限定在工作区根目录之内;这些编辑是用户授权的,因此接受这一点,并记入两个 README。
|
||||
|
||||
`WorkspaceChangedFile` 类型新增 `oversized`,`WorkspaceChanges` 新增 `diff`,两个提供给客户端的类型 `WorkspaceDiffHunk` 和 `WorkspaceFileDiff` 加入子系统页面。Session 日志不变。改动文件卡片的行在两种语言里都标为打开该文件的改动,因此录制的 Web 场景的黄金文件变了;同一场景现在会从快照打开一个 shell 追加过的文件的对比,从副本打开一个被忽略文件的对比。
|
||||
|
||||
聚焦测试覆盖整文件捕获的分类、超时退化、从快照树提供的对比、改名、删除的文件、过大的 blob 和副本、对比路由、tab 的地址、store 与各状态,以及卡片行的接线。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.md
|
||||
2026-09-15-sidebar-workspace-hierarchy.md: 82e63684ec1d8c5d86e062b125aff6e1144ef8fa
|
||||
2026-09-15-sidebar-workspace-hierarchy.zh.md: 07f9ff89b6e33fd4d46b5e386b4240ad12c177c5
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Sidebar Workspace Hierarchy
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-15-sidebar-workspace-hierarchy.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A flat Workspace list makes related projects hard to browse when many directories share one parent. Treating that parent as the owner of every descendant Session would conflict with the Workspace requirement that a member Session's canonical working directory equals the registered path.
|
||||
|
||||
## Decision
|
||||
|
||||
The sidebar defaults to sibling Workspace sections. Selecting **Workspace Tree** in **View options → Group by** derives a recursive hierarchy from registered Workspace paths. The selected mode persists in the existing browser-local viewing store. Each Workspace appears under its nearest strict ancestor; path equality never creates a self-parent. Comparison respects directory separators and Host case spelling, without resolving symlink aliases. Siblings retain their Host order.
|
||||
|
||||
Adding a directory registers a normal Workspace and opens its Session directly. Parent Workspaces retain their own Sessions and standard row actions, with child Workspaces preceding their own Sessions. The existing browser-local expansion state controls both child Workspaces and the parent's Session rows; ancestors default to expanded when no explicit preference exists. Explicit collapse also hides the current Session, while ancestor folder icons identify its containing subtree. All rows have equal-width fills and hit targets, with content indentation per level. Workspace drag stays among siblings; descendant drop targets delegate to the nearest compatible ancestor, and unchanged sibling positions do not write Host order. Search navigation expands every ancestor.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Tree grouping by default:** this hides projects under ancestors even when users want to browse every Workspace directly. An explicit viewing mode preserves the default layout without adding a confirmation to directory adoption.
|
||||
|
||||
**Recursive Workspace membership:** this changes the working-directory invariant and makes a Session eligible for multiple accounts. Display nesting needs neither change.
|
||||
|
||||
**Separate parent-folder records and an add-time choice:** this duplicates directories already represented by Workspaces and adds a confirmation to ordinary addition. Deriving hierarchy from the registry preserves one add flow and lets every directory own Sessions.
|
||||
|
||||
**Automatic directory discovery:** this could show unregistered projects, but requires filesystem listing, refresh, and adoption behavior. The sidebar consumes the current Workspace list only.
|
||||
|
||||
## Consequences
|
||||
|
||||
Users can collapse related projects without changing Session ownership. Deleting an ancestor preserves child registrations; the independent [Workspace deletion semantics](2026-07-27-workspace-registration-deletion.md) still govern the deleted Workspace's own Sessions. That decision remains active. Browser-local collapse preferences do not synchronize, and registered path spelling after separator normalization determines nesting.
|
||||
|
||||
Pure path tests cover strict ancestry, segment boundaries, POSIX backslashes, and Windows separators. Component and browser scenarios exercise default sibling grouping, tree-mode selection and restoration, direct addition, later registrations, independent parent Sessions, collapse restoration, search revelation, sibling sorting through descendant targets, parent-subtree moves, collapsed-current hints, and equal-width rows.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 侧栏工作区层级
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-15-sidebar-workspace-hierarchy.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
许多目录共享同一父目录时,平铺的 Workspace 列表不便浏览相关项目。若让父目录拥有所有后代 Session,则会违背 Workspace 成员 Session 的规范工作目录必须等于注册路径这一要求。
|
||||
|
||||
## 决策
|
||||
|
||||
侧栏默认将 Workspace 显示为同级分组。在**视图选项 → 分组方式**中选择**按工作区树**后,侧栏从已注册的 Workspace 路径派生递归层级。所选模式保存在现有的浏览器本地视图存储中。每个 Workspace 位于最近的严格祖先下;相同路径不会成为自己的父级。比较遵循目录分隔符和 Host 的大小写拼写,不解析符号链接别名。同级项目保留 Host 顺序。
|
||||
|
||||
添加目录会直接注册普通 Workspace 并打开其 Session。父 Workspace 保留自己的 Session 和标准行操作,子 Workspace 位于父级自己的 Session 之前。现有的浏览器本地展开状态同时控制子 Workspace 和父级自己的 Session 行;没有显式偏好时,祖先默认展开。显式折叠也会隐藏当前 Session,但祖先文件夹图标会标识它所在的子树。所有行的高亮与点击区域等宽,内容按层级缩进。Workspace 拖拽限制在同级之间;后代落点交给最近的兼容祖先,同级位置未变时不写入 Host 顺序,搜索导航会展开全部祖先。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**默认采用树形分组:**这会把项目隐藏在祖先之下,即使用户希望直接浏览全部 Workspace。显式视图模式保留默认布局,也不为目录添加流程增加确认。
|
||||
|
||||
**递归 Workspace 成员关系:**这会改变工作目录不变量,并让一个 Session 同时符合多个记账。展示嵌套不需要这两项改变。
|
||||
|
||||
**独立父目录记录与添加时选择:**这会重复表示已有 Workspace 目录,并为普通添加增加确认。根据注册表派生层级可保留单一添加流程,并让每个目录都能拥有 Session。
|
||||
|
||||
**自动发现目录:**这能展示尚未注册的项目,但需要文件系统列举、刷新和采用行为。侧栏仅使用当前 Workspace 列表。
|
||||
|
||||
## 结果
|
||||
|
||||
用户可以折叠相关项目,而不改变 Session 归属。删除祖先会保留子工作区注册;独立的 [Workspace 删除语义](2026-07-27-workspace-registration-deletion.zh.md)仍决定被删除 Workspace 自身 Session 的处理,该决策继续有效。浏览器本地折叠偏好不跨浏览器同步,嵌套取决于分隔符归一化后的注册路径拼写。
|
||||
|
||||
纯路径测试覆盖严格祖先关系、路径段边界、POSIX 反斜杠和 Windows 分隔符。组件与浏览器场景覆盖默认同级分组、树形模式选择与恢复、直接添加、后续注册、父级独立 Session、折叠恢复、搜索展开、通过后代落点进行同级排序、父级子树移动、折叠当前会话提示及整行等宽。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md
|
||||
2026-09-16-known-site-link-marks.md: 9d029af06f9ca0f3fa8290112c7023ec1b9e0b84
|
||||
2026-09-16-known-site-link-marks.zh.md: 2c7630d122d1576ba41cb653cf0b86e9c5a2ddb4
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: Known-site link marks
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-known-site-link-marks.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Every transcribed anchor led with the same globe, so a transcript full of GitHub, npm, and documentation links gave no signal about where a link goes until the reader parsed its label. The [clickable-link vocabulary](2026-09-04-web-clickable-link-styles.md) reserved that leading seat for one category glyph and left per-site marks as a possible later extension of the `url` category.
|
||||
|
||||
## Decision
|
||||
|
||||
`LinkIcon` takes an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site.
|
||||
|
||||
`SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe.
|
||||
|
||||
The artwork is the [Simple Icons](https://simpleicons.org) set (CC0-1.0) consumed as a devDependency of ui-primitives, so adding a site is one host-map entry and the artwork version comes from the lockfile rather than a copied path. Each mark renders as that set's single path on its 24-unit viewBox, inset to a 28-unit box so it keeps the ~8% margin the 20-unit `ic_ds_*` glyphs draw inside their own box, and takes the link's `currentColor` rather than the brand fill the set records. Every mark is `aria-hidden`; the anchor's own text remains the accessible name.
|
||||
|
||||
Taking `currentColor` is deliberate for a functional link glyph rather than brand presentation: YouTube, X, Instagram, and other brands publish guidelines against recoloring their marks, and the first alternative below weighs the fixed-brand-fill option. CC0-1.0 covers the path data; every mark remains its owner's trademark, and the generated third-party notices cover the package rather than this artwork.
|
||||
|
||||
Two consumers pass their destination: the markdown renderer's `renderSafeLink`, which covers authored anchors, reference links, and URL-promoted inline code, and the web card's source and fetch links. Both already hold the sanitized destination.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Fetching each site's favicon** from the site itself or a favicon service. This covers arbitrary sites, but rendering a transcript would issue network requests to every linked host, which discloses reading activity and turns a text render into a network operation; it also fails offline and behind a strict `img-src` policy. Rejected: a fixed local vocabulary keeps rendering deterministic and private, and the globe remains the honest fallback.
|
||||
- **Filling the mark with the site's brand color.** Rejected: the link glyphs are `currentColor`-only so they follow the link alias, dark mode, and hover; a fixed fill would be the first exception and would fight the link's own hover color.
|
||||
- **Copying the path data into this module.** Rejected: a third-party artwork set that changes upstream is exactly what a pinned dependency tracks; fourteen embedded copies would leave no update path.
|
||||
- **Adding site values to `LinkIconKind`.** Rejected: the kind is the category the consumer states, while the site is derived from the destination; folding both into one union would make every consumer spell out a site it does not know.
|
||||
- **A monogram tile for every unmapped host.** Rejected as noise: a generated letter capsule claims a site identity without carrying one, and 14px leaves no room for readable initials.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding a site needs a host-map entry in `SiteGlyph.tsx` and, to be checked, a URL in the `link-icon` spec: that spec lists a representative URL per mapped site, requires thirty-four distinct marks across them, and pins the GitHub, npm, YouTube, Wikipedia, X, Telegram, and WhatsApp aliases. A dropped or duplicated entry fails the count, while a site added to the map alone stays uncovered by the spec.
|
||||
- The vocabulary stays bounded by category rather than by demand: each site costs one map entry, one upstream mark, and one spec URL, and the thirty-four marks add roughly 23 KB of path data to the source, which the shell build reduces to a few KB in the entry chunk after tree-shaking, minification, and compression.
|
||||
- `simple-icons` enters the browser build graph as a development dependency of ui-primitives; the shell build tree-shakes the imported marks instead of the complete set.
|
||||
- Unknown hosts, non-http schemes, unparseable destinations, and file categories keep the existing globe or category glyph; the `mailto` link in a transcript still shows the globe.
|
||||
- The vocabulary is deliberately finite. Sites such as `example.com` never get a mark from this mechanism; recognizing them would require the network fetching the alternatives reject.
|
||||
- Coverage: the LinkIcon spec covers the aliases, the fallback branches, and the sizing seat; the markdown spec pins one known and one unknown anchor; the web-card spec pins a source and a fetch link against the same marks.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: 已知站点链接标记
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-known-site-link-marks.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
转写内容中的每个锚点都以同一个地球图形开头,因此满是 GitHub、npm 与文档链接的对话在读者解析标签之前,无法从图形看出链接去向。[可点击链接词汇](2026-09-04-web-clickable-link-styles.zh.md) 把该前置位置留给一个分类图形,并把按站点区分的标记留作 `url` 类别日后可能的扩展。
|
||||
|
||||
## Decision
|
||||
|
||||
`LinkIcon` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。
|
||||
|
||||
ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。
|
||||
|
||||
图形取自 [Simple Icons](https://simpleicons.org) 图标集(CC0-1.0),以 ui-primitives 的 devDependency 形式引入,因此新增站点只是主机映射中的一条记录,图形版本由 lockfile 固定,而不是复制路径数据。每个标记以该图标集的单条 path 绘制在其 24 单位 viewBox 上,并内缩到 28 单位盒中,以保留 20 单位 `ic_ds_*` 图形在自身盒内约 8% 的留白;填充使用链接的 `currentColor`,而不是图标集记录的品牌填充色。所有标记均为 `aria-hidden`,锚点自身文本仍是可访问名称。
|
||||
|
||||
对功能性链接图形采用 `currentColor` 是刻意的取舍,而非品牌呈现:YouTube、X、Instagram 等品牌公布的规范禁止为其标记改色,下方第一个备选方案比较了固定品牌填充的做法。CC0-1.0 覆盖路径数据;每个标记仍属其所有者的商标,生成的第三方声明覆盖该 npm 包本身,而不是这份图形。
|
||||
|
||||
两处消费者传入自己的目的地:markdown 渲染器的 `renderSafeLink`(覆盖作者书写的锚点、引用式链接与提升为链接的行内代码)以及 web 卡片的来源与抓取链接。两者本已持有经安全校验的目的地。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **抓取各站点的 favicon**,无论取自站点本身还是 favicon 服务。这能覆盖任意站点,但渲染一份转写内容会向每个被链接的主机发起网络请求,泄露阅读行为并把文本渲染变成网络操作;它在离线与严格 `img-src` 策略下同样失败。拒绝:固定的本地词汇表让渲染保持确定与私密,地球仍是诚实的回退。
|
||||
- **以站点品牌色填充标记。** 拒绝:链接图形只使用 `currentColor`,以跟随链接 alias、暗色模式与 hover;固定填充会成为首个例外,并与链接自身的 hover 颜色冲突。
|
||||
- **把路径数据复制进本模块。** 拒绝:上游会持续更新的第三方图形集正是需要固定版本依赖来跟踪的内容;十四个内嵌副本没有更新路径。
|
||||
- **把站点值加进 `LinkIconKind`。** 拒绝:kind 是消费者陈述的分类,而站点由目的地推导;把两者折进同一联合类型会迫使每个消费者写出自己并不知晓的站点。
|
||||
- **为每个未映射主机生成首字母方块。** 以噪音为由拒绝:自动生成的字母胶囊宣称了一种站点身份却没有承载它,14px 也没有留下可读字母的空间。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 新增站点需要在 `SiteGlyph.tsx` 的主机映射中加一条记录,并在 `link-icon` spec 中补一个 URL 才会被检查:该 spec 为每个已映射站点列出一个代表 URL、要求它们共产生三十四个互不相同的标记,并固定 GitHub、npm、YouTube、Wikipedia、X、Telegram 与 WhatsApp 的别名。记录丢失或重复会让数量断言失败,而只改主机映射、不补 spec URL 的新站点仍未被 spec 覆盖。
|
||||
- 词汇表按类别而非按需求增长:每个站点需要一条主机映射、一个上游标记与一个 spec URL;这三十四个标记在源码中增加约 23 KB 路径数据,经 shell 构建的 tree-shake、压缩与 gzip 后进入入口 chunk 的只有几 KB。
|
||||
- `simple-icons` 作为 ui-primitives 的开发依赖进入浏览器构建图;shell 构建会 tree-shake 所导入的标记,而不是整套图标。
|
||||
- 未收录主机、非 http scheme、无法解析的目的地与文件类别都保留原有的地球或分类图形;转写内容中的 `mailto` 链接仍显示地球。
|
||||
- 该词汇表刻意有限。像 `example.com` 这样的站点不会由此机制获得标记;要识别它们就需要被上述备选方案拒绝的网络抓取。
|
||||
- 覆盖:LinkIcon spec 覆盖别名、各回退分支与尺寸座位;markdown spec 固定一个已知锚点与一个未知锚点;web 卡片 spec 用同一批标记固定一个来源链接与一个抓取链接。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.md
|
||||
2026-09-16-unified-model-input-controls.md: 73913880924577caf86b30990baef98efdbe751e
|
||||
2026-09-16-unified-model-input-controls.zh.md: 691ca5229c017919e708bf7df92285dee25ef6b4
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Unified model input controls
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-unified-model-input-controls.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Separate DeepSeek and pi-ai row renderers let labels, icons, and spacing diverge. An automatic column count moves image input between the capacity row and a second row with small width changes. The image-support selector also cannot express image-only input, although both adapters accept it.
|
||||
|
||||
## Decision
|
||||
|
||||
Both catalogs render a shared model row. The two capacity fields occupy two columns, and Input types occupies the full next row. Text and Image use the controlled native Checkbox in `ui-primitives`, with caller-owned localized labels and native keyboard behavior. At least one type remains selected; the last checked type is disabled until another is selected.
|
||||
|
||||
An undeclared or empty input field displays the installed model input types, then the route default, then Text, without materializing an override on open. Pi-ai catalog discovery carries optional `inputModalities` through the LLM service and Remote response; adopted candidates copy it to `input`. Known provider editors read the installed catalog without endpoint I/O, so existing capacity-only rows also display inherited vision support. Catalog reads for a previous provider are discarded, and input controls wait for the current read to settle. The first checkbox edit writes the exact nonempty selection to DeepSeek's `inputModalities` or pi-ai's `input`. Existing explicit selections, including image-only input, remain visible. Unrelated metadata survives edits; disabling DeepSeek images removes its image request limits because the adapter rejects those limits without image input. Saving uses the existing catalog-array mutation and adapter validation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Independent model-row renderers.** Adapter-specific defaults and model discovery remain with their editors, but duplicate presentation creates avoidable visual drift. Shared rows keep those behaviors independent of field layout.
|
||||
|
||||
**Keep the image-support selector.** Settings-file-only capability editing leaves custom vision models effectively text-only. A selector with an explicit Default option preserves visible pi-ai catalog inheritance; a two-state image switch would misrepresent inherited vision support as disabled. The checkbox interface exposes both input types directly, resolving inherited capabilities before enabling edits. Merely opening a row still preserves inheritance; restoring it after a checkbox edit requires removing the input field in settings or resetting the catalog override.
|
||||
|
||||
**Allow no checked types.** DeepSeek rejects an empty list and pi-ai treats it as inheritance. Keeping a nonempty selection prevents the same gesture from meaning different things across adapters.
|
||||
|
||||
**Keep image limits when disabling DeepSeek images.** This leaves a configuration that the adapter refuses to save. Clearing the image-specific limits makes the selected text-only state valid while preserving unrelated model fields.
|
||||
|
||||
## Consequences
|
||||
|
||||
Users can edit text-only, image-only, and combined declarations through one layout. Configuration declares upstream capabilities; it does not add image processing to a text-only model. Removing a pi-ai input declaration can change effective capabilities as catalog or provider defaults change. DeepSeek image limits need reconfiguration after images are disabled. Provider routing and [catalog recovery](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md) retain their existing owners. Component checks cover defaults, exact selections, metadata preservation, and disabled controls; browser scenarios cover saved adapter capabilities, reopened selections, the separate input-type row, and installed vision metadata across discovery, adoption, and reopening.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note:统一模型输入控件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-unified-model-input-controls.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
DeepSeek 和 pi-ai 分别渲染模型行,导致标签、图标和间距不一致。自动列数会在宽度稍有变化时,将图片输入移到容量字段同一行或下一行。图片支持选择器也无法表达仅图片输入,而两个适配器都接受这种声明。
|
||||
|
||||
## 决策
|
||||
|
||||
两个模型目录渲染同一个模型行组件。两个容量字段占据两列,输入类型独占下一整行。文本和图片使用 `ui-primitives` 的受控原生 Checkbox(复选框),由调用方提供本地化标签,并保留原生键盘行为。至少保留一种输入类型;最后一个勾选项保持禁用,直到选中另一项。
|
||||
|
||||
未声明或为空的输入字段优先显示已安装模型的输入类型,其次是路由默认值,最后回退为文本,打开时不会生成覆盖值。Pi-ai 目录发现将可选的 `inputModalities` 经 LLM 服务和 Remote(远程调用)响应传递给界面;采纳候选时将其复制到 `input`。已知提供方的编辑器读取已安装目录,不向端点发送请求,因此已有的仅容量模型行也能显示继承的视觉能力。上一个提供方的目录响应会被丢弃,输入控件会等待当前读取完成。首次修改复选框会将确切的非空选择写入 DeepSeek 的 `inputModalities` 或 pi-ai 的 `input`。已有显式选择保持可见,包括仅图片输入。编辑保留无关元数据;禁用 DeepSeek 图片时会移除图片请求限制,因为适配器在没有图片输入时拒绝这些限制。保存使用现有的模型目录数组变更和适配器校验。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**独立的模型行渲染器。** 适配器专属默认值和模型发现仍由各自编辑器负责,但重复的展示实现会造成可避免的视觉差异。共享模型行将这些行为与字段布局分离。
|
||||
|
||||
**保留图片支持选择器。** 仅通过设置文件编辑能力,会让自定义视觉模型实际保持仅文本。带有显式「默认」选项的选择器会清楚展示 pi-ai 模型目录继承;两态图片开关则会将继承的视觉能力误表示为禁用。复选框界面直接展示两种输入类型,并在允许编辑前解析继承的能力。仅打开模型行仍保留继承;修改复选框后,要恢复继承需在设置中移除输入字段,或重置整个模型目录覆盖。
|
||||
|
||||
**允许全部取消。** DeepSeek 拒绝空列表,pi-ai 则将其视为继承。保留非空选择,避免同一个操作在两个适配器中具有不同含义。
|
||||
|
||||
**禁用 DeepSeek 图片时保留图片限制。** 这会留下适配器拒绝保存的配置。清除图片专属限制,可以让选中的仅文本状态有效,同时保留模型的无关字段。
|
||||
|
||||
## 影响
|
||||
|
||||
用户通过相同布局编辑仅文本、仅图片和组合声明。配置声明上游能力,不会为仅文本模型增加图片处理能力。移除 pi-ai 输入声明后,有效能力可能随模型目录或提供方默认值变化。禁用图片后,DeepSeek 图片限制需要重新配置。提供方路由和[模型目录恢复规则](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md)保留现有责任方。组件检查覆盖默认值、确切选择、元数据保留和禁用控件;浏览器场景覆盖保存后的适配器能力、重新打开后的选择,独占一行的输入类型,以及发现、采纳和重新打开过程中传递的内置视觉元数据。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-16-author-approval-credit.md
|
||||
2026-09-16-author-approval-credit.md: ab7aba88801271c22a0f054d5b3888726b94e97a
|
||||
2026-09-16-author-approval-credit.zh.md: 316a09ea5d82f78915a9550fde7f29bd55761e44
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Author approval credit
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-author-approval-credit.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The approval policy needs author experience to contribute enough points for an established author and one ordinary reviewer to meet the two-point requirement.
|
||||
|
||||
## Decision
|
||||
|
||||
The [approval policy](../../../../.github/review-ownership/README.md) awards 0.011 points per merged PR in this repository, capped at 1.1 points after 100 PRs. History lookup stops at 100 matching PRs. Author credit alone remains insufficient, and blocking reviews still prevent approval.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the 0.6-point cap at 150 PRs.** That cap cannot combine with an ordinary one-point review to meet the requirement without an ownership boost.
|
||||
|
||||
## Consequences
|
||||
|
||||
One ordinary approval with no ownership boost meets the requirement at 91 merged PRs; 90 yields only 1.99 points. The lower counting cap limits required history traversal for prolific authors. Merged PR count measures contribution history rather than review quality.
|
||||
|
||||
## Verification
|
||||
|
||||
Policy tests cover credit below, at, and above the cap; the 90/91-PR approval threshold; author-only rejection; pagination; and below-threshold scores whose display rounds to two.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: 作者审批积分
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-author-approval-credit.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
审批策略需要让作者经验贡献足够的积分,使有经验的作者与一位普通审阅者能够共同满足两分要求。
|
||||
|
||||
## Decision
|
||||
|
||||
[审批策略](../../../../.github/review-ownership/README.md)为作者在本仓库中每个已合并 PR 计入 0.011 分,达到 100 个 PR 时以 1.1 分封顶。历史查询在找到 100 个匹配 PR 后停止。仅靠作者积分仍不足以通过审批,阻塞性审阅仍会阻止通过。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留 150 个 PR 时封顶 0.6 分的规则。** 在没有代码所有权加分的情况下,该上限与普通审阅的一分相加仍无法满足要求。
|
||||
|
||||
## Consequences
|
||||
|
||||
没有代码所有权加分时,一次普通批准与 91 个已合并 PR 的作者积分相加即可满足要求;90 个则只有 1.99 分。较低的计数上限减少了高贡献作者所需的历史遍历。已合并 PR 数量衡量贡献历史,而非审阅质量。
|
||||
|
||||
## Verification
|
||||
|
||||
策略测试覆盖积分低于、达到及超过上限的情况、90/91 个 PR 的审批阈值、仅靠作者积分不能通过、分页,以及显示值舍入为两分但实际低于阈值的情况。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md
|
||||
2026-07-30-web-browser-snapshot-ci-gate.md: 719ac059d1208601e3fdd7ef10ae48464bd92877
|
||||
2026-07-30-web-browser-snapshot-ci-gate.zh.md: 57e23491e2123f9e14f478b98331dac2726ef2a2
|
||||
2026-07-30-web-browser-snapshot-ci-gate.md: d1c8dcdd859d0515804cc6aed0d2f3341beb6dd0
|
||||
2026-07-30-web-browser-snapshot-ci-gate.zh.md: 9d642819e09abc63384f4f4eb105d13261a9eb95
|
||||
@@ -16,7 +16,7 @@ The consumer job owns the [single Linux build](../../archived/process/2026-07-30
|
||||
|
||||
Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written.
|
||||
|
||||
CI's `scripts/run-web-snapshots.ts` first runs `hmr-live.e2e.ts` and `cordis-tool-round.e2e.ts` as separate serial Vitest invocations. The HMR scenario mutates built workspace state, while the Cordis scenario owns a lifecycle-sensitive approval and steering sequence whose turn grouping is made deterministic by waiting for the initial turn to settle before approval. After both pass, one six-worker Vitest pool runs every remaining file. Every child inherits stdio, and the enclosing gate streams that output through `run-gates`.
|
||||
CI’s `scripts/run-web-snapshots.ts` runs `hmr-live.e2e.ts` separately because it mutates built workspace state. One six-worker Vitest pool then runs every remaining file, including historical Cordis-card rendering. Every child inherits stdio, and `run-gates` streams its output.
|
||||
|
||||
For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
|
||||
|
||||
@@ -30,10 +30,10 @@ Completed local replays measured the six-worker browser command at about 65–71
|
||||
|
||||
**Create a standalone browser job and rebuild the entire repository.** Rejected: it would duplicate dependency installation and the publishable build. The existing Linux consumer job already owns that build and is part of the unified required verdict.
|
||||
|
||||
**Run HMR and Cordis inside the parallel pool.** Rejected because HMR mutates shared built state and the Cordis approval continuation requires a serial preflight. Every other file shares one bounded pool; dedicated long-file processes add scheduling code and leave part of a reduced worker budget idle after those files complete.
|
||||
**Run HMR inside the parallel pool.** Rejected because it mutates shared built state. Other browser fixtures own isolated state and share one bounded pool.
|
||||
|
||||
**Replace real Chromium with jsdom snapshots.** Rejected: jsdom does not cover the browser, HTTP/SSE carriage, or the composition of real client plugin bundles. It remains useful for fast lower-layer feedback, but cannot replace the assembled browser chain.
|
||||
|
||||
## Consequences
|
||||
|
||||
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium provisioning, two serial scenarios, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
|
||||
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium provisioning, one serial scenario, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
|
||||
@@ -16,7 +16,7 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览
|
||||
|
||||
本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。
|
||||
|
||||
CI 的 `scripts/run-web-snapshots.ts` 先用相互独立的 Vitest 调用串行运行 `hmr-live.e2e.ts` 与 `cordis-tool-round.e2e.ts`。HMR 场景会修改已构建工作区状态;Cordis 场景则拥有一条对生命周期时序敏感的批准与 steering(中途引导)序列,它通过在批准前等待初始轮次结束来确定轮次分组。两者通过后,其余全部文件进入同一个 6-worker Vitest 池。所有子进程都继承 stdio,外围门禁再通过 `run-gates` 流式传递输出。
|
||||
CI 的 `scripts/run-web-snapshots.ts` 单独运行会修改构建产物的 `hmr-live.e2e.ts`。随后由一个六 worker Vitest 池运行其余文件,包括历史 Cordis 卡片渲染。所有子进程继承 stdio,由 `run-gates` 流式转发输出。
|
||||
|
||||
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
|
||||
|
||||
@@ -30,7 +30,7 @@ CI 的 `scripts/run-web-snapshots.ts` 先用相互独立的 Vitest 调用串行
|
||||
|
||||
**新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux 消费方 job 已负责该构建,并已被统一的 required verdict 聚合。
|
||||
|
||||
**把 HMR 与 Cordis 也放进并行池。** 不予采用,因为 HMR 会修改共享的已构建状态,Cordis 批准 continuation 则需要串行预检。其余全部文件共用一个有界池;专用长文件进程会增加调度代码,并在这些文件结束后让缩减后的部分 worker 预算闲置。
|
||||
**在并行池中运行 HMR。** 拒绝,因为它会修改共享构建产物。其他浏览器 fixture 拥有隔离状态,共用一个有界池。
|
||||
|
||||
**用 jsdom 快照代替真实 Chromium。** 已否决:jsdom 不覆盖浏览器、HTTP/SSE 承载及真实客户端插件包的组合;它仍可用于快速的下层反馈,但不能替代组装后的浏览器链路。
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ The weighted approval workflow exposes two pull-request checks. The `weighted ap
|
||||
|
||||
Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count.
|
||||
|
||||
The PR author contributes `min(0.6, mergedPRCount / 250)` points: 0 merged PRs → 0 points, 100 → 0.4, and 150 or more → 0.6. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 150 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error.
|
||||
The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error.
|
||||
|
||||
A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when reviewer points plus author credit already meet the threshold or a blocking review exists. Displayed scores use at most three decimal places; the decision uses unrounded scores with a tolerance of `1e-12` points for floating-point error. The curve endpoints come from the policy’s default and required points.
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/** Author credit from merged pull requests in the same repository. */
|
||||
const CAP_COUNT = 150
|
||||
const CAP_COUNT = 100
|
||||
|
||||
/**
|
||||
* Convert merged PR count to author points, capped at 0.6.
|
||||
* Convert merged PR count to author points, capped at 1.1.
|
||||
* @param {number} mergedCount Merged PR count.
|
||||
* @returns {number} Author approval points.
|
||||
*/
|
||||
export function authorCreditPoints(mergedCount) {
|
||||
return Math.min(CAP_COUNT, mergedCount) / 250
|
||||
return Math.min(CAP_COUNT, mergedCount) * 11 / 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Count merged PRs by immutable author account, stopping at the credit cap.
|
||||
* @param {{repository: string, number: number, authorId: string}} pull Current pull request.
|
||||
* @param {(path: string, options: object) => Promise<unknown>} api GitHub API caller.
|
||||
* @returns {Promise<number>} Merged count, capped at 150; incomplete responses reject.
|
||||
* @returns {Promise<number>} Merged count, capped at 100; incomplete responses reject.
|
||||
*/
|
||||
export async function countMergedAuthorPulls(pull, api) {
|
||||
const [owner, name] = pull.repository.split('/')
|
||||
|
||||
@@ -20,16 +20,16 @@ test('counts only the same account and excludes the current PR and deleted accou
|
||||
assert.equal(count, 1)
|
||||
})
|
||||
|
||||
test('follows cursors, deduplicates overlapping pages, and stops at 150', async () => {
|
||||
test('follows cursors, deduplicates overlapping pages, and stops at 100', async () => {
|
||||
let calls = 0
|
||||
const count = await countMergedAuthorPulls(pull, async (path, { body }) => {
|
||||
calls++
|
||||
if (calls === 1) return page(Array.from({ length: 100 }, (_, i) => entry(i + 1)), true, 'next')
|
||||
if (calls === 1) return page(Array.from({ length: 100 }, (_, i) => entry(i + 1, i < 50 ? pull.authorId : 'another-id')), true, 'next')
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(body.variables.after, 'next')
|
||||
return page(Array.from({ length: 100 }, (_, i) => entry(i + 100)), true, 'unused')
|
||||
return page(Array.from({ length: 100 }, (_, i) => entry(i + 51)), true, 'unused')
|
||||
})
|
||||
assert.equal(count, 150)
|
||||
assert.equal(count, 100)
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
|
||||
@@ -485,7 +485,7 @@ test('publishes setup phases without evaluating or installing dependencies', asy
|
||||
await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
|
||||
})
|
||||
|
||||
for (const [mergedCount, credit] of [[0, 0], [1, 0.004], [50, 0.2], [100, 0.4], [125, 0.5], [150, 0.6], [200, 0.6]]) {
|
||||
for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089], [100, 1.1], [101, 1.1], [200, 1.1]]) {
|
||||
test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
|
||||
@@ -499,16 +499,31 @@ for (const [mergedCount, credit] of [[0, 0], [1, 0.004], [50, 0.2], [100, 0.4],
|
||||
}
|
||||
|
||||
test('combines author credit and reviewer ownership at the passing threshold', async () => {
|
||||
for (let mergedCount = 0; mergedCount <= 150; mergedCount++) {
|
||||
for (let mergedCount = 0; mergedCount <= 90; mergedCount++) {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
|
||||
getOwnership: async () => ({ totalLines: 1000, reviewerLines: { writer: 250 - mergedCount } }),
|
||||
getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 250000 - 2750 * mergedCount } }),
|
||||
api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
|
||||
})
|
||||
assert.equal(result.state, 'success', `author merged ${mergedCount}`)
|
||||
}
|
||||
})
|
||||
|
||||
for (const [mergedCount, state] of [[90, 'pending'], [91, 'success'], [100, 'success']]) {
|
||||
test(`one ordinary approval with ${mergedCount} merged PRs is ${state}`, async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
|
||||
getOwnership: async () => {
|
||||
assert.equal(mergedCount, 90, 'sufficient author credit must skip ownership lookup')
|
||||
return { totalLines: 0, reviewerLines: {} }
|
||||
},
|
||||
api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
|
||||
})
|
||||
assert.equal(result.state, state)
|
||||
assert.equal(result.approvals[0].points, 1)
|
||||
})
|
||||
}
|
||||
|
||||
test('blockers skip history and ignore non-write reviewers', async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(), policySource, getMergedCount: async () => { throw new Error('blockers must skip history') },
|
||||
@@ -544,8 +559,8 @@ test('drafts skip history and history failures revoke success with error', async
|
||||
|
||||
test('a score just below the threshold remains pending even if its display rounds to two', async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(), policySource, getMergedCount: async () => 150,
|
||||
getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 99999 } }),
|
||||
event: pullRequestEvent(), policySource, getMergedCount: async () => 50,
|
||||
getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 112499 } }),
|
||||
api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
|
||||
})
|
||||
assert.equal(result.state, 'pending')
|
||||
@@ -558,13 +573,13 @@ test('the publisher counts merged history through the production API path', asyn
|
||||
const output = []
|
||||
const result = await runWithHistory({
|
||||
event, policySource, runUrl: 'https://github.example/run/1', write: line => output.push(line),
|
||||
getOwnership: async () => ({ totalLines: 8, reviewerLines: { writer: 1 } }),
|
||||
getOwnership: async () => ({ totalLines: 80, reviewerLines: { writer: 9 } }),
|
||||
api: async (path, options) => {
|
||||
if (path.includes('/comments?')) return []
|
||||
if (path === '/graphql') {
|
||||
assert.equal(options.body.variables.owner, 'deepseek-harness')
|
||||
return { data: { repository: { pullRequests: {
|
||||
nodes: Array.from({ length: options.body.variables.after ? 25 : 100 }, (_, i) => ({
|
||||
nodes: Array.from({ length: 25 }, (_, i) => ({
|
||||
number: i + (options.body.variables.after ? 200 : 100), author: { id: event.pull_request.user.node_id },
|
||||
})),
|
||||
pageInfo: { hasNextPage: !options.body.variables.after, endCursor: 'next' },
|
||||
@@ -577,10 +592,10 @@ test('the publisher counts merged history through the production API path', asyn
|
||||
},
|
||||
})
|
||||
assert.equal(result.points, 2)
|
||||
assert.equal(result.authorCredit.points, 0.5)
|
||||
assert.equal(result.approvals[0].points, 1.5)
|
||||
assert.equal(result.authorCredit.points, 0.55)
|
||||
assert.equal(result.approvals[0].points, 1.45)
|
||||
assert.deepEqual(states, ['pending', 'success'])
|
||||
assert.equal(output[0], 'Author credit: 0.5 (125 merged PRs).')
|
||||
assert.equal(output[0], 'Author credit: 0.55 (50 merged PRs).')
|
||||
})
|
||||
|
||||
test('sufficient reviewer points and drafts publish without querying author history', async () => {
|
||||
@@ -617,11 +632,11 @@ test('bot authors receive the same history credit', async () => {
|
||||
const event = pullRequestEvent({ author: 'dependabot[bot]' })
|
||||
event.pull_request.user.type = 'Bot'
|
||||
const result = await evaluateApproval({
|
||||
event, policySource, getMergedCount: async () => 150,
|
||||
event, policySource, getMergedCount: async () => 100,
|
||||
getOwnership: async () => ({ totalLines: 10, reviewerLines: { writer: 1 } }),
|
||||
api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
|
||||
})
|
||||
assert.equal(result.authorCredit.points, 0.6)
|
||||
assert.equal(result.authorCredit.points, 1.1)
|
||||
assert.equal(result.state, 'success')
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
|
||||
terminal/ persistent terminals
|
||||
ptc-runtime/ PTC execution
|
||||
sandbox/ process confinement
|
||||
deliverables/ turn deliverables
|
||||
fs/ filesystem access
|
||||
lsp/ language servers
|
||||
skill/ skill loading
|
||||
|
||||
@@ -110,6 +110,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
| [`semver`](https://github.com/npm/node-semver) | ISC |
|
||||
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
|
||||
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
|
||||
| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
|
||||
| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
|
||||
| [`tsx`](https://github.com/privatenumber/tsx) | MIT |
|
||||
| [`turndown`](https://github.com/mixmark-io/turndown) | MIT |
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Opt-in Web composition for inspecting the self-referential Cordis tools.
|
||||
# Temporary Plugin code can reach every injected live capability; treat this
|
||||
# deployment like shell access, not as a security boundary.
|
||||
# This file is a PATCH OVERLAY over the web profile (dsh-base + dsh-web-app
|
||||
# bundle layers), not a tree: `dsh web --patch` applies it as one more sibling
|
||||
# patch list at the same include level, so these patches reach every bundle
|
||||
# row. A patch replaces the targeted row's whole `config`.
|
||||
|
||||
# Pinning the port here keeps this demo off the default 3080.
|
||||
- id: webserver
|
||||
config:
|
||||
host: 127.0.0.1
|
||||
port: 3081
|
||||
|
||||
- insert:
|
||||
- id: cordis-host-runner
|
||||
name: '@deepseek-ai/dsh-cordis-host-runner'
|
||||
- id: tool-cordis
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
@@ -235,6 +235,8 @@ export interface RunProfileOptions {
|
||||
patchFiles: readonly string[]
|
||||
/** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
|
||||
args: readonly string[]
|
||||
/** Application-owned package runtime, scoped to plugin package operations. */
|
||||
packageManager?: ProfileContext['packageManager']
|
||||
/** Module fallback backend; pkg executables always use runtime resolution. */
|
||||
resolutionMode?: ProfileResolutionMode
|
||||
}
|
||||
@@ -293,6 +295,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
|
||||
const profileContext: ProfileContext = {
|
||||
name: options.profile,
|
||||
...(options.packageManager === undefined ? {} : { packageManager: options.packageManager }),
|
||||
dir: composed.profile.dir, patchPath: composed.profile.patchPath,
|
||||
installAnchor: options.resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,
|
||||
startedBundles: composed.profile.layers.map(layer => layer.packageName),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { fork } from 'node:child_process'
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { finished } from 'node:stream/promises'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
@@ -33,8 +33,8 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
|
||||
writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
|
||||
writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
|
||||
import { writeFileSync } from 'node:fs';
|
||||
export function runProfile() {
|
||||
process.send({ type: 'booting' });
|
||||
export function runProfile(options) {
|
||||
process.send({ type: 'booting', packageManager: options.packageManager });
|
||||
return new Promise((resolve, reject) => process.once('disconnect', () => {
|
||||
if (${String(fail)}) { reject(new Error('fixture boot failure')); return; }
|
||||
resolve({ ctx: { plugin: async () => {}, connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
|
||||
@@ -44,7 +44,9 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
|
||||
`)
|
||||
const entry = join(root, 'index.js')
|
||||
copyFileSync(join(hostDirectory, 'lib', 'index.js'), entry)
|
||||
const child = fork(entry, [root, root], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
const pnpm = join(root, 'bundled-pnpm.mjs')
|
||||
const nodeBin = join(root, 'bin')
|
||||
const child = fork(entry, [root, root, root, 'runtime', pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
let stderr = ''
|
||||
child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
|
||||
const exited = new Promise<number | null>(resolve => child.once('exit', resolve))
|
||||
@@ -55,11 +57,17 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('message', () => { resolve() })
|
||||
const boot = await new Promise<{
|
||||
packageManager: { command: string; args: string[]; env: Record<string, string> }
|
||||
}>((resolve, reject) => {
|
||||
child.once('message', resolve)
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code) => { reject(new Error(`Host exited before booting: ${String(code)} ${stderr}`)) })
|
||||
})
|
||||
expect(boot.packageManager.command).toBe(process.execPath)
|
||||
expect(boot.packageManager.args).toEqual(['--expose-internals', pnpm])
|
||||
expect(boot.packageManager.env.ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
expect(boot.packageManager.env.PATH).toBe(`${nodeBin}${delimiter}${process.env.PATH ?? ''}`)
|
||||
child.disconnect()
|
||||
expect(await exited).toBe(fail ? 1 : 0)
|
||||
await drained
|
||||
|
||||
@@ -277,7 +277,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
}, 15_000)
|
||||
|
||||
it('uses versioned Cordis DTO ids directly for running and pending Plugins, then confirms removal', async () => {
|
||||
it('uses runtime inspection results directly through PTC', async () => {
|
||||
ctx = await typedPtcModeHarness()
|
||||
await ctx.plugin(CordisHostRunner)
|
||||
await ctx.plugin(ToolCordis)
|
||||
@@ -287,70 +287,15 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
|
||||
} as unknown as Agent
|
||||
|
||||
const value = completion(await runCode(ctx, `
|
||||
const activeDefinition = await tools.cordis_define({
|
||||
plugin: { kind: 'new', idPrefix: 'active' },
|
||||
name: 'active-ptc-plugin',
|
||||
purpose: 'prove an active Host half',
|
||||
code: { host: "return { name: 'active-ptc-plugin', apply(ctx) {} }" },
|
||||
const listed = await tools.cordis_inspect_list({});
|
||||
const provider = listed.providers.find(item => item.id === 'Tool');
|
||||
const inspected = await tools.cordis_inspect_query({
|
||||
platform: provider.platform, provider: provider.id, method: provider.methods[0].name,
|
||||
});
|
||||
const active = await tools.cordis_run({
|
||||
pluginId: activeDefinition.pluginId,
|
||||
packageId: activeDefinition.packageId,
|
||||
mode: 'run',
|
||||
});
|
||||
const pendingDefinition = await tools.cordis_define({
|
||||
plugin: { kind: 'new', idPrefix: 'queue' },
|
||||
name: 'pending-ptc-plugin',
|
||||
purpose: 'prove a Host half waiting for a Service',
|
||||
code: { host: "return { name: 'pending-ptc-plugin', inject: ['missing-ptc-service'], apply(ctx) {} }" },
|
||||
});
|
||||
const pending = await tools.cordis_run({
|
||||
pluginId: pendingDefinition.pluginId,
|
||||
packageId: pendingDefinition.packageId,
|
||||
mode: 'run',
|
||||
});
|
||||
const before = await tools.cordis_inspect_self({});
|
||||
const removed = await tools.cordis_undefine({ pluginId: active.pluginId });
|
||||
const after = await tools.cordis_inspect_self({});
|
||||
await tools.cordis_undefine({ pluginId: pending.pluginId });
|
||||
return {
|
||||
active: {
|
||||
pluginId: active.pluginId,
|
||||
packageId: active.packageId,
|
||||
pluginRunId: active.pluginRunId,
|
||||
status: active.host.status,
|
||||
},
|
||||
pending: {
|
||||
pluginId: pending.pluginId,
|
||||
packageId: pending.packageId,
|
||||
pluginRunId: pending.pluginRunId,
|
||||
status: pending.host.status,
|
||||
waitingFor: pending.host.waitingFor,
|
||||
},
|
||||
removed,
|
||||
beforeContainsId: before.plugins.some(plugin => plugin.pluginId === active.pluginId),
|
||||
afterContainsId: after.plugins.some(plugin => plugin.pluginId === active.pluginId),
|
||||
};
|
||||
return { provider: provider.id, names: inspected.data.tools.map(tool => tool.name) };
|
||||
`, testToolSignal, agent))
|
||||
|
||||
expect(value).toEqual({
|
||||
active: {
|
||||
pluginId: 'active-1',
|
||||
packageId: 'pkg-1',
|
||||
pluginRunId: 'run-1',
|
||||
status: 'running',
|
||||
},
|
||||
pending: {
|
||||
pluginId: 'queue-2',
|
||||
packageId: 'pkg-2',
|
||||
pluginRunId: 'run-2',
|
||||
status: 'waiting',
|
||||
waitingFor: ['missing-ptc-service'],
|
||||
},
|
||||
removed: { pluginId: 'active-1', wasRunning: true },
|
||||
beforeContainsId: true,
|
||||
afterContainsId: false,
|
||||
})
|
||||
expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query', 'run_code'] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/** Built Web profile: MCP reaches existing/new Creator sessions and survives a process restart. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { expect, it } from 'vitest'
|
||||
import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
|
||||
import { startHttpMcpFixture } from '../../../../../../packages/mcp/mcp-client/tests/http-fixture.ts'
|
||||
|
||||
interface Observation {
|
||||
approvals: string[]
|
||||
permission: string
|
||||
before: string[]
|
||||
denied: { isError: boolean; content: unknown }
|
||||
afterDenied: string[]
|
||||
bundlesAfterDenied: { name: string }[]
|
||||
after: string[]
|
||||
other: string[]
|
||||
remaining: string[]
|
||||
result: unknown
|
||||
ping: unknown
|
||||
removed?: unknown
|
||||
}
|
||||
|
||||
const repo = fileURLToPath(new URL('../../../../../../', import.meta.url))
|
||||
|
||||
it('configures MCP on a live profile, restores it on restart, and removes its tools', async (test) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'creator-manager-'))
|
||||
test.onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const mcp = await startHttpMcpFixture()
|
||||
test.onTestFinished(mcp.close)
|
||||
await mkdir(join(root, 'workspace'))
|
||||
const bundle = join(root, 'demo-mcp')
|
||||
await mkdir(bundle)
|
||||
await writeFile(join(bundle, 'package.json'), JSON.stringify({ name: '@test/creator-mcp', version: '1.0.0',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } } }))
|
||||
await writeFile(join(bundle, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'demo',
|
||||
name: '@deepseek-ai/dsh-mcp-client', config: { serverName: 'demo', transport: 'streamable-http',
|
||||
url: mcp.url, failOnStartupError: true },
|
||||
}] }]))
|
||||
const patch = join(root, 'test.patch.yml')
|
||||
await writeFile(patch, JSON.stringify([{ insert: [{ id: 'creator-manager-observer',
|
||||
name: new URL('./fixtures/creator-plugin-manager.mjs', import.meta.url).href, config: { bundle },
|
||||
}] }]))
|
||||
const start = async () => {
|
||||
const child = spawn(process.execPath, [join(repo, 'apps/cli/lib/bin.js'), '--profile', 'web', '--patch', patch,
|
||||
'--port', '0', '--no-open'], { cwd: join(root, 'workspace'),
|
||||
env: { ...process.env, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-model-calls' },
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
})
|
||||
let output = ''
|
||||
const completion = new Promise<void>((resolve, reject) => {
|
||||
child.once('close', () => { resolve() })
|
||||
child.once('error', reject)
|
||||
})
|
||||
const stop = async () => { if (child.exitCode === null) child.kill('SIGTERM'); await completion }
|
||||
test.onTestFinished(stop)
|
||||
for (const stream of [child.stdout, child.stderr]) stream!.on('data', (data) => { output = (output + String(data)).slice(-30_000) })
|
||||
await expect.poll(() => {
|
||||
if (child.exitCode !== null) throw new Error(output)
|
||||
return output.includes('dsh web: http://')
|
||||
}, { timeout: 60_000 }).toBe(true)
|
||||
return { stop, request: (phase: string): Promise<Observation> => new Promise((resolve, reject) => {
|
||||
child.once('message', (value: { result: Observation; error?: string }) => {
|
||||
if (value.error !== undefined) reject(new Error(value.error))
|
||||
else resolve(value.result)
|
||||
})
|
||||
child.send(phase)
|
||||
}) }
|
||||
}
|
||||
const first = await start()
|
||||
const initial = await first.request('initial')
|
||||
expect(initial.before).toContain('plugin_manager')
|
||||
for (const retired of ['cordis_define', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'cordis_inspect_self']) {
|
||||
expect(initial.before).not.toContain(retired)
|
||||
}
|
||||
expect(initial.before).not.toContain('mcp__demo__ping')
|
||||
expect(initial.denied.isError).toBe(true)
|
||||
expect(JSON.stringify(initial.denied.content)).toContain('the user rejected escalating')
|
||||
expect(initial.afterDenied).toEqual(initial.before)
|
||||
expect(initial.bundlesAfterDenied.map(bundle => bundle.name)).not.toContain('@test/creator-mcp')
|
||||
expect(initial.approvals).toEqual(['rejected', 'allowed-once'])
|
||||
expect(initial.permission).toBe('workspace-write')
|
||||
expect(initial.result).toMatchObject({ application: 'applied', changed: true })
|
||||
expect(initial.after).toContain('mcp__demo__ping')
|
||||
expect(initial.other).toContain('mcp__demo__ping')
|
||||
expect(JSON.stringify(initial.ping)).toContain('pong')
|
||||
const saved = readProfileManifest('dsh', join(root, 'home/profiles/web'))
|
||||
expect(saved.dsh?.profile?.bundles).toContain('@test/creator-mcp')
|
||||
expect(saved.dependencies).toHaveProperty('@test/creator-mcp')
|
||||
await first.stop()
|
||||
const second = await start()
|
||||
const restarted = await second.request('restart')
|
||||
expect(restarted.result).toEqual(expect.arrayContaining([expect.objectContaining({ name: '@test/creator-mcp' })]))
|
||||
expect(restarted.approvals).toEqual(['rejected', 'allowed-once'])
|
||||
expect(restarted.permission).toBe('workspace-write')
|
||||
expect(restarted.before).toContain('mcp__demo__ping')
|
||||
expect(JSON.stringify(restarted.ping)).toContain('pong')
|
||||
expect(restarted.removed).toMatchObject({ application: 'applied', changed: true })
|
||||
expect(restarted.remaining).not.toContain('mcp__demo__ping')
|
||||
expect(mcp.calls).toEqual(['ping', 'ping'])
|
||||
await second.stop()
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Test-only IPC assertions over real Creator presets and profile management. */
|
||||
export const inject = ['agents', 'agentPresets', 'tools', 'pluginManager', 'permissionPresets']
|
||||
|
||||
export function apply(ctx, config) {
|
||||
const receive = message => {
|
||||
if (message !== 'initial' && message !== 'restart') return
|
||||
void inspect(message).then(result => process.send({ result }), error => process.send({ error: String(error.stack ?? error) }))
|
||||
}
|
||||
ctx.effect(() => {
|
||||
process.on('message', receive)
|
||||
return () => process.off('message', receive)
|
||||
})
|
||||
async function inspect(phase) {
|
||||
const handles = []
|
||||
let activeSession
|
||||
let answer = 'rejected'
|
||||
const approvals = []
|
||||
const disposeApproval = ctx.on('approval/request', (request, next) => {
|
||||
if (request.toolName !== 'plugin_manager') return next()
|
||||
approvals.push(answer)
|
||||
return Promise.resolve(answer)
|
||||
}, { prepend: true })
|
||||
const make = async id => {
|
||||
const handle = await ctx.agents.create({ sessionId: id, cwd: process.cwd(),
|
||||
setup: scope => ctx.agentPresets.mount(scope, 'cordis').then(() => undefined) })
|
||||
handles.push(handle)
|
||||
return handle.agent
|
||||
}
|
||||
const names = agent => ctx.tools.schemas(agent).map(tool => tool.name)
|
||||
try {
|
||||
const first = await make(`${phase}-first`)
|
||||
const before = names(first)
|
||||
const manage = args => ctx.tools.execute({ name: 'plugin_manager', arguments: args, agent: first,
|
||||
callId: `${phase}-manage`, signal: new AbortController().signal })
|
||||
ctx.permissionPresets.set(first.session, 'workspace-write')
|
||||
first.session.append('turn/start', { turn: 1 })
|
||||
activeSession = first.session
|
||||
const denied = await manage({ action: 'install_bundle', target: config.bundle })
|
||||
const afterDenied = names(first)
|
||||
const bundlesAfterDenied = await ctx.pluginManager.listBundles()
|
||||
answer = 'allowed-once'
|
||||
const result = phase === 'initial'
|
||||
? JSON.parse((await manage({ action: 'install_bundle', target: config.bundle })).value)
|
||||
: await ctx.pluginManager.listBundles()
|
||||
const second = await make(`${phase}-second`)
|
||||
const after = names(first)
|
||||
const other = names(second)
|
||||
const ping = await ctx.tools.execute({ name: 'mcp__demo__ping', arguments: {}, agent: first,
|
||||
callId: `${phase}-ping`, signal: new AbortController().signal })
|
||||
const removed = phase === 'restart'
|
||||
? JSON.parse((await manage({ action: 'remove_bundle', target: '@test/creator-mcp' })).value) : undefined
|
||||
return { approvals, permission: ctx.permissionPresets.current(first.session), before, denied, afterDenied, bundlesAfterDenied, result, after, other, ping, removed, remaining: names(first) }
|
||||
} finally {
|
||||
disposeApproval()
|
||||
activeSession?.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await Promise.all(handles.map(handle => handle.dispose()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
boot,
|
||||
initProfile,
|
||||
createProfileResolutionGeneration,
|
||||
loadOverlayPatches,
|
||||
loadProfile,
|
||||
@@ -79,6 +80,8 @@ async function bootWeb(
|
||||
// moved into the presets that a host row still waits for. The boot audit
|
||||
// is that assertion.
|
||||
{ id: 'webserver', disabled: true },
|
||||
// This composition has no application readiness or file-watching lifecycle.
|
||||
{ id: 'hmr', disabled: true },
|
||||
// The web bundle's runtime row injects `webServer`, so it cannot
|
||||
// activate without the bound port disabled above. It owns dist serving
|
||||
// and the URL prompt line — surface glue, not anything that decides an
|
||||
@@ -122,6 +125,7 @@ async function bootWeb(
|
||||
const home = dirname(settingsFile)
|
||||
const profileDir = join(home, 'profiles', 'spec')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
if (profileBundles === undefined) initProfile(profileDir, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
|
||||
// Product Bundles are installed into the Profile, not the dsh app. Model
|
||||
// pnpm's package link for only the selected products; their own production
|
||||
// dependencies resolve from the linked workspace packages, while shared
|
||||
@@ -156,6 +160,10 @@ async function bootWeb(
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
await writeFile(rootConfig, '[]\n')
|
||||
return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], async (bootCtx) => {
|
||||
bootCtx.provide('profileContext', { name: 'spec', dir: profileDir, patchPath: profile.patchPath,
|
||||
installAnchor: INSTALL_ANCHOR, home, cwd: home,
|
||||
startedBundles: profileBundles ?? ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
|
||||
overlays: overrides, telemetryDisabledEnv: '1' })
|
||||
await bootCtx.plugin(PluginPackages, { generation: resolution })
|
||||
bootCtx.provide('connection', {
|
||||
fetch: { register: () => () => {} },
|
||||
@@ -351,12 +359,12 @@ describe('the shipped Web composition', () => {
|
||||
})
|
||||
try {
|
||||
const tools = toolNames(ctx, handle.agent)
|
||||
// The self-referential toolset is what distinguishes this preset.
|
||||
expect(tools).toEqual(expect.arrayContaining([
|
||||
'cordis_inspect_list', 'cordis_inspect_query', 'cordis_inspect_self',
|
||||
'cordis_define', 'cordis_run', 'cordis_stop', 'cordis_undefine',
|
||||
'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager',
|
||||
]))
|
||||
// And it keeps the standard agent's own tools rather than replacing them.
|
||||
for (const removed of ['cordis_define', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'cordis_inspect_self']) {
|
||||
expect(tools).not.toContain(removed)
|
||||
}
|
||||
expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
|
||||
expect(tools).not.toContain('str_replace_editor')
|
||||
expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Launch the Desktop profile through the Web application and report its URL to Electron. */
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
|
||||
import { runProfile } from '@deepseek-ai/dsh/profile-boot'
|
||||
import type {} from '@deepseek-ai/dsh-client-connection'
|
||||
@@ -20,6 +20,17 @@ async function main(): Promise<void> {
|
||||
resolvedProfile: { profile, installAnchor },
|
||||
patchFiles: [],
|
||||
args: ['--no-open', '--port', '19387'],
|
||||
...(process.argv[6] === undefined ? {} : {
|
||||
packageManager: {
|
||||
command: process.execPath,
|
||||
args: ['--expose-internals', process.argv[6]],
|
||||
env: {
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
DSH_DESKTOP_NODE_EXECUTABLE: process.execPath,
|
||||
PATH: `${process.argv[7] ?? ''}${delimiter}${process.env.PATH ?? ''}`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
const stop = async (): Promise<void> => {
|
||||
// Startup failure is reported by main; shutdown only owns a tree that booted.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: d19304ae123aefe57af649c5d6a02c4db867314e
|
||||
README.zh.md: 3d5b84c23701c4915fb1983fd6363c1ce2dc3059
|
||||
README.md: 53ae9eed92171bfbdb7263a402b4306d4c0df049
|
||||
README.zh.md: fb129940c3fb81461e0d587a79d9ebded8a2335a
|
||||
@@ -6,6 +6,8 @@ The desktop application is an Electron shell around the complete dsh Web applica
|
||||
|
||||
Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
|
||||
|
||||
Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations.
|
||||
|
||||
## Key technical decisions
|
||||
|
||||
The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
|
||||
@@ -62,11 +64,11 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
|
||||
|
||||
CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
|
||||
|
||||
Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable all third-party plugins and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
|
||||
Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
|
||||
|
||||
Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
|
||||
|
||||
Recovery waits for Host shutdown before changing plugin activation. The native recovery action disables third-party bundles by writing the profile under its transaction lock without loading runtime metadata or deleting files. Invalid profile data or write failures are reported as recovery-operation errors; Desktop does not restart as though disabling succeeded. Desktop has no profile-reset action or emergency HTML document.
|
||||
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
|
||||
|
||||
Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
|
||||
|
||||
Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。
|
||||
|
||||
## 关键技术决策
|
||||
|
||||
设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
|
||||
@@ -62,11 +64,11 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
|
||||
|
||||
CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
|
||||
|
||||
主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
|
||||
主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
|
||||
|
||||
原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作禁用第三方 bundle 时持有事务锁写入 profile,不加载运行时元数据,也不删除文件。profile 数据无效或写入失败会作为恢复操作错误报告;Desktop 不会假装禁用成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export class DesktopHostProcess {
|
||||
* @param onFailure - Receives the first unexpected child failure, including after readiness.
|
||||
* @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling
|
||||
* `office-skills` resources fail Host startup.
|
||||
* @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.
|
||||
* @param profileResolution - Package resolution mode for the application-owned profile.
|
||||
*/
|
||||
constructor(
|
||||
@@ -85,6 +86,7 @@ export class DesktopHostProcess {
|
||||
private readonly onFailure?: (error: Error) => void,
|
||||
private readonly primaryRuntime?: string,
|
||||
private readonly profileResolution: 'link' | 'runtime' = 'link',
|
||||
private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -102,6 +104,7 @@ export class DesktopHostProcess {
|
||||
this.projectDir,
|
||||
this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'),
|
||||
this.profileResolution,
|
||||
...this.packageManager === undefined ? [] : [this.packageManager.pnpm, this.packageManager.nodeBin],
|
||||
], {
|
||||
cwd: this.projectDir,
|
||||
env: desktopNodeEnvironment(this.node, undefined, this.environment),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const en = {
|
||||
exitApplication: 'Exit',
|
||||
restartApplication: 'Restart',
|
||||
recoveryOperationFailed: 'The recovery operation failed',
|
||||
disableThirdPartyPlugins: 'Disable all third-party plugins and restart',
|
||||
disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart',
|
||||
pluginsMenu: 'Desktop Plugins…',
|
||||
checkUpdatesMenu: 'Check for Updates…',
|
||||
updateCheckFailedTitle: 'Update Check Failed',
|
||||
@@ -59,7 +59,7 @@ export const zh = {
|
||||
exitApplication: '退出',
|
||||
restartApplication: '重启',
|
||||
recoveryOperationFailed: '恢复操作失败',
|
||||
disableThirdPartyPlugins: '禁用全部第三方插件并重启',
|
||||
disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启',
|
||||
pluginsMenu: '桌面插件…',
|
||||
checkUpdatesMenu: '检查更新…',
|
||||
updateCheckFailedTitle: '更新检查失败',
|
||||
|
||||
@@ -37,7 +37,8 @@ const recovery = new DesktopFatalRecovery({
|
||||
stop: () => { shuttingDown = true; return stopForRecovery() },
|
||||
disablePlugins: async () => {
|
||||
const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources())
|
||||
await manager.disableAllPlugins()
|
||||
const backupPath = await manager.disableAllPlugins()
|
||||
console.info('Desktop profile recovery completed:', { profilePatchBackup: backupPath ?? null, homePatch: 'unchanged' })
|
||||
},
|
||||
exit: () => { app.quit() },
|
||||
restart: () => { app.relaunch(); app.quit() },
|
||||
@@ -219,7 +220,7 @@ async function main(): Promise<void> {
|
||||
hostInspectPort, process.env, onFailure,
|
||||
development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
|
||||
: join(process.resourcesPath, 'runtime', 'primary-runtime'),
|
||||
development ? 'link' : 'runtime')
|
||||
development ? 'link' : 'runtime', resources)
|
||||
return {
|
||||
start: async () => {
|
||||
const ready = await host.start()
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { DesktopPaths } from './paths.ts'
|
||||
import type { DesktopRelease } from './release.ts'
|
||||
import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
|
||||
import {
|
||||
initProfile, PROFILE_TEMPLATES, readProfileManifest, readProfilePlugins, reconcileProfilePlugins,
|
||||
initProfile, PROFILE_TEMPLATES, readProfilePlugins, reconcileProfilePlugins, sanitizeProfile,
|
||||
unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { migrateDesktopProfileLinks } from './profile-packages.ts'
|
||||
@@ -122,14 +122,12 @@ export class DesktopProjectManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable third-party bundles without loading application resources or deleting plugin files.
|
||||
* @returns Completion of the locked profile write; the caller must stop the Host first.
|
||||
* Back up the profile patch and disable third-party bundles without loading application resources.
|
||||
* The caller must stop the Host first.
|
||||
* @returns Backup path after the locked profile write, or undefined if the patch was absent.
|
||||
*/
|
||||
async disableAllPlugins(): Promise<void> {
|
||||
await this.withLock(() => {
|
||||
if (!existsSync(join(this.paths.profile, 'package.json'))) return
|
||||
writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles)
|
||||
})
|
||||
async disableAllPlugins(): Promise<string | undefined> {
|
||||
return this.withLock(() => sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles))
|
||||
}
|
||||
|
||||
/** Read the dsh version supplied by this application's verified resources. */
|
||||
|
||||
@@ -6,4 +6,4 @@ Plugin initialization failed
|
||||
If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
|
||||
Exit
|
||||
Restart
|
||||
Disable all third-party plugins and restart
|
||||
Disable third-party plugins, back up profile patch, and restart
|
||||
@@ -6,4 +6,4 @@ Plugin initialization failed
|
||||
如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
|
||||
退出
|
||||
重启
|
||||
禁用全部第三方插件并重启
|
||||
禁用第三方插件、备份 profile patch 并重启
|
||||
@@ -79,13 +79,13 @@ describe('desktop host process', () => {
|
||||
|
||||
it('passes external dependencies and runtime profile resolution to the Host', async () => {
|
||||
const runtime = projectWithHost(HTTP_HOST.replace('runtime: process.argv[2]',
|
||||
'primaryRuntime: process.argv[4], profileResolution: process.argv[5], runtime: process.argv[2]'))
|
||||
'pnpm: process.argv[6], nodeBin: process.argv[7], primaryRuntime: process.argv[4], profileResolution: process.argv[5], runtime: process.argv[2]'))
|
||||
const primaryRuntime = join(runtime, 'external-primary-runtime')
|
||||
const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env,
|
||||
undefined, primaryRuntime, 'runtime')
|
||||
undefined, primaryRuntime, 'runtime', { pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
|
||||
hosts.push(host)
|
||||
const { url } = await host.start()
|
||||
expect(await (await fetch(url)).json()).toMatchObject({ primaryRuntime, profileResolution: 'runtime' })
|
||||
expect(await (await fetch(url)).json()).toMatchObject({ primaryRuntime, profileResolution: 'runtime', pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
|
||||
})
|
||||
|
||||
it('reports a fatal event after readiness once', async () => {
|
||||
|
||||
@@ -64,6 +64,7 @@ const harness = await vi.hoisted(async () => {
|
||||
readonly node: string, readonly runtime: string, readonly profile: string,
|
||||
readonly inspectPort?: number, readonly environment?: NodeJS.ProcessEnv, readonly onFailure?: (error: Error) => void,
|
||||
readonly primaryRuntime?: string, readonly profileResolution?: string,
|
||||
readonly packageManager?: { pnpm: string; nodeBin: string },
|
||||
) { hosts.push(this) }
|
||||
}
|
||||
const app = Object.assign(new EventEmitter(), {
|
||||
@@ -97,7 +98,10 @@ const harness = await vi.hoisted(async () => {
|
||||
openExternal: vi.fn(),
|
||||
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
|
||||
mutateFailure: vi.fn<() => void>(),
|
||||
disableAllPlugins: vi.fn(async () => { pluginsEnabled = false }),
|
||||
disableAllPlugins: vi.fn(async () => {
|
||||
pluginsEnabled = false
|
||||
return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000'
|
||||
}),
|
||||
get preparing() { return preparing }, get prepared() { return prepared },
|
||||
get hostStarted() { return hostStarted }, get navigated() { return navigated },
|
||||
get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted },
|
||||
@@ -167,6 +171,7 @@ beforeEach(() => {
|
||||
harness.reset()
|
||||
harness.dialog.showMessageBox.mockImplementation(() => { harness.dialogShown.resolve(); return new Promise(() => {}) })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
|
||||
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
|
||||
vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
|
||||
@@ -392,7 +397,7 @@ describe('desktop main startup', () => {
|
||||
harness.prepared.reject(new Error('runtime resources missing'))
|
||||
await harness.dialogShown.promise
|
||||
expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('runtime resources missing')
|
||||
expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable all third-party plugins and restart'])
|
||||
expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart'])
|
||||
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
||||
})
|
||||
|
||||
@@ -448,6 +453,13 @@ describe('desktop main startup', () => {
|
||||
await harness.quitCompleted.promise
|
||||
expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
|
||||
expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
|
||||
if (response === 2) {
|
||||
expect(console.info).toHaveBeenCalledWith('Desktop profile recovery completed:', {
|
||||
profilePatchBackup: 'desktop-test-profile/cordis.patch.yml.bak-1789555200000', homePatch: 'unchanged',
|
||||
})
|
||||
} else {
|
||||
expect(console.info).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
||||
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -109,15 +109,19 @@ describe('desktop external plugin profile', () => {
|
||||
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables plugins before runtime initialization and preserves configuration and package files', async () => {
|
||||
it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => {
|
||||
const { manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
const patch = join(manager.paths.profile, 'cordis.patch.yml')
|
||||
writeFileSync(patch, ': broken')
|
||||
const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
|
||||
await uninitialized.disableAllPlugins()
|
||||
expect(readFileSync(patch, 'utf8')).toBe(': broken')
|
||||
const backupPath = await uninitialized.disableAllPlugins()
|
||||
expect(existsSync(patch)).toBe(false)
|
||||
const backups = readdirSync(manager.paths.profile).filter(name => name.startsWith('cordis.patch.yml.bak-'))
|
||||
expect(backups).toHaveLength(1)
|
||||
expect(backupPath).toBe(join(manager.paths.profile, backups[0]!))
|
||||
expect(readFileSync(join(manager.paths.profile, backups[0]!), 'utf8')).toBe(': broken')
|
||||
expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
|
||||
const manifest = JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
|
||||
dependencies: Record<string, string>
|
||||
@@ -126,11 +130,13 @@ describe('desktop external plugin profile', () => {
|
||||
expect(manifest.dependencies.plugin).toBe('1.0.0')
|
||||
expect(manifest.dsh.profile.bundles).not.toContain('plugin')
|
||||
expect(manifest.dsh.profile.bundles).toContain('@deepseek-ai/dsh-web-app')
|
||||
await manager.applyRelease()
|
||||
expect(readFileSync(patch, 'utf8')).toContain('[]')
|
||||
})
|
||||
|
||||
it('needs no runtime or package manifest when no plugins have been installed', async () => {
|
||||
const { manager } = setup()
|
||||
await manager.disableAllPlugins()
|
||||
await expect(manager.disableAllPlugins()).resolves.toBeUndefined()
|
||||
expect(existsSync(join(manager.paths.profile, 'package.json'))).toBe(false)
|
||||
expect(existsSync(manager.paths.lock)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"vitest": "^4.1.8",
|
||||
"ws": "8.21.0",
|
||||
"@deepseek-ai/dsh-launch-environment": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-changes": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-experimental-auto-review": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: OVERLAY,
|
||||
profile: { packages: [] },
|
||||
agentPresets: {
|
||||
// The shipped root is the plugin's own, prepended before this.
|
||||
roots: [{ path: userRoot, trust: 'user' }],
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card; its rows open the review. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type {} from '@deepseek-ai/dsh-workspace-changes'
|
||||
import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import {
|
||||
assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden,
|
||||
fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url))
|
||||
const FIXTURE = join(DIR, 'session.v3.jsonl')
|
||||
const MODE = webSnapshotMode()
|
||||
const PROMPT = '不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。'
|
||||
|
||||
/** Seed a committed repository so the turn's own edits are the only difference between its snapshots; `*.local` stays ignored. */
|
||||
async function seedRepository(cwd: string): Promise<void> {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
await writeFile(join(cwd, 'intro.md'), '# 示例项目\n\n一个用于演示的仓库。\n')
|
||||
await writeFile(join(cwd, 'notes.txt'), 'start\n')
|
||||
await writeFile(join(cwd, '.gitignore'), '*.local\n')
|
||||
const git = (...args: string[]) => execFileSync('git', ['-c', '[email protected]', '-c', 'user.name=seed', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'ignore' })
|
||||
git('init', '-q', '-b', 'main')
|
||||
git('add', '-A')
|
||||
git('commit', '-q', '-m', 'seed')
|
||||
}
|
||||
|
||||
describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let cwd: string
|
||||
let replayRoot: string | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
let replayOverride: string | undefined
|
||||
if (MODE !== 'record') {
|
||||
replayRoot = await mkdtemp(join(tmpdir(), 'dsh-changed-files-turn-replay-'))
|
||||
replayOverride = join(replayRoot, 'replay.override.json')
|
||||
const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
|
||||
// Recorded absolute paths must follow each isolated Session's working directory.
|
||||
const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}'
|
||||
await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1)))
|
||||
}
|
||||
scaffold = await launchWebScaffold({
|
||||
compareReplaySession: true,
|
||||
extraOverlayPath: fileURLToPath(new URL('./changed-files-turn.overlay.yml', import.meta.url)),
|
||||
...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }),
|
||||
})
|
||||
await seedRepository(join(scaffold.workspaceCwd, 'workspace'))
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({
|
||||
viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]')
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
try {
|
||||
await scaffold?.close()
|
||||
} finally {
|
||||
if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('records the edited, created, and shell-appended files with their line counts', async () => {
|
||||
if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
const session = scaffold.ctx.agents.get(sessionId)?.session
|
||||
if (session?.header.cwd === undefined) throw new Error('changed-files Session has no workspace')
|
||||
cwd = session.header.cwd
|
||||
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
|
||||
const announced = session.snapshotEvents().filter(event => event.type === 'workspace/changes').at(-1)
|
||||
expect(announced, 'the turn must announce its changed files').toBeDefined()
|
||||
if (announced === undefined) throw new Error('no changed-files announcement')
|
||||
expect(announced.data).toEqual({ turn: 1 })
|
||||
// The log carries only the turn; the Host serves the summary for the announcing event while the Session lives.
|
||||
const summary = scaffold.ctx.workspaceChanges.summary(sessionId, announced.seq)
|
||||
if (summary === undefined) throw new Error('the Host serves no summary for the announcement')
|
||||
// app.local is ignored by the repository, so its counts come from the write call rather than git.
|
||||
expect(summary.files.map(file => file.display)).toEqual(['app.local', 'intro.md', 'notes.txt', 'src/util.ts'])
|
||||
expect(summary.total).toBe(4)
|
||||
for (const file of summary.files) expect(file.added).toBeGreaterThan(0)
|
||||
expect(summary.files[0]).toMatchObject({ path: 'app.local', added: 1, deleted: 0 })
|
||||
expect(summary.files[2]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 })
|
||||
expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n')
|
||||
|
||||
const card = page.locator('[data-changed-files]')
|
||||
await card.waitFor({ state: 'visible' })
|
||||
expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1)
|
||||
expect(await card.getByRole('listitem').count()).toBe(3)
|
||||
expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1)
|
||||
// The header and every row open the turn's review in the Sidebar, with or without a Host desktop.
|
||||
expect(await card.getByRole('button', { name: '在侧边栏查看本轮改动' }).count()).toBe(1)
|
||||
expect(await card.getByRole('button', { name: '查看 notes.txt 的改动' }).count()).toBe(1)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('reviews a shell-appended file from the snapshots and an ignored file from its captured copies in one tab', async () => {
|
||||
const card = page.locator('[data-changed-files]')
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
const drawn = (root: ReturnType<typeof column.locator>) =>
|
||||
root.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`))
|
||||
const review = column.locator('[data-changes-review]')
|
||||
// The header lands on the first listed file; a row lands on its own.
|
||||
await card.getByRole('button', { name: '在侧边栏查看本轮改动' }).click()
|
||||
await review.locator('[data-review-file="app.local"]').waitFor({ state: 'visible' })
|
||||
await card.getByRole('button', { name: '查看 notes.txt 的改动' }).click()
|
||||
await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' })
|
||||
expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1)
|
||||
await expect.poll(() => drawn(review)).toEqual(['context:11 start', 'add:2+done'])
|
||||
// The ignored file has no snapshot; its comparison comes from the copies captured around the write call.
|
||||
await review.getByRole('button', { name: '选择要查看的文件' }).click()
|
||||
await page.getByRole('menuitem').filter({ hasText: 'app.local' }).click()
|
||||
await review.locator('[data-review-file="app.local"]').waitFor({ state: 'visible' })
|
||||
await expect.poll(() => drawn(review)).toEqual(['add:1+mode=demo'])
|
||||
expect(await review.getByText('本轮新建的文件').count()).toBe(1)
|
||||
// A card row opens the same tab on another file; the split and wrap choices switch the drawing.
|
||||
await card.getByRole('button', { name: '查看 intro.md 的改动' }).click()
|
||||
await review.locator('[data-review-file="intro.md"]').waitFor({ state: 'visible' })
|
||||
expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1)
|
||||
await review.getByRole('button', { name: '左右对比' }).click()
|
||||
await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' })
|
||||
await expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['del:1# 示例项目', 'context:2', 'context:3一个用于演示的仓库。'])
|
||||
expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['del:1# 项目说明', 'context:2', 'context:3一个用于演示的仓库。'])
|
||||
await review.getByRole('button', { name: '自动换行' }).click()
|
||||
await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' })
|
||||
await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。'])
|
||||
// No desktop, so the tools offer the sidebar file but no native open.
|
||||
expect(await review.locator('[data-review-tool="open-file"]').count()).toBe(1)
|
||||
expect(await review.locator('[data-review-tool="open-native"]').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('replays the workspace and the Chinese conversation', async () => {
|
||||
await assertFinalWorkspaceSnapshot(DIR, cwd, { ignoredRootEntries: ['.git'] })
|
||||
const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
# This transcript includes the no-desktop state on every test platform, so the
|
||||
# card header stays a label and rows preview in the Sidebar.
|
||||
- id: session-controller
|
||||
config:
|
||||
nativeOpen: false
|
||||
@@ -2,7 +2,8 @@
|
||||
// in one settled keyless turn — the regression anchor for unifying link
|
||||
// styles. One fixture turn produces:
|
||||
// - prose: Markdown link, reference-style link, mailto link, inline-code URL,
|
||||
// produced-file mention, plus inert contrasts (ambiguous basename, unwritten
|
||||
// produced-file mention, a known-site link carrying that site's own leading
|
||||
// mark, plus inert contrasts (ambiguous basename, unwritten
|
||||
// file, command code, URL-with-flags code, javascript: destination,
|
||||
// footnote superscript, remote image, fenced code block with its copy chrome)
|
||||
// - artifacts: seven produced files (chips overflow into the "+N" remainder
|
||||
@@ -40,8 +41,8 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/clickable-links-gallery',
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui.expected.md', import.meta.url))
|
||||
// The golden holds the show-in-folder affordance; pin the native-opener
|
||||
// capability so headless Linux CI and desktop developer hosts expose the same
|
||||
// UI branch (same pin as produced-files.e2e.ts, whose overlay this shares).
|
||||
const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.meta.url))
|
||||
// UI branch.
|
||||
const OVERLAY = fileURLToPath(new URL('./clickable-links-gallery.overlay.yml', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'clickable-links-gallery-web-e2e'
|
||||
const DONE = 'LINK_GALLERY_DONE'
|
||||
@@ -55,6 +56,9 @@ const MAILTO_URL = 'mailto:[email protected]'
|
||||
const SOURCE_URL = 'https://docs.example.test/links'
|
||||
const INERT_SOURCE_URL = 'ftp://mirror.example.test/spec'
|
||||
const FETCH_URL = 'https://docs.example.test/tokens'
|
||||
// A mapped host, so the prose pins that the leading glyph is the site's mark
|
||||
// rather than the globe the unmapped docs host keeps.
|
||||
const REPO_URL = 'https://github.com/example/link-gallery'
|
||||
|
||||
/** One-part text content for a built message. */
|
||||
function text(value: string): { type: 'text'; text: string }[] {
|
||||
@@ -256,6 +260,8 @@ function galleryFixture(imageUrl: string): string {
|
||||
`Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], `
|
||||
+ `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`,
|
||||
'',
|
||||
`Upstream: [the repository](${REPO_URL}).`,
|
||||
'',
|
||||
`Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`,
|
||||
'',
|
||||
'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
|
||||
@@ -345,13 +351,12 @@ describe('web e2e: clickable links gallery', () => {
|
||||
expect(await markdown.locator(`img[src="${imageUrl}"]`).count()).toBe(1)
|
||||
|
||||
// Produced files: one unique-basename mention links; the shared basename
|
||||
// and the unwritten file stay inert code. Seven produced paths overflow
|
||||
// the chip row; the failed write joins neither surface.
|
||||
// and the unwritten file stay inert code. The seeded session carries no
|
||||
// recorded change summary, so no changed-files card follows the prose.
|
||||
const mentions = markdown.locator('code button')
|
||||
expect(await mentions.count()).toBe(1)
|
||||
expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
|
||||
expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
|
||||
expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5)
|
||||
expect(await page.locator('[data-changed-files]').count()).toBe(0)
|
||||
expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0)
|
||||
|
||||
// Tool rows: five writes, the edit, and the read carry the dotted file
|
||||
@@ -408,26 +413,30 @@ describe('web e2e: clickable links gallery', () => {
|
||||
const styleOf = async (target: ReturnType<Page['locator']>, property: string): Promise<string> =>
|
||||
target.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), property)
|
||||
const guideLink = markdown.locator(`a[href="${GUIDE_URL}"]`).first()
|
||||
const chip = page.locator('button[aria-label="Open site/report.html"]').first()
|
||||
for (const [name, link] of [
|
||||
['markdown anchor', guideLink],
|
||||
['file mention', mentions.first()],
|
||||
['search source', sourceLink.first()],
|
||||
['fetch url', page.locator(`a[href="${FETCH_URL}"]`).first()],
|
||||
['produced chip', chip],
|
||||
] as const) {
|
||||
expect.soft(await styleOf(link, 'color'), `${name} color`).toBe(LINK_BLUE)
|
||||
expect.soft(await styleOf(link, 'font-weight'), `${name} weight`).toBe('500')
|
||||
expect.soft(await styleOf(link, 'text-decoration-line'), `${name} at rest`).toBe('none')
|
||||
expect.soft(await link.locator('svg').count(), `${name} glyph`).toBe(1)
|
||||
}
|
||||
// A mapped host leads with its own mark; the unmapped docs host keeps the
|
||||
// globe in the same seat.
|
||||
const repoLink = markdown.locator(`a[href="${REPO_URL}"]`)
|
||||
expect(await repoLink.count()).toBe(1)
|
||||
const repoMark = await repoLink.locator('svg path').getAttribute('d')
|
||||
const globeMark = await guideLink.locator('svg path').getAttribute('d')
|
||||
expect(repoMark).not.toBe(globeMark)
|
||||
await guideLink.hover()
|
||||
expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline')
|
||||
expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted')
|
||||
expect(await styleOf(guideLink, 'text-underline-offset')).toBe('3px')
|
||||
await chip.hover()
|
||||
expect(await styleOf(chip, 'text-decoration-style')).toBe('dotted')
|
||||
expect(await styleOf(chip, 'background-color')).toBe('rgba(0, 0, 0, 0)')
|
||||
await mentions.first().hover()
|
||||
expect(await styleOf(mentions.first(), 'text-decoration-style')).toBe('dotted')
|
||||
// The excluded grey affordance: tool-row file links keep their own color.
|
||||
expect(await styleOf(page.locator('button[class*="fileLink"]').first(), 'color')).not.toBe(LINK_BLUE)
|
||||
}, 90_000)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# The gallery asserts native open actions without launching them. Pin the
|
||||
# capability so headless Linux CI and desktop developer hosts expose the same
|
||||
# UI branch; platform opener behavior belongs to the Host unit tests.
|
||||
- id: session-controller
|
||||
config:
|
||||
nativeOpen: true
|
||||
- id: settings-controller
|
||||
config:
|
||||
nativeOpen: true
|
||||
@@ -1,99 +1,36 @@
|
||||
// Web e2e scenario for the opt-in Cordis tools. Record mode drives a real
|
||||
// model through inspect, define, run, and stop; replay pins the same shipped Web
|
||||
// composition, durable calls, Cordis-owned rows, the define card's own source view,
|
||||
// and conversation accessibility tree.
|
||||
//
|
||||
// The approval is never in the fixture. The fixture pins what the MODEL said;
|
||||
// tools execute for real, and this test answers the approval before starting the
|
||||
// stop turn. The package therefore carries a browser half whose only
|
||||
// job is to be visible (`[data-snapshot-probe]`): its absence before the answer
|
||||
// and presence after it is the v3 user gate, proven rather than described.
|
||||
/** Historical generated-plugin cards remain readable after their tool APIs are removed. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, readPersistedEvents, parseSeedFixture, realizeSeedFixture,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.v3.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/ui.expected.md', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/cordis-history/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const CORDIS_TOOLS = ['cordis_inspect_self', 'cordis_define', 'cordis_run', 'cordis_stop'] as const
|
||||
const PACKAGE_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }'
|
||||
// The browser half is the PROBE this scenario turns on: it renders a marker into
|
||||
// the frame-wide overlay, so "did the plugin actually run in this page" becomes a
|
||||
// DOM fact. A host-only package would sidestep the approval round trip entirely
|
||||
// (the host runs those immediately), which would drop the v3 user gate out of
|
||||
// coverage — the one thing this scenario exists to prove.
|
||||
const CLIENT_CODE = 'return { inject: ["slots"], apply(ctx) { ctx.slots.register('
|
||||
+ '{ name: "shell.overlay", id: "snapshot-probe" }, '
|
||||
+ '() => React.createElement("div", { "data-snapshot-probe": "loaded" })) } }'
|
||||
const PROMPT = 'Use only Cordis tools. First call cordis_inspect_self with no arguments. '
|
||||
+ 'Then call cordis_define with plugin kind "new", idPrefix "snap", name "snapshot noop", '
|
||||
+ 'purpose "does nothing, for the snapshot", '
|
||||
+ `code.host exactly ${JSON.stringify(PACKAGE_CODE)} and code.client exactly ${JSON.stringify(CLIENT_CODE)}. `
|
||||
+ 'Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode "run". '
|
||||
+ 'After the run request returns, reply exactly CORDIS_UI_READY and stop.'
|
||||
const STOP_PROMPT = 'Use only Cordis tools. Call cordis_stop with pluginId "snap-1". '
|
||||
+ 'After it succeeds, reply exactly CORDIS_UI_DONE and stop.'
|
||||
const SEED_ID = 'cordis-history'
|
||||
|
||||
function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
|
||||
const turnEnd = events.findLast(
|
||||
(event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
|
||||
)
|
||||
const reason = turnEnd?.data.reason
|
||||
expect(reason).toEqual({ kind: 'completed' })
|
||||
|
||||
const calls = events.filter(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/call' }> => event.type === 'tool/call',
|
||||
)
|
||||
expect(calls.map(event => event.data.name)).toEqual(CORDIS_TOOLS)
|
||||
|
||||
const callIds = new Set(calls.map(event => String(event.data.callId)))
|
||||
const results = events.filter(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
|
||||
event.type === 'tool/result' && callIds.has(String(event.data.message.source.callId)),
|
||||
)
|
||||
expect(results).toHaveLength(CORDIS_TOOLS.length)
|
||||
expect(results.every(event => !event.data.message.content[0].isError)).toBe(true)
|
||||
}
|
||||
|
||||
describe('web e2e: Cordis tools use their owned cards', () => {
|
||||
describe.skipIf(MODE === 'record')('web e2e: historical Cordis cards', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
const modelFrames: string[] = []
|
||||
const modelChanges: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
cordisTools: true,
|
||||
compareReplaySession: true,
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
scaffold.ctx.sessionProjections.onChanged((_session, key, value, seq) => {
|
||||
if (key === 'modelSelection') modelChanges.push(`${String(seq)}:${JSON.stringify(value)}`)
|
||||
})
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
page.on('websocket', (socket) => {
|
||||
socket.on('framereceived', (frame) => {
|
||||
const payload = String(frame.payload)
|
||||
if (payload.includes('modelSelection')) modelFrames.push(payload)
|
||||
})
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.locator('[role="treeitem"]').first().click()
|
||||
await page.locator('[role="treeitem"]').nth(1).click()
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -101,118 +38,25 @@ describe('web e2e: Cordis tools use their owned cards', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('drives the recorded Cordis lifecycle to a settled turn (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-drive'))
|
||||
if (MODE !== 'record') {
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STOP_PROMPT])
|
||||
}
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const runTurnSettled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
|
||||
// The approval is the TEST's action in every mode: the fixture pins what the
|
||||
// model said, and the gate is a real round trip through the real panel.
|
||||
const approve = page.locator('[data-cordis-approve]').first()
|
||||
await approve.waitFor({ timeout: 90_000 })
|
||||
// The one assertion this scenario cannot give up: the model asking to run is
|
||||
// NOT the plugin running. Until a person answers, the browser half has not
|
||||
// been fetched, evaluated, or mounted anywhere on this page.
|
||||
expect(await page.locator('[data-snapshot-probe]').count()).toBe(0)
|
||||
const sessionId = await runTurnSettled
|
||||
// Approving from idle makes the run-outcome steer a distinct continuation
|
||||
// turn, matching the recorded replay and keeping turn grouping deterministic.
|
||||
const approvalTurnSettled = scaffold.whenTurnSettled()
|
||||
await approve.click()
|
||||
await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 30_000 }).toBe(1)
|
||||
await approvalTurnSettled
|
||||
await expect.poll(() => page.getByText('The Cordis Plugin is running.', { exact: true }).count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
|
||||
const stopTurnSettled = scaffold.whenTurnSettled()
|
||||
await input.fill(STOP_PROMPT)
|
||||
await input.press('Enter')
|
||||
await stopTurnSettled
|
||||
await expect.poll(() => {
|
||||
const stop = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
|
||||
event.type === 'tool/call' && event.data.name === 'cordis_stop',
|
||||
)
|
||||
return stop !== undefined && sessionEvents.some(
|
||||
event => event.type === 'tool/result'
|
||||
&& String(event.data.message.source.callId) === String(stop.data.callId),
|
||||
)
|
||||
}, { timeout: 15_000 }).toBe(true)
|
||||
if (MODE === 'record') {
|
||||
assertCompleteCordisLifecycle(sessionEvents)
|
||||
await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('the durable log carries one complete Cordis lifecycle', () => {
|
||||
assertCompleteCordisLifecycle(sessionEvents)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('renders localized Cordis lifecycle cards', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-rows'))
|
||||
await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
|
||||
const inspectRow = page.locator('[data-tool="cordis_inspect_self"]').filter({ hasText: 'Inspect' }).first()
|
||||
await expandOwningTurnProcess(page, inspectRow)
|
||||
await inspectRow.waitFor({ timeout: 10_000 })
|
||||
|
||||
// cordis_define does NOT go through the generic row: ui-cordis registers a
|
||||
// keyed toolview for it, and a keyed hit replaces the generic card. So the
|
||||
// title here is the CARD's ("Cordis Plugin"), and the expanded body is the
|
||||
// card's own two code sections rather than a generic args dump.
|
||||
const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first()
|
||||
await expandOwningTurnProcess(page, defineRow)
|
||||
await defineRow.waitFor({ timeout: 10_000 })
|
||||
// The whole summary row is the expand toggle (unified tool-row interaction).
|
||||
await defineRow.locator('[aria-expanded]').first().click()
|
||||
await expect.poll(() => defineRow.textContent(), { timeout: 10_000 }).toContain('data-snapshot-probe')
|
||||
await defineRow.getByRole('tab', { name: 'Host' }).click()
|
||||
await expect.poll(() => defineRow.textContent()).toContain(PACKAGE_CODE)
|
||||
|
||||
const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run Cordis Plugin' }).first()
|
||||
await expandOwningTurnProcess(page, runRow)
|
||||
await runRow.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => runRow.textContent()).toContain('snap-')
|
||||
|
||||
const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop Cordis Plugin' }).first()
|
||||
await expandOwningTurnProcess(page, stopRow)
|
||||
await stopRow.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => stopRow.textContent()).toContain('snap-')
|
||||
await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok')
|
||||
// Stopping withdraws the browser half from every page, probe included.
|
||||
await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 15_000 }).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-aria'))
|
||||
console.log('MODEL_TRACE', { modelChanges, frameCount: modelFrames.length, modelFrames })
|
||||
// Final Assistant text precedes turn/end. Three footers prove every turn
|
||||
// reached the render state covered by the ARIA golden.
|
||||
await expect.poll(
|
||||
() => page.getByRole('button', { name: 'Branch into a new conversation', exact: true }).count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(3)
|
||||
await page.locator('[data-conversation-scroll]').evaluate((host) => { host.scrollTop = host.scrollHeight })
|
||||
await expect.poll(
|
||||
async () => page.getByRole('button', { name: 'Back to bottom', exact: true }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
await page.mouse.move(0, 0)
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
it('renders recorded definition source and lifecycle results without registering retired tools', async () => {
|
||||
const persisted = await readPersistedEvents(scaffold, SessionId(SEED_ID))
|
||||
const expected = parseSeedFixture(realizeSeedFixture(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)).events
|
||||
const tools = (events: typeof persisted) => events.filter(event => event.type === 'tool/call' || event.type === 'tool/result')
|
||||
.map(event => ({ type: event.type, data: event.data }))
|
||||
expect(tools(persisted)).toEqual(tools(expected))
|
||||
const names = scaffold.ctx.tools.schemas().map(tool => tool.name)
|
||||
expect(names).not.toEqual(expect.arrayContaining(['cordis_define']))
|
||||
const define = page.locator('[data-tool="cordis_define"]').first()
|
||||
await expandOwningTurnProcess(page, define)
|
||||
await define.locator('[aria-expanded]').first().click()
|
||||
await define.getByRole('tab', { name: 'Host' }).click()
|
||||
await expect.poll(() => define.textContent()).toContain('snapshot-noop')
|
||||
const stop = page.locator('[data-tool="cordis_stop"]').first()
|
||||
await expandOwningTurnProcess(page, stop)
|
||||
await expect.poll(() => stop.getAttribute('data-state')).toBe('ok')
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('stayed clean: no page errors or reconnect churn', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
// Recorded-session Sidebar geometry and per-Session view state through the shipped browser composition.
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
@@ -15,6 +15,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/details-session-lifecycle', import.meta.url))
|
||||
const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
|
||||
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
|
||||
const BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-session.expected.md')
|
||||
const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
|
||||
const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
|
||||
@@ -127,17 +128,20 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
|
||||
await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed')
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event.type) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await appFrame(page).waitFor({ timeout: 30_000 })
|
||||
expect(await page.locator('[data-sidebar-right-expand]').count()).toBe(0)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
@@ -154,12 +158,77 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
await mkdir(SHOT_DIR, { recursive: true })
|
||||
await saveFailureShot(page, `screenshots/0907-sidebar-rules/details-session-lifecycle-${MODE}-${process.pid}`)
|
||||
})
|
||||
const blankColumn = page.locator('[data-rightbar-col]')
|
||||
const workspace = join(scaffold.workspaceCwd, 'workspace')
|
||||
await writeFile(join(workspace, 'before-chat.md'), '# Before the first message\n\nWorkspace preview is available.\n')
|
||||
await page.locator('[data-sidebar-right-expand]').waitFor({ state: 'visible' })
|
||||
await page.screenshot({ path: join(SHOT_DIR, `blank-collapsed-${MODE}-${process.pid}.png`), fullPage: true })
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await blankColumn.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await blankColumn.locator('[data-files-entry="file"]').getByRole('button', { name: 'before-chat.md', exact: true }).click()
|
||||
await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
|
||||
await page.getByText('Into the Unknown', { exact: false }).waitFor()
|
||||
const blankPanes = await paneSnapshot(page)
|
||||
expect(blankPanes.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'before-chat.md']])
|
||||
await page.screenshot({ path: join(SHOT_DIR, `blank-preview-${MODE}-${process.pid}.png`), fullPage: true })
|
||||
|
||||
const blankViewport = page.viewportSize()!
|
||||
try {
|
||||
await blankColumn.locator('[data-sidebar-right-toggle]').click()
|
||||
await page.setViewportSize({ width: 767, height: blankViewport.height })
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox())
|
||||
.toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height })
|
||||
await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
|
||||
await blankColumn.locator('[data-sidebar-right-toggle]').click()
|
||||
} finally {
|
||||
await page.setViewportSize(blankViewport)
|
||||
}
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await blankColumn.locator('[data-dockkit-add-tab]').click()
|
||||
await blankColumn.locator('[data-sidebar-right-guide-entry="terminal"]')
|
||||
.getByRole('button', { name: /^New terminal/u }).click()
|
||||
const agent = scaffold.ctx.agents.list().find(agent => agent.session.header.cwd === workspace)
|
||||
if (agent === undefined) throw new Error('Blank Session has no workspace Agent')
|
||||
await expect.poll(() => scaffold.ctx.terminalController.list(agent.id).map(terminal => terminal.state)).toEqual(['running'])
|
||||
await page.locator('.xterm-helper-textarea:visible').click()
|
||||
await page.keyboard.insertText('node -e "require(\'fs\').writeFileSync(\'before-chat-terminal.txt\',\'READY\')"')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => readFile(join(workspace, 'before-chat-terminal.txt'), 'utf8')).toBe('READY')
|
||||
expect(sessionEvents).not.toContain('turn/start')
|
||||
expect(sessionEvents).not.toContain('user/message')
|
||||
await blankColumn.locator('[data-dockkit-tab][aria-selected="true"]').hover()
|
||||
// The tab disappears before the Host finishes process cleanup; close responds after quiescence.
|
||||
const [closed] = await Promise.all([
|
||||
page.waitForResponse('**/api/terminal/close'),
|
||||
blankColumn.locator('[data-dockkit-tab][aria-selected="true"] [data-dockkit-tab-close]').click(),
|
||||
])
|
||||
expect(await closed.json()).toMatchObject({ result: { ok: true } })
|
||||
expect(scaffold.ctx.terminalController.list(agent.id)).toEqual([])
|
||||
await blankColumn.locator('[data-dockkit-tab]').filter({ hasText: 'before-chat.md' }).click()
|
||||
await compareOrRefreshGolden(BLANK_EXPECTED, [
|
||||
'# Blank Session workspace sidebar', '',
|
||||
'- No selected Session: expand control absent',
|
||||
'- Selected workspace before first message: expand control visible',
|
||||
'- Files: before-chat.md opens as a Markdown preview',
|
||||
'- Narrow viewport: reopened preview fills the viewport',
|
||||
'- Terminal: writes a file in the selected workspace before any user message or turn', '',
|
||||
`\`\`\`json\n${JSON.stringify(await paneSnapshot(page), null, 2)}\n\`\`\``,
|
||||
].join('\n'), MODE)
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
expect(await paneSnapshot(page)).toEqual(blankPanes)
|
||||
await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
|
||||
for (const title of ['before-chat.md', 'Files']) {
|
||||
const tab = blankColumn.locator('[data-dockkit-tab]').filter({ hasText: title })
|
||||
await tab.hover()
|
||||
await tab.locator('[data-dockkit-tab-close]').click()
|
||||
}
|
||||
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
|
||||
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
|
||||
@@ -356,6 +425,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, checkpoints.join('\n\n'), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md', 'blank-session.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -59,7 +59,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"':
|
||||
- img
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"':
|
||||
- img
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"':
|
||||
- img
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
- img
|
||||
- menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program."
|
||||
- menuitem "Minimal mode Single-tool coding agent with a persistent shell."
|
||||
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
|
||||
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance."
|
||||
- menuitem "Refusing mode Resolves, then refuses to start."
|
||||
@@ -142,6 +142,11 @@
|
||||
- text: ", and check the fine print"
|
||||
- superscript: "1"
|
||||
- text: .
|
||||
- paragraph:
|
||||
- text: "Upstream:"
|
||||
- link "the repository":
|
||||
- /url: https://github.com/example/link-gallery
|
||||
- text: .
|
||||
- paragraph:
|
||||
- text: "Inert contrasts:"
|
||||
- code: curl https://docs.example.test/api
|
||||
@@ -169,14 +174,6 @@
|
||||
- list:
|
||||
- listitem:
|
||||
- paragraph: Footnote references stay inert superscripts. ↩
|
||||
- text: Files changed
|
||||
- button "Open site/report.html": report.html
|
||||
- button "Open a/style.css": style.css
|
||||
- button "Open b/style.css": style.css
|
||||
- button "Open site/index.html": index.html
|
||||
- button "Open site/app.js": app.js
|
||||
- button "Open src/tokens.css": tokens.css
|
||||
- text: + 1 file
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Good response":
|
||||
|
||||
+6
-6
@@ -1,8 +1,6 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use only Cordis tools. First" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open right sidebar":
|
||||
@@ -18,7 +16,7 @@
|
||||
- img
|
||||
- img
|
||||
- text: System prompt
|
||||
- text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. {{clock}}"
|
||||
- text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "3 tool calls" [expanded]:
|
||||
@@ -76,7 +74,7 @@
|
||||
- button "Ran for {{duration}}":
|
||||
- img
|
||||
- text: Ran for {{duration}}
|
||||
- text: {{clock}}
|
||||
- text: 9/1 {{clock}}
|
||||
- button "Thought for a while":
|
||||
- text: Thought for a while
|
||||
- img
|
||||
@@ -92,7 +90,7 @@
|
||||
- button "Ran for {{duration}}":
|
||||
- img
|
||||
- text: Ran for {{duration}}
|
||||
- text: {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. {{clock}}
|
||||
- text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "1 tool call" [expanded]:
|
||||
@@ -114,7 +112,9 @@
|
||||
- button "Ran for {{duration}}":
|
||||
- img
|
||||
- text: Ran for {{duration}}
|
||||
- text: {{clock}}
|
||||
- text: 9/1 {{clock}}
|
||||
- button "Back to bottom":
|
||||
- img
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands":
|
||||
- img
|
||||
@@ -0,0 +1,84 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "已归档会话":
|
||||
- img
|
||||
- text: 已归档会话
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
- img "API 密钥已配置"
|
||||
- button "编辑 minimax-cn": 编辑
|
||||
- button "删除 minimax-cn": 删除
|
||||
- listitem:
|
||||
- text: openai
|
||||
- button "编辑 openai": 编辑
|
||||
- button "删除 openai": 删除
|
||||
- text: openai API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥,或留空使用环境认证
|
||||
- group:
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: 提供方默认
|
||||
- region "模型目录":
|
||||
- text: 模型目录 已自定义模型目录
|
||||
- button "恢复默认模型"
|
||||
- button "获取可用模型"
|
||||
- textbox "模型 ID 1":
|
||||
- /placeholder: 模型 ID
|
||||
- text: gpt-6-astra
|
||||
- textbox "显示名称 1":
|
||||
- /placeholder: 显示名称
|
||||
- text: GPT-6 Astra
|
||||
- button "模型选项 1" [expanded]:
|
||||
- img
|
||||
- button "删除模型 1":
|
||||
- img
|
||||
- text: 上下文窗口
|
||||
- textbox "上下文窗口 1":
|
||||
- /placeholder: 256K
|
||||
- text: 272K
|
||||
- text: 最大输出 token 数
|
||||
- textbox "最大输出 token 数 1":
|
||||
- /placeholder: 32K
|
||||
- text: 128K
|
||||
- group "输入类型 1":
|
||||
- text: 输入类型
|
||||
- checkbox "文本" [checked]
|
||||
- text: 文本
|
||||
- checkbox "图片" [checked]
|
||||
- text: 图片
|
||||
- button "添加模型":
|
||||
- img
|
||||
- text: 添加模型
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- listitem:
|
||||
- text: Acme 网关 自定义
|
||||
- button "编辑 Acme 网关 (acme-gateway)": 编辑
|
||||
- button "删除 Acme 网关 (acme-gateway)": 删除
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
- button "添加自定义提供方":
|
||||
- img
|
||||
- text: 添加自定义提供方
|
||||
@@ -58,20 +58,25 @@
|
||||
- text: acme-large
|
||||
- textbox "显示名称 1":
|
||||
- /placeholder: 显示名称
|
||||
- button "模型选项 1" [expanded]
|
||||
- button "删除模型 1"
|
||||
- button "模型选项 1" [expanded]:
|
||||
- img
|
||||
- button "删除模型 1":
|
||||
- img
|
||||
- text: 上下文窗口
|
||||
- textbox "上下文窗口 1":
|
||||
- /placeholder: 256K
|
||||
- text: 最大输出 token
|
||||
- textbox "最大输出 token 1":
|
||||
- text: 最大输出 token 数
|
||||
- textbox "最大输出 token 数 1":
|
||||
- /placeholder: 32K
|
||||
- text: 图片输入
|
||||
- combobox "图片输入 1":
|
||||
- option "使用默认值"
|
||||
- option "支持" [selected]
|
||||
- option "不支持"
|
||||
- button "添加模型"
|
||||
- group "输入类型 1":
|
||||
- text: 输入类型
|
||||
- checkbox "文本" [checked]
|
||||
- text: 文本
|
||||
- checkbox "图片" [checked]
|
||||
- text: 图片
|
||||
- button "添加模型":
|
||||
- img
|
||||
- text: 添加模型
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- button "添加提供方":
|
||||
|
||||
@@ -54,11 +54,12 @@
|
||||
- text: 最大输出 token 数
|
||||
- textbox "最大输出 token 数 1":
|
||||
- /placeholder: 256K
|
||||
- text: 图片输入
|
||||
- combobox "图片输入 1":
|
||||
- option "默认(仅文本)"
|
||||
- option "支持" [selected]
|
||||
- option "不支持"
|
||||
- group "输入类型 1":
|
||||
- text: 输入类型
|
||||
- checkbox "文本" [checked]
|
||||
- text: 文本
|
||||
- checkbox "图片" [checked]
|
||||
- text: 图片
|
||||
- textbox "模型 ID 2":
|
||||
- /placeholder: 模型 ID
|
||||
- text: deepseek-v4-pro
|
||||
|
||||
@@ -56,11 +56,12 @@
|
||||
- textbox "最大输出 token 数 1":
|
||||
- /placeholder: 256K
|
||||
- text: 64K
|
||||
- text: 图片输入
|
||||
- combobox "图片输入 1":
|
||||
- option "默认(仅文本)"
|
||||
- option "支持" [selected]
|
||||
- option "不支持"
|
||||
- group "输入类型 1":
|
||||
- text: 输入类型
|
||||
- checkbox "文本" [checked]
|
||||
- text: 文本
|
||||
- checkbox "图片" [checked]
|
||||
- text: 图片
|
||||
- button "添加模型":
|
||||
- img
|
||||
- text: 添加模型
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
- menu:
|
||||
- text: Group by
|
||||
- menuitem "WorkSpace":
|
||||
- text: WorkSpace
|
||||
- img
|
||||
- menuitem "Workspace Tree"
|
||||
- menuitem "In one list"
|
||||
- separator
|
||||
- text: Order by
|
||||
- menuitem "Manual"
|
||||
- menuitem "Last updated":
|
||||
- text: Last updated
|
||||
- img
|
||||
@@ -0,0 +1,11 @@
|
||||
- treeitem "folder-group" [expanded]:
|
||||
- img
|
||||
- text: folder-group
|
||||
- group:
|
||||
- treeitem "project-two" [expanded]:
|
||||
- img
|
||||
- text: project-two
|
||||
- treeitem "New Session" [selected]
|
||||
- treeitem "project-one" [expanded]:
|
||||
- img
|
||||
- text: project-one
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Model input types stay below both capacity fields across editor widths. */
|
||||
import type { Locator, Page } from 'playwright'
|
||||
import { expect } from 'vitest'
|
||||
|
||||
/**
|
||||
* Verify the expanded row at the current viewport and a narrow viewport.
|
||||
* @param page - page with an explicit viewport, restored even when an assertion fails.
|
||||
* @param editor - expanded model editor containing the capacity and input-type fields.
|
||||
* @returns after checking both widths and restoring the caller's size.
|
||||
*/
|
||||
export async function assertModelInputLayout(page: Page, editor: Locator): Promise<void> {
|
||||
const original = page.viewportSize()
|
||||
if (original === null) throw new Error('Model input layout checks require an explicit viewport')
|
||||
try {
|
||||
for (const viewport of [original, { width: 760, height: 1000 }]) {
|
||||
await page.setViewportSize(viewport)
|
||||
const context = await editor.getByLabel('上下文窗口 1', { exact: true }).boundingBox()
|
||||
const output = await editor.getByLabel('最大输出 token 数 1', { exact: true }).boundingBox()
|
||||
const types = await editor.getByRole('group', { name: '输入类型 1' }).boundingBox()
|
||||
expect(context).not.toBeNull()
|
||||
expect(output).not.toBeNull()
|
||||
expect(types).not.toBeNull()
|
||||
if (context === null || output === null || types === null) throw new Error('Expanded model fields are not visible')
|
||||
expect(Math.abs(context.y - output.y)).toBeLessThan(1)
|
||||
expect(types.y).toBeGreaterThanOrEqual(Math.max(context.y + context.height, output.y + output.height))
|
||||
expect(types.width).toBeGreaterThan(context.width)
|
||||
}
|
||||
} finally {
|
||||
await page.setViewportSize(original)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 466 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user