diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index bb7be001de..56f2e6e156 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -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-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: bb1d68cc045710d46a44ffb295c2307628338521 -2026-08-09-client-conversation-node-assembly.zh.md: 6291211d589b3389e7dc9a42d15fe6ac0d9e7ab9 +2026-08-09-client-conversation-node-assembly.md: c8773b77fe761c3a566e02f0bd6aeb18f26e0a89 +2026-08-09-client-conversation-node-assembly.zh.md: 076ad35178996e01eae26b3909feae8d967414c1 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index bb1d68cc04..c8773b77fe 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -14,6 +14,8 @@ Business events also use different correlation models. Tool has call IDs, Assist ## Decision +The [three-stage Tool lifecycle](2026-09-22-tool-call-three-phases.md) owns transient initialization, repeated start matches for one ID, and tool preparation; this record's other node-assembly responsibilities remain applicable. + Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous `SessionEventLikeEntry` window to the engine and publishes its snapshot instead of interpreting individual conversation businesses. The entry's outer discriminator distinguishes standard and packed records, while both carry an aligned inner `SessionEventLike` for Definition dispatch. This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 6291211d58..076ad35178 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -14,6 +14,8 @@ Client Session 既维护传输窗口、连接状态和待处理交互,也在 ## 决策 +start 的瞬态创建入口、同 ID 多次 start 及工具准备阶段以[工具调用三阶段](2026-09-22-tool-call-three-phases.zh.md)为准;本记录的其他节点组装职责继续适用。 + Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builder;Session 只负责把当前连续 `SessionEventLikeEntry` window 送入引擎并发布它的 snapshot,且不解释具体 conversation 业务。entry 的外层 discriminator 区分标准与 packed record,两者都携带字段对齐的内部 `SessionEventLike`,供 Definition dispatch。 本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml index f40d8780e9..6bb50069a8 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml @@ -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-11-sandboxed-node-ptc-runtime.md -2026-09-11-sandboxed-node-ptc-runtime.md: f851775460c5bd01760d31552bde8c691807ec06 -2026-09-11-sandboxed-node-ptc-runtime.zh.md: 94f3a37eb702a97161c1d08cec531fcffb3460f2 +2026-09-11-sandboxed-node-ptc-runtime.md: 7efe06e8a4a48b2c1d4dcb672abc09961f3a2b14 +2026-09-11-sandboxed-node-ptc-runtime.zh.md: 2382b02247ebe6e0c524cd89fa64c8e59cf49797 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md index f851775460..7efe06e8a4 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md @@ -34,6 +34,8 @@ Program completion, timeout, cancellation and protocol failure all close executi The default elapsed deadline is 120 seconds, capped at 600 seconds by default. Trusted service consumers may request `timeoutMs: null` to disable this timer; [workflow sandbox reuse](2026-09-13-workflow-ptc-sandbox-reuse.md) owns that caller-controlled lifetime. Omitted and numeric requests, including model-facing `run_code`, retain the numeric defaults and caps. An enabled deadline includes runtime setup and nested tool or approval waits. V8 old-generation memory, serialized outer output and control traffic have separate configured bounds. The heap limit excludes native allocations and descendant memory, and elapsed time is not a process-tree CPU budget. +The separate [best-effort persistent provider](2026-09-23-best-effort-persistent-ptc.md) retains disposable memory under explicit full access; this decision continues to own the default confined provider. + ## Alternatives considered **Keep the worker and add tool checks.** Tool checks cannot intercept direct Node imports or establish OS confinement. Keeping a worker inside a confined supervisor process preserves worker metering but adds another execution lifetime without supplying a process-tree CPU limit. diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md index 94f3a37eb7..2382b02247 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md @@ -34,6 +34,8 @@ Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前 默认经过时间截止为 120 秒,默认上限为 600 秒。可信服务消费方可以请求 `timeoutMs: null` 来禁用该定时器;[工作流沙箱复用](2026-09-13-workflow-ptc-sandbox-reuse.zh.md)负责这种由调用方控制的生命周期。省略 timeout 或使用数值的请求(包括面向模型的 `run_code`)保留数值默认值与上限。启用的截止包括运行时准备以及嵌套工具或审批等待。V8 老生代内存、序列化外层输出与控制通信具有独立配置的上限。堆限制不包含原生分配和后代内存,经过时间也不是进程树 CPU 预算。 +独立的[尽力持久提供方](2026-09-23-best-effort-persistent-ptc.zh.md)在显式完全访问下保留可丢失的内存;本决定继续负责默认的受约束提供方。 + ## 考虑过的替代方案 **保留 worker 并增加工具检查。** 工具检查不能拦截直接 Node 导入,也不能建立 OS 约束。在受限监督进程中保留 worker 可以保留 worker 计量,但会增加一层执行生命周期,仍不能提供进程树 CPU 限制。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml index a5b7444619..e1a4357fcd 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml @@ -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-guided-plugin-installation.md -2026-09-15-guided-plugin-installation.md: 451b1f316eebfdce9c9efcffbf48202177d04343 -2026-09-15-guided-plugin-installation.zh.md: 0c228695828238b4dbfdae7d2153e0358e92360c +2026-09-15-guided-plugin-installation.md: 68bd519b7cb4342c42761dfaef53940c83ceb2d8 +2026-09-15-guided-plugin-installation.zh.md: f3cbefedeb151d37078b55140635b69516fafed2 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md index 451b1f316e..68bd519b7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md @@ -14,7 +14,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina **A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store. -**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. +**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../bug-fix/2026-09-23-bounded-pnpm-runs.md)). The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. **GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling. diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md index 0c22869582..f3cbefedeb 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md @@ -14,7 +14,7 @@ Status: implemented **失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。 -**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 +**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../bug-fix/2026-09-23-bounded-pnpm-runs.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 **GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。 diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml new file mode 100644 index 0000000000..bfbfe3cdf3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml @@ -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-22-tool-call-three-phases.md +2026-09-22-tool-call-three-phases.md: e7b42a4ed9e340b73257f7053bddd5221ccc0d20 +2026-09-22-tool-call-three-phases.zh.md: 5a68575fd8e2a68199cbedf1ac180a22254b7c54 diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md new file mode 100644 index 0000000000..e7b42a4ed9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md @@ -0,0 +1,67 @@ +# Agent Note: Tool calls own preparation, dispatch, and results by callId + +Status: implemented + +English | [中文](2026-09-22-tool-call-three-phases.zh.md) + +## Problem + +The model often provides a tool name and call ID while it is still generating arguments, but `tool/call` arrives only after the entire Assistant stream ends. When one response contains several calls, an earlier call waits for later calls even after its own arguments are complete. File content is part of the arguments to `write`, so this delay can last several seconds. + +Creating tool nodes only at `tool/call` presents that interval as no tool activity. Putting a preparing call in an Assistant part instead requires the Group to switch references between Assistant and Tool nodes. Preparation and dispatch belong to one tool lifecycle and do not require two cooperating nodes. + +## Decision + +### One Tool owns three stages + +The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) groups calls by callId and supplies explicit stage data to tool components. + +| Stage | Creation or update evidence | Available data and presentation | +|---|---|---| +| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no argument body, and one non-expandable row. | +| `start` | `tool/call` | Complete arguments; enables the tool's existing call presentation. | +| `result` | `tool/result` | The result and any complete arguments paired within the loaded window. | + +An argument block ending does not trigger dispatch presentation. Showing preparation changes neither model requests, tool dispatch timing, Session events, nor the persisted format. An empty argument string does not stand in for a dispatched call. + +### Start marks an initialization candidate + +The [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) retains its match/start/update interface and existing roles. Durable events and transient deltas may both match as start. The earliest start in the current event sequence initializes State; every later Match, including another start for the same ID, goes through update. + +A named tool delta and `tool/call` are both initialization candidates. A live delta can create preparation; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches. + +When stream settlement removes temporary deltas, the Assembler selects the start from the remaining Matches, refreshes predecessor indexes, and recomputes State. A published preparation node hides when no call evidence remains; a later dispatch retains the same callId. Undispatched calls do not receive synthetic execution results. + +### Converge on final state without replaying preparation + +Historical pages do not expand settled Assistant messages into live deltas. Reconnection restores only an attempt that is still live. Live presentation may follow preparing → start → result, while history follows start → result directly. Equal durable events produce equal final state; intermediate presentation and preparation-group identity need not match. + +Groups include Tool nodes from preparation onward and classify and count them by tool name. AssistantNodeView does not search tool blocks, and Groups neither encode tool groupPart values nor take over tool lifecycles. Grouping retains the [node-input grouping decision](2026-09-21-conversation-build-groups.md); the Builder does not interpret preparation business rules. + +### Shared rows reuse their existing presentation + +[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no arguments or result, and the shared argument parser returns null without parsing JSON. ToolRow prevents preparation rows from expanding. + +Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Custom rows such as Bash, Skill, Present, and Cordis keep their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced. + +### Relationship to existing records + +This decision replaces the [business-node assembly](2026-08-09-client-conversation-node-assembly.md) restriction that starts must be durable and a Context may receive only one start. That record's stable IDs, business Definitions, Contexts, Locations, predecessor dependencies, and target Builder responsibilities remain applicable. + +## Alternatives considered + +**Create nodes only at `tool/call`.** This cannot show a known tool identity while arguments are being generated and leaves a visible wait in multi-call responses. + +**Transfer presentation through Assistant parts and Groups.** A tool would need two presentation sources, additional deduplication, and identity transfer. Lifecycle ownership becomes distributed, and renderers must recover Assistant content from part identifiers. + +**Persist or replay preparing.** Preparation describes current generation; durable calls and results already suffice to reconstruct the final presentation. Reproducing the same intermediate process adds unnecessary state and event interpretation. + +**Add a preparation registration mechanism.** Generic tools already share ToolRow and argument models, including reusable icons and titles. Additional declarations or configuration for this stage would maintain another representation of tool presentation. + +## Consequences + +- A tool may appear before its arguments are complete, but preparation neither implies execution nor exposes interactions requiring arguments. +- Multiple starts with one ID belong to one lifecycle. Independent calls must use distinct IDs; a second-start error no longer detects identity reuse. +- Shared rows do not replace the whole row component when preparation becomes dispatch. Custom rows may still switch their internal components by stage. +- Partial JSON parsing and `useToolCallDelta` are not provided; argument-driven early presentation is outside this implementation. +- Assembly tests separately cover live preparation, withdrawal, active-stream reconstruction, durable history, and paged convergence. Component tests cover argument-free presentation and stage changes; a recorded session covers browser preparation and reload results. diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md new file mode 100644 index 0000000000..5a68575fd8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md @@ -0,0 +1,67 @@ +# Agent Note: 工具调用按 callId 管理准备、派发与结果 + +Status: implemented + +[English](2026-09-22-tool-call-three-phases.md) | 中文 + +## 问题 + +模型生成工具参数时,工具名和调用 ID 往往已经可用,但 `tool/call` 要等整条 Assistant 流结束后才到达。一次响应包含多个调用时,前一个调用即使已经生成完整参数,仍要等待后续调用。文件正文也属于 `write` 参数,这段等待可能持续数秒。 + +只从 `tool/call` 创建工具节点,会把这段时间表现为没有工具活动;把准备中的调用放在 Assistant 片段里,又需要 Group 在 Assistant 与正式 Tool 节点之间切换引用。准备态和正式调用属于同一个工具生命周期,不应由两个节点配合表达。 + +## 决策 + +### 同一个 Tool 拥有三个阶段 + +[Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) 按 callId 聚合调用,向工具组件提供明确的阶段数据。 + +| 阶段 | 创建或更新依据 | 可用数据与展示 | +|---|---|---| +| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供参数正文,只显示不可展开的一行。 | +| `start` | `tool/call` | 完整参数;启用工具原有的调用展示。 | +| `result` | `tool/result` | 调用结果,以及当前窗口内可配对的完整参数。 | + +参数块结束不触发派发阶段。展示准备态不改变模型请求、工具派发时机、Session 事件或持久化格式,也不把空参数字符串伪装成正式调用。 + +### start 表示可创建入口 + +沿用 [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 的 `match/start/update` 接口和既有角色。持久事件与瞬态 delta 都可以匹配为 start;当前事件序列中最早的 start 初始化 State,后续 Match,包括同 ID 的其他 start,都交给 update。 + +Tool 的具名 delta 和 `tool/call` 都是创建入口。实时先收到 delta,就从 preparing 创建;历史先收到 `tool/call`,就从 start 创建。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。 + +流结束移除临时 delta 后,Assembler 从剩余 Match 重新选择 start,刷新前序索引并重算 State。没有剩余调用证据时,已发布的准备节点隐藏;后续正式调用仍使用相同 callId。未派发的调用不合成执行结果。 + +### 最终状态收敛,不重现准备过程 + +历史分页不把已结束 Assistant 消息展开为实时 delta。重连只恢复仍在生成的活跃流。实时展示可以经历 preparing → start → result,历史回放可以直接经历 start → result;相同持久事件得到相同最终状态即可,不要求中间过程或准备态分组身份一致。 + +Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。AssistantNodeView 不查找工具块,Group 不编码工具 groupPart,也不接替工具生命周期。分组仍遵循[节点输入的分组决策](2026-09-21-conversation-build-groups.zh.md),Builder 不解释准备态业务。 + +### 通用行复用现有外壳 + +[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有参数或结果,共享参数解析入口直接返回 null,不解析 JSON;ToolRow 禁止准备行展开。 + +read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash、Skill、Present、Cordis 等自定义外壳保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。 + +### 与已有记录的关系 + +本记录替代[业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md)中“start 只能是持久事件,同一 Context 只能收到一次 start”的限制。该记录的稳定 ID、业务 Definition、Context、Location、前序依赖和目标 Builder 职责继续适用。 + +## 考虑过的替代方案 + +**只在 `tool/call` 创建节点。** 无法展示参数生成期间已经确定的工具身份,多调用响应仍有明显空等。 + +**通过 Assistant 片段和 Group 转接。** 工具需要两种展示来源、额外去重和身份衔接;生命周期归属分散,渲染器还要根据片段标识反查 Assistant 内容。 + +**保存或回放 preparing。** 准备态只描述当前生成过程,持久调用和结果已经足以重建最终展示;恢复相同中间过程会增加不必要的状态和事件解释。 + +**新增准备态注册机制。** 通用工具已经共享 ToolRow 和参数模型,已有图标与标题可以直接复用。仅为这一阶段增加声明或配置,会多维护一套工具展示信息。 + +## 影响 + +- 工具可以在参数完整前出现,但准备展示不表示工具已经执行,也不暴露依赖参数的交互。 +- 同一 ID 的多个 start 被视为同一个生命周期;业务必须为独立调用提供不同 ID,不能再依赖第二个 start 报错检测身份复用。 +- 通用行不因准备/派发分支替换整行组件;自定义外壳仍可按阶段切换内部组件。 +- 不提供部分 JSON 解析或 `useToolCallDelta`,参数驱动的提前展示不属于本次实现。 +- 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。 diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml new file mode 100644 index 0000000000..89b99cd7d7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml @@ -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-23-best-effort-persistent-ptc.md +2026-09-23-best-effort-persistent-ptc.md: ab35a716ff6c0c7330e0c62500b84ee6173b988a +2026-09-23-best-effort-persistent-ptc.zh.md: 0ec7ae1f7c789eadd87c80365354f914fc720b98 diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md new file mode 100644 index 0000000000..ab35a716ff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md @@ -0,0 +1,29 @@ +# Agent Note: Best-effort persistent PTC memory + +Status: implemented + +English | [中文](2026-09-23-best-effort-persistent-ptc.zh.md) + +## Problem + +Interactive automation retains application objects, imported modules and helper functions between model calls. Recreating every object for every program prevents consumers from using ordinary JavaScript references across observations and actions. + +## Decision + +The optional `dsh-ptc-runtime-node/persistent` provider keeps an unsandboxed Node REPL process per explicit, object-identity state owner. PTC uses the live Agent as owner and awaits release during its context disposal. The default [sandboxed provider](2026-09-11-sandboxed-node-ptc-runtime.md) retains its fresh-process behavior. This extends the [PTC foundation](../feature/2026-06-15-ptc.md) with disposable memory, while preserving registry dispatch and logged model requests. + +The Session records submitted programs, nested tool calls and returned output. It does not serialize the REPL heap or rerun earlier programs after restart, resume or fork: replaying automation could repeat external side effects. A missing variable is a normal program error, and the model-visible instructions require recreating variables and reacquiring resources. No environment identifier or new durable Session event is required because the provider exposes no unlogged model input. + +Cancellation, deadline expiry, protocol or output-limit failure, owner disposal and process exit discard the environment. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink. + +## Alternatives considered + +Serializing arbitrary JavaScript objects cannot preserve native resources and closures. Replaying prior programs can repeat writes and input actions. Both would imply recovery guarantees that this provider does not offer. + +A realm inside the Harness process shares process lifetime with model code and cannot terminate a synchronous loop independently. A managed child process permits cancellation and resource cleanup while retaining imports and variables during healthy use. This process is not a file sandbox or a multi-tenant security boundary. + +## Consequences + +Enabling the provider grants direct Node code full host access. It is opt-in, rejects explicit sandbox policies, and leaves nested tools under their normal authorization and logging. Memory and native handles may disappear without a Harness restart. The model must recover from reference or resource errors; the runtime never retries a program automatically. + +An idle owner retains a process with no expiration timer. Callers release owners explicitly; ownerless service calls use temporary processes. Programs use REPL statements and a final expression rather than top-level return, and calls for one retained owner cannot overlap. diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md new file mode 100644 index 0000000000..0ec7ae1f7c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 尽力保留的持久 PTC 内存 + +Status: implemented + +[English](2026-09-23-best-effort-persistent-ptc.md) | 中文 + +## Problem + +交互式自动化需要在模型调用之间保留应用对象、导入模块和辅助函数。每个程序都重新创建对象,会使消费者无法在观察和操作之间使用普通 JavaScript 引用。 + +## Decision + +可选提供方 `dsh-ptc-runtime-node/persistent` 为每个以对象身份标识的显式状态所有者保留无沙盒 Node REPL 进程。PTC 使用存活 Agent 作为所有者,并在其上下文销毁时等待释放。默认的[沙盒提供方](2026-09-11-sandboxed-node-ptc-runtime.zh.md)保留每次启动新进程的行为。此决定为 [PTC 基础](../feature/2026-06-15-ptc.zh.md)增加可丢失的内存,同时保留注册表分派和模型请求日志。 + +Session 记录提交的程序、嵌套工具调用和返回输出。它不序列化 REPL 堆,也不在重启、恢复或派生后重跑以前的程序,因为回放自动化可能重复外部副作用。缺失变量是普通程序错误,模型可见说明要求重新创建变量和获取资源。提供方没有暴露未记录的模型输入,因此不需要环境标识符或新的持久 Session 事件。 + +取消、截止到期、协议失败、输出超限、所有者销毁和进程退出会丢弃环境。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行产生的异步工作不能取得后续执行的工具权限或输出接收器。 + +## Alternatives considered + +序列化任意 JavaScript 对象无法保留原生资源和闭包。回放以前的程序可能重复写入和输入操作。这两种方案都会隐含本提供方没有承诺的恢复保证。 + +Harness 进程内的执行域与模型代码共享进程寿命,无法单独终止同步循环。受管子进程允许取消和资源清理,同时在正常使用时保留导入模块和变量。该进程不提供文件沙盒或多租户安全隔离。 + +## Consequences + +启用提供方会允许直接 Node 代码完全访问宿主。它需要显式启用,拒绝显式沙盒策略,嵌套工具继续遵循原有授权和日志规则。即使 Harness 没有重启,内存和原生句柄也可能丢失。模型必须从引用错误或资源错误中恢复;运行时不会自动重试程序。 + +空闲所有者保留进程,没有过期计时器。调用方显式释放所有者;没有所有者的服务调用使用临时进程。程序使用 REPL 语句和末尾表达式,不能使用顶层 return;同一保留环境的调用不能重叠。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml new file mode 100644 index 0000000000..420cb47036 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml @@ -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-22-windows-open-through-shell-resolution.md +2026-09-22-windows-open-through-shell-resolution.md: 7bfe559e7f26b96fa37d3bbed44f0403d4e51b59 +2026-09-22-windows-open-through-shell-resolution.zh.md: ff33455da1653f9d24f9ddcc21c1594e8875bb5e diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md new file mode 100644 index 0000000000..7bfe559e7f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md @@ -0,0 +1,43 @@ +# Agent Note: Windows opens go through explorer.exe + +Status: implemented + +English | [中文](2026-09-22-windows-open-through-shell-resolution.zh.md) + +## Problem + +The Windows desktop client's settings sheet offers **Open configuration file**, which prepares the active profile's `cordis.patch.yml` and hands the path to the native text-document opener (issue #4428). On the reporting machine the gesture opened nothing and reported nothing. The opener ran `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath ''"`, so an association resolved inside the host process decided the outcome, and `Invoke-Item` exits 0 when that resolution finds no application. + +That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest` (progid `VSCode.yml`), with no `UserChoice` record and no `HKCU\Software\Classes\.yml` default. Explorer opens VS Code on a double-click of the same file. Run from the host process, `Invoke-Item`, `cmd /c start`, the `Shell.Application` default verb, and `AssocQueryString` all reported no application for that extension, and `cmd /c start` displayed the shell's own application chooser instead of opening one. `Invoke-Item` still exited 0, so `SettingsController.openSettingsDocument` answered `{ opened: true }` and the sheet rendered no error. + +## Decision + +`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Both encode the target as a file URI with its commas and equals signs percent-encoded, because Explorer parses its own command line and splits fields at both — a raw path containing either opens a different target without reporting it. Nothing else is escaped: Explorer rejects percent-encoded non-ASCII in a file URI and falls back to Documents, so those characters reach it literally. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence. + +`runExplorer` holds the one Explorer invocation rule for both operations: exit code 1 is the handoff Explorer returns after passing the request to the desktop process already running, and it resolves the caller's promise; every other failure and every cancellation still rejects. The helper replaced the PowerShell command string, and with it `powershellLiteral` and its single-quote doubling: a path crosses the process boundary as an argv element, which removes the shell-layer escaping rather than the encoding Explorer's own parser requires. `explorerTarget` owns that one encoding step for both intents. + +Windows still names no browser for HTML and SVG, so `openInBrowser` continues to decline the platform and the default, association, and text-editor intents all reach the same Explorer handoff. + +## Alternatives considered + +**Keep `Invoke-Item`.** Rejected. It asks a different resolver than the shell does, so a user whose default Explorer honors can still get a silent no-op, and its zero exit code removes the error the sheet would otherwise render. + +**Resolve the default inside the host process.** Rejected. Reading `UserChoiceLatest`, then `UserChoice`, then the classes default, and launching the winning progid's `shell\open\command` reimplements an order Windows owns, including the `UserChoice` hash Windows validates; the shell's own answer is the definition of the application a double-click uses. + +**`cmd /c start `.** Rejected. It needs a command shell for the same resolution Explorer already performs, and it reported no application on the machine that reproduced the defect. + +**Fall back to a fixed editor when no application resolves.** Rejected. A Windows installation always ships a usable editor, but choosing it in the opener would override the user's association and turn a missing association into a silently wrong application. + +## Consequences + +The settings sheet's open gesture and the artifact and workspace opens share one Windows behavior: the application a double-click would launch. A machine whose recorded default Explorer honors but `AssocQueryString` does not — the reported state — now opens the configured editor. + +The opener can no longer distinguish "the shell opened it" from "the shell declined": Explorer reports 0 or 1 for both, so the Remote still answers `{ opened: true }`, and a machine with no handler shows the shell's chooser as its only feedback. Nothing here verifies that a window appeared. + +An interactive desktop session is a precondition. In a non-interactive Windows session (a service, or a scheduled task without an interactive logon) `explorer.exe` invokes no association and still exits 1, so that exit code does not tell this case apart from a delegated open: an SSH session 0 on Windows 11 ARM64 build 26200 measured exit code 1 after about a second, where an `Invoke-Item` command resolved the association in-process. A Host started that way loses the capability and pays the wait, and a caller's abort signal is what bounds it. No fallback for that session is implemented. + +Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe ` spawn, which is not what this handoff does: the `explorer.exe` child inherits the host environment and no detached process is created, while the application or folder window Explorer delegates to starts from the desktop session's environment rather than the host's. + +## Verification + +`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy. On Windows 11 ARM64 build 26200 an independent pass drove the module's own `openNativeAssociatedPath`, `openNativePath`, and `openNativeTextFile` from a sha256-matched copy beside an association probe that recorded every argument the shell passed: each comma-bearing target arrived complete as `%2C`, a plain file opened, and a directory named for a comma opened as a window rooted there. The negative control — the same file as a file URI whose comma is left raw — invoked no association and opened the user's Documents folder, and still exited 1. Exit 1 therefore records the delegation rather than the outcome, so accepting it cannot turn a wrong target into a success; the encoding is what keeps the path whole. Nothing here observes a window. A later pass on the same build read the shell's own window and selection state instead of pixels: the encoded `/select,` target selected the named file; an unencoded `=` opened Documents instead of the file; and a target whose non-ASCII characters stay percent-encoded opened Documents where the literal characters opened the file. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md new file mode 100644 index 0000000000..ff33455da1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Windows 打开经 explorer.exe 完成 + +Status: implemented + +[English](2026-09-22-windows-open-through-shell-resolution.md) | 中文 + +## 问题 + +Windows 桌面客户端设置页的「打开配置文件」会准备当前 profile 的 `cordis.patch.yml`,并把该路径交给原生文本编辑器打开器(issue #4428)。在报告者机器上,这个动作既没有打开窗口,也没有任何提示。打开器执行的是 `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath ''"`,因此由宿主进程内的关联解析决定结果,而 `Invoke-Item` 在解析不到应用时同样以退出码 0 结束。 + +该机器的 `.yml` 默认值只记录在 `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest`(progid 为 `VSCode.yml`),既没有 `UserChoice` 记录,也没有 `HKCU\Software\Classes\.yml` 默认值。对同一个文件双击,资源管理器会打开 VS Code。而在宿主进程中执行时,`Invoke-Item`、`cmd /c start`、`Shell.Application` 的默认动词和 `AssocQueryString` 都报告该扩展名没有应用,`cmd /c start` 则弹出 shell 自己的应用选择框而不是打开某个应用。`Invoke-Item` 仍然返回 0,于是 `SettingsController.openSettingsDocument` 回答 `{ opened: true }`,设置页不渲染任何错误。 + +## 决策 + +`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对**逗号与等号**做百分号编码,因为资源管理器自己解析命令行并以这两个字符分段——含其中任一字符的原始路径都会打开另一个目标且不作任何提示。其余字符不做转义:资源管理器会拒绝 file URI 里百分号编码的非 ASCII 并回落到「文档」,因此这些字符以字面形式交给它。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 + +`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径以 argv 元素跨进程边界,去掉的是 shell 层转义,而不是资源管理器自身解析所需的编码;该编码由 `explorerTarget` 为两种意图统一持有。 + +Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 继续拒绝该平台,默认意图、关联意图与文本编辑器意图都走同一个 Explorer 交接。 + +## 考虑过的替代方案 + +**继续使用 `Invoke-Item`。** 已否决。它询问的解析器与 shell 不同,因此即使某个用户的默认值能被资源管理器识别,仍会得到静默的无效操作;而且它的零退出码正好抹掉了设置页本应渲染的错误。 + +**在宿主进程内自行解析默认值。** 已否决。依次读取 `UserChoiceLatest`、`UserChoice` 和 classes 默认值,再启动胜出 progid 的 `shell\open\command`,等于重新实现一套 Windows 自己拥有的顺序,其中还包括 Windows 会校验的 `UserChoice` 哈希;而双击所用应用的定义就是 shell 自己的答案。 + +**使用 `cmd /c start `。** 已否决。它为资源管理器本已执行的同一套解析引入一个命令 shell,并且在复现该缺陷的机器上同样报告没有应用。 + +**解析不到应用时回退到固定编辑器。** 已否决。Windows 安装总是带有可用的编辑器,但在打开器里选择它等于覆盖用户的关联,并把「缺少关联」变成「静默地打开错误应用」。 + +## 后果 + +设置页的打开动作与产物、工作区的打开动作共享同一个 Windows 行为:双击会启动的那个应用。对于「资源管理器认、`AssocQueryString` 不认」这一被报告的状态,现在会打开已配置的编辑器。 + +打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。 + +交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何关联,且同样以退出码 1 返回,因此该退出码无法与「已委派」区分:Windows 11 ARM64 build 26200 的 SSH session 0 实测约 1 秒后返回退出码 1,而同一会话里 `Invoke-Item` 命令能在进程内解析关联。这样启动的 Host 会失去能力且要付出等待,只有调用方的 abort 信号能给这段等待封顶。该会话下的兜底尚未实现。 + +目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:`explorer.exe` 子进程继承宿主环境,也不创建 detached 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。 + +## 验证 + +`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows。在 Windows 11 ARM64 build 26200 上,一次独立验证用 sha256 与源码一致的副本直接驱动模块自身的 `openNativeAssociatedPath`、`openNativePath`、`openNativeTextFile`,旁边挂一个记录 shell 实收参数的探针关联:每个含逗号的目标都以 `%2C` 完整到达、普通文件正常打开、名为 `dir,comma` 的目录打开为根目录正确的窗口。反证——同一个文件以逗号未编码的 file URI 交给 `explorer.exe`——没有触发任何关联、打开了用户的「文档」,而且**同样返回退出码 1**。因此退出码 1 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现。同一 build 的后续一轮改为直接读取 shell 自己的窗口与选中状态:编码后的 `/select,` 目标选中了目标文件;未编码的 `=` 会打开「文档」而不是文件;非 ASCII 字符保持百分号编码时同样打开「文档」,改为字面字符则正常打开。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.i18n.yaml new file mode 100644 index 0000000000..727ff382c0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.i18n.yaml @@ -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-23-bounded-pnpm-runs.md +2026-09-23-bounded-pnpm-runs.md: 7ce6b1776e4587240072201781c9da5bc78650e8 +2026-09-23-bounded-pnpm-runs.zh.md: 1cb91132cca22a1130fdc48ce3d3dbd354fa5d16 diff --git a/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.md b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.md new file mode 100644 index 0000000000..7ce6b1776e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.md @@ -0,0 +1,59 @@ +# Agent Note: Bounding a pnpm run that stops progressing + +Status: implemented + +English | [中文](2026-09-23-bounded-pnpm-runs.zh.md) + +## Problem + +A profile package operation could hold the profile write lock forever. A `dsh web` process held `~/.dsh/profiles/web/package.json.lock` for 22 minutes after its pnpm child printed `Done in 2s`: that child never exited, so the operation awaited an end that never came, the lock was never released, and every later management call in the process queued behind it. pnpm 11.13.0 consumes its one-shot worker-pool teardown once, re-creates the pool lazily afterwards, and unreferences a worker only during destruction, so the pool keeps the parent process alive with no repository-side change able to fix it. + +A second cause reaches the same end by another route: a lifecycle script that inherited pnpm's stdout and stderr keeps those pipes open after pnpm exits, so a reader waiting for end of file never sees one, and the lock stays held while that reader waits. + +## Decision + +- A run completes when its process exits, not when its pipes end. The operation awaits the raw child's `exit` event and races execa's promise, which resolves only once the piped stdio closes. +- The pipes then drain under a fixed 2000 ms grace period, after which this operation closes them. Failure classification reads a tail pnpm writes before it exits, so the grace period belongs to finishing a run rather than to deployment configuration. +- Only the close this drain causes is ignored. A reading that failed before the cut still surfaces as the run's failure, and the log records that the tail was cut short. +- A run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` beside its own exit status, and is not asked of the next registry, which bounds how long one operation can hold the lock. +- A terminated run is classified `timeout` whatever exit status the signal left behind. Installation and removal report failure instead of success, so a manifest pnpm had half written is restored rather than activated. +- A terminated run stops its whole process tree and waits for it to disappear before the caller restores the profile and releases the lock, because an approved lifecycle script runs as a child of pnpm and outlives it. execa's `killDescendants` addresses the tree on every kill path, including cancellation, and is passed only for a captured run, so a run that inherits the terminal keeps the caller's process group. +- `dsh plugin` inherits the caller's terminal and captures nothing, so no silence bound applies to it and its operator keeps the ability to interrupt. + +## Why a silence bound rather than a duration bound + +A capture-only run that prints nothing for ten minutes is stuck: pnpm reports its own progress and its scripts' output there. A total duration bound would instead kill legitimate work, because a non-TTY pnpm prints a build script's output when that script finishes, and a native build on a slow machine compiles silently for longer than any fixed budget we could justify for it. Silence separates a stalled run from a slow one without guessing a duration, and 600000 ms leaves an approved build ten times the slowest silent stretch maintainers expect from it. + +## Alternatives considered + +**Wait for execa's promise.** execa resolves after the piped stdio ends, which is the end a held pipe withholds, so the completion signal had to move to the process itself. + +**Bound total duration instead of silence.** A stalled run and a compiling run are indistinguishable by duration alone, and the bound would destroy legitimate work whenever the machine is slow enough. + +**Terminate only the pnpm process.** An approved `postinstall` keeps writing into the profile after the operation reports a timeout and releases the lock, so a later restoration races that script. Reproduced with a local dependency whose postinstall writes a file, and with pnpm 11.13.0. + +**Detach the run and reap the tree later.** Rollback and lock release must happen after the tree stopped writing, and deferring the reap leaves open exactly the window this decision closes. + +**Keep the pipes as the completion signal and bound only that wait.** The drain does bound the wait, but a stalled worker pool never ends its pipes, so the process exit remains the completion signal. + +## Testing + +| Evidence | Behaviour | +|---|---| +| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | Silence-bound termination, a run that trapped the signal and exited 0, the bounded drain over a held pipe, a reading failure that still surfaces through the cut, a rejection reason that is not an Error, and the cut notice. | +| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | Real descendants: a pipe that outlives its process still drains, and a stalled run's tree is stopped before the operation returns, so a marker its script would have written never appears. | +| [run-tree.spec.ts](../../../../packages/boot/plugin-manager/tests/run-tree.spec.ts) | Platform group-leadership decisions, the probe target each platform addresses, and the wait bound. | +| [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) | A terminated run that exited 0 restores the manifest, reports `timeout`, and is not activated; a terminated removal reports failure. | + +The real-process cases cover POSIX only: Windows cannot stage a descendant that keeps inherited pipes open past its parent, and the drain itself is platform-independent. With those cases skipped, the mocked cases still hold the per-file coverage gate on Windows. + +## Related + +[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.md) remains the owner of what an installation or removal does with a finished run: the manifest snapshot it restores, the classification it renders, and the cancellation contract. This note owns only how a run ends and how long it may hold the profile lock. + +## Consequences + +- A stalled run is reported up to `idleTimeoutMs` late and the profile lock is held for that long. A silent but healthy native build longer than the default is killed and classified `timeout`; a profile that compiles such a dependency raises `idleTimeoutMs`, trading slower stall detection for tolerance. +- A cut tail is incomplete, so the failure classification and the returned output see only what pnpm wrote inside the grace period. The diagnostic log records the cut. +- Terminating a run kills its whole tree, so a lifecycle script that had already started is stopped instead of being allowed to finish. +- The bound is a workaround for a third-party defect (issue #4981, PR #4982) and becomes unnecessary once pnpm tears down its worker pool before a command returns. diff --git a/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.zh.md b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.zh.md new file mode 100644 index 0000000000..1cb91132cc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.zh.md @@ -0,0 +1,59 @@ +# Agent Note: 为停止推进的 pnpm 运行设界 + +Status: implemented + +[English](2026-09-23-bounded-pnpm-runs.md) | 中文 + +## 问题 + +一次 profile 包操作可能永久持有 profile 写锁。某个 `dsh web` 进程在其 pnpm 子进程打印 `Done in 2s` 之后仍持有 `~/.dsh/profiles/web/package.json.lock` 达 22 分钟:该子进程从未退出,于是操作等待一个永不到来的结束点,锁始终未释放,进程内后续每一次管理调用都排在它后面。pnpm 11.13.0 只消费一次性的 worker 池收尾,之后又惰性重建该池,并且只在销毁时才对 worker 取消引用,因此该池会让父进程一直存活,仓库侧无从修复。 + +第二条成因以另一条路径导致同一结果:继承了 pnpm stdout 与 stderr 的生命周期脚本会在 pnpm 退出后继续持有这些管道,于是等待文件结束的读取方永远等不到,锁在读等期间一直被持有。 + +## 决策 + +- 运行的完成点是进程退出,而非管道结束。操作等待原始子进程的 `exit` 事件,并与 execa 的 promise 竞速;后者只在管道化 stdio 关闭后才 resolve。 +- 随后管道在一个固定的 2000 毫秒宽限窗口内排空,窗口结束后由本次操作关闭它们。失败分类读取的是 pnpm 退出前写入的尾部,因此该宽限属于「完成一次运行」,而不是部署配置项。 +- 只有本次排空造成的关闭才被忽略。切断之前的读取失败仍作为该运行的失败上报,日志会记录尾部被切断。 +- 捕获输出的运行若在 `idleTimeoutMs` 内没有任何输出即被终止,在自身退出状态之外报告 `timedOut`,且不再询问下一个注册表,从而使单次操作持有锁的时长有上界。 +- 被终止的运行不论信号留下什么退出状态都归类为 `timeout`。安装与删除都报告失败而非成功,因此 pnpm 只写了一半的 manifest 会被还原,而不会被启用。 +- 被终止的运行会停止整棵进程树并等待其消失,之后调用方才还原 profile 并释放锁,因为获批的生命周期脚本是 pnpm 的子进程且存活更久。execa 的 `killDescendants` 覆盖每一条 kill 路径(含取消),且只对捕获输出的运行启用,因此继承终端的运行仍留在调用方的进程组内。 +- `dsh plugin` 继承调用方终端、不捕获输出,因此不受静默上界约束,其操作者仍可中断。 + +## 为何用静默上界而非时长上界 + +只捕获输出、十分钟没有任何输出的运行已经卡住:pnpm 自身的进度与脚本输出都写在那里。而总时长上界会误杀正当工作,因为非 TTY 的 pnpm 只在该脚本结束时才打印它的输出,慢机器上的原生构建以超过任何可辩护固定预算的时间静默编译。静默把卡住的运行与较慢的运行区分开,无需猜测时长;600000 毫秒给获批构建留下的静默余量,是维护者预期的最慢静默时长的十倍。 + +## 考虑过的替代方案 + +**等待 execa 的 promise。** execa 在管道化 stdio 结束后才 resolve,而那正是被持有管道扣住不发的结束点,因此完成信号必须移到进程本身。 + +**改为对总时长设界。** 仅凭时长无法区分卡住的运行与正在编译的运行,而且只要机器够慢,这个上界就会破坏正当工作。 + +**只终止 pnpm 进程。** 获批的 `postinstall` 会在操作报告超时并释放锁之后继续写入 profile,于是后续的还原与该脚本互相竞争。已用 pnpm 11.13.0 与一个 postinstall 会写文件的本地依赖复现。 + +**让运行脱离父进程、稍后再回收进程树。** 还原与释放锁必须在进程树停止写入之后发生,推迟回收恰好留下本决策要关闭的那个窗口。 + +**保留管道作为完成信号,只对该等待设界。** 排空确实为等待设了界,但卡住的 worker 池永不结束其管道,因此进程退出仍是完成信号。 + +## 验证 + +| 证据 | 覆盖行为 | +|---|---| +| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | 静默上界终止、捕获信号后以 0 退出的运行、持有管道下的有界排空、经切断仍上报的读取失败、非 Error 的拒绝原因,以及切断提示。 | +| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | 真实孙进程:存活超过其父进程的管道仍能排空;卡住运行的进程树在操作返回前被停止,因此其脚本本应写入的标记不会出现。 | +| [run-tree.spec.ts](../../../../packages/boot/plugin-manager/tests/run-tree.spec.ts) | 各平台的进程组归属判定、各平台探测的目标,以及等待上界。 | +| [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) | 以 0 退出的被终止运行会还原 manifest、报告 `timeout` 且不被启用;被终止的删除报告失败。 | + +真进程用例仅覆盖 POSIX:Windows 无法搭出「孙进程在其父进程退出后仍持有继承管道」的前提,而排空本身与平台无关。跳过这些用例时,mock 用例仍能在 Windows 上满足逐文件覆盖率门禁。 + +## 相关 + +[引导式插件安装](../architecture/2026-09-15-guided-plugin-installation.zh.md)仍是「安装或删除如何处理一次已结束运行」的归属:它还原的 manifest 快照、渲染的分类与取消约定。本记录只负责运行如何结束,以及它可以持有 profile 锁多久。 + +## 后果 + +- 卡住的运行最迟在 `idleTimeoutMs` 之后才被报告,profile 锁在这段时间内一直被持有。静默但健康、且超过默认值的原生构建会被终止并归类为 `timeout`;需要编译此类依赖的 profile 应提高 `idleTimeoutMs`,以更慢的卡住检测换取容忍度。 +- 被切断的尾部不完整,因此失败分类与返回输出只看到 pnpm 在宽限窗口内写入的内容。诊断日志记录了这次切断。 +- 终止运行会杀掉整棵进程树,因此已经开始的生命周期脚本会被停止,而不会被允许跑完。 +- 该上界是针对第三方缺陷的变通(issue #4981、PR #4982);一旦 pnpm 在命令返回前完成其 worker 池收尾,它就不再必要。 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index 0e6c3a467b..bf097e40c1 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -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-06-15-ptc.md -2026-06-15-ptc.md: d5ed3fc79d4586d3754b7273e08970fb066fe6e9 -2026-06-15-ptc.zh.md: 1434db5c352cdb14b857dc5d4ca3bb2f5332e98e +2026-06-15-ptc.md: 220f8d27fa912fe708be251e04947cf6e1a46239 +2026-06-15-ptc.zh.md: e244d0173e3734ebe00c07f5362da0662310dd8d diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index d5ed3fc79d..220f8d27fa 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -101,6 +101,8 @@ Deployments switching to `'ptc'` must update any native-only `toolOrder`. Assemb - **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a PTC mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. - **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. The TypeScript SDK PTC scenario mounts its Node runtime through an explicit test-owned profile patch and pins Session events and JSON-RPC notifications; its expected response and completed-turn checks run before refresh writes. +The optional [best-effort persistent provider](../architecture/2026-09-23-best-effort-persistent-ptc.md) permits disposable cross-call memory without restoring objects or replaying programs after restart; the default provider stays stateless. + ## Alternatives considered **An add-on consumer plugin with zero core changes.** Rejected because `agent/request` is call-config-only under [reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md), while transforming an assembled tool list would have to undo `toolOrder` canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store. diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 1434db5c35..e244d0173e 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -101,6 +101,8 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 - **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 PTC mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 - **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。TypeScript SDK PTC 场景通过测试拥有的显式 profile patch 挂载 Node 运行时,并固定 Session 事件与 JSON-RPC 通知;预期回复和完成轮次检查在 refresh 写入前执行。 +可选的[尽力持久提供方](../architecture/2026-09-23-best-effort-persistent-ptc.zh.md)允许跨调用保留可丢失的内存,不在重启后恢复对象或回放程序;默认提供方保持无状态。 + ## 曾考虑的替代方案 **一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../architecture/2026-07-05-reconstructable-requests.zh.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。 diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml index 2f5a36785b..a806e17e4e 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml @@ -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-25-promote-open-anywhere-plugin.md -2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f -2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96 +2026-08-25-promote-open-anywhere-plugin.md: e5a435d24c2400b68fad8cccb094b176ad83f590 +2026-08-25-promote-open-anywhere-plugin.zh.md: 108091f3e7c2736063d67bc6b2d7fe0c039b35db diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md index 83888cb548..e5a435d24c 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md @@ -21,7 +21,7 @@ The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `pac - **Resolution sources are platform-honest and cheap.** macOS checks the known application directories (`/Applications`, `~/Applications`) for the entry's bundle spellings and launches `open -a `; Xcode follows `xcode-select -p`. Windows reads `App Paths`, then Uninstall records kept only when they prove an executable on disk, then known paths and the newest versioned install directory where an application uses one — one batched `reg.exe query` per root per pass. GitHub Desktop resolves its versioned executable together with the packaged `cli.js` and invokes the supported `github open ` behavior without a command shell. CLI names resolve in-process through `ctx.subprocess.resolveExecutable()` (PATH/PATHEXT stat, no shell, no `which`/`where.exe` children); Linux GUI entries whose CLI is off PATH fall back to their XDG desktop entry's verified `TryExec`/`Exec`, while `xdg-open` is offered only when the host announces a display server. The remaining host commands (`xcode-select`, `reg.exe`, icon extraction) run through `@deepseek-ai/dsh-native-command` (argv, never a shell). - **Three independent deadlines replace the single `commandTimeoutMs`**: `probeTimeoutMs` (resolution commands), `iconTimeoutMs` (icon extraction commands), and `launchWatchMs` (the early-failure watch window), so tuning one operation never changes another's response time — the shipped bundle keeps conservative 10 s command deadlines (timeouts are failure bounds, not latency budgets) with a 1 s watch window, which is what bounds how long the open route and the button's busy dress hold a successful launch. Launches spawn detached with a credential-scrubbed environment (`scrubbedParentEnv` from `dsh-subprocess`) plus explicit adapter entries; Windows GUI launchers remain visible unless an adapter explicitly hides its CLI process because that process opens the GUI separately. A launcher still running when the watch window closes counts as launched and is never killed or awaited (kitty and the JetBrains IDEs stay in the foreground for their window lifetime). - **Icons extract on every platform with a host source.** macOS converts the resolved bundle's `.icns` to a 128px PNG (`plutil` + `sips`); Windows extracts the resolved executable's associated icon as a 32px PNG through a generated PowerShell script run with positional `-File` args (no command-line path parsing); Linux follows the spec's desktop entry `Icon=` into the hicolor theme and pixmaps directories (PNG or SVG, filesystem only). Any failure answers 404 and the browser keeps its generic glyph. -- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a ` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, the OS shell's open verb with the full parent environment, not a detached scrubbed spawn), because a direct `explorer.exe ` spawn does not reliably raise a window. Closed unions end in `assertNever`. +- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a ` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, which hands the directory to the OS shell with the full parent environment, not a detached scrubbed spawn), because a detached, credential-scrubbed `explorer.exe ` spawn does not reliably raise a window; the opener's own `explorer.exe` handoff does ([Windows open resolution](../bug-fix/2026-09-22-windows-open-through-shell-resolution.md)). Closed unions end in `assertNever`. - **Every route runs behind the composition connection service's trust fence** (`requestRejection`: the Host/Origin fence defeating DNS rebinding and cross-site calls, plus browser authentication), the same guard the API gateway applies to its WebSocket upgrade; the mechanism's one home is the `src/index.ts` module comment. On top of that fence the open route validates the body at the wire: an `application/json` media type (exact essence, not a substring match), a 64 KiB bound with a drained 413, string field types, only probed-available catalog ids, and an absolute path naming an existing directory. - **The DSH-version gate is deleted.** It existed because the plugin rode release-to-release against an interface it did not own; a first-party package is versioned with the repository, so the gate, its `sessionStorage`/`localStorage` trust ledger, and the argv-walking version probe have no referent. - **The last choice persists through `createSnapshotStore(..., { persist })`** (`dsh.open-in-app.choice`), replacing hand-rolled `localStorage` access. A fresh store has no platform-specific choice; the component uses the first available host entry until the user chooses one. diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md index 4ef476ce70..108091f3e7 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md @@ -21,7 +21,7 @@ Status: implemented - **解析来源按平台务实且廉价。** macOS 在已知应用目录(`/Applications`、`~/Applications`)查条目的 bundle 拼写,启动 `open -a <解析出的 bundle>`;Xcode 跟随 `xcode-select -p`。Windows 依次读 `App Paths`、只在能证明磁盘可执行文件时采用的 Uninstall 记录、已知路径,以及采用版本化安装目录的应用中最新的目录——每趟每根一条批量 `reg.exe query`。GitHub Desktop 会同时解析版本化可执行文件与随包提供的 `cli.js`,不经命令 shell 调用受支持的 `github open ` 行为。CLI 名称经 `ctx.subprocess.resolveExecutable()` 进程内解析(PATH/PATHEXT stat,无 shell、无 `which`/`where.exe` 子进程);CLI 不在 PATH 上的 Linux GUI 条目回退到其 XDG desktop 条目验证过的 `TryExec`/`Exec`,且只有主机声明了 display server 时才提供 `xdg-open`。其余主机命令(`xcode-select`、`reg.exe`、图标提取)经 `@deepseek-ai/dsh-native-command`(argv,绝不走 shell)执行。 - **三个独立期限取代单一 `commandTimeoutMs`**:`probeTimeoutMs`(解析命令)、`iconTimeoutMs`(图标提取命令)、`launchWatchMs`(早期失败看护窗口),调整一种操作的超时不再改变其他操作的响应时间——随发行 bundle 保守地保留 10 秒命令期限(超时是失败上界而非延迟预算),看护窗口 1 秒,它才是约束 open 路由与按钮忙碌态挂起一次成功启动时长的量。启动以清理过凭据的环境(`dsh-subprocess` 的 `scrubbedParentEnv`)叠加适配器显式环境后 detached 派生;Windows GUI launcher 默认保持可见,只有负责另行打开 GUI 的 CLI 适配器会显式隐藏自己的进程。看护窗口关闭时仍在运行的启动器计为已启动,绝不会被杀死或等待(kitty 与 JetBrains IDE 在整个窗口生命周期内保持前台)。 - **有主机来源的平台都提取图标。** macOS 把解析出的 bundle 的 `.icns` 转 128px PNG(`plutil` + `sips`);Windows 用生成的 PowerShell 脚本以位置式 `-File` 参数(路径不经命令行解析)提取解析出的可执行文件的关联图标为 32px PNG;Linux 沿 spec 的 desktop 条目 `Icon=` 查 hicolor 主题与 pixmaps 目录(PNG 或 SVG,纯文件系统)。任何失败应答 404,浏览器保持通用占位图形。 -- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a `)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,OS shell 的 open verb,携带完整父环境,而非 detached 的清理环境 spawn),因为直接 spawn `explorer.exe ` 不能可靠地弹出窗口。封闭 union 以 `assertNever` 收尾。 +- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a `)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,把目录交给 OS shell 并携带完整父环境,而非 detached 的清理环境 spawn),因为 detached 且经过凭据清洗的 `explorer.exe ` 派生进程不能可靠地弹出窗口;打开器自身的 `explorer.exe` 交接可以([Windows 打开解析](../bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md))。封闭 union 以 `assertNever` 收尾。 - **所有路由都运行在组合 connection 服务的信任栅栏之后**(`requestRejection`:挫败 DNS rebinding 与跨站调用的 Host/Origin 栅栏,加上浏览器认证),与 API gateway 施加在其 WebSocket upgrade 上的守卫相同;机制的唯一出处是 `src/index.ts` 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型(精确 essence,而非子串匹配)、以排空后 413 的方式把 body 限制在 64 KiB、校验字段类型、只接受探测为可用的目录 id,并要求指向现存目录的绝对路径。 - **删除了 DSH 版本门禁。** 它存在是因为插件逐版本骑乘一个它不拥有的接口;第一方包与仓库同版本发布,门禁、它的 `sessionStorage`/`localStorage` 信任台账和 argv 遍历版本探测都失去了所指。 - **上次选择经 `createSnapshotStore(..., { persist })` 持久化**(`dsh.open-in-app.choice`),替代手写 `localStorage` 访问。新存储没有平台特定的初始选择;用户首次选择前,组件使用主机提供的第一个可用条目。 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 85fe4f719e..5edfca99c1 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -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-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: 15db888330b2a1a6d6d78dad46689962466d531d -2026-09-16-browser-excel-preview.zh.md: 6b3a6a617a0cd18dcd3da1682a31f26bd7e70386 +2026-09-16-browser-excel-preview.md: 8a4bbd5dc838bc79e61f9b412af43b912a8fe019 +2026-09-16-browser-excel-preview.zh.md: f04170b28fe4c6d267b12958f47a392638bfaadb diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 15db888330..8a4bbd5dc8 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -24,7 +24,7 @@ The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@ The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with ` { +it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli', 'missing-assets', 'wrong-type'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => { const root = await mkdtemp(join(tmpdir(), 'sdk-office-')) onTestFinished(() => rm(root, { recursive: true, force: true })) const enabled = mode !== 'unset' && mode !== 'empty' - const { source, paths } = await officeFixture(root, mode === 'python-only') + const { source, paths } = await officeFixture(root, mode.startsWith('python-only')) if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true }) if (mode === 'wrong-type') { await rm(paths.python) @@ -460,9 +460,11 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', ' const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture did not bind') const home = join(root, 'home') + const cliPatch = join(root, 'cli.patch.yml') + await writeFile(cliPatch, JSON.stringify(mode === 'python-only-no-cli' ? [{ id: 'skill-office', config: { assetRoot: join(root, 'resources', 'office-skills'), cli: false } }] : [])) const officeLaunch = resolveExampleLaunch({ srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib', - configArgs: ['--profile', 'sdk'], + configArgs: ['--profile', 'sdk', '--patch', cliPatch], env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' || mode === 'bundled' ? undefined : mode === 'empty' ? '' : source + '/', DSH_BUNDLED_PRIMARY_RUNTIME: mode === 'unset' ? undefined : mode === 'bundled' || mode === 'empty' ? source : join(root, 'unused-default'), DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', @@ -492,7 +494,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', ' const names = (requests[0]!.tools as { name: string }[]).map(value => value.name) expect(names.includes('load_workspace_dependencies')).toBe(enabled) for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets') + expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets' && mode !== 'python-only') } if (mode === 'wrong-type') { expect(requests).toHaveLength(2) @@ -521,6 +523,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', ' expect.objectContaining({ role: 'user', content }), ])) } + if (mode === 'python-only') expect(stderr).toContain('node') if (mode === 'missing-assets') expect(stderr).toContain('check_office.py') await send(3, 'shutdown') const exit = await child diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts new file mode 100644 index 0000000000..359e559241 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -0,0 +1,125 @@ +/** + * Agent discovery of the installed Office CLI through the shipped SDK profile. + * Defaults to built Node packages. DSH_OFFICE_TEST_EXECUTABLE/ENTRY select a + * relocated Node or Electron carrier; CARRIER=sdk selects the standalone executable. + * NODE/CLI/ASSETS under the same prefix supply Desktop resources outside ASAR. + * API=openai-completions selects a development gateway instead of the default Messages API. + */ +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile, chmod } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createInterface } from 'node:readline' +import { execa } from 'execa' +import { strToU8, zipSync } from 'fflate' +import { expect, it } from 'vitest' + +const repo = fileURLToPath(new URL('../../../../../', import.meta.url)) +const carrier = process.env.DSH_OFFICE_TEST_CARRIER ?? 'npm' +const executable = process.env.DSH_OFFICE_TEST_EXECUTABLE ?? process.execPath +const entry = process.env.DSH_OFFICE_TEST_ENTRY ?? join(repo, 'apps/cli/lib/bin.js') + +it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent discovers and executes the ${carrier} Office CLI without Node on PATH`, async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh office discovery ')) + try { + await writeFile(join(root, 'input.docx'), zipSync({ + '[Content_Types].xml': strToU8(''), + '_rels/.rels': strToU8(''), + 'word/document.xml': strToU8('Office CLI discovery'), + })) + const patch = join(root, 'office.patch.yml') + await writeFile(patch, JSON.stringify([ + ...(process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? [{ id: 'llm-deepseek', disabled: true }, { id: 'llm-pi-ai', config: { providers: { 'office-test': { api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL, compat: { thinkingFormat: 'deepseek' }, models: [{ id: process.env.MODEL_NAME ?? 'deepseek-v4-flash', contextWindow: 128000, maxTokens: 8000 }] } } } }] : []), + { id: 'session-persistence-jsonl', config: { root: join(root, 'sessions'), compression: 'none' } }, + ...carrier === 'sdk' ? [] : [{ insert: [{ + id: 'office-cli-discovery', name: '@deepseek-ai/dsh-skill-office', config: { + ...(process.env.DSH_OFFICE_TEST_NODE ? { node: process.env.DSH_OFFICE_TEST_NODE } : {}), + ...(process.env.DSH_OFFICE_TEST_CLI ? { cli: process.env.DSH_OFFICE_TEST_CLI } : {}), + ...(process.env.DSH_OFFICE_TEST_ASSETS ? { assetRoot: process.env.DSH_OFFICE_TEST_ASSETS } : {}), + }, + }] }], + ])) + // A private POSIX tool directory also excludes /usr/bin/node on Linux. + const path = join(root, 'bin') + await mkdir(path) + for (const name of ['bash', 'sh', 'cat', 'ls', 'head', 'tail', 'mkdir']) { + const binary = (existsSync('/bin/' + name) ? '/bin/' : '/usr/bin/') + name + await access(binary) + await symlink(binary, join(path, name)) + } + const decoyMarker = join(root, 'system-office-invoked') + for (const name of ['libreoffice', 'soffice']) { + const decoy = join(path, name) + await writeFile(decoy, '#!/bin/sh\nprintf invoked > ' + "'" + decoyMarker.replaceAll("'", "'\\''") + "'" + '\nexit 86\n') + await chmod(decoy, 0o755) + } + const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v libreoffice-kit'], { env: { PATH: path }, reject: false }) + expect(lookup.exitCode, 'the fixture must not expose Node or the Office CLI through PATH').not.toBe(0) + const child = execa(executable, [ + ...carrier === 'sdk' ? [] : [entry], '--profile', 'sdk', '--patch', patch, + ], { + cwd: root, env: { + PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native', + DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '', + }, + timeout: 110_000, killSignal: 'SIGKILL', reject: false, + }) + const lines = createInterface({ input: child.stdout }) + const records: Record[] = [] + let protocolError: Error | undefined + const send = (id: number, method: string, params?: object): void => { + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n') + } + lines.on('line', (line) => { + try { + const value = JSON.parse(line) as Record + records.push(value) + if (value.id === 1) send(2, 'session/prompt', { sessionId: 'main', contentBlocks: [{ type: 'text', text: + 'Load the appropriate Office skill and convert input.docx to result.pdf. Also save the installed LibreOffice Kit capabilities command output verbatim to capabilities.json. Use the supplied runtime; do not install anything. Report briefly after the files exist.', + }] }) + const params = value.params as { event?: { type?: string } } | undefined + if (value.error !== undefined || params?.event?.type === 'turn/end') send(3, 'shutdown') + } catch (error) { + protocolError = error instanceof Error ? error : new Error(String(error)) + child.kill('SIGKILL') + } + }) + try { + send(1, 'initialize', { cwd: root, provider: process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? 'office-test' : 'deepseek-official', model: process.env.MODEL_NAME ?? 'deepseek-v4-flash', maxTokens: 8000 }) + const result = await child + const diagnostic = `${result.stderr}\n${result.stdout}` + expect(protocolError, diagnostic).toBeUndefined() + expect(result.timedOut, diagnostic).toBe(false) + expect(result.signal, diagnostic).toBeUndefined() + expect(result.exitCode, diagnostic).toBe(0) + expect(records.filter(record => record.error !== undefined), diagnostic).toEqual([]) + const calls = records.flatMap((record) => { + const params = record.params as { event?: { type: string; data: { name?: string; arguments?: unknown } } } | undefined + return params?.event?.type === 'tool/call' ? [params.event.data] : [] + }) + expect(calls.some(call => call.name === 'skill'), diagnostic).toBe(true) + const commands = calls.filter(call => call.name === 'bash') + expect(commands.length, diagnostic).toBeGreaterThan(0) + expect(calls.findIndex(call => call.name === 'skill'), diagnostic).toBeLessThan(calls.findIndex(call => call.name === 'bash')) + const commandText = JSON.stringify(commands) + for (const fragment of ['cli.js', 'capabilities', 'convert']) expect(commandText, diagnostic).toContain(fragment) + expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false) + expect(commandText, 'the skill supplies an entry; searching for other Office binaries is unnecessary').not.toMatch(/(?:which|whereis|command -v|find).{0,100}(?:libreoffice|soffice)/iu) + const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } } + expect(capabilities.runtime.version).toBe('0.1.0') + expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u) + const pdf = await readFile(join(root, 'result.pdf')) + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-') + expect(pdf.length).toBeGreaterThan(1000) + } finally { + lines.close() + child.stdin.end() + child.kill('SIGKILL') + await child + } + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 5d9628bdee..eea02dc7ce 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -10,6 +10,7 @@ "lib/index.js" ], "dependencies": { + "@deepseek-ai/libreoffice-kit": "^0.1.0", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 209882013e..ecbff835ce 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -71,6 +71,7 @@ async function main(): Promise { const { ctx } = await application control.updateTasks = installDesktopUpdateTaskControl(ctx) await ctx.plugin(desktopOffice, { + runtimeDir, source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'), root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'), }) diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts index 8420aa0feb..13941060ef 100644 --- a/apps/desktop-host/src/office.ts +++ b/apps/desktop-host/src/office.ts @@ -1,6 +1,9 @@ /** Desktop Office skills and bundled authoring dependencies. */ -import { dirname, join } from 'node:path' +import { realpathSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { runtimeArchivePath } from './office-engine.ts' import type { Context } from '@deepseek-ai/cordis' import * as officeSkills from '@deepseek-ai/dsh-skill-office' import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies' @@ -13,6 +16,8 @@ export interface Config { readonly source: string /** Harness-home directory where workspace dependencies are installed. */ readonly root: string + /** Prepared or ASAR-contained application dependency directory. */ + readonly runtimeDir: string } /** @@ -22,5 +27,12 @@ export interface Config { */ export async function apply(ctx: Context, config: Config): Promise { await ctx.plugin(workspaceDependencies, config) - await ctx.plugin(officeSkills, { assetRoot: join(dirname(config.source), 'office-skills') }) + const archive = runtimeArchivePath(config.runtimeDir) === undefined ? undefined : dirname(realpathSync(config.runtimeDir)) + const manifest = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json')) + const packageRoot = dirname(archive === undefined ? manifest : join(`${archive}.unpacked`, relative(archive, manifest))) + await ctx.plugin(officeSkills, { + assetRoot: join(dirname(config.source), 'office-skills'), + node: join(config.source, 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node'), + cli: join(packageRoot, 'lib', 'cli.js'), + }) } diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts index f26b5fc6e0..862f832029 100644 --- a/apps/desktop-host/tests/office.spec.ts +++ b/apps/desktop-host/tests/office.spec.ts @@ -1,4 +1,4 @@ -import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -12,12 +12,15 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import { expect, it } from 'vitest' import * as desktopOffice from '../src/office.ts' -it('loads Desktop Office skills without a document renderer and removes them on disposal', async () => { +it('loads Desktop Office CLI paths and removes them on disposal', async () => { const root = await mkdtemp(join(tmpdir(), 'desktop-office-')) const ctx = new Context() try { const assets = join(root, 'runtime', 'office-skills') await cp(new URL('../../../packages/skill/skill-office/assets/', import.meta.url), assets, { recursive: true }) + const nodeDirectory = join(root, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin') + await mkdir(nodeDirectory, { recursive: true }) + await cp(process.execPath, join(nodeDirectory, process.platform === 'win32' ? 'node.exe' : 'node')) ctx.baseUrl = pathToFileURL(root).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include @@ -38,13 +41,16 @@ it('loads Desktop Office skills without a document renderer and removes them on await writeFile(config, [ '- name: agents', '- name: systemPrompt', '- name: tools', '- name: skills', '- name: office', ' config:', ` source: ${JSON.stringify(join(root, 'runtime', 'primary-runtime'))}`, - ` root: ${JSON.stringify(join(root, 'installed'))}`, '', + ` root: ${JSON.stringify(join(root, 'installed'))}`, + ` runtimeDir: ${JSON.stringify(join(root, 'dsh'))}`, '', ].join('\n')) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) await ctx.loader.await() for (const entry of ctx.loader.entries()) await entry.fiber?.await() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office-docx', 'office-pptx', 'office-xlsx']) expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx') }) + expect((await ctx.skills.get('office-pptx'))?.content).toContain('libreofficeKit') + expect((await ctx.skills.get('office-pptx'))?.content).toContain(JSON.stringify(nodeDirectory).slice(1, -1)) expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies']) const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'office') expect(entry).toBeDefined() diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 77500865f6..7d355c9801 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e -README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c +README.md: 87b16ac8fd7ec269e8c02a34a32e6593b38aec0a +README.zh.md: 895fdd972148c90bad00916a35b21cebff8842e8 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b07b2b9d9..87b16ac8fd 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -185,9 +185,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Complete native engine packages, including executables, libraries, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/`. The Desktop Host resolves engine manifests to these physical directories so native subprocesses can read their resources; the JavaScript API and WASM engine remain in ASAR. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). +The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4ebae34332..895fdd9721 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -185,7 +185,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整原生引擎包(包括可执行文件、库、数据、许可证和 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/` 下。Desktop Host 将引擎清单解析到这些物理目录,使原生子进程能读取资源;JavaScript API 和 WASM 引擎保留在 ASAR 中。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 +[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 2cee72d23d..e0a707dc32 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -1,7 +1,8 @@ +import { officePackageDirectories } from '../../../scripts/libreoffice-packages.mjs' import { X509Certificate } from 'node:crypto' import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import { join } from 'node:path' +import { join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { execFile } from 'node:child_process' import { promisify } from 'node:util' @@ -163,6 +164,10 @@ export function createElectronBuilderConfig( writeUpdateInfo: false, }, beforePack: async context => { + const office = await officePackageDirectories(buildPaths.dsh, { platform: resolvedPlatform, arch: resolvedArch }) + const patterns = office.map(directory => `**/${relative(buildPaths.dsh, directory).split(sep).join('/')}/**/*`) + const existing = context.packager.config.asarUnpack ?? [] + context.packager.config.asarUnpack = [...(typeof existing === 'string' ? [existing] : existing), ...patterns] if (packagesWindows) windowsCode = await prepareWindowsAsarUnpack(context, buildPaths.dsh) if (windowsSigner !== undefined) { primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime') diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index a89247ad18..377c300efb 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -31,7 +31,7 @@ import { } from './macos-runtime.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts' -import { selectOfficeEngine } from '../../../scripts/libreoffice-engine.ts' +import { selectOfficeEngine } from '../../../scripts/libreoffice-packages.mjs' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_PATHS = resolveDesktopTargetBuildPaths() diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index 19b26fac1a..f15f10a928 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -49,11 +49,30 @@ export async function smokeDesktopRuntime( })) writeFileSync(join(plugin, 'index.js'), ` import { Context } from '@deepseek-ai/cordis' -import { inspect } from 'node:util' +import { inspect, promisify } from 'node:util' +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' export function apply(ctx) { if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke', handler(_request, response) { response.end('plugin route ready') } })) + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-cli', + async handler(_request, response) { + try { + const skill = await ctx.skills.get('office-docx') + const json = skill?.content.match(/\\n(\\{\\n[\\s\\S]+)$/u)?.[1] + if (json === undefined) throw new Error('Office skill did not supply CLI paths') + const { libreofficeKit: { node, cli } } = JSON.parse(json) + const options = { cwd: ${JSON.stringify(home)}, env: { ...process.env, PATH: '' }, timeout: 120_000 } + const capabilities = await promisify(execFile)(node, [cli, 'capabilities'], options) + const output = ${JSON.stringify(join(home, 'cli.pdf'))} + await promisify(execFile)(node, [cli, 'convert', '--input', ${JSON.stringify(join(home, 'input.docx'))}, '--output', output], options) + response.end(JSON.stringify({ capabilities: JSON.parse(capabilities.stdout), pdf: (await readFile(output)).toString('base64') })) + } catch (error) { + response.statusCode = 500 + response.end(inspect(error, { depth: 5 })) + } + } })) for (const input of ${JSON.stringify(inputs)}) { ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office/' + input.extension, async handler(_request, response) { @@ -71,7 +90,7 @@ export function apply(ctx) { } } `) - writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf]\n') + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills]\n') const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { dependencies: Record dsh: { profile: { bundles: string[] } } @@ -103,7 +122,15 @@ export function apply(ctx) { throw new Error(`desktop runtime: invalid ${extension} PDF output`) } } - console.log('desktop runtime: DOCX, XLSX, PPTX to PDF passed') + const cliResponse = await fetch(new URL('/desktop-smoke-office-cli', ready.url), { + headers: { cookie }, signal: AbortSignal.timeout(120_000), + }) + if (!cliResponse.ok) throw new Error(`desktop runtime: skill CLI failed: ${await cliResponse.text()}`) + const cliResult = await cliResponse.json() as { capabilities: { runtime: { cliPath: string } }; pdf: string } + if (!cliResult.capabilities.runtime.cliPath.endsWith('cli.js') || Buffer.from(cliResult.pdf, 'base64').subarray(0, 5).toString() !== '%PDF-') { + throw new Error('desktop runtime: skill CLI did not return capabilities and a PDF') + } + console.log('desktop runtime: DOCX, XLSX, PPTX to PDF and skill CLI discovery passed') } finally { clearTimeout(timer) await host.stop() diff --git a/apps/desktop/tests/installed-update-package-content.spec.ts b/apps/desktop/tests/installed-update-package-content.spec.ts index 8ecfeb9f39..76aea94736 100644 --- a/apps/desktop/tests/installed-update-package-content.spec.ts +++ b/apps/desktop/tests/installed-update-package-content.spec.ts @@ -1,4 +1,6 @@ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import type { WriteStream } from 'node:fs' +import { finished } from 'node:stream/promises' import { createHash } from 'node:crypto' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' @@ -30,8 +32,9 @@ afterEach(() => { external.archive.mockReset(); external.signature.mockReset(); const require = createRequire(import.meta.url) const builderRequire = createRequire(require.resolve('app-builder-lib/package.json')) -const { createPackageWithOptions } = builderRequire('@electron/asar') as { - createPackageWithOptions: (source: string, destination: string, options: { unpack: string }) => Promise +const { createPackageWithOptions, uncache } = builderRequire('@electron/asar') as { + createPackageWithOptions: (source: string, destination: string, options: { unpack: string }) => Promise + uncache: (archive: string) => boolean } const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const const publisher = 'CN=Fixture,O=Fixture,C=CN' @@ -45,6 +48,7 @@ async function fixture(body: (context: { resealRuntime: () => Promise }) => Promise, version: string = versions[0]): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-package-content-')) + const archive = join(root, 'payload/resources/app.asar') try { const run = await createInstalledUpdateRun(root, versions, { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] }) const manifest = join(run.root, 'run.json') @@ -80,14 +84,20 @@ async function fixture(body: (context: { url: `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}`, publisherName: [publisher], updaterCacheDirName: `dsh-update-test-${run.id}-updater` })) const seal = async () => { - await createPackageWithOptions(source, join(payload, 'resources/app.asar'), { unpack: '**/*.exe' }) + // ASAR 3 returns the output stream after end(), before its writes finish. + const output = await createPackageWithOptions(source, archive, { unpack: '**/*.exe' }) + await finished(output, { cleanup: true }) + uncache(archive) } await seal() await body({ manifest, source, payload, version, seal, resealRuntime: async () => { reseal(join(source, 'dsh')) await seal() } }) - } finally { await rm(root, { recursive: true, force: true }) } + } finally { + uncache(archive) + await rm(root, { recursive: true, force: true }) + } } describe('installed update archive contents', () => { diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts index a9bb43c8f1..7522430879 100644 --- a/apps/desktop/tests/welcome-flow.e2e.ts +++ b/apps/desktop/tests/welcome-flow.e2e.ts @@ -100,6 +100,9 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { }, }) cpSync(join(repository, 'packages/skill/skill-office/assets'), join(root, 'runtime/office-skills'), { recursive: true }) + const nodeBin = join(root, 'runtime/primary-runtime/dependencies/node/bin') + mkdirSync(nodeBin, { recursive: true }) + cpSync(process.execPath, join(nodeBin, process.platform === 'win32' ? 'node.exe' : 'node')) const paths = resolveDesktopPaths(home) const manager = new DesktopProjectManager(paths, { dsh: project, diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index 1be1eb6266..2fe4b3aaf2 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -74,6 +74,16 @@ async function fixture(external: boolean) { } await writeFile(join(source, 'node_modules', 'foo', 'companion.json'), '{}') await writeFile(join(source, 'node_modules', 'foo', '$xarchy.binary'), 'neighbor') + for (const [name, manifest] of [ + ['@deepseek-ai/libreoffice-kit', { name: '@deepseek-ai/libreoffice-kit', optionalDependencies: { '@deepseek-ai/libreoffice-kit-win32-x64': '0.0.4' }, dependencies: { 'office-codec': '1' } }], + ['@deepseek-ai/libreoffice-kit-win32-x64', { name: '@deepseek-ai/libreoffice-kit-win32-x64' }], + ['office-codec', { name: 'office-codec' }], + ] as const) { + const directory = join(source, 'node_modules', name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), JSON.stringify(manifest)) + await writeFile(join(directory, 'cli.js'), 'export {}') + } const config = { files: [{ from: source, to: 'dsh', filter: ['**/*'] }, { from: join(source, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }], @@ -235,6 +245,9 @@ it.each([false, true])('keeps the complete Office engine outside ASAR with exter await packageFixture(input) const archive = await readAsar(join(input.resources, 'app.asar')) expect(archive.getFile(join('dsh', 'node_modules', '@deepseek-ai', 'libreoffice-kit-wasm', 'package.json')).unpacked).not.toBe(true) + for (const name of ['@deepseek-ai/libreoffice-kit', 'office-codec']) { + expect(archive.getFile(join('dsh', 'node_modules', name, 'cli.js')).unpacked).toBe(true) + } for (const file of files) { expect(archive.getFile(join('dsh', engine, file), false).unpacked).toBe(true) expect(await readFile(join(input.resources, 'app.asar.unpacked', 'dsh', engine, file), 'utf8')).toBe('{}') diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 301b268177..5ab43f206b 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -1,4 +1,4 @@ -/** The preset settings page only selects a preset; creating one starts a Creator-mode task. */ +/** The preset settings page selects a preset and shows what it declares; creating one starts a Creator-mode task. */ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -27,13 +27,13 @@ describe('web e2e: preset roster guidance', () => { }, 120_000) afterAll(async () => { await browser?.close(); await scaffold?.close() }) - it('shows the shipped roster with mode help and no editing actions', async () => { + it('shows the shipped roster with mode help and a read-only view, and no editing actions', async () => { onTestFailed(() => saveFailureShot(page, 'preset-roster-section')) await expect.poll(() => page.locator('[data-agent-preset-id]').count()).toBe(4) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(EXPECTED, 'section.expected.md'), snapshot, mode) expect(snapshot).toContain('让 Agent 帮我创建预设模式') - expect(snapshot).not.toContain('查看配置') + expect(snapshot).toContain('查看配置: 标准模式') expect(snapshot).not.toContain('复制预设') expect(snapshot).not.toContain('编辑插件') expect(snapshot).not.toContain('打开目录') @@ -55,6 +55,27 @@ describe('web e2e: preset roster guidance', () => { expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true') }) + it('views a shipped composition read-only', async () => { + onTestFailed(() => saveFailureShot(page, 'preset-roster-view')) + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.getByRole('button', { name: '查看配置: PTC 模式', exact: true }).click() + const viewer = page.getByRole('dialog', { name: '查看配置 · PTC 模式', exact: true }) + await viewer.waitFor({ timeout: 10_000 }) + // The real shipped declaration, not a golden: the viewer shows whatever + // the deployment ships, and this lane only asserts it is shown read-only + // in the Loader's own dialect. + const shown = await viewer.locator('pre').textContent() + expect(shown).toContain("- id: persona\n name: '@deepseek-ai/dsh-persona'\n") + expect(shown).toContain('- id: workflow-ptc\n') + expect(shown).toContain("disabled: !!js process.platform === 'win32'\n") + expect(shown).not.toContain('__jsExpr') + expect(await viewer.getByRole('textbox').count()).toBe(0) + // The header X and the footer button share the 关闭 name; the footer one is last. + await viewer.getByRole('button', { name: '关闭', exact: true }).last().click() + await viewer.waitFor({ state: 'detached', timeout: 10_000 }) + expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true') + }) + it('starts a Creator-mode task from the section entry', async () => { onTestFailed(() => saveFailureShot(page, 'preset-roster-creator')) // The entry stages the self-referential preset and lands a new task on it, diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 8365c34f45..321d21fe3b 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -343,6 +343,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { for (const element of elements) element.scrollLeft = 0 }) } + const primaryTextColour = await review.evaluate(element => getComputedStyle(element).color) const addedLine = review.locator('[data-diff-side="right"] [data-diff-line="add"]') const rightContextLine = review.locator('[data-diff-side="right"] [data-diff-line="context"]') const addedRule = await addedLine.evaluate((line) => { @@ -351,7 +352,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { if (number === undefined || text === null) throw new Error('added diff line is incomplete') const left = line.getBoundingClientRect().left return { - shadow: getComputedStyle(line).boxShadow, + shadow: getComputedStyle(number).boxShadow, + markerColour: getComputedStyle(number).color, textColour: getComputedStyle(text).color, numberLeft: number.getBoundingClientRect().left - left, textLeft: text.getBoundingClientRect().left - left, @@ -367,7 +369,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { textLeft: text.getBoundingClientRect().left - left, } }) - expect(addedRule.shadow).toContain(addedRule.textColour) + expect(addedRule.shadow).toContain(addedRule.markerColour) + expect(addedRule.textColour).toBe(primaryTextColour) expect(addedRule.numberLeft).toBeCloseTo(rightContextRule.numberLeft, 1) expect(addedRule.textLeft).toBeCloseTo(rightContextRule.textLeft, 1) // The ignored file has no snapshot; its comparison comes from the copies captured around the write call. @@ -402,7 +405,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { if (number === undefined || text === null) throw new Error('deleted diff line is incomplete') const left = line.getBoundingClientRect().left return { - shadow: getComputedStyle(line).boxShadow, + shadow: getComputedStyle(number).boxShadow, + markerColour: getComputedStyle(number).color, textColour: getComputedStyle(text).color, numberLeft: number.getBoundingClientRect().left - left, textLeft: text.getBoundingClientRect().left - left, @@ -418,7 +422,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { textLeft: text.getBoundingClientRect().left - left, } }) - expect(deletedRule.shadow).toContain(deletedRule.textColour) + expect(deletedRule.shadow).toContain(deletedRule.markerColour) + expect(deletedRule.textColour).toBe(primaryTextColour) expect(deletedRule.numberLeft).toBeCloseTo(leftContextRule.numberLeft, 1) expect(deletedRule.textLeft).toBeCloseTo(leftContextRule.textLeft, 1) await compareTool.click() @@ -470,11 +475,17 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。']) const wrappedAddition = review.locator('[data-diff-line="del"] > span').nth(1) const wrappedAdditionRule = await wrappedAddition.evaluate((cell) => { + const number = cell.children[0] const text = cell.querySelector('[data-diff-code]') - if (text === null) throw new Error('wrapped addition has no text') - return { shadow: getComputedStyle(cell).boxShadow, textColour: getComputedStyle(text).color } + if (number === undefined || text === null) throw new Error('wrapped addition is incomplete') + return { + shadow: getComputedStyle(number).boxShadow, + markerColour: getComputedStyle(number).color, + textColour: getComputedStyle(text).color, + } }) - expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.textColour) + expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.markerColour) + expect(wrappedAdditionRule.textColour).toBe(primaryTextColour) // 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) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index a28b0b3525..14c2728288 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -9,12 +9,12 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { realOfficeBytes } from './office-fixture.ts' -import { excelFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' +import { excelFixture, excelFreezeFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-drawing-fixture.ts' import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts' import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' -import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' +import { openSettings, connectFreshWorkspace, connectFreshWorkspaceZh, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url)) @@ -104,15 +104,39 @@ async function hideDocumentZoom(page: Page, preview: Locator): Promise { await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull() } -it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while retaining the selected cell', async () => { +/** The canvas fills the space between the formula bar and sheet tabs without a statistics row. */ +async function expectExcelLayout(excel: Locator): Promise { + await expect.poll(() => excel.locator('.fortune-stat-area').isVisible()).toBe(false) + expect(await excel.locator('.luckysheet-sheets-item-function:visible').count()).toBe(0) + await expect.poll(() => excel.evaluate((node) => { + const pane = node.getBoundingClientRect() + const canvas = node.querySelector('canvas')!.getBoundingClientRect() + const formula = node.querySelector('.fortune-workarea')!.getBoundingClientRect() + const tabs = node.querySelector('.luckysheet-sheet-area')!.getBoundingClientRect() + const scroller = node.querySelector('.fortune-sheettab-container')!.getBoundingClientRect() + const controls = [...node.querySelectorAll('.fortune-sheettab-scroll, .fortune-zoom-button')] + .map(control => control.getBoundingClientRect()) + return Math.max(Math.abs(canvas.left - pane.left), Math.abs(canvas.width - pane.width), + Math.abs(canvas.top - formula.bottom), Math.abs(canvas.bottom - tabs.top), Math.abs(tabs.bottom - pane.bottom), + Math.abs(scroller.left - tabs.left), scroller.right - controls[0]!.left, + ...controls.map((control, index) => control.right - (controls[index + 1]?.left ?? tabs.right))) + })).toBeLessThanOrEqual(1) + expect(await excel.locator('.fortune-sheettab-scroll, .fortune-zoom-button').evaluateAll(controls => controls.every((control) => { + const rect = control.getBoundingClientRect() + return control.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)) + }))).toBe(true) +} + +it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pane with read-only controls in %s', async (locale) => { const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false }) let browser: Browser | undefined try { browser = await chromium.launch() - const page = await newEnglishPage(browser) - onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${process.pid}`) }) + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale, timezoneId: 'Asia/Shanghai' }) + const consoleErrors = watchConsole(page) + onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${locale}-${process.pid}`) }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await connectFreshWorkspace(page, scaffold.workspaceCwd) + await (locale === 'zh-CN' ? connectFreshWorkspaceZh : connectFreshWorkspace)(page, scaffold.workspaceCwd) const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) @@ -120,7 +144,14 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while const sessionId = await settled const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd if (cwd === undefined) throw new Error('settled Session has no workspace cwd') - await writeFile(join(cwd, 'budget.xlsx'), await excelFixture()) + const longCellText = 'Monthly export/import matrix and category comparison. 各商品类别分月出口/进口矩阵与结构对比图(公式汇总) '.repeat(3) + await Promise.all([ + writeFile(join(cwd, 'budget.xlsx'), await excelFixture()), + writeFile(join(cwd, 'meeting.xlsx'), await meetingMinutesFixture()), + writeFile(join(cwd, 'legacy.xls'), xlsFixture()), + writeFile(join(cwd, 'values.csv'), `${longCellText},2\n3,4\n`), + writeFile(join(cwd, 'values.tsv'), '1\t2\n3\t4\n'), + ]) await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() const column = page.locator('[data-rightbar-col]') await page.locator('[data-sidebar-right-expand]').click() @@ -132,6 +163,16 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } }) const formula = excel.locator('.fortune-fx-input') await expect.poll(() => formula.innerText()).toBe('46281') + await page.keyboard.press('Shift+ArrowLeft') + const selection = excel.locator('.fortune-name-box') + await expect.poll(() => selection.innerText()).toBe('A1:B1') + await expectExcelLayout(excel) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('42\t2026-09-16') + const arrow = excel.locator('.luckysheet-sheets-item-function').first() + await arrow.evaluate((node) => { (node as HTMLElement).focus() }) + expect(await arrow.evaluate(node => document.activeElement === node)).toBe(false) const canvas = excel.locator('canvas').first() const originalCanvas = await canvas.elementHandle() const original = await canvas.boundingBox() @@ -145,17 +186,227 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while await expect.poll(() => formula.innerText()).toBe('46281') expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true) expect(page.viewportSize()).toEqual(viewport) - await successShot(page, 'excel-resize-wide') + await expectExcelLayout(excel) + await successShot(page, `excel-resize-wide-${locale}`) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) + await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(360) + await expectExcelLayout(excel) + expect(await excel.locator('.fortune-sheettab-container').evaluate((node) => { + const selected = node.querySelector('.luckysheet-sheets-item-active')!.getBoundingClientRect() + return selected.right - node.getBoundingClientRect().right + })).toBeLessThanOrEqual(1) + await page.setViewportSize({ width: 1680, height: 600 }) + await expect.poll(async () => Math.round(original.height - (await canvas.boundingBox())!.height)).toBe(400) + await expectExcelLayout(excel) + expect(await selection.innerText()).toBe('A1:B1') + await successShot(page, `excel-resize-narrow-${locale}`) + await page.setViewportSize(viewport!) await layout.evaluate((node) => { node.textContent = '' }) await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width)) expect(await formula.innerText()).toBe('46281') - await successShot(page, 'excel-resize-restored') + expect(await selection.innerText()).toBe('A1:B1') + expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true) + await expectExcelLayout(excel) + await successShot(page, `excel-resize-restored-${locale}`) + const zoom = excel.locator('.fortune-zoom-ratio-current') + await excel.locator('.fortune-zoom-button').first().click() + await expect.poll(() => zoom.innerText()).toBe('90%') + await excel.locator('.fortune-zoom-button').last().click() + await expect.poll(() => zoom.innerText()).toBe('100%') + await zoom.click() + await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click() + await expect.poll(() => zoom.innerText()).toBe('150%') + await zoom.click() + await excel.locator('.fortune-zoom-ratio-item').getByText('100%', { exact: true }).click() + const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText(locale === 'zh-CN' ? '文件' : 'Files', { exact: true }) }) + const preview = column.locator('[data-textpreview-url]') + await openPreviewFile(column, filesTab, preview, 'meeting.xlsx') + const tabScroller = excel.locator('.fortune-sheettab-container-c') + const activeSheet = excel.locator('.luckysheet-sheets-item-active .luckysheet-sheets-item-name') + await expect.poll(() => activeSheet.innerText()).toBe('会议信息') + const meetingCanvas = await canvas.elementHandle() + if (meetingCanvas === null) throw new Error('meeting spreadsheet canvas is unavailable') + await expectExcelLayout(excel) + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 60, y: 40 } }) + await expect.poll(() => formula.innerText()).toBe('会议纪要') + await expect.poll(() => selection.innerText()).toBe('A1') + for (const width of [1000, 360, 1000, 360]) { + await layout.evaluate((node, width) => { node.textContent = `[data-sidebar-right-panel] { width: ${width}px !important; }` }, width) + await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width) + await expectExcelLayout(excel) + expect(await meetingCanvas.evaluate(node => node.isConnected)).toBe(true) + expect(await selection.innerText()).toBe('A1') + await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0) + } + for (const direction of ['right', 'left'] as const) { + const arrow = excel.locator(`#fortune-sheettab-${direction}scroll`) + for (let step = 0; step < 8; step += 1) { + const { offset, maximum } = await tabScroller.evaluate(node => ({ + offset: node.scrollLeft, maximum: node.scrollWidth - node.clientWidth, + })) + const target = direction === 'right' ? Math.min(offset + 150, maximum) : Math.max(offset - 150, 0) + if (Math.abs(target - offset) <= 1) break + await arrow.click() + await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeCloseTo(target, 0) + } + const sheetName = direction === 'right' ? '填写说明' : '会议信息' + await excel.locator('.luckysheet-sheets-item-name').getByText(sheetName, { exact: true }).click() + await expect.poll(() => activeSheet.innerText()).toBe(sheetName) + await expectExcelLayout(excel) + await successShot(page, `excel-navigation-${direction}-${locale}`) + } + expect(await tabScroller.evaluate(node => Math.abs( + node.firstElementChild!.getBoundingClientRect().left - node.getBoundingClientRect().left, + ))).toBeLessThanOrEqual(1) + await excel.locator('.fortune-zoom-button').first().click() + await expect.poll(() => zoom.innerText()).toBe('90%') + await excel.locator('.fortune-zoom-button').last().click() + await expect.poll(() => zoom.innerText()).toBe('100%') + await zoom.click() + await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click() + await expect.poll(() => zoom.innerText()).toBe('150%') + expect(await activeSheet.innerText()).toBe('会议信息') + await expectExcelLayout(excel) + await successShot(page, `excel-navigation-zoom-${locale}`) + await layout.evaluate((node) => { node.textContent = '' }) + await meetingCanvas.dispose() + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + for (const name of ['budget.xlsx', 'meeting.xlsx', 'legacy.xls', 'values.csv', 'values.tsv']) { + await openPreviewFile(column, filesTab, preview, name) + await excel.locator('.fortune-sheet-overlay').waitFor() + await expectExcelLayout(excel) + if (name === 'values.csv') { + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) + await expect.poll(() => formula.textContent()).toBe(longCellText) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) + await expectExcelLayout(excel) + await expect.poll(() => formula.evaluate((node) => { + const range = document.createRange() + range.selectNodeContents(node) + return range.getClientRects().length + })).toBe(1) + expect(await formula.evaluate(node => node.scrollHeight <= node.clientHeight)).toBe(true) + expect(await formula.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true) + await formula.hover() + await page.mouse.wheel(400, 0) + await expect.poll(() => formula.evaluate(node => node.scrollLeft)).toBeGreaterThan(0) + await successShot(page, `excel-formula-single-line-${locale}-${colorScheme}`) + await layout.evaluate((node) => { node.textContent = '' }) + await expectExcelLayout(excel) + } + await successShot(page, `excel-controls-${name}-${locale}-${colorScheme}`) + } + } + expect(consoleErrors.pageErrors).toEqual([]) await originalCanvas.dispose() } finally { try { await browser?.close() } finally { await scaffold.close() } } }) +/** Sample the pixel band before the divider center, excluding the adjacent cell grid line. */ +async function freezeDividerInk(excel: Locator, axis: 'x' | 'y'): Promise { + return await excel.evaluate((node, direction) => { + const canvas = node.querySelector('canvas')! + const bounds = canvas.getBoundingClientRect() + const scale = canvas.width / bounds.width + const columnHeader = node.querySelector('.fortune-col-header')! + const rowHeader = node.querySelector('.fortune-row-header')! + const columnHandle = node.querySelector('.fortune-cols-freeze-handle')! + const rowHandle = node.querySelector('.fortune-rows-freeze-handle')! + // Canvas coordinates include the 1.5px omitted from FortuneSheet's header elements. + const x = Number.parseFloat(rowHeader.style.width) + 1.5 + Number.parseFloat(columnHandle.style.left) - columnHeader.scrollLeft + const y = Number.parseFloat(columnHeader.style.height) + 1.5 + Number.parseFloat(rowHandle.style.top) - rowHeader.scrollTop - 2 + const context = canvas.getContext('2d')! + let ink = 0 + for (let offset = -Math.round(scale); offset < 0; offset += 1) { + const px = direction === 'x' ? Math.floor(x * scale) + offset : Math.floor((bounds.width - 25) * scale) + const py = direction === 'y' ? Math.floor(y * scale) + offset : Math.floor((y + 15) * scale) + const pixel = context.getImageData(px, py, 1, 1).data + ink += 255 - pixel[0]! + } + return ink / scale + }, axis) +} + +it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without dividers at DPR %i', async (deviceScaleFactor) => { + const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false }) + let browser: Browser | undefined + try { + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor, locale: 'en-US' }) + const consoleErrors = watchConsole(page) + onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-freeze-${deviceScaleFactor}-${process.pid}`) }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const settled = scaffold.whenTurnSettled() + await page.locator('[data-composer-input]').first().fill(PROMPT) + await page.locator('[data-composer-input]').first().press('Enter') + const cwd = scaffold.ctx.agents.get(await settled)?.session.header.cwd + if (cwd === undefined) throw new Error('settled Session has no workspace cwd') + await writeFile(join(cwd, 'freeze.xlsx'), await excelFreezeFixture()) + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + const column = page.locator('[data-rightbar-col]') + await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'freeze.xlsx', exact: true }).click() + const excel = column.locator('[data-excel-preview]') + const selectTopLeft = async () => { + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 80, y: 35 } }) + return await excel.locator('.fortune-fx-input').innerText() + } + for (const sheet of ['Both', 'Rows', 'Columns', 'None']) { + await excel.locator('.luckysheet-sheets-item-name').getByText(sheet, { exact: true }).click() + await expect.poll(selectTopLeft).toBe('R1C1') + for (const handle of ['.fortune-cols-freeze-handle', '.fortune-rows-freeze-handle']) { + expect(await excel.locator(handle).isVisible()).toBe(false) + expect(await excel.locator(handle).evaluate(node => node.getClientRects().length)).toBe(0) + } + for (const ratio of ['100%', '150%']) { + await excel.locator('.fortune-zoom-ratio-current').click() + await excel.locator('.fortune-zoom-ratio-item').getByText(ratio, { exact: true }).click() + await expect.poll(() => excel.locator('.fortune-zoom-ratio-current').innerText()).toBe(ratio) + await excel.locator('.fortune-fx-input').click() + for (const axis of ['x', 'y'] as const) { + if (sheet === 'Both' || sheet === (axis === 'x' ? 'Columns' : 'Rows')) { + await expect.poll(() => freezeDividerInk(excel, axis)).toBe(0) + } + } + await successShot(page, `excel-freeze-${sheet}-${ratio}-${deviceScaleFactor}`) + } + await excel.locator('.luckysheet-scrollbar-x').evaluate((node) => { node.scrollLeft = 240 }) + await excel.locator('.luckysheet-scrollbar-y').evaluate((node) => { node.scrollTop = 240 }) + await expect.poll(async () => { + const value = await selectTopLeft() + const match = /^R(\d+)C(\d+)$/.exec(value) + return match && { rowFixed: match[1] === '1', columnFixed: match[2] === '1' } + }).toEqual({ rowFixed: sheet === 'Both' || sheet === 'Rows', columnFixed: sheet === 'Both' || sheet === 'Columns' }) + await successShot(page, `excel-freeze-scrolled-${sheet}-${deviceScaleFactor}`) + } + await excel.locator('.luckysheet-sheets-item-name').getByText('Both', { exact: true }).click() + await expect.poll(selectTopLeft).toBe('R1C1') + const canvas = await excel.locator('canvas').first().elementHandle() + const selection = await excel.locator('.fortune-name-box').innerText() + const layout = await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 360px !important; }' }) + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + await expectExcelLayout(excel) + await successShot(page, `excel-freeze-narrow-${colorScheme}-${deviceScaleFactor}`) + } + await layout.evaluate(node => node.parentNode!.removeChild(node)) + await expectExcelLayout(excel) + expect(await canvas!.evaluate(node => node.isConnected)).toBe(true) + expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection) + await canvas!.dispose() + expect(consoleErrors.pageErrors).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +}) + describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => { let scaffold: WebScaffold let browser: Browser diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index c1d3acc103..4ce3d29216 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -21,6 +21,7 @@ - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - 'button "模式说明: 标准模式"': 模式说明 - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 - listitem: - 'button "设为新任务默认: PTC 模式"': - text: PTC 模式 内置 @@ -28,6 +29,7 @@ - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - 'button "模式说明: PTC 模式"': 模式说明 - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 - listitem: - 'button "设为新任务默认: 极简模式"': - text: 极简模式 内置 @@ -35,6 +37,7 @@ - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - 'button "模式说明: 极简模式"': 模式说明 - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 - listitem: - 'button "设为新任务默认: 创造模式"': - text: 创造模式 内置 @@ -42,5 +45,6 @@ - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - 'button "模式说明: 创造模式"': 模式说明 - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 - heading "自定义" [level=3] - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index ab3230e5bd..06c0c5258b 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -9,7 +9,7 @@ - button "Copy" - status: Worked - button "Took {{duration}}" [expanded] -- button "Edited files, called tools, searched code, etc." [expanded] +- button "Wrote files, called tools, searched code, etc." [expanded] - button "Write site/report.html +1 -0": - text: Write - button "site/report.html" diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index abf392c727..4c95f6f14a 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -17,6 +17,7 @@ const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session. const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url)) const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url)) const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url)) +const PREPARING_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/preparing.expected.md', import.meta.url)) const MODE = webSnapshotMode() // Elicits the successful and failed sub-rows this scenario asserts. @@ -29,6 +30,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { let page: Page let tripwire: ReturnType const sessionEvents: SessionEvent[] = [] + let releasePreparation: (() => void) | undefined beforeAll(async () => { scaffold = await launchWebScaffold({ @@ -46,6 +48,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { }, 120_000) afterAll(async () => { + releasePreparation?.() await browser?.close() await scaffold?.close() }) @@ -58,9 +61,38 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled() - await input.fill(PROMPT) - await input.press('Enter') - const sessionId = await settled + const gate = Promise.withResolvers() + const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) { + let held = false + for await (const chunk of next()) { + yield chunk + if (!held && chunk.type === 'tool-call-delta' && chunk.name) { + held = true + await gate.promise + } + } + }, { prepend: true }) + releasePreparation = () => { gate.resolve(undefined); dispose() } + const observePreparation = async () => { + try { + await input.fill(PROMPT) + await input.press('Enter') + const row = page.locator('[data-chat-call-id] [data-state="preparing"]').first() + await row.waitFor({ state: 'attached', timeout: 30_000 }) + await expandOwningTurnProcess(page, row) + await row.waitFor({ state: 'visible' }) + await page.getByRole('button', { name: 'Preparing to run code', exact: true }).waitFor() + expect(sessionEvents.some(event => event.type === 'tool/call')).toBe(false) + expect(await row.getByRole('button').count()).toBe(0) + expect(await row.locator('pre').count()).toBe(0) + const group = row.locator('xpath=ancestor::*[@data-chat-group-key][1]') + await compareOrRefreshGolden(PREPARING_EXPECTED, await group.ariaSnapshot(), MODE) + } finally { + releasePreparation?.() + } + } + const [sessionId] = await Promise.all([settled, observePreparation()]) + await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0) if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } @@ -118,6 +150,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { await page.reload({ waitUntil: 'load' }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0) } const nest = page.locator('[data-subcalls]').first() const frame = page.locator('[style*="grid-template-columns"]').first() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index b15eba3852..b6c34b6dd1 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 49e6270d1fbe4448bc7798cc23f84eac660642fe -config-catalog.zh.md: d96fd56531bf8d994ae982e287cd4c7ff7b61983 +config-catalog.md: 21f24e79b8ab6906807e612821d6cc8d1f704c12 +config-catalog.zh.md: 42c1d1528c8de305420baca69792ac60c6071773 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49e6270d1f..21f24e79b8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2213,6 +2213,8 @@ export interface Config { inspectTimeoutMs?: number /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ githubConnectionTimeoutMs?: number + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** @@ -2276,7 +2278,7 @@ export interface LaunchConfig { } ``` -Source: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts) +Source: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:25`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts) @@ -2796,10 +2798,14 @@ Requires: `skills` export interface Config { /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ assetRoot?: string + /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ + node?: string + /** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */ + cli?: string | false } ``` -Source: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts) +Source: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d96fd56531..42c1d1528c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2215,6 +2215,8 @@ export interface Config { inspectTimeoutMs?: number /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ githubConnectionTimeoutMs?: number + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** @@ -2278,7 +2280,7 @@ export interface LaunchConfig { } ``` -来源: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts) +来源: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:25`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts) @@ -2798,10 +2800,14 @@ export interface Config { export interface Config { /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ assetRoot?: string + /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ + node?: string + /** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */ + cli?: string | false } ``` -来源: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts) +来源: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 04a443a565..1024da2857 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 1d844013f1230d6dbe535c3942279683f03738ce -event-producer-consumer.zh.md: 5961d9d623b753d0e2e478dabf3a26c7b566a26c +event-producer-consumer.md: 54a5e36bb6d2c39606c2e0263cad89293e6bb613 +event-producer-consumer.zh.md: 3c0da089d7c99bd96dfe8bb45a4e9c07dcd9f2be diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1d844013f1..54a5e36bb6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -52,9 +52,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 5961d9d623..3c0da089d7 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -8,7 +8,7 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -52,9 +52,9 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index bab89e6efd..10e0a602c4 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -64,7 +64,7 @@ | spawn | spawn | | | | | steering | steering | steering(中途引导) | | | | job id | job id | | 任务 id | 保留英文 | -| subagent | subagent | | | | +| subagent | subagent | | | 文档正文保留英文;中文 UI 中译作「子智能体」,不使用「子代理」 | | transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 | | Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 | | waterfall | waterfall | waterfall(瀑布式事件) | | | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 32d3d4d57d..e53b404a8c 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: 200e7156f65c4cba6fab6808a84326e85df69ddd -persistence-catalog.zh.md: fa48684d4456c6338b67d58bc051c3672d6ee2c9 +persistence-catalog.md: cc41ab5b6acd977a680e92b0b509bc4edb672dc9 +persistence-catalog.zh.md: a4d5d4cb4ab2ac5d55de0d3f2ea0d2c46265c277 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 200e7156f6..cc41ab5b6a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -6654,7 +6654,7 @@ Sources: [`packages/session/session-title/src/types.ts:30`](../packages/session/ SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -Sources: [`packages/core/tools/src/ptc.ts:14`](../packages/core/tools/src/ptc.ts) +Sources: [`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index fa48684d44..a4d5d4cb4a 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -6656,7 +6656,7 @@ SHA-256: `b81e9de81a481b24e1e1f9adf038bfc59e57b4f35ce62f8f1e45284186f4f812` SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -来源:[`packages/core/tools/src/ptc.ts:14`](../packages/core/tools/src/ptc.ts) +来源:[`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 211f6c6fe6..7bfb583e8c 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -78437,7 +78437,7 @@ }, "names": [], "sources": [ - "packages/core/tools/src/ptc.ts:14" + "packages/core/tools/src/ptc.ts:15" ] }, { diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 4df5918665..598649b415 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -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 docs/subsystems/boot.md -boot.md: 6ec2204ffe04fe91acb89fe16fa8f8d3332299e7 -boot.zh.md: f5fde00f0cd858450ecd7c75ca42b64e2f4348e4 +boot.md: 5faf482b0532058e480adf13bf7fee8bb882498d +boot.zh.md: 1f50732fd36a996c6bc15b406f7e9a4ac83bcb26 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 6ec2204ffe..5faf482b05 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -16,7 +16,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginRegistries` carries the configured first registry, `null` for the one pnpm's own configuration names, the fallbacks asked after it, and `resolved`, the URL pnpm's own configuration names or `null` while unread. `InspectOptions.registry` names the registry a lookup asks first. -`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from. +`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index f5fde00f0c..1f50732fd3 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -16,7 +16,7 @@ `PluginRegistries` 携带配置的第一个注册表(`null` 即 pnpm 自身配置指定的那个)、随后依次询问的备选注册表,以及 `resolved`——pnpm 自身配置指向的 URL,未读到时为 `null`。`InspectOptions.registry` 指定一次查询首先询问的注册表。 -`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 +`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index 56327598b7..be624bc56d 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -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 docs/subsystems/conversation.md -conversation.md: 9455858acac16584b4d300dc8cd2634eb2a20310 -conversation.zh.md: 2e7c48ab5f65935c2fcb4de05c4484c965622746 +conversation.md: dca3d513398cb1e29a8fe16d00fd1bac9c21ce58 +conversation.zh.md: 18d2a2b282f0ccb02cc903855cf65d542e4160da diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 9455858aca..dca3d51339 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -13,7 +13,7 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se | Concept | Owner and purpose | |---|---| | Event Definition | A business package matches one durable or Client-only transient event at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. | -| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A transient event occupies one update Match; update-only evidence may remain pending until pagination supplies its unique durable start. | +| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. Durable and transient events may be starts. The earliest loaded start initializes State; later Matches update it. Update-only evidence stays pending until its start is loaded. | | Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. | | View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. | | Group Definition | A business package derives one target's root references and group snapshots from materialized Nodes; it owns membership, segmentation, summaries, and incremental caches. | @@ -69,7 +69,7 @@ Use the producer-owned branded id type across the process boundary. Put the `Ses Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events. -Live Assistant deltas arrive as Client-only `assistant/live-chunk` updates. Reconnect baselines expand the active process-local compact stream into the same transient events, while durable `assistant/message` and `assistant/attempt` events embed complete compact streams for history replay. Transient events can only be updates; `start()` receives a standard `SessionEvent`. A Definition that consumes Assistant output handles live chunks and durable settlements in the same `match()` and `update()` methods, while unrelated Definitions return `null` without expanding a stream. +Live Assistant deltas arrive as Client-only `assistant/live-chunk` events. Reconnect baselines expand the active process-local compact stream into the same transient events, while durable `assistant/message` and `assistant/attempt` events embed complete compact streams for history replay. A transient event may initialize a Context. A named tool delta and its later tool/call can both match as start under the same callId; only the earliest calls start(), and later Matches call update(). Historical pages do not expand settled messages into live deltas. A Definition that consumes Assistant output handles live chunks and durable settlements in the same `match()` and `update()` methods, while unrelated Definitions return `null` without expanding a stream. ## Definition and typed Chat payload @@ -243,11 +243,11 @@ export function apply(ctx: ClientContext): void { } ``` -`match(event)` is an identity extractor, not a fold: it receives only the current `SessionEventLike` and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and calls `start` once for a standard event or `update` for a standard or packed event. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics. +`match(event)` is an identity extractor, not a fold: it receives only the current `SessionEventLike` and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and initializes from its earliest loaded start, whether durable or transient. Every later Match calls `update`, including another start. Removing transient Matches reselects the start from remaining evidence and recomputes State; no remaining start leaves State undefined. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics. `buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`. -`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. +`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from the matched event ordering, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. ## Predecessor reads @@ -274,7 +274,7 @@ With `D` registered Definitions, one incoming scalar event or packed run perform Add focused tests that establish these outcomes: 1. A complete window passed through replace produces the expected final State, Location data, Node payload, and `anchorSeq`. -2. An update-only tail stays pending; prepending the unique start produces the same result as a complete replace. +2. An update-only tail stays pending; prepending its start produces the same result as a complete replace. 3. Initial history followed by live append produces the same result as replaying the combined window. 4. Prepending an older page adds earlier rows without replacing existing keyed Node values whose data did not change. 5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 2e7c48ab5f..18d2a2b282 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -13,7 +13,7 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session | 概念 | Owner 与用途 | |---|---| | Event Definition | 业务包一次匹配一个持久 event 或 Client-only 瞬态 event,以稳定 `(kind, id)` 关联输入、折叠确定性 State,并可选择 materialize 一个 target node。 | -| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个瞬态 event 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一持久 start。 | +| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。持久和瞬态事件都可以作为 start。当前最早的 start 初始化 State,后续 Match 更新它。只有 update 的证据保持 pending,直到其 start 加载。 | | Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 | | View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 | | Group Definition | 业务包从物化后的 Node 派生一个目标的根引用和组快照,拥有成员归属、分段、摘要及增量缓存。 | @@ -69,7 +69,7 @@ Group 存储在安装前校验完整提交结果:根 Group 引用与记录一 系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。 -实时 Assistant delta 作为 Client-only `assistant/live-chunk` update 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 只能充当 update;`start()` 只接收标准 `SessionEvent`。消费 Assistant 输出的 Definition 在同一组 `match()` 与 `update()` 方法里处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。 +实时 Assistant delta 作为 Client-only `assistant/live-chunk` event 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 可以初始化 Context。具名工具 delta 和后续 tool/call 可以按同一个 callId 匹配为 start;只有最早的 Match 调用 start(),后续 Match 调用 update()。历史分页不把已结束消息展开为实时 delta。消费 Assistant 输出的 Definition 在同一组 `match()` 与 `update()` 方法里处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。 ## Definition 与类型化 Chat payload @@ -243,7 +243,7 @@ export function apply(ctx: ClientContext): void { } ``` -`match(event)` 是身份提取器,不是 fold:它只能收到当前 `SessionEventLike`,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context;标准 event 可触发一次 `start`,标准或 packed event 可把当前 State 交给 `update`。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。 +`match(event)` 是身份提取器,不是 fold:它只能收到当前 `SessionEventLike`,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context;当前最早的 start 初始化 State,不论它是持久事件还是瞬态事件。后续所有 Match,包括其他 start,都调用 `update`。移除瞬态 Match 后,从剩余事件重新选择 start 并重算 State;没有剩余 start 时 State 为 undefined。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。 `buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。 @@ -274,7 +274,7 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的 添加聚焦测试,证明以下结果: 1. 完整窗口通过 replace 后产生预期的最终 State、Location data、Node payload 与 `anchorSeq`。 -2. 只有 update 的尾部窗口保持 pending;prepend 唯一 start 后,结果与完整 replace 相同。 +2. 只有 update 的尾部窗口保持 pending;prepend 其 start 后,结果与完整 replace 相同。 3. 初始历史后继续实时 append,与回放合并后的完整窗口得到相同结果。 4. prepend 更早分页只增加更早的行;数据未变化的既有 keyed Node value 不被替换。 5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 29fbf9d0ae..51b124f552 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -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 docs/subsystems/core.md -core.md: aa9af309c03ebedae353139c4017d19f2607dd7d -core.zh.md: b18ff5bba1c0309e9b9dcdf1f3e97adb5895caf6 +core.md: 64344148708b29fc186a2e33ef187025c4bc5300 +core.zh.md: 0b85519b916490d6692a31f59ed89527ce72fa43 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index aa9af309c0..6434414870 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -510,6 +510,12 @@ async list(): Promise */ async resolve(id?: string): Promise +/** Read one declaration's child plugin list as YAML, for viewing only. + * @param agentPreset Preset identity. + * @returns The declared composition beside its published metadata. + */ +@Remote('read') readDocument(agentPreset: string): Promise + /** Bind an unpublished Agent to the current preset revision. * @param ctx Agent context from its setup callback. * @param id Requested preset, or the default. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index b18ff5bba1..0b85519b91 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -520,6 +520,12 @@ async list(): Promise */ async resolve(id?: string): Promise +/** Read one declaration's child plugin list as YAML, for viewing only. + * @param agentPreset Preset identity. + * @returns The declared composition beside its published metadata. + */ +@Remote('read') readDocument(agentPreset: string): Promise + /** Bind an unpublished Agent to the current preset revision. * @param ctx Agent context from its setup callback. * @param id Requested preset, or the default. diff --git a/docs/subsystems/ptc-runtime.i18n.yaml b/docs/subsystems/ptc-runtime.i18n.yaml index 0e6f21ce73..d7b4bb2da6 100644 --- a/docs/subsystems/ptc-runtime.i18n.yaml +++ b/docs/subsystems/ptc-runtime.i18n.yaml @@ -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 docs/subsystems/ptc-runtime.md -ptc-runtime.md: efab5bcefa0aaec50c29c73f981f6a6b717cd087 -ptc-runtime.zh.md: f35c5bd84d79ddaccc40653707d792f5672e73d9 +ptc-runtime.md: 194431e3b6320eaf245244c6dfcb8b8120eeed7a +ptc-runtime.zh.md: 9f1d67fb842c024bb74f213133fbcae6d262d428 diff --git a/docs/subsystems/ptc-runtime.md b/docs/subsystems/ptc-runtime.md index efab5bcefa..194431e3b6 100644 --- a/docs/subsystems/ptc-runtime.md +++ b/docs/subsystems/ptc-runtime.md @@ -18,11 +18,18 @@ Source: [`packages/ptc-runtime/ptc-runtime/src/types.ts`](../../packages/ptc-run interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It - * runs as the body of an async function: top-level `await` and `return` - * are available, and the completion value becomes + * follows the provider's programFormat: an async function body supports + * top-level `await` and `return`; a REPL supports top-level declarations, + * `await`, and a final expression. The completion value becomes * {@link PtcRunResult.value}. */ program: string + /** + * Object-identity owner for optional in-memory state. Omission requests a fresh + * environment. Consumers must await runtime.release(owner) when the owner ends. + * Stateless providers ignore this field; retained state is never durable. + */ + stateOwner?: object /** Host functions exposed to the program, one global object per namespace. */ bindings: PtcBindingNamespace[] /** Working directory in the mounted filesystem and subprocess execution world. */ @@ -77,7 +84,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (its top-level `return`), when it ran to + * The program's completion value (a return or final REPL expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -94,6 +101,8 @@ interface PtcRunResult { } ``` +`programFormat` distinguishes async function bodies from REPL statements. `stateOwner` is an in-process object identity for optional retained memory; distinct objects remain isolated. Consumers await `release(owner)` when that owner ends. Omitted owners are one-shot, and stateless providers ignore ownership. The [persistent Node provider](../../packages/ptc-runtime/ptc-runtime-node/README.md#persistent-repl) defines reset and recovery behavior; no retained object enters the durable Session format. + ## Bindings: host functions as program globals Each `PtcBindingNamespace` becomes a global object of async callables; PTC passes `tools`. Arguments and resolutions must be lossless JSON. Providers enforce their own transport caps; the seam sets no uniform binding-byte limit. An optional error-class descriptor creates program-visible typed rejections without naming a consumer inside the runtime. Binding names are own properties, so `__proto__` cannot traverse a prototype: @@ -209,9 +218,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.ptcRuntime` — `PtcRuntime` (abstract seam) -Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. +Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate distinct state owners, and terminate and await in-flight runs during disposal. ```ts cordis-catalog +/** + * Release an owner's retained environment and await its execution resources. + * Stateless providers have no retained resources to release. + * @param owner - The exact object supplied as the request's state owner. + */ +release(owner: object): Promise + /** * Resolve supported options and provider defaults before execution. * @param request - Program, bindings, cancellation and optional execution choices. diff --git a/docs/subsystems/ptc-runtime.zh.md b/docs/subsystems/ptc-runtime.zh.md index f35c5bd84d..9f1d67fb84 100644 --- a/docs/subsystems/ptc-runtime.zh.md +++ b/docs/subsystems/ptc-runtime.zh.md @@ -18,11 +18,18 @@ PTC 执行[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13- interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It - * runs as the body of an async function: top-level `await` and `return` - * are available, and the completion value becomes + * follows the provider's programFormat: an async function body supports + * top-level `await` and `return`; a REPL supports top-level declarations, + * `await`, and a final expression. The completion value becomes * {@link PtcRunResult.value}. */ program: string + /** + * Object-identity owner for optional in-memory state. Omission requests a fresh + * environment. Consumers must await runtime.release(owner) when the owner ends. + * Stateless providers ignore this field; retained state is never durable. + */ + stateOwner?: object /** Host functions exposed to the program, one global object per namespace. */ bindings: PtcBindingNamespace[] /** Working directory in the mounted filesystem and subprocess execution world. */ @@ -77,7 +84,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (its top-level `return`), when it ran to + * The program's completion value (a return or final REPL expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -94,6 +101,8 @@ interface PtcRunResult { } ``` +`programFormat` 区分异步函数体与 REPL 语句。`stateOwner` 是标识可选保留内存的进程内对象身份;不同对象相互隔离。消费者在所有者结束时等待 `release(owner)`。省略所有者时仅执行一次,无状态提供方忽略所有权。[持久 Node 提供方](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#persistent-repl)定义重置和恢复行为;保留对象不会进入持久 Session 格式。 + ## 绑定:宿主函数作为程序全局变量 每个 `PtcBindingNamespace` 成为一个异步可调用函数的全局对象;PTC 传入 `tools`。参数与返回值必须是无损 JSON。提供方强制各自的传输上限;seam 不设统一的绑定字节上限。可选错误类描述符创建程序可见的类型化拒绝,无需在运行时内点名消费方。绑定名是自有属性,因此 `__proto__` 不能遍历原型: @@ -209,9 +218,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.ptcRuntime` — `PtcRuntime` (abstract seam) -Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. +Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate distinct state owners, and terminate and await in-flight runs during disposal. ```ts cordis-catalog +/** + * Release an owner's retained environment and await its execution resources. + * Stateless providers have no retained resources to release. + * @param owner - The exact object supplied as the request's state owner. + */ +release(owner: object): Promise + /** * Resolve supported options and provider defaults before execution. * @param request - Program, bindings, cancellation and optional execution choices. diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index a27c97fc81..c612967bc1 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md -README.md: 803566c8650df7186c5344945ea0fe2359e8e70c -README.zh.md: 657e4587e2d813dd53b44fdb15a23f905f2c0dca +README.md: 24936df8b1be4e2997c2382e0d84fddd2dfc5345 +README.zh.md: 122ccc9a2796caf891b19a38c3c90e9d32a22fdc diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 803566c865..24936df8b1 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -68,6 +68,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe | `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. | | `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. | | `lockWaitMs` | `120000` | Maximum time in milliseconds to acquire the profile write lock. | +| `idleTimeoutMs` | `600000` | Maximum time in milliseconds a service package run may capture no output before the manager terminates it; a run with inherited descriptors (`dsh plugin`) is never bound. | ----- @@ -77,7 +78,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe
Implementation internals — click to expand -The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. Dependency-only changes do not trigger configuration reloads. +The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Dependency-only changes do not trigger configuration reloads. Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 657e4587e2..122ccc9a27 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -68,6 +68,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro | `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 | | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 | | `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 | +| `idleTimeoutMs` | `600000` | service 包操作允许持续无捕获输出的最长毫秒数,达到即被管理器终止;继承描述符运行的 `dsh plugin` 不受此上界约束。 | ----- @@ -77,7 +78,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro
实现细节——点击展开 -服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。仅依赖字段变化不会触发配置重载。 +服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。仅依赖字段变化不会触发配置重载。 结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 93d0287b9e..818b68ac16 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -46,6 +46,8 @@ export interface Config { inspectTimeoutMs?: number /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ githubConnectionTimeoutMs?: number + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** @@ -176,6 +178,7 @@ export class PluginManager extends TypertRemoteService { lockWaitMs: z.number().step(1).min(0).default(120000), inspectTimeoutMs: z.number().step(1).min(1000).default(20000), githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000), + idleTimeoutMs: z.number().step(1).min(1000).default(600000), registry: z.string().pattern(REGISTRY_URL), fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]), }) @@ -188,6 +191,7 @@ export class PluginManager extends TypertRemoteService { private readonly lockWaitMs: number private readonly inspectTimeoutMs: number private readonly githubConnectionTimeoutMs: number + private readonly idleTimeoutMs: number private readonly pnpmCommand: string private readonly configuredRegistries: Omit private readonly ownerContext: Context @@ -205,6 +209,7 @@ export class PluginManager extends TypertRemoteService { this.lockWaitMs = (config as Required).lockWaitMs this.inspectTimeoutMs = (config as Required).inspectTimeoutMs this.githubConnectionTimeoutMs = (config as Required).githubConnectionTimeoutMs + this.idleTimeoutMs = (config as Required).idleTimeoutMs this.pnpmCommand = (config as Required).pnpmCommand this.configuredRegistries = { registry: config.registry === undefined ? null : normalizeRegistry(config.registry), @@ -470,18 +475,24 @@ export class PluginManager extends TypertRemoteService { run = await this.runPnpm(['add', spec, ...registryArguments(registry)], control.abort.signal, requestId) result.packageResult = run if (stopped()) throw new InstallCancelledError() - /* v8 ignore next 2 -- runPnpm classifies every failed run, so kind is never absent here */ - if (run.exitCode === 0 || run.kind === undefined) break - const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec)) + // A run this manager terminated is not a success, even when pnpm trapped the signal and exited 0. + if (run.exitCode === 0 && run.timedOut !== true) break + /* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */ + if (run.kind === undefined) break // What the last failed run could not reach; a later run that succeeds leaves nothing to say. delete result.failedAt + // A run this manager terminated got no answer from the registry at all, so no registry explains it. + if (run.timedOut === true) break + const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec)) if (failedAt !== 'other') result.failedAt = failedAt if (failedAt !== 'registry' || index === plan.length - 1) break } /* v8 ignore next -- the plan is never empty, so a run always settled */ if (run === undefined) throw new Error('no registry was asked') - if (run.exitCode === 0) delete result.failedAt - if (run.exitCode !== 0) { + // A terminated run reports no usable exit status, so neither its files nor its bundle are trusted. + const succeeded = run.exitCode === 0 && run.timedOut !== true + if (succeeded) delete result.failedAt + if (!succeeded) { // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval. try { result.pendingBuilds = await readPendingBuilds(this.profile.dir) } catch (error) { @@ -573,7 +584,9 @@ export class PluginManager extends TypertRemoteService { } }) result.packageResult = await this.runPnpm(['remove', name]) - if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output) + if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) { + throw new Error(result.packageResult.output) + } }, { stage: 'remove', target: name }, 'remove') } @@ -620,7 +633,7 @@ export class PluginManager extends TypertRemoteService { const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, { execution: 'service', ...this.profile.packageManager ?? { command: this.pnpmCommand }, signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]), - outputBytes: this.outputBytes, activateNewBundles: false, + outputBytes: this.outputBytes, activateNewBundles: false, idleTimeoutMs: this.idleTimeoutMs, onOutput: (text, stream) => { this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text }) }, @@ -631,7 +644,12 @@ export class PluginManager extends TypertRemoteService { this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stdout', text: '', exitCode: signal?.aborted === true ? null : result.exitCode, }) - return result.exitCode === 0 ? result : { ...result, kind: classifyInstallFailure({ log: result.output }) } + // A terminated run keeps its own kind even when pnpm trapped the signal and exited 0. + if (result.exitCode === 0 && result.timedOut !== true) return result + return { + ...result, + kind: classifyInstallFailure({ log: result.output, ...result.timedOut === true ? { timedOut: true } : {} }), + } } catch (error) { this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stderr', text: messageOf(error), exitCode: null }) throw error diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index 883e79713e..817a4e2219 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -1,4 +1,5 @@ /** Shared profile package operations used by dsh plugin and the running manager. */ +import { once } from 'node:events' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, open } from 'node:fs/promises' import { join, resolve } from 'node:path' @@ -9,6 +10,7 @@ import { resolveBundleDir, resolveProfileDir, loadOverlayPatches, type ProfileManifest, } from '@deepseek-ai/dsh-app-boot' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { awaitTreeGone, leadsOwnGroup } from './run-tree.ts' import type { PackageResult, Registry } from './types.ts' /** Profile and invocation locations supplied by the launcher. */ @@ -36,6 +38,13 @@ export interface PackageOperationOptions { onOutput?: (text: string, stream: 'stdout' | 'stderr') => void activateNewBundles?: boolean lockWaitMs?: number + /** + * Terminate the run once its captured output has been silent for this long, in + * milliseconds. A run stopped this way reports `timedOut`; without a bound, a + * child that stops progressing without exiting holds its caller forever. A run + * with inherited descriptors captures nothing and is never bound. + */ + idleTimeoutMs?: number } /** Resolve relative package specs against the caller's directory. @@ -96,11 +105,39 @@ async function reconcile(before: ProfileManifest, dir: string, anchor: string, o await saveManifest(dir, after) } +/** + * How long the pipes keep draining after their process exited, as a fixed part of + * finishing a run rather than a deployment knob: a descendant that inherited them + * holds them open, and the tail a failure classification reads is written by then. + */ +const DRAIN_AFTER_EXIT_MS = 2_000 + +/** Whether every collector finished within `ms`. + * @param collectors The pipe readers racing the bound. + * @param ms The longest wait, in milliseconds. + * @returns True when all collectors settled in time. + */ +async function drainWithin(collectors: readonly Promise[], ms: number): Promise { + if (collectors.length === 0) return true + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + Promise.allSettled(collectors).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => { resolve(false) }, ms) + }), + ]) + } finally { + clearTimeout(timer) + } +} + /** Execute pnpm inside a profile whose caller already holds the profile write lock. * @param context Launcher-owned profile and resolution locations. * @param args Pnpm arguments, before relative path anchoring. * @param options Output, activation and cancellation policy. - * @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors. + * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path. + * Service output is bounded; CLI output uses inherited descriptors. */ export async function runProfilePnpm( context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions, @@ -115,53 +152,131 @@ export async function runProfilePnpm( let output = Buffer.alloc(0) let truncated = false const cancellation = new AbortController() + // A service run captures output, so execa terminates the tree it leads when the + // run is killed: a lifecycle script outlives the pnpm process that started it. + // The CLI keeps the caller's process group, so an interrupt still reaches it. + const grouped = leadsOwnGroup(options.execution) const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], { cwd: dir, env: { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }, extendEnv: false, reject: false, stdout: options.execution === 'cli' ? 'inherit' : 'pipe', stderr: options.execution === 'cli' ? 'inherit' : 'pipe', + killDescendants: options.execution === 'service', buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]), }) + const append = (bytes: Buffer): void => { + output = Buffer.concat([output, bytes]) + if (output.length > options.outputBytes) { + truncated = true + output = output.subarray(output.length - options.outputBytes) + } + } let writes = Promise.resolve() + /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */ + const control = { settled: false, stalled: false } + /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */ + let cut = false + /** The first failure a reading hit before that cut, which the run still reports. */ + let failure: Error | undefined + let idleTimer: NodeJS.Timeout | undefined + /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */ + const armIdle = (): void => { + if (control.settled || options.idleTimeoutMs === undefined) return + clearTimeout(idleTimer) + idleTimer = setTimeout(() => { + control.stalled = true + // execa's kill reaches the whole tree of a service run, and escalates on its own. + child.kill() + }, options.idleTimeoutMs) + } const collect = async (stream: AsyncIterable, kind: 'stdout' | 'stderr') => { try { for await (const chunk of stream) { + armIdle() const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) writes = writes.then(async () => { await log.write(bytes) }) await writes options.onOutput?.(bytes.toString('utf8'), kind) - output = Buffer.concat([output, bytes]) - if (output.length > options.outputBytes) { - truncated = true - output = output.subarray(output.length - options.outputBytes) - } + append(bytes) } } catch (error) { - cancellation.abort() + // A reading this call cut short is not a failure the run hit, and the run has + // already exited, so there is nothing left for the cancellation to stop. + if (!cut) { + failure ??= error instanceof Error ? error : new Error(String(error)) + cancellation.abort() + } throw error } } + const collectors = [ + ...child.stdout === null ? [] : [collect(child.stdout, 'stdout')], + ...child.stderr === null ? [] : [collect(child.stderr, 'stderr')], + ] + // The drain decides whether a failure surfaces, so each reading is claimed now: + // an unclaimed rejection would be reported as unhandled while the pipes drain. + for (const collector of collectors) void collector.catch(() => { /* reported through `failure` and the settled readings */ }) + // Inherited descriptors hand pnpm the terminal, so there is no captured output + // for a silence bound to observe: it would fire on a healthy run. + if (collectors.length > 0) armIdle() let exitCode: number try { - const [completion, ...streams] = await Promise.allSettled([child, - ...child.stdout === null ? [] : [collect(child.stdout, 'stdout')], - ...child.stderr === null ? [] : [collect(child.stderr, 'stderr')], - ]) - for (const stream of streams) if (stream.status === 'rejected') throw stream.reason + // execa resolves its promise only once the piped stdio has ended, so the + // process's own exit — the run's completion — is read from the raw child. A + // spawn failure settles without one. + const settled = Promise.allSettled([child]) + await Promise.race([once(child.nodeChildProcess, 'exit').catch(() => undefined), settled]) + control.settled = true + clearTimeout(idleTimer) + // A stalled run stops its whole tree first, so the caller's rollback and lock + // release happen only after the scripts it started stopped writing. + if (control.stalled) await awaitTreeGone({ pid: child.pid, grouped }) + // A descendant that inherited the pipes can hold them open past the process; + // the tail drains under a bound instead of being awaited forever. + const drained = await drainWithin(collectors, DRAIN_AFTER_EXIT_MS) + if (drained) { + for (const stream of await Promise.allSettled(collectors)) if (stream.status === 'rejected') throw stream.reason + } else { + // Cutting the tail short is this call's own end, not a failure the run hit; + // a failure from before the cut still surfaces, and the cut leaves a notice + // in the log because a classification may read an incomplete tail. + cut = true + child.stdout?.destroy() + child.stderr?.destroy() + const notice = 'dsh: pnpm output was cut short after its process exited\n' + await log.write(notice) + // A failure from before the cut is the run's own and replaces the notice a + // caller would otherwise read; a rejection the cut itself causes is its end. + if (failure !== undefined) throw failure + options.onOutput?.(notice, 'stderr') + append(Buffer.from(notice)) + } + const [completion] = await settled if (completion.status === 'rejected') throw completion.reason const result = completion.value exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1) + if (control.stalled) { + const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n` + await log.write(notice) + options.onOutput?.(notice, 'stderr') + append(Buffer.from(notice)) + } if (result.failed && output.length === 0) { const diagnostic = result.shortMessage ?? 'pnpm failed' await log.write(diagnostic) truncated = Buffer.byteLength(diagnostic) > options.outputBytes output = Buffer.from(diagnostic).subarray(0, options.outputBytes) } - if (exitCode === 0 && options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options) + // A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection. + if (exitCode === 0 && !control.stalled && options.activateNewBundles !== false) { + await reconcile(before, dir, context.installAnchor, options) + } } finally { + control.settled = true + clearTimeout(idleTimer) await log.close() } - return { exitCode, output: output.toString('utf8'), truncated, logPath } + return { exitCode, output: output.toString('utf8'), truncated, logPath, ...control.stalled ? { timedOut: true } : {} } } /** Initialize and run the dsh plugin command with the same write lock as the service. diff --git a/packages/boot/plugin-manager/src/run-tree.ts b/packages/boot/plugin-manager/src/run-tree.ts new file mode 100644 index 0000000000..bce2f4d872 --- /dev/null +++ b/packages/boot/plugin-manager/src/run-tree.ts @@ -0,0 +1,70 @@ +/** Waiting for one package run's process tree to disappear before its caller touches the profile. */ + +/** How long a terminated tree may take to disappear before the caller stops waiting. */ +const TREE_WAIT_MS = 5_000 + +/** Poll cadence while waiting for a terminated tree to disappear. */ +const TREE_POLL_MS = 15 + +/** The process (or process group) one run occupies. */ +export interface RunTree { + /** The run's process id, when its spawn published one. */ + pid: number | undefined + /** Whether the run leads its own process group, which is what a POSIX group probe addresses. */ + grouped: boolean +} + +/** Injectable process operations, so every platform's probing is testable on any host. */ +export interface RunTreeInternals { + /** Host platform override for target decisions. */ + platform?: NodeJS.Platform + /** Liveness probe override, defaulting to `process.kill(target, 0)`. */ + alive?: (target: number) => boolean + /** Wait bound override, defaulting to the tree wait constant. */ + waitMs?: number +} + +/** + * Whether one run leads its own process group, which is the target a POSIX + * liveness probe addresses for the whole tree. A run that captures output is + * spawned as its own group leader, because its tree is terminated as a unit; a + * run that inherits the caller's descriptors keeps the caller's group, so an + * interrupt still reaches it. + * @param execution Whether the run captures output or inherits the caller's descriptors. + * @param platform Host platform deciding how a tree is addressed. + * @returns True when the run leads its own process group. + */ +export function leadsOwnGroup(execution: 'cli' | 'service', platform: NodeJS.Platform = process.platform): boolean { + return execution === 'service' && platform !== 'win32' +} + +/** The target a tree probe addresses: a POSIX group when the run leads one, else the process itself. */ +function targetOf(pid: number, grouped: boolean, platform: NodeJS.Platform): number { + return platform !== 'win32' && grouped ? -pid : pid +} + +/** + * Wait until a terminated run's tree is gone, so the caller restores and + * unlocks the profile only after the scripts it started stopped writing. + * @param tree The run's process id and whether it leads its own group. + * @param internals Injectable process operations. + * @returns Fulfillment once no member remains, or the wait bound elapsed. + */ +export async function awaitTreeGone(tree: RunTree, internals: RunTreeInternals = {}): Promise { + const pid = tree.pid + if (pid === undefined) return + const platform = internals.platform ?? process.platform + const alive = internals.alive ?? ((target: number) => { process.kill(target, 0); return true }) + const remaining = (): boolean => { + try { + return alive(targetOf(pid, tree.grouped, platform)) + } catch { + return false + } + } + const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS) + while (remaining()) { + if (Date.now() >= deadline) return + await new Promise(resolve => setTimeout(resolve, TREE_POLL_MS)) + } +} diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 4eb78b96f6..e544d53d7d 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -90,6 +90,8 @@ export interface PackageResult { logPath: string /** Present when the run failed: what kind of failure its exit and output describe. */ kind?: PluginInstallFailureKind + /** The manager terminated the run after it printed nothing for its silence bound; `exitCode` still reports how it ended. */ + timedOut?: boolean } /** Persisted change and independently observed application outcome. */ diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 5c372a07a8..d80755bba2 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -582,6 +582,38 @@ it('unloads before removing packages and retries inactive dependencies whose fil expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false) }) +it('treats a terminated run that trapped the signal and exited zero as a failure', async () => { + const { manager, dir } = await fixture() + const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + // pnpm rewrote the manifest before it trapped the manager's signal. + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, half: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: 'installing\n', truncated: false, logPath: join(dir, 'pnpm.log'), timedOut: true } + }) + onTestFinished(() => { install.mockRestore() }) + const result = await manager.installBundle('half', { enabled: false }) + expect(result).toMatchObject({ application: 'failed', packageResult: { exitCode: 0, timedOut: true, kind: 'timeout' } }) + // The half-written dependency goes back, and nothing is activated. + expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' }) + expect((await manager.listBundles()).some(row => row.name === 'half')).toBe(false) +}) + +it('reports a terminated removal as failed even when pnpm exited zero', async () => { + const { manager, dir } = await fixture() + const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + // The unload already happened, so the removal is reported through its own run. + const manifest = readProfileManifest('test', dir) + delete manifest.dependencies?.extra + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: 'removing\n', truncated: false, logPath: join(dir, 'pnpm.log'), timedOut: true } + }) + onTestFinished(() => { remove.mockRestore() }) + expect(await manager.removeBundle('extra')).toMatchObject({ + application: 'failed', packageResult: { exitCode: 0, timedOut: true, kind: 'timeout' }, + }) +}) + it('restores the manifest and lockfile after a failed package run, classifying the failure', async () => { const { manager, dir } = await fixture() const lockPath = join(dir, 'pnpm-lock.yaml') @@ -1202,6 +1234,46 @@ it('installs from the next registry after one is unreachable, restoring the file expect(chunks.at(-1)).toMatchObject({ requestId, argv: ['pnpm', 'add', 'fallen', `--registry=${MIRROR}`], exitCode: 0 }) }) +it('does not ask the next registry after the manager terminated a silent run', async () => { + const { manager } = await fixture() + const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ + exitCode: 1, output: 'installing\ndsh: pnpm printed nothing for 600000ms and was terminated\n', + truncated: false, logPath: '/dev/null', timedOut: true, + }) + onTestFinished(() => { install.mockRestore() }) + const stalled = await manager.installBundle('silent') + expect(stalled).toMatchObject({ application: 'failed', registries: [null], packageResult: { kind: 'timeout', timedOut: true } }) + expect(stalled.failedAt).toBeUndefined() + expect(install).toHaveBeenCalledTimes(1) +}) + +it('drops the earlier registry failure once a later attempt is terminated for silence', async () => { + const { manager } = await fixture() + const install = vi.spyOn(operations, 'runProfilePnpm') + .mockResolvedValueOnce({ + exitCode: 1, output: 'ERR_PNPM_META_FETCH_FAIL GET https://registry.npmjs.org/flaky: ETIMEDOUT', + truncated: false, logPath: '/dev/null', + }) + .mockResolvedValueOnce({ + exitCode: 1, output: 'installing\ndsh: pnpm printed nothing for 600000ms and was terminated\n', + truncated: false, logPath: '/dev/null', timedOut: true, + }) + onTestFinished(() => { install.mockRestore() }) + const stalled = await manager.installBundle('flaky') + expect(stalled).toMatchObject({ application: 'failed', registries: [null, MIRROR], packageResult: { kind: 'timeout', timedOut: true } }) + expect(stalled.failedAt).toBeUndefined() + expect(install).toHaveBeenCalledTimes(2) +}) + +it('bounds each package run with the configured silence timeout', async () => { + const { manager } = await fixture('live', false, undefined, { idleTimeoutMs: 1234 }) + const install = vi.spyOn(operations, 'runProfilePnpm') + .mockResolvedValue({ exitCode: 1, output: 'plain failure', truncated: false, logPath: '/dev/null' }) + onTestFinished(() => { install.mockRestore() }) + await manager.installBundle('quiet') + expect(install.mock.calls[0]?.[2]).toMatchObject({ idleTimeoutMs: 1234 }) +}) + it('stops at a failure no registry changes, at a host the spec itself is fetched from, and after a registry outside the configured set', async () => { const { manager } = await fixture() const failing = (output: string) => ({ exitCode: 1, output, truncated: false, logPath: '/dev/null' }) diff --git a/packages/boot/plugin-manager/tests/operations-process.spec.ts b/packages/boot/plugin-manager/tests/operations-process.spec.ts new file mode 100644 index 0000000000..d4ad5f28ce --- /dev/null +++ b/packages/boot/plugin-manager/tests/operations-process.spec.ts @@ -0,0 +1,141 @@ +/** The bounded drain and the tree stop are measured against real child processes. */ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { describe, expect, it, onTestFinished } from 'vitest' +import { initProfile } from '@deepseek-ai/dsh-app-boot' +import { runProfilePnpm, type PackageOperationOptions } from '../src/operations.ts' + +/** Holds the inherited pipes until its stop file appears, and ends on its own after a minute. */ +const HOLDER_SCRIPT = ` +const { existsSync } = require('node:fs') +setInterval(() => { if (existsSync(process.env.DSH_DESCENDANT_STOP_FILE)) process.exit(0) }, 50) +setTimeout(() => process.exit(0), 60000) +` + +/** Writes a marker after the run is over, which only a stopped tree can prevent. */ +const LATE_SCRIPT = ` +setTimeout(() => { require('node:fs').writeFileSync(process.env.DSH_LATE_FILE, 'late') }, 1500) +setTimeout(() => process.exit(0), 60000) +` + +/** + * A child that exits immediately while its descendant inherits both pipes and + * outlives it, so execa's promise cannot settle before the bounded drain. It + * runs from a file because Windows cannot carry a nested `-e` argument. + */ +const DRAIN_CHILD = ` +const { spawn } = require('node:child_process') +const { writeFileSync } = require('node:fs') +const descendant = spawn(process.execPath, [process.env.DSH_DESCENDANT_SCRIPT], { + stdio: ['ignore', 'inherit', 'inherit'], +}) +writeFileSync(process.env.DSH_DESCENDANT_PID_FILE, String(descendant.pid)) +console.log('installed') +setTimeout(() => process.exit(0), 50) +` + +/** A child that starts a descendant and then never exits, so only the silence bound ends the run. */ +const STALLED_CHILD = ` +const { spawn } = require('node:child_process') +const { writeFileSync } = require('node:fs') +const descendant = spawn(process.execPath, [process.env.DSH_DESCENDANT_SCRIPT], { + stdio: ['ignore', 'inherit', 'inherit'], +}) +writeFileSync(process.env.DSH_DESCENDANT_PID_FILE, String(descendant.pid)) +console.log('installing') +setTimeout(() => process.exit(0), 60000) +` + +/** Read the descendant's published pid, waiting for the write a loaded runner can delay. */ +async function readPid(pidFile: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (existsSync(pidFile)) { + const pid = Number(readFileSync(pidFile, 'utf8')) + if (pid > 0) return pid + } + await sleep(25) + } + throw new Error('the descendant published no process id') +} + +/** Wait until a descendant this spec killed is really gone, so its directory can be removed. */ +async function awaitGone(pid: number): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { process.kill(pid, 0) } catch { return /* the descendant is gone */ } + await sleep(25) + } +} + +/** A profile whose package run is `child`, with its descendant stopped during teardown. */ +function fixture(child: string, descendant: string) { + const home = mkdtempSync(join(tmpdir(), 'manager-run-')) + const dir = join(home, 'profiles', 'test') + const installAnchor = join(home, 'package.json') + writeFileSync(installAnchor, '{}\n') + initProfile(dir, []) + const pidFile = join(home, 'descendant.pid') + const stopFile = join(home, 'descendant.stop') + const scriptFile = join(home, 'descendant.cjs') + const lateFile = join(home, 'late.marker') + writeFileSync(scriptFile, descendant) + onTestFinished(async () => { + // The stop file ends a surviving descendant on its own; the kill only shortens the wait. + writeFileSync(stopFile, '') + if (existsSync(pidFile)) { + const pid = Number(readFileSync(pidFile, 'utf8')) + try { process.kill(pid, 'SIGKILL') } catch { /* the descendant exited with its own timer */ } + await awaitGone(pid) + } + rmSync(home, { recursive: true, force: true }) + }) + const options: PackageOperationOptions = { + command: process.execPath, args: ['-e', child], execution: 'service', outputBytes: 1000, + activateNewBundles: false, + env: { + DSH_DESCENDANT_PID_FILE: pidFile, DSH_DESCENDANT_STOP_FILE: stopFile, + DSH_DESCENDANT_SCRIPT: scriptFile, DSH_LATE_FILE: lateFile, + }, + } + return { context: { home, profile: 'test', installAnchor, cwd: home }, pidFile, lateFile, options } +} + +/** + * Windows cannot stage these fixtures: there a descendant neither keeps the + * inherited pipes open past the direct child's exit nor leads a group the run + * can stop, so the drain's premise holds on POSIX alone. The drain itself is + * platform-independent, and the mocked-child cases in operations.spec.ts cover + * it everywhere. + */ +describe.skipIf(process.platform === 'win32')('a run with descendants', () => { + it('settles a run whose pipes a descendant keeps open', async () => { + const { context, pidFile, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT) + const outcome = await runProfilePnpm(context, ['add', 'held'], options) + const descendant = await readPid(pidFile) + expect(outcome).toMatchObject({ exitCode: 0 }) + expect(outcome.output).toContain('dsh: pnpm output was cut short after its process exited') + // The descendant still holds the pipes, so the run settled under its drain bound instead of waiting for their end. + expect(() => process.kill(descendant, 0)).not.toThrow() + }) + + it('surfaces an output consumer failure that the bounded drain cuts short', async () => { + const { context, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT) + const run = runProfilePnpm(context, ['add', 'held'], { + ...options, + onOutput() { throw new Error('output destination closed') }, + }) + await expect(run).rejects.toThrow('output destination closed') + }) + + it('stops the whole tree of a stalled run before returning', async () => { + const { context, pidFile, lateFile, options } = fixture(STALLED_CHILD, LATE_SCRIPT) + const outcome = await runProfilePnpm(context, ['add', 'stalled'], { ...options, idleTimeoutMs: 300 }) + const descendant = await readPid(pidFile) + expect(outcome).toMatchObject({ timedOut: true }) + // The descendant is gone, so the marker it would have written 1.5s in never appears. + expect(() => process.kill(descendant, 0)).toThrow() + await sleep(2_000) + expect(existsSync(lateFile)).toBe(false) + }) +}) diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index d63f3374ab..190f567cf9 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -1,4 +1,5 @@ /** The CLI and manager share package reconciliation, path anchoring and diagnostics. */ +import { EventEmitter } from 'node:events' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' @@ -7,7 +8,23 @@ import { expect, it, onTestFinished, vi } from 'vitest' import { initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { anchorPathSpec, readProfileRegistry, runPluginCommand, runProfilePnpm, viewProfilePackage } from '../src/operations.ts' -const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => ReturnType>() })) +/** What execa resolves for a run that settled. */ +interface FakeOutcome { + exitCode: number | undefined + failed: boolean + code?: string + shortMessage?: string +} + +/** The child execa hands back, including the descriptors an inherited CLI run reports as null. */ +interface FakeChild { + stdout: PassThrough | null + stderr: PassThrough | null + nodeChildProcess: EventEmitter + kill?: () => boolean +} + +const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => Promise & FakeChild>() })) vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) })) function fixture() { @@ -26,13 +43,68 @@ function result( ) { const stdout = new PassThrough() const stderr = new PassThrough() + const raw = new EventEmitter() const done = Promise.resolve().then(() => { mutate() stdout.end(output) stderr.end() return { exitCode, failed: exitCode !== 0, ...details } }) - return Object.assign(done, { stdout, stderr }) + // A launch failure reports on the raw child, which never emits an exit; anything else exits once execa's promise settles. + // Both arrive on a macrotask, so a fixture built before the run still reaches the listener the run attaches. + if (details.code === undefined) { + void done.then(() => { setTimeout(() => { raw.emit('exit', exitCode, null) }, 0) }, () => {}) + } else { + setTimeout(() => { raw.emit('error', Object.assign(new Error('pnpm failed to launch'), { code: details.code })) }, 0) + } + return Object.assign(done, { stdout, stderr, nodeChildProcess: raw }) +} + +/** A child that printed once and never exits on its own: only the silence bound can end it. */ +function silentChild(output: string, exitCode?: number) { + const stdout = new PassThrough() + const stderr = new PassThrough() + const raw = new EventEmitter() + const exit = Promise.withResolvers<{ exitCode: number | undefined; failed: boolean }>() + const kill = () => { + stdout.end() + stderr.end() + raw.emit('exit', exitCode ?? null, 'SIGTERM') + exit.resolve({ exitCode, failed: exitCode !== 0 }) + return true + } + stdout.write(output) + return Object.assign(exit.promise, { stdout, stderr, kill, nodeChildProcess: raw }) +} + +/** A child that exited while its pipes stay open, as a descendant that inherited them leaves them. */ +function heldPipeChild(output: string) { + const stdout = new PassThrough() + const stderr = new PassThrough() + const raw = new EventEmitter() + const exit = Promise.withResolvers<{ exitCode: number; failed: boolean }>() + let ended = 0 + // execa settles only once both pipes end, which the bounded drain forces by destroying them. + for (const stream of [stdout, stderr]) { + stream.on('close', () => { + ended += 1 + if (ended === 2) exit.resolve({ exitCode: 0, failed: false }) + }) + } + // The process exits as soon as the run watches the raw child, so a fixture the + // test built before the run cannot emit past the listener the run attaches. + let watched = false + const watch = (): void => { + if (watched) return + watched = true + setTimeout(() => { raw.emit('exit', 0, null) }, 0) + } + const on = raw.on.bind(raw) + const once = raw.once.bind(raw) + raw.on = (event, listener) => { const holder = on(event, listener); if (event === 'exit') watch(); return holder } + raw.once = (event, listener) => { const holder = once(event, listener); if (event === 'exit') watch(); return holder } + stdout.write(output) + return Object.assign(exit.promise, { stdout, stderr, nodeChildProcess: raw }) } function install(dir: string, name: string) { @@ -107,6 +179,72 @@ it('initializes missing profiles under the same lock and reports initialization' expect(messages.filter(text => text.includes('initialized profile'))).toHaveLength(2) }) +it('terminates a run that stopped printing and reports the silence bound', async () => { + const { context } = fixture() + command.run.mockImplementationOnce(() => silentChild('installing\n')) + const outcome = await runProfilePnpm(context, ['add', 'silent'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 }) + expect(outcome).toMatchObject({ exitCode: 1, timedOut: true }) + expect(outcome.output).toBe('installing\ndsh: pnpm printed nothing for 20ms and was terminated\n') + expect(readFileSync(outcome.logPath, 'utf8')).toBe(outcome.output) +}) + +it('settles a run whose pipes stay open past the process exit', async () => { + const { context } = fixture() + const child = heldPipeChild('installed\n') + command.run.mockImplementationOnce(() => child) + const outcome = await runProfilePnpm(context, ['add', 'held'], { + execution: 'service', outputBytes: 100, activateNewBundles: false, + }) + expect(outcome).toMatchObject({ exitCode: 0 }) + expect(outcome.output).toBe('installed\ndsh: pnpm output was cut short after its process exited\n') + // The pipes never ended on their own, so the run settled by cutting them here. + expect(child.stdout.destroyed).toBe(true) + expect(child.stderr.destroyed).toBe(true) + expect(outcome.timedOut).toBeUndefined() +}) + +it('reports a terminated run that trapped the signal and exited zero', async () => { + const { context } = fixture() + command.run.mockImplementationOnce(() => silentChild('installing\n', 0)) + const outcome = await runProfilePnpm(context, ['add', 'trapped'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 }) + // The independent facts stay separate: the signal was trapped, so the exit status is still zero. + expect(outcome).toMatchObject({ exitCode: 0, timedOut: true }) + expect(outcome.output).toContain('printed nothing for 20ms and was terminated') +}) + +it('surfaces an output consumer failure the bounded drain cuts short', async () => { + const { context } = fixture() + command.run.mockImplementationOnce(() => heldPipeChild('installed\n')) + await expect(runProfilePnpm(context, ['add', 'held'], { + execution: 'service', outputBytes: 100, activateNewBundles: false, + onOutput() { throw new Error('output destination closed') }, + })).rejects.toThrow('output destination closed') +}) + +it('reports a reading that fails with something other than an Error by its text', async () => { + const { context } = fixture() + const child = heldPipeChild('installed\n') + command.run.mockImplementationOnce(() => child) + await expect(runProfilePnpm(context, ['add', 'held'], { + execution: 'service', outputBytes: 100, activateNewBundles: false, + // A reading can reject with any value; its text is what the failure reports. + onOutput() { throw 'pipe broke' }, + })).rejects.toThrow('pipe broke') +}) + +it('terminates a service run as a tree and leaves the CLI in the caller group', async () => { + const { context } = fixture() + command.run.mockImplementation(() => result(0, '')) + await runProfilePnpm(context, ['add', 'tree'], { execution: 'service', outputBytes: 100, activateNewBundles: false }) + await runProfilePnpm(context, ['add', 'tree'], { execution: 'cli', outputBytes: 100, activateNewBundles: false }) + expect(command.run).toHaveBeenCalledWith( + expect.anything(), expect.anything(), expect.objectContaining({ killDescendants: true }), + ) + expect(command.run).toHaveBeenLastCalledWith( + expect.anything(), expect.anything(), expect.objectContaining({ killDescendants: false }), + ) +}) + it('retains built-in layers, removes deleted dependencies and warns about plain packages', async () => { const { context, dir } = fixture() install(dir, 'removed') @@ -172,7 +310,9 @@ it('preserves an unexpected subprocess rejection after both streams settle', asy const stderr = new PassThrough() stdout.end() stderr.end() - command.run.mockImplementationOnce(() => Object.assign(Promise.reject(new Error('subprocess failed')), { stdout, stderr })) + command.run.mockImplementationOnce(() => Object.assign(Promise.reject(new Error('subprocess failed')), { + stdout, stderr, nodeChildProcess: new EventEmitter(), + })) await expect(runProfilePnpm(context, ['root'], { execution: 'service', outputBytes: 100 })).rejects.toThrow('subprocess failed') }) @@ -207,8 +347,8 @@ it.each(['cli', 'service'] as const)('uses the %s environment and interaction po it('settles inherited CLI descriptors without requiring captured streams', async () => { const { context } = fixture() command.run.mockImplementationOnce(() => Object.assign( - Promise.resolve({ exitCode: 0, failed: false }), { stdout: null, stderr: null }, - ) as unknown as ReturnType) + Promise.resolve({ exitCode: 0, failed: false }), { stdout: null, stderr: null, nodeChildProcess: new EventEmitter() }, + )) expect(await runPluginCommand(context, ['approve-builds'], { execution: 'cli', outputBytes: 100 })).toMatchObject({ exitCode: 0, output: '' }) }) diff --git a/packages/boot/plugin-manager/tests/run-tree.spec.ts b/packages/boot/plugin-manager/tests/run-tree.spec.ts new file mode 100644 index 0000000000..0d72b672b2 --- /dev/null +++ b/packages/boot/plugin-manager/tests/run-tree.spec.ts @@ -0,0 +1,47 @@ +/** Waiting for one run's tree is decided by platform and target, without starting a real process. */ +import { expect, it } from 'vitest' +import { awaitTreeGone, leadsOwnGroup, type RunTree } from '../src/run-tree.ts' + +/** One run's process or process group. */ +function tree(pid: number | undefined, grouped = true): RunTree { + return { pid, grouped } +} + +it.each([ + ['service', 'linux', true], + ['service', 'darwin', true], + ['service', 'win32', false], + ['cli', 'linux', false], + ['cli', 'win32', false], +] as const)('decides group leadership for %s on %s', (execution, platform, expected) => { + expect(leadsOwnGroup(execution, platform)).toBe(expected) +}) + +it('waits for a tree that disappears on its own', async () => { + let alive = true + await expect(awaitTreeGone(tree(42), { + platform: 'linux', + alive: () => { const first = alive; alive = false; return first }, + })).resolves.toBeUndefined() +}) + +it('probes the group a run leads and the process it does not', async () => { + const probes: number[] = [] + const gone = { alive: (target: number) => { probes.push(target); return false } } + await expect(awaitTreeGone(tree(42), { ...gone, platform: 'linux' })).resolves.toBeUndefined() + await expect(awaitTreeGone(tree(42, false), { ...gone, platform: 'linux' })).resolves.toBeUndefined() + await expect(awaitTreeGone(tree(42), { ...gone, platform: 'win32' })).resolves.toBeUndefined() + expect(probes).toEqual([-42, 42, 42]) +}) + +it('returns at once for a tree with no process id or one already gone', async () => { + await expect(awaitTreeGone(tree(undefined), { alive: () => { throw new Error('never probed') } })).resolves.toBeUndefined() + // The host platform answers for a run that names none, and the default probe reports a vanished process. + await expect(awaitTreeGone(tree(99_999_999, false))).resolves.toBeUndefined() + await expect(awaitTreeGone(tree(99_999_999, false), { platform: 'linux' })).resolves.toBeUndefined() +}) + +it('gives up on a tree that never disappears', async () => { + // The wait bound is an input, so this needs no global clock: fake timers leak into the suites sharing the worker. + await expect(awaitTreeGone(tree(42), { platform: 'linux', alive: () => true, waitMs: 0 })).resolves.toBeUndefined() +}) diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 2abfbc952d..85aeaca996 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -38,3 +38,4 @@ disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)" config: assetRoot: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" + node: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 291cc0e58f..8ee1332d47 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -192,6 +192,6 @@ "@deepseek-ai/dsh-shell-env": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", - "@deepseek-ai/libreoffice-kit": "0.0.1" + "@deepseek-ai/libreoffice-kit": "^0.1.0" } } diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts index 0c7f0d5c77..dcf2bcff18 100644 --- a/packages/bundle/web-app/tests/document-preview.spec.ts +++ b/packages/bundle/web-app/tests/document-preview.spec.ts @@ -60,7 +60,12 @@ it('loads the shipped Office rows with separately patched settings and authorize await writeFile(outputPath, pdf) return { backend: 'native', missingFonts: ['Missing Serif'] } }) - kit.create.mockReset().mockResolvedValue({ backend: 'native', render, dispose: async () => {} }) + kit.create.mockReset().mockResolvedValue({ + backend: 'native', render, dispose: async () => {}, + renderImages: vi.fn().mockRejectedValue(new Error('Unexpected Converter.renderImages call')), + convert: vi.fn().mockRejectedValue(new Error('Unexpected Converter.convert call')), + recalculate: vi.fn().mockRejectedValue(new Error('Unexpected Converter.recalculate call')), + }) ctx.baseUrl = pathToFileURL(directory).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 961b1712a0..4b97f37141 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 176957e32695debf6a8aedd98368ec0d985d9182 -README.zh.md: a9496780ce5ea48ae95420d11d9f182134518b93 +README.md: f529c62f16873ce7b10fce70907f56630b133bdb +README.zh.md: 281a667980c66ca90c4180b5ae6bd176d34568f4 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 176957e326..f529c62f16 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -1,5 +1,5 @@ --- -description: "Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode." +description: "Choose Agent presets and the new-task default in Web, read what each mode does and what it declares. Authoring is guided to Creator mode." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode. +Choose Agent presets and the new-task default in Web, read what each mode does and what it declares. Authoring is guided to Creator mode. ## Table of Contents @@ -25,7 +25,7 @@ Choose Agent presets and the new-task default in Web, and read what each mode do ## Use this package -Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. The page edits nothing: that entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. +Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. The “Choose a mode for new tasks” switch controls whether the saved user default is active. Hiding selection uses the deployment default; showing it restores the user preference. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. The new-session picker additionally requires Developer tools in General Settings. @@ -37,7 +37,7 @@ Known shipped presets offer mode details and usage examples in a read-only dialo
Implementation internals — click to expand -`agentPresets/list` supplies the roster and the chooser policy; default and visibility changes write the `agent-presets` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster. +`agentPresets/list` supplies the roster and the chooser policy, and `agentPresets/read` one declaration's YAML for the viewer; default and visibility changes write the `agent-presets` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster.
@@ -61,7 +61,7 @@ Selection changes affect only later tasks; existing plugins and prompts remain u -- Web creates and edits no preset: a bundle installed through Creator mode declares a new preset or overrides a shipped one by row id, replacing its complete child list. +- Web creates and edits no preset: the configuration viewer is read-only, and a bundle installed through Creator mode declares a new preset or overrides a shipped one by row id, replacing its complete child list. ### Dev Note diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index a9496780ce..281a667980 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -1,5 +1,5 @@ --- -description: "在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。" +description: "在 Web 中选择 Agent preset 和新任务默认值,查看各模式的说明与声明的配置。创建与修改引导至创造模式。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。 +在 Web 中选择 Agent preset 和新任务默认值,查看各模式的说明与声明的配置。创建与修改引导至创造模式。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。本页不编辑任何内容:该入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 +设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 「新任务可选择模式」开关控制保存的用户默认值是否生效。关闭时使用部署默认值,重新开启则恢复用户偏好。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。新会话选择器还要求通用设置开启开发者工具。 @@ -37,7 +37,7 @@ kind: "package-reference"
实现细节 — 点击展开 -`agentPresets/list` 提供列表和选择器策略;默认值与可见性的修改写入 `agent-presets` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。 +`agentPresets/list` 提供列表和选择器策略,`agentPresets/read` 为查看器提供一条声明的 YAML;默认值与可见性的修改写入 `agent-presets` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。
@@ -61,7 +61,7 @@ kind: "package-reference" -- Web 不创建也不编辑 preset:通过创造模式安装的 bundle 声明新 preset,或按行 id 覆盖内置 preset,覆盖会替换整个子插件列表。 +- Web 不创建也不编辑 preset:配置查看器是只读的;通过创造模式安装的 bundle 声明新 preset,或按行 id 覆盖内置 preset,覆盖会替换整个子插件列表。 ### 开发备注 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 33827b02c4..f801fe90cd 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -317,6 +317,77 @@ color: var(--dsw-alias-label-primary); } +/* Icon-only actions: the label rides `data-tip` so the row stays quiet until + someone reaches for it. */ +.iconButton { + position: relative; + appearance: none; + border: 0; + border-radius: 7px; + padding: 6px; + background: none; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + display: inline-flex; + align-items: center; +} + +.iconButton:hover { + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +.iconButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -1px; +} + +.iconButton::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 3px 8px; + border-radius: 6px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); + font-size: 11px; + line-height: 17px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity .12s; +} + +.iconButton:hover::after, +.iconButton:focus-visible::after { + opacity: 1; +} + +.dialog { + width: min(720px, 100%); +} + +/* A declared composition can be long; the dialog scrolls it rather than grow. */ +.viewerCode { + margin: 0; + padding: 12px; + max-height: min(60vh, 560px); + overflow: auto; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-2); + color: var(--dsw-alias-label-secondary); + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; + line-height: 1.5; + white-space: pre; + tab-size: 2; + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + .error { margin: 0; font-size: 12px; diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 6650be7d9e..d15cb50649 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -1,12 +1,14 @@ -/** Preset selection settings: the roster, its default, mode help and the Creator-mode entry. */ +/** Preset selection settings: the roster, its default, mode help, a read-only view of each composition, and the Creator-mode entry. */ import type { ReactNode } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Button, IconPlusOutlineRegular, Switch, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, IconBrowseOutlineRegular, IconPlusOutlineRegular, Modal, Switch, Tag, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentPresetSectionState } from './section-store.ts' import { isBuiltInPreset, presetDisplayText } from './locales.ts' -import { PresetGuideDialog, presetGuide, type PresetGuidePage } from './PresetGuideDialog.tsx' +import { PresetGuideDialog, presetGuide, trapPresetReaderTab, type PresetGuidePage } from './PresetGuideDialog.tsx' import css from './AgentPresetSection.module.css' /** Settings actions and their shared controller state. */ @@ -19,6 +21,10 @@ export interface AgentPresetSectionInjected { /** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */ startCreatorDraft?: () => void load: () => Promise + /** Open one preset's declared composition in the read-only viewer. */ + view: (id: string) => Promise + /** Close the read-only viewer. */ + closeView: () => void makeDefault: (id: string) => Promise setPickerVisible: (visible: boolean) => Promise } @@ -51,12 +57,13 @@ function CardDescription({ text }: { text: string }): ReactNode { ) } -/** Render the roster with its default, mode help, and the guidance to Creator mode. +/** Render the roster with its default, mode help, composition viewer, and the guidance to Creator mode. * @param props Settings actions, snapshot hooks and localized text. * @returns The preset settings section. */ export function AgentPresetSection({ - useAgentPresetSection, load, makeDefault, setPickerVisible, startCreatorDraft, close: closeSettings, useDeveloperTools, t, + useAgentPresetSection, load, view, closeView, makeDefault, setPickerVisible, startCreatorDraft, + close: closeSettings, useDeveloperTools, t, }: AgentPresetSectionProps) { const state = useAgentPresetSection(value => value) const developerTools = useDeveloperTools(enabled => enabled) @@ -64,7 +71,15 @@ export function AgentPresetSection({ content: NonNullable> page: PresetGuidePage } | null>(null) + const viewTrigger = useRef(null) + const closeViewOnUnmount = useRef(closeView) useEffect(() => { void load() }, [load]) + useLayoutEffect(() => { closeViewOnUnmount.current = closeView }, [closeView]) + useEffect(() => () => { closeViewOnUnmount.current() }, []) + const closeViewer = () => { closeView(); viewTrigger.current?.focus() } + const viewed = state.view + const viewedRow = viewed === null ? undefined : state.rows.find(row => row.id === viewed.id) + const viewedTitle = viewed === null ? '' : viewedRow === undefined ? viewed.title : presetDisplayText(viewedRow, t).name // Creator mode authors presets in conversation; it needs the flow to land a // session in and the self-referential preset on the roster. const creator = startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') ? startCreatorDraft : undefined @@ -138,14 +153,21 @@ export function AgentPresetSection({ {row.broken === undefined ? null : {row.broken}} - {help === undefined ? null :
-
+
+ {help === undefined ? null :
-
-
} +
} + {/* Reading the declaration is the one thing this page offers + beyond choosing: a broken preset's YAML is also where its + diagnostic points, so the viewer stays available for it. */} + +
})} } @@ -153,5 +175,17 @@ export function AgentPresetSection({ })} {guide === null ? null : { setGuide(null) }} />} + { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + closeViewer() + } else trapPresetReaderTab(event) + }} + title={viewed === null ? '' : `${t('view')} · ${viewedTitle}`} className={css.dialog as string} + footer={}> + {viewed === null ? null :
{viewed.content}
} +
} diff --git a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx index 8ca5270d52..310d397dbc 100644 --- a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx +++ b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx @@ -33,6 +33,25 @@ export function presetGuide(id: string, trust: string): PresetGuide | undefined return trust === 'system' ? guides.get(id) : undefined } +/** Keep keyboard focus inside a preset reader while Tab moves through its controls. + * @param event Keyboard event from the active reader. + */ +export function trapPresetReaderTab(event: KeyboardEvent): void { + if (event.key !== 'Tab') return + const targets = Array.from(event.currentTarget.querySelectorAll( + 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]', + )).filter(element => !element.closest('[hidden]')) + const first = targets[0] + const last = targets[targets.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last?.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first?.focus() + } +} + /** Curated usage dictionaries contain only level-three example sections. */ function GuideUsage({ text, t }: { text: string @@ -90,20 +109,7 @@ export function PresetGuideDialog({ guide, initialPage, t, onClose }: { event.preventDefault() event.stopPropagation() onClose() - } else if (event.key === 'Tab') { - const targets = Array.from(event.currentTarget.querySelectorAll( - 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]', - )).filter(element => !element.closest('[hidden]')) - const first = targets[0] - const last = targets[targets.length - 1] - if (event.shiftKey && document.activeElement === first) { - event.preventDefault() - last?.focus() - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault() - first?.focus() - } - } + } else trapPresetReaderTab(event) } return ( diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 9121d5b07d..a253ac3888 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -2,7 +2,8 @@ * Agent-preset surface plugin, browser half — three surfaces over one roster: * a chip on the new-session screen for the session about to start, a * read-only label in the session header, and a settings section that lists - * the roster (selection, the new-task default, and the way into Creator mode). + * the roster (selection, the new-task default, a read-only view of each + * declared composition, and the way into Creator mode). * * A running session keeps the composition it began with (the host refuses to * adopt an existing session under a different preset). That is what splits @@ -49,7 +50,7 @@ export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPre export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' export type { AgentPresetSeatState } from './seat-store.ts' -export type { AgentPresetSectionState } from './section-store.ts' +export type { AgentPresetSectionState, PresetView } from './section-store.ts' export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts' export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' @@ -199,6 +200,8 @@ export function apply(ctx: ClientContext): void { const sectionInjected = (): AgentPresetSectionInjected => ({ hooks: { agentPresetSection: section.store, developerTools: ctx.configForms.developerTools.enabled }, load: () => section.load(), + view: (id: string) => section.view(id), + closeView: () => { section.closeView() }, ...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft }, makeDefault: (id: string) => section.makeDefault(id, captureBlankSessionSync()), setPickerVisible: (showPicker: boolean) => section.setPickerVisible(showPicker, captureBlankSessionSync()), diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 90652929f9..885ff581a2 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -12,6 +12,7 @@ export type AgentPresetSettingsKey = | 'nav' | 'sectionIntro' | 'setDefault' + | 'view' | 'presetStandardName' | 'presetStandardDescription' | 'presetPtcName' @@ -44,6 +45,7 @@ export const en: Record = { nav: 'Agent presets', setDefault: 'Set as new task default', + view: 'View configuration', presetStandardName: 'Standard mode', presetStandardDescription: @@ -89,6 +91,7 @@ export const zh: Record = { nav: 'Agent 预设', setDefault: '设为新任务默认', + view: '查看配置', presetStandardName: '标准模式', presetStandardDescription: '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。', diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 334a134d87..7ce1154b2f 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -1,10 +1,19 @@ -/** Preset roster and selection policy for the settings section. */ +/** Preset roster, selection policy and the read-only composition viewer for the settings section. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types' import { writeDefaultPreset, writeModeSelectionEnabled } from './settings-store.ts' +/** The read-only composition viewer over one preset. */ +export interface PresetView { + /** The preset being read. */ + id: string + /** Display name the preset published, or its id. */ + title: string + /** The declared child plugin list as YAML. */ + content: string +} /** Settings page state. */ export interface AgentPresetSectionState { status: 'idle' | 'loading' | 'ready' | 'error' @@ -12,15 +21,18 @@ export interface AgentPresetSectionState { showPicker: boolean policySaving: boolean rows: readonly AgentPresetRow[] + /** The open viewer, or null. */ + view: PresetView | null } -const INITIAL: AgentPresetSectionState = { status: 'idle', error: null, showPicker: true, policySaving: false, rows: [] } +const INITIAL: AgentPresetSectionState = { status: 'idle', error: null, showPicker: true, policySaving: false, rows: [], view: null } const message = (error: unknown): string => error instanceof Error ? error.message : String(error) -/** Loads the roster and writes the default and chooser policy. */ +/** Loads the roster, writes the default and chooser policy, and reads one composition at a time. */ export class AgentPresetSectionController { - /** Observable roster and selection state. */ + /** Observable roster, selection and viewer state. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) private loading: Promise | undefined + private viewRequest = 0 constructor(private readonly ctx: Context) {} private set(patch: Partial): void { this.store.set({ ...this.store.getSnapshot(), ...patch }) } @@ -39,6 +51,24 @@ export class AgentPresetSectionController { } catch (error) { this.set({ status: 'error', error: message(error) }) } } + /** Open one preset's declared composition in the viewer. + * @param id Preset to read. + * @returns Once the read settles; a current failure lands in `error`, while a read superseded by close or another read is ignored. + */ + async view(id: string): Promise { + const request = ++this.viewRequest + this.set({ error: null, view: null }) + try { + const result = await this.ctx.remote.agentPresets.read(id) + if (request !== this.viewRequest) return + if (!result.ok) throw new Error(result.error.message) + const { name, content } = result.value + this.set({ view: { id, title: name ?? id, content } }) + } catch (error) { if (request === this.viewRequest) this.set({ error: message(error) }) } + } + /** Close the viewer. */ + closeView(): void { this.viewRequest++; this.set({ view: null }) } + /** Set the default and synchronize the current blank task when supplied. * @param id Selected default. * @param sync Blank-session synchronization callback. diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 23e75612d0..ed6e9698a5 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -131,9 +131,9 @@ async function bench(options: { } return Promise.resolve(ROSTER) }, - read: () => Promise.resolve({ + read: (id: string) => Promise.resolve({ ok: true as const, - value: { agentPreset: 'standard', content: '', revision: 'rev' }, + value: { agentPreset: id, content: `# ${id}\n[]\n` }, }), save: (id: string) => { calls.push(`save:${id}`) @@ -350,6 +350,11 @@ describe('ui-agent-preset apply', () => { await section.makeDefault('standard') expect(section.hooks.agentPresetSection.getSnapshot().rows) .toEqual([{ id: 'standard', isDefault: true }]) + await section.view('standard') + expect(section.hooks.agentPresetSection.getSnapshot().view) + .toEqual({ id: 'standard', title: 'standard', content: '# standard\n[]\n' }) + section.closeView() + expect(section.hooks.agentPresetSection.getSnapshot().view).toBeNull() }) it('refreshes a showing surface when its namespace changes, and ignores others', async () => { diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index 1a708fd4ec..22a76c2df0 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -4,6 +4,7 @@ import { AgentPresetSectionController } from '../src/client/section-store.ts' function fixture() { const remote = { agentPresets: { list: vi.fn(async () => ({ ok: true as const, value: { presets: [{ id: 'standard', isDefault: true }], modeSelectionEnabled: true } })), + read: vi.fn(async (id: string) => ({ ok: true as const, value: { agentPreset: id, name: 'Standard', content: '- name: fs\n' } })), }, settings: { update: vi.fn(async () => ({ ok: true as const, value: {} })) } } const controller = new AgentPresetSectionController({ remote } as never) return { remote, controller, state: () => controller.store.getSnapshot() } @@ -14,7 +15,7 @@ describe('the preset roster', () => { const { controller, remote, state } = fixture() await Promise.all([controller.load(), controller.load()]) expect(remote.agentPresets.list).toHaveBeenCalledOnce() - expect(state()).toMatchObject({ status: 'ready', rows: [{ id: 'standard', isDefault: true }], showPicker: true }) + expect(state()).toMatchObject({ status: 'ready', rows: [{ id: 'standard', isDefault: true }], showPicker: true, view: null }) remote.agentPresets.list.mockRejectedValueOnce(new Error('offline')) await controller.load() expect(state()).toMatchObject({ status: 'error', error: 'offline' }) @@ -26,6 +27,43 @@ describe('the preset roster', () => { expect(state().error).toBe('gone') }) + it('opens one declared composition for reading and keeps a failed read out of the viewer', async () => { + const { controller, remote, state } = fixture() + await controller.view('standard') + expect(remote.agentPresets.read).toHaveBeenCalledWith('standard') + expect(state().view).toEqual({ id: 'standard', title: 'Standard', content: '- name: fs\n' }) + controller.closeView() + expect(state().view).toBeNull() + remote.agentPresets.read.mockResolvedValueOnce({ ok: true, value: { agentPreset: 'mine', content: '[]\n' } } as never) + await controller.view('mine') + expect(state().view).toEqual({ id: 'mine', title: 'mine', content: '[]\n' }) + remote.agentPresets.read.mockResolvedValueOnce({ ok: false, error: { message: 'Unknown agent preset: gone' } } as never) + await controller.view('gone') + expect(state()).toMatchObject({ view: null, error: 'Unknown agent preset: gone' }) + remote.agentPresets.read.mockRejectedValueOnce(new Error('offline')) + await controller.view('standard') + expect(state()).toMatchObject({ view: null, error: 'offline' }) + }) + + it('ignores a read that settles after the viewer closes or a newer read opens', async () => { + const { controller, remote, state } = fixture() + const late = Promise.withResolvers>>() + remote.agentPresets.read.mockImplementationOnce(() => late.promise) + const first = controller.view('standard') + controller.closeView() + late.resolve({ ok: true, value: { agentPreset: 'standard', name: 'Standard', content: 'old' } }) + await first + expect(state().view).toBeNull() + + const failed = Promise.withResolvers>>() + remote.agentPresets.read.mockImplementationOnce(() => failed.promise) + const stale = controller.view('standard') + await controller.view('mine') + failed.reject(new Error('stale read')) + await stale + expect(state()).toMatchObject({ error: null, view: { id: 'mine', content: '- name: fs\n' } }) + }) + it('keeps default selection and blank-session synchronization on their existing settings path', async () => { const { controller, remote, state } = fixture() const sync = vi.fn(async () => undefined) diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 2079d0c67a..8bad88af50 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { afterEach, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { AgentPresetSection, type AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' import type { AgentPresetSectionState } from '../src/client/section-store.ts' import { en } from '../src/client/locales.ts' @@ -11,10 +12,12 @@ const translations: ReadonlyMap = new Map(Object.entries(en)) function unusedHook(): never { throw new Error('This section does not read global slot sources') } -function view(partial: Partial = {}, startCreatorDraft?: () => void, developerTools = true) { +function view(partial: Partial = {}, startCreatorDraft?: () => void, developerTools = true, + outerClose?: () => void) { const store = createSnapshotStore({ status: 'ready', error: null, - showPicker: true, policySaving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }], ...partial }) - const actions = { load: vi.fn(async () => {}), makeDefault: vi.fn(async () => {}), + showPicker: true, policySaving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }], + view: null, ...partial }) + const actions = { load: vi.fn(async () => {}), view: vi.fn(async () => {}), closeView: vi.fn(), makeDefault: vi.fn(async () => {}), setPickerVisible: vi.fn(async () => {}), close: vi.fn() } const props: AgentPresetSectionProps = { ...actions, ...(startCreatorDraft === undefined ? {} : { startCreatorDraft }), @@ -23,8 +26,9 @@ function view(partial: Partial = {}, startCreatorDraft? useAgentPresetSection: bindSnapshotSelector(store), useDeveloperTools: bindSnapshotSelector(createSnapshotStore(developerTools)), t: key => translations.get(key) ?? key } - render() - return actions + render(outerClose === undefined ? + : ) + return { ...actions, store } } function rowFor(id: string): HTMLElement { const row = document.querySelector(`[data-agent-preset-id="${id}"]`) @@ -87,6 +91,52 @@ it('disables the Creator entry when mode selection is hidden', () => { fireEvent.click(button) expect(launch).not.toHaveBeenCalled() }) +it('reads a declared composition read-only from every card, broken ones included', () => { + const actions = view({ rows: [{ id: 'standard', isDefault: true }, { id: 'broken', isDefault: false, broken: 'Missing plugin' }] }) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })) + expect(actions.view).toHaveBeenCalledWith('standard') + fireEvent.click(within(rowFor('broken')).getByRole('button', { name: `${en.view}: broken` })) + expect(actions.view).toHaveBeenLastCalledWith('broken') + expect(actions.makeDefault).not.toHaveBeenCalled() +}) +it('shows the open composition under the preset display name, without copy, and closes it from the footer', () => { + const actions = view({ view: { id: 'standard', title: 'standard', content: '- id: tool-fs\n name: fs\n' } }) + const dialog = screen.getByRole('dialog', { name: `${en.view} · ${en.presetStandardName}` }) + expect(dialog.querySelector('pre')?.textContent).toBe('- id: tool-fs\n name: fs\n') + expect(within(dialog).queryByRole('textbox')).toBeNull() + expect(dialog.querySelectorAll('p')).toHaveLength(0) + fireEvent.click(within(dialog).getAllByRole('button', { name: en.close }).at(-1)!) + expect(actions.closeView).toHaveBeenCalledOnce() + cleanup() + view({ view: { id: 'gone', title: 'Gone', content: '[]\n' } }) + expect(screen.getByRole('dialog', { name: `${en.view} · Gone` })).toBeTruthy() +}) +it('keeps Escape inside the composition viewer when Settings is also open', () => { + const closeSettings = vi.fn() + const actions = view({}, undefined, true, closeSettings) + const trigger = within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` }) + trigger.focus() + fireEvent.click(trigger) + act(() => { actions.store.set({ ...actions.store.getSnapshot(), view: { id: 'standard', title: 'standard', content: '[]\n' } }) }) + const dialog = screen.getByRole('dialog', { name: `${en.view} · ${en.presetStandardName}` }) + const [header, footer] = within(dialog).getAllByRole('button', { name: en.close }) + expect(document.activeElement).toBe(footer) + fireEvent.keyDown(footer!, { key: 'Tab' }) + expect(document.activeElement).toBe(header) + fireEvent.keyDown(header!, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(footer) + fireEvent.keyDown(footer!, { key: 'Escape' }) + expect(actions.closeView).toHaveBeenCalledOnce() + expect(closeSettings).not.toHaveBeenCalled() + expect(actions.close).not.toHaveBeenCalled() + expect(document.activeElement).toBe(trigger) +}) +it('clears an open viewer when the settings section unmounts', () => { + const actions = view({ view: { id: 'standard', title: 'standard', content: '[]\n' } }) + cleanup() + expect(actions.closeView).toHaveBeenCalledOnce() +}) it('shows roster errors while the policy switch stays usable', () => { const actions = view({ error: 'Roster stale', rows: [{ id: 'broken', isDefault: false, broken: 'Missing plugin' }] }) expect(screen.getAllByRole('alert').map(node => node.textContent)).toEqual(['Roster stale', 'Missing plugin']) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index f30604f200..a272be594a 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 1aa7d71b3f932a7a14ef6d23e9ace7eda444fc3b -README.zh.md: 2239234448e1e00fd395634de39a96c2a80a35f7 +README.md: b3d649c319c58ff807f11cc6b6d0e634eb931711 +README.zh.md: 6feb9d459299755968a3d99d5789d0cfae8b6e8e diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 1aa7d71b3f..b3d649c319 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -85,7 +85,9 @@ Work-details modes control process-group display and reasoning previews; eligibl Chat registers its process Group Definition through `uiConversation.groups`. React renders the mixed `node`/`group` root sequence through stable Group and Node seats; group headers subscribe to data separately from member arrays. Settled group titles remain independent of the live-detail preference; only running titles update when that preference changes. [Process-group business rules](src/client/conversation-nodes/README.md#process-grouping) define segmentation and activity summaries. -`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. +`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. + +Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Repeated named deltas retain the Tool node and its data when the projected call, anchor, location, and visibility are unchanged. The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 2239234448..6feb9d4592 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -85,7 +85,9 @@ Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第 Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过稳定的 Group 与 Node 容器渲染混合 `node`/`group` 根序列,组头数据与成员数组分别订阅。已结束组的标题独立于实时详情偏好,只有运行中的标题在该偏好变化时更新。[过程分组业务规则](src/client/conversation-nodes/README.zh.md#process-grouping) 定义切分方式与活动摘要。 -`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 +`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 + +实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。重复具名 delta 在投影调用、锚点、位置及可见性均未变化时保留 Tool 节点及其数据引用。 过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。 diff --git a/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx b/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx index 32c3d3e415..f042628086 100644 --- a/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx +++ b/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx @@ -32,7 +32,7 @@ export function ApprovalCommand({ callId, useChat }: PropsRuntime<'conversation. const command = useChat((snapshot) => { for (const node of snapshot.nodes.values()) { const root = node.kind === 'tool-call' ? (node as ChatNode<'tool-call'>).data.root : undefined - if (root !== undefined && root.callId === callId && !('kind' in root)) return commandOf(root) + if (root !== undefined && root.callId === callId && !('kind' in root) && root.phase === 'start') return commandOf(root) } return undefined }) diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index f2d7fa17d3..2944f85c38 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -31,13 +31,16 @@ type ProcessTitleActivity = ProcessActivity | 'thinking' interface LiveProcessTitle { readonly activity: ProcessTitleActivity readonly detail: string + readonly preparing: boolean } const PROCESS_ICONS: Record = { thinking: , read: , + readImage: , search: , edit: , + write: , commands: , code: , webSearch: , @@ -49,7 +52,7 @@ const PROCESS_ICONS: Record = { } function sameLiveProcessTitle(left: LiveProcessTitle, right: LiveProcessTitle): boolean { - return left.activity === right.activity && left.detail === right.detail + return left.activity === right.activity && left.detail === right.detail && left.preparing === right.preparing } function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean): LiveProcessTitle { @@ -73,7 +76,7 @@ function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean): } const timer = setTimeout(commit, remaining) return () => { clearTimeout(timer) } - }, [active, desired.activity, desired.detail]) + }, [active, desired.activity, desired.detail, desired.preparing]) return active ? displayed : desired } @@ -99,9 +102,12 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG const live = useStableLiveProcessTitle({ activity: data?.summary.running ?? 'thinking', detail: data?.summary.runningDetail ?? '', + preparing: data?.summary.preparing === true, }, data !== undefined && !data.closed) if (data === undefined) return null - const label = data.closed ? processTitle(data.summary, t) : t(`message.stepProcess.${live.activity}`) + const label = data.closed ? processTitle(data.summary, t) + : live.preparing ? t(`message.stepProcess.prepare.${live.activity === 'thinking' ? 'tools' : live.activity}`) + : t(`message.stepProcess.${live.activity}`) const detail = detailed && !data.closed ? live.detail : '' const title = detail === '' ? label : `${label}${t('message.turnProcess.separator')}${detail}` const activity = data.closed ? data.summary.counts[0]?.kind ?? 'thinking' : live.activity diff --git a/packages/client/ui-chat/src/client/contract/process-groups.ts b/packages/client/ui-chat/src/client/contract/process-groups.ts index 0c7bc7445a..1c2cddb6fb 100644 --- a/packages/client/ui-chat/src/client/contract/process-groups.ts +++ b/packages/client/ui-chat/src/client/contract/process-groups.ts @@ -1,5 +1,5 @@ /** Business data for Chat process groups; source Nodes remain in the Chat store. */ -export type ProcessActivity = 'read' | 'search' | 'edit' | 'commands' | 'code' +export type ProcessActivity = 'read' | 'readImage' | 'search' | 'write' | 'edit' | 'commands' | 'code' | 'webSearch' | 'webFetch' | 'subagents' | 'plan' | 'questions' | 'tools' /** Distinct-call category ranking and the current live task detail. */ @@ -7,6 +7,8 @@ export interface ProcessActivitySummary { readonly counts: readonly { readonly kind: ProcessActivity; readonly count: number }[] readonly running: ProcessActivity | undefined readonly runningDetail: string + /** Present only while the selected live activity has not reached tool/call. */ + readonly preparing?: true } /** Presentation facts for one group between independent replies or input. */ diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index bc080c3c6f..80d19ba50a 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -8,7 +8,7 @@ import type { TurnProcessSpec } from './turn-process.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig, AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, - ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, + ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 028cbb1caf..d93cefddcd 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/src/client/conversation-nodes/README.md -README.md: 3a1660677e173461c353da1d40af3e8adae6d216 -README.zh.md: 024ea0f5da55826651611a7059a647b0c6478e08 +README.md: 3b6b82685c763837e706c60d8ea69b896f582ae2 +README.zh.md: ae43726417becea973b22e2f2a16871cec116584 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 3a1660677e..3b6b82685c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -122,13 +122,17 @@ A closed group's header names the first three categories from its ranked summary ### Group-title rules +All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Only the generic “Preparing tool calls” category appends the wire tool name in Detailed mode; other categories omit it. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation. + The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category. | Category | Running label | Closed label | |---|---|---| | No live category / no counted categories | Analyzing the request | Analysis completed | | `read` | Reading files | Read files | +| `readImage` | Reading images | Read images | | `search` | Searching code | Searched code | +| `write` | Writing files | Wrote files | | `edit` | Editing files | Edited files | | `commands` | Running commands | Ran commands | | `code` | Running code | Ran code | @@ -210,7 +214,7 @@ Counts describe calls in this group, not successful operations, files changed, c | Call evidence in this group | Counting rule | |---|---| -| Running call, successful result, failed result, or projected interrupted result with its original call | Count one call in the category selected by its recorded tool name. Settlement and failure do not add or subtract a call. | +| Preparing call, running call, successful result, failed result, or projected interrupted result with its original call | Count one call in the category selected by its recorded tool name. Settlement and failure do not add or subtract a call. | | Repeated `callId` | Count only the first occurrence, including when it appears both as a root and as a nested call. Later occurrences and their subtrees are skipped. | | Parent and child with different `callId` values | Count both, in their own categories; visit the parent before its children and siblings in recorded order. | | Repeated calls to the same tool with different `callId` values | Count each call, even with identical arguments or the same terminal/session target. | @@ -225,9 +229,11 @@ Classification uses the recorded tool name exactly: no case folding, namespace s | Category | Tool-name match | |---|---| -| `read` | `read`, `read_image`, `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource` | +| `read` | `read` | +| `readImage` | `read_image` | | `search` | `grep`, `glob`, or suffix `_inspect` | -| `edit` | `write`, `edit`, `apply_patch` | +| `write` | `write` | +| `edit` | `edit`, `apply_patch` | | `commands` | `bash`, `pwsh`, `exec_command`, `write_stdin`, or prefix `terminal_` | | `code` | `run_code` | | `webSearch` | `web_search` | @@ -235,7 +241,7 @@ Classification uses the recorded tool name exactly: no case folding, namespace s | `subagents` | `subagent` or prefix `subagent_` | | `plan` | `todo_write`, `create_goal`, `update_goal`, `get_goal` | | `questions` | `ask_user_question`, `request_user_input` | -| `tools` | Every other name | +| `tools` | Every other name, including `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` | ### Subagents, background jobs, and nested calls @@ -256,6 +262,8 @@ Exact-name rules also mean that a recorded name such as `functions.read`, `mcp.r ### Live activity and detail +Preparing calls use their first named delta time; only the generic tool category provides the tool name as detail. Dispatched calls use their tool/call time and complete arguments. + Among running calls, the greatest `time` selects the live category and detail; equal times select the later visited call. With no running call, the category is absent and detail comes from the last nonempty reasoning paragraph of the latest running Assistant with nonempty reasoning, in member order. Reasoning detail removes `**` markers and does not require a newline-terminated first line; the individual reasoning-row preview has separate rules. Live selection uses call start `time`, not the latest output/progress time. A call is a running candidate while its projected tool value has no result; pending approval or waiting for a result does not receive a separate category. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 024ea0f5da..ae43726417 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -122,19 +122,23 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" ### 组标题规则 +三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在 Detailed 中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。 + 以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。 | 类别 | 未结束组的活动文案 | 已结束组的摘要文案 | |---|---|---| | 无实时类别 / 无计数类别 | 正在分析请求 | 已完成分析 | | `read` | 正在读取文件 | 已读取文件 | +| `readImage` | 正在读取图片 | 已读取图片 | | `search` | 正在搜索代码 | 已搜索代码 | +| `write` | 正在写入文件 | 已写入文件 | | `edit` | 正在编辑文件 | 修改了文件 | | `commands` | 正在运行命令 | 执行了命令 | | `code` | 正在运行代码 | 运行了代码 | | `webSearch` | 正在搜索网页 | 已搜索网页 | | `webFetch` | 正在访问网页 | 已访问网页 | -| `subagents` | 正在协调子任务 | 已协调子任务 | +| `subagents` | 正在协调子代理 | 已协调子代理 | | `plan` | 正在更新计划 | 更新了计划 | | `questions` | 等待你的操作 | 向用户提出了问题 | | `tools` | 正在调用工具 | 已调用工具 | @@ -210,7 +214,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 本组中的调用记录 | 计数规则 | |---|---| -| 运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 | +| 准备中调用、运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 | | 重复的 `callId` | 只计首次出现,包括同时作为根调用和嵌套调用出现的情况;后续重复记录及其子树不再处理。 | | 父子调用的 `callId` 不同 | 父子分别按各自类别计数;父调用先于子调用,同级按记录顺序处理。 | | 同一工具使用不同 `callId` 多次调用 | 每次都计数,即使参数相同,或操作的是同一个终端、Session。 | @@ -225,9 +229,11 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 类别 | 工具名匹配 | |---|---| -| `read` | `read`、`read_image`、`list_mcp_resources`、`list_mcp_resource_templates`、`read_mcp_resource` | +| `read` | `read` | +| `readImage` | `read_image` | | `search` | `grep`、`glob`,或以 `_inspect` 结尾 | -| `edit` | `write`、`edit`、`apply_patch` | +| `write` | `write` | +| `edit` | `edit`、`apply_patch` | | `commands` | `bash`、`pwsh`、`exec_command`、`write_stdin`,或以 `terminal_` 开头 | | `code` | `run_code` | | `webSearch` | `web_search` | @@ -235,7 +241,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | `subagents` | `subagent`,或以 `subagent_` 开头 | | `plan` | `todo_write`、`create_goal`、`update_goal`、`get_goal` | | `questions` | `ask_user_question`、`request_user_input` | -| `tools` | 其他所有名称 | +| `tools` | 其他所有名称,包括 `list_mcp_resources`、`list_mcp_resource_templates`、`read_mcp_resource` | ### Subagent、后台任务与嵌套调用 @@ -256,6 +262,8 @@ Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成 ### 实时活动与详情 +准备中的调用使用首个具名 delta 的时间,只有通用工具类别提供工具名详情。已派发调用使用 tool/call 的时间和完整参数。 + 运行中的调用以最大 `time` 决定实时类别和详情;时间相同时取后遍历的调用。没有运行调用时,类别为空,详情取成员顺序中最近一个含有非空推理段落的运行中 Assistant 的最后一个非空段落。推理详情移除 `**` 标记,不要求首行以换行结束;单个推理行的预览另有规则。 实时选择依据调用开始的 `time`,不是最近一次输出或进度的时间。投影后的工具值尚无结果时就参与运行中候选;等待批准或等待结果不会产生另一个类别。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts index d6766b1954..b072b29d7e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts @@ -5,9 +5,11 @@ import { isRunningTool } from '../contract/chat-nodes.ts' import type { ToolCallBlock } from '../contract/snapshot.ts' function activity(name: string): ProcessActivity { - if (['read', 'read_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(name)) return 'read' + if (name === 'read') return 'read' + if (name === 'read_image') return 'readImage' if (name === 'grep' || name === 'glob' || name.endsWith('_inspect')) return 'search' - if (['write', 'edit', 'apply_patch'].includes(name)) return 'edit' + if (name === 'write') return 'write' + if (name === 'edit' || name === 'apply_patch') return 'edit' if (['bash', 'pwsh', 'exec_command', 'write_stdin'].includes(name) || name.startsWith('terminal_')) return 'commands' if (name === 'run_code') return 'code' if (name === 'web_search') return 'webSearch' @@ -95,6 +97,7 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm let running: ProcessActivity | undefined let runningDetail = '' let runningTime = -Infinity + let preparing: boolean | undefined const visit = (tool: ToolCallBlock): void => { if (seen.has(tool.callId)) return seen.add(tool.callId) @@ -103,7 +106,10 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm const kind = activity(call.name) if (isRunningTool(tool) && tool.time >= runningTime) { running = kind - runningDetail = liveToolDetail(tool.name, tool.argsRaw) + preparing = tool.phase === 'preparing' + runningDetail = tool.phase === 'preparing' + ? kind === 'tools' ? tool.name : '' + : liveToolDetail(tool.name, tool.argsRaw) runningTime = tool.time } counts.set(kind, (counts.get(kind) ?? 0) + 1) @@ -118,5 +124,6 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count), running, runningDetail, + ...preparing ? { preparing: true } : {}, } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts index bff0c532ac..36cb552d32 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts @@ -29,6 +29,7 @@ function reply(node: ChatNode): boolean { function sameSummary(left: ProcessActivitySummary, right: ProcessActivitySummary): boolean { return left.running === right.running && left.runningDetail === right.runningDetail + && left.preparing === right.preparing && left.counts.length === right.counts.length && left.counts.every((value, index) => value.kind === right.counts[index]?.kind && value.count === right.counts[index].count) } @@ -66,7 +67,7 @@ class ProcessGroup { const unchanged = nodes.length === this.nodes.length && nodes.every((node, index) => node === this.nodes[index]) const previous = this.snapshot.data const activity = unchanged && previous.closed === closed ? previous.summary : processActivity(nodes) - const summary = closed ? { ...activity, running: undefined, runningDetail: '' } : activity + const summary = closed ? { counts: activity.counts, running: undefined, runningDetail: '' } : activity this.nodes = nodes if (previous.closed !== closed || !sameSummary(previous.summary, summary)) { this.snapshot = { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts index fd14a8115c..fbaf31f882 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts @@ -68,6 +68,7 @@ export function systemMessageDefinition(inspect: SystemPromptInspector): Convers ? { id: String(event.seq), role: 'start' } : null, start: (_context, match, reader) => { + if (match.event.type === 'assistant/live-chunk') throw new Error('system-message requires a durable event') return inspect(reader.previous('system-message')?.state, match.event) }, update: context => context.state, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index 2e914e5072..ffbc94de5a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,12 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, StartedToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' -import type { ToolChatData } from '../contract/chat-nodes.ts' -import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' +import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode, contextLocation } from './common.ts' declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { @@ -37,8 +37,20 @@ function jsonArguments(value: unknown): string { } function rootCall(match: ConversationMatch): RunningToolCall { + const event = match.event + if (event.type === 'assistant/live-chunk') { + const chunk = event.data.chunk + if (chunk.type !== 'tool-call-delta' || !chunk.name) throw new Error('tool preparation requires a named call delta') + return { + phase: 'preparing', + callId: String(chunk.id), name: chunk.name, + turn: event.data.turn, step: event.data.step, time: event.time, + subCalls: [], + } + } if (match.event.type !== 'tool/call') throw new Error('tool-call start requires tool/call') return { + phase: 'start', callId: String(match.event.data.callId), name: match.event.data.name, argsRaw: match.event.data.arguments, @@ -49,7 +61,7 @@ function rootCall(match: ConversationMatch): RunningToolCall { } } -function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined { +function rootResult(match: ConversationMatch, previous?: StartedToolCall): ToolResultNode | undefined { if (match.event.type !== 'tool/result') return undefined const message = match.event.data.message return { @@ -77,8 +89,9 @@ interface DispatchData { readonly content?: ToolResultNode['content'] } -function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { +function childCall(match: ConversationMatch, data: DispatchData): StartedToolCall { return { + phase: 'start', callId: data.subCallId, parentCallId: data.parentCallId, name: data.name, @@ -174,6 +187,7 @@ function projectBlock( visited = new Set(), depth = 1, ): ToolCallBlock { + if (!('kind' in block) && block.phase === 'preparing') return block if (visited.has(block.callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] } const nextVisited = new Set(visited) nextVisited.add(block.callId) @@ -229,11 +243,16 @@ function fallbackState(context: ConversationNodeContext): ToolState | return state } -/** Root Tool lifecycle and nested PTC dispatch Definition. */ +/** Root Tool preparation, dispatch, result, and nested PTC calls. */ export const toolDefinition: ConversationNodeDefinition = { kind: 'tool-call', target: 'chat', match: (event) => { + if (event.type === 'assistant/live-chunk') { + const chunk = event.data.chunk + return chunk.type === 'tool-call-delta' && chunk.name + ? { id: String(chunk.id), role: 'start' } : null + } if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { return { id: String(event.data.message.source.callId), role: 'update' } @@ -248,20 +267,33 @@ export const toolDefinition: ConversationNodeDefinition = { }, start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }), update: (context, match) => { + if (match.event.type === 'tool/call') return { ...context.state, root: rootCall(match) } if (match.event.type === 'tool/result') { - const running = 'kind' in context.state.root ? undefined : context.state.root + const root = context.state.root + const running = !('kind' in root) && root.phase === 'start' ? root : undefined const result = rootResult(match, running) return result === undefined ? context.state : { ...context.state, root: result } } return updateDispatch(context.state, match) }, + publication: match => match.event.type === 'assistant/live-chunk' ? 'animation-frame' : 'immediate', buildViewNode: (context) => { + const current = context.current.get('chat') as ChatNode<'tool-call'> | null | undefined const state = context.state ?? fallbackState(context) - if (state === undefined) return null - const projected = projectBlock(state.root, state, interruption(context)) + if (state === undefined) { + return current == null ? null : current.visibility === 'hidden' ? current : { ...current, visibility: 'hidden' } + } + const interruptedAt = interruption(context) + const projected = projectBlock(state.root, state, interruptedAt) const anchor = context.start?.event.seq ?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0) - return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData) + const preparing = !('kind' in projected) && projected.phase === 'preparing' + const visibility = preparing && interruptedAt !== undefined ? 'hidden' : 'visible' + const location = contextLocation(context) + const data = current?.data.root === projected ? current.data : { root: projected } satisfies ToolChatData + if (current?.data === data && current.anchorSeq === anchor + && current.visibility === visibility && current.location === location) return current + return chatNode(context, 'tool-call', anchor, data, { visibility, location }) }, } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index b2a1ca67fe..0ef02f17c7 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -18,7 +18,7 @@ export type { AssistantTiming, ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ChatTurnProcessPresentation, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, - LegacyConversationSlice, ModelRetryNode, PartialAssistant, RunningToolCall, + LegacyConversationSlice, ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, TurnNavigationItem, UnknownSurfaceNode, UserMessageNode, } from './contract/snapshot.ts' diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 9638f554e0..5a5eb9f094 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -7,25 +7,42 @@ export const NS = 'chat' export const zh = { 'message.stepProcess.thinking': '正在分析请求', 'message.stepProcess.read': '正在读取文件', + 'message.stepProcess.readImage': '正在读取图片', + 'message.stepProcess.write': '正在写入文件', 'message.stepProcess.search': '正在搜索代码', 'message.stepProcess.edit': '正在编辑文件', 'message.stepProcess.commands': '正在运行命令', 'message.stepProcess.code': '正在运行代码', 'message.stepProcess.webSearch': '正在搜索网页', 'message.stepProcess.webFetch': '正在访问网页', - 'message.stepProcess.subagents': '正在协调子任务', + 'message.stepProcess.subagents': '正在协调子智能体', 'message.stepProcess.plan': '正在更新计划', 'message.stepProcess.questions': '等待你的操作', 'message.stepProcess.tools': '正在调用工具', + 'message.stepProcess.prepare.read': '准备读取文件', + 'message.stepProcess.prepare.readImage': '准备读取图片', + 'message.stepProcess.prepare.write': '准备写入文件', + 'message.stepProcess.prepare.search': '准备搜索代码', + 'message.stepProcess.prepare.edit': '准备编辑文件', + 'message.stepProcess.prepare.commands': '准备运行命令', + 'message.stepProcess.prepare.code': '准备运行代码', + 'message.stepProcess.prepare.webSearch': '准备搜索网页', + 'message.stepProcess.prepare.webFetch': '准备访问网页', + 'message.stepProcess.prepare.subagents': '准备协调子智能体', + 'message.stepProcess.prepare.plan': '准备更新计划', + 'message.stepProcess.prepare.questions': '准备提问', + 'message.stepProcess.prepare.tools': '准备调用工具', 'message.stepProcess.done.thinking': '已完成分析', 'message.stepProcess.done.read': '已读取文件', + 'message.stepProcess.done.readImage': '已读取图片', + 'message.stepProcess.done.write': '已写入文件', 'message.stepProcess.done.search': '已搜索代码', 'message.stepProcess.done.edit': '修改了文件', 'message.stepProcess.done.commands': '执行了命令', 'message.stepProcess.done.code': '运行了代码', 'message.stepProcess.done.webSearch': '已搜索网页', 'message.stepProcess.done.webFetch': '已访问网页', - 'message.stepProcess.done.subagents': '已协调子任务', + 'message.stepProcess.done.subagents': '已协调子智能体', 'message.stepProcess.done.plan': '更新了计划', 'message.stepProcess.done.questions': '向用户提出了问题', 'message.stepProcess.done.tools': '已调用工具', @@ -165,6 +182,8 @@ export type ChatKey = keyof typeof zh export const en = { 'message.stepProcess.thinking': 'Analyzing the request', 'message.stepProcess.read': 'Reading files', + 'message.stepProcess.readImage': 'Reading images', + 'message.stepProcess.write': 'Writing files', 'message.stepProcess.search': 'Searching code', 'message.stepProcess.edit': 'Editing files', 'message.stepProcess.commands': 'Running commands', @@ -175,8 +194,23 @@ export const en = { 'message.stepProcess.plan': 'Updating the plan', 'message.stepProcess.questions': 'Waiting for your action', 'message.stepProcess.tools': 'Calling tools', + 'message.stepProcess.prepare.read': 'Preparing to read files', + 'message.stepProcess.prepare.readImage': 'Preparing to read images', + 'message.stepProcess.prepare.write': 'Preparing to write files', + 'message.stepProcess.prepare.search': 'Preparing to search code', + 'message.stepProcess.prepare.edit': 'Preparing to edit files', + 'message.stepProcess.prepare.commands': 'Preparing to run commands', + 'message.stepProcess.prepare.code': 'Preparing to run code', + 'message.stepProcess.prepare.webSearch': 'Preparing to search the web', + 'message.stepProcess.prepare.webFetch': 'Preparing to visit web pages', + 'message.stepProcess.prepare.subagents': 'Preparing to coordinate subagents', + 'message.stepProcess.prepare.plan': 'Preparing to update the plan', + 'message.stepProcess.prepare.questions': 'Preparing questions', + 'message.stepProcess.prepare.tools': 'Preparing tool calls', 'message.stepProcess.done.thinking': 'Analysis completed', 'message.stepProcess.done.read': 'Read files', + 'message.stepProcess.done.readImage': 'Read images', + 'message.stepProcess.done.write': 'Wrote files', 'message.stepProcess.done.search': 'Searched code', 'message.stepProcess.done.edit': 'Edited files', 'message.stepProcess.done.commands': 'Ran commands', diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index 8426873941..1f29e3c121 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -58,6 +58,7 @@ export class ToolCallTree { if (event.type === 'tool/ptc-dispatch-start') { const data = event.data const running: RunningToolCall = { + phase: 'start', callId: data.subCallId, parentCallId: data.parentCallId, name: data.name, diff --git a/packages/client/ui-chat/tests/approval-command.client.spec.tsx b/packages/client/ui-chat/tests/approval-command.client.spec.tsx index 5a38f1eb4e..6b18ea146a 100644 --- a/packages/client/ui-chat/tests/approval-command.client.spec.tsx +++ b/packages/client/ui-chat/tests/approval-command.client.spec.tsx @@ -30,18 +30,19 @@ describe('ApprovalCommand', () => { it('renders the running correlated Tool command', () => { render() expect(screen.getByText('pnpm test')).toBeTruthy() }) - it('omits absent, uncorrelated, and settled Tool calls', () => { + it('omits absent, uncorrelated, preparing, and settled Tool calls', () => { const { container, rerender } = render( { expect(container.textContent).toBe('') rerender() expect(container.textContent).toBe('') }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index dc70093fc5..3068bff875 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -9,7 +9,7 @@ import { useEffect } from 'react' import type { AssistantMessageNode, ChatNode, ChatNodeHookContext, ChatNodeOwnerProps, ChatSnapshot, ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, - LegacyConversationSlice, ModelRetryNode, RunningToolCall, SteeringMessageNode, + LegacyConversationSlice, ModelRetryNode, StartedToolCall, SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData, TranscriptViewMode, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -206,8 +206,8 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode callTime: seq * 1_000 - 500, content: [], isError: false, subCalls: [], }) -const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ - callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [], +const runningCall = (callId: string, name = 'bash'): StartedToolCall => ({ + phase: 'start' as const, callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [], }) const command = (over: Partial = {}): CommandNode => ({ kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index dfb75e4151..df659888d8 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1002,7 +1002,8 @@ describe('built-in conversation node Definitions', () => { }, { surfaceOp: 'append' }), ]) const toolOnlySnapshot = snapshot(toolOnlyValue) - expect(toolOnlySnapshot.order.map(key => toolOnlySnapshot.nodes.get(key)?.kind)).toEqual(['turn-process']) + expect(toolOnlySnapshot.order.map(key => toolOnlySnapshot.nodes.get(key)?.kind)).toEqual(['turn-process', 'tool-call']) + expect(node(toolOnlySnapshot, 'tool-call')?.data).toMatchObject({ root: { phase: 'preparing' } }) expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden') expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{ kind: 'assistant', diff --git a/packages/client/ui-chat/tests/process-groups.client.spec.ts b/packages/client/ui-chat/tests/process-groups.client.spec.ts index 8fdc292da1..2f98938274 100644 --- a/packages/client/ui-chat/tests/process-groups.client.spec.ts +++ b/packages/client/ui-chat/tests/process-groups.client.spec.ts @@ -35,7 +35,7 @@ function tool(key: string, seq: number, name = 'bash', owner = turn): ChatNode<' return { key, id: key, kind: 'tool-call', target: 'chat', anchorSeq: seq, location: { kind: 'turn', turn: owner }, visibility: 'visible', - data: { root: { callId: key, name, argsRaw: '{"command":"pwd"}', turn: owner.turn, step: 1, time: seq, subCalls: [] } }, + data: { root: { phase: 'start' as const, callId: key, name, argsRaw: '{"command":"pwd"}', turn: owner.turn, step: 1, time: seq, subCalls: [] } }, } } @@ -67,6 +67,56 @@ function harness(nodes: ChatNode[], currentTimeline = timeline) { } describe('Definition-owned Chat process groups', () => { + it('clears preparation from a closed group while retaining its recorded activity', () => { + const preparing: ChatNode<'tool-call'> = { + ...tool('call', 2, 'write'), + data: { root: { phase: 'preparing', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [] } }, + } + const h = harness([preparing]) + const group = h.store.entries[0]! + if (group.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(group.key) + expect(source.getSnapshot()?.data.summary.preparing).toBe(true) + h.builder.apply({ upserts: [separator(3, true)], timeline }) + h.commit() + expect(source.getSnapshot()?.data).toEqual({ + turn: 1, closed: true, + summary: { counts: [{ kind: 'write', count: 1 }], running: undefined, runningDetail: '' }, + }) + }) + + it.each([ + ['read', 'read'], ['read_image', 'readImage'], ['write', 'write'], ['edit', 'edit'], ['apply_patch', 'edit'], + ['todo_write', 'plan'], ['create_goal', 'plan'], ['update_goal', 'plan'], ['get_goal', 'plan'], + ['list_mcp_resources', 'tools'], ['list_mcp_resource_templates', 'tools'], ['read_mcp_resource', 'tools'], + ] as const)('%s keeps its activity category through preparation, dispatch, and result', (name, kind) => { + const started = tool('call', 2, name) + const preparing: ChatNode<'tool-call'> = { + ...started, + data: { root: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 2, subCalls: [] } }, + } + const h = harness([preparing]) + const group = h.store.entries[0]! + if (group.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(group.key) + expect(source.getSnapshot()?.data.summary).toEqual({ + counts: [{ kind, count: 1 }], running: kind, preparing: true, runningDetail: kind === 'tools' ? name : '', + }) + h.builder.apply({ upserts: [started], timeline }) + h.commit() + expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: kind, runningDetail: 'pwd' }) + const settled: ChatNode<'tool-call'> = { + ...started, + data: { root: { + kind: 'tool-result', callId: 'call', seq: 3, time: 3, callTime: 2, + call: { name, argsRaw: '{"command":"pwd"}' }, content: [], isError: false, subCalls: [], + } }, + } + h.builder.apply({ upserts: [settled], timeline }) + h.commit() + expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: undefined, runningDetail: '' }) + }) + it('retains a group through repeated prepends and a later append', () => { const b = tool('b', 4) const c = tool('c', 6) diff --git a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts index fbfbcb1c47..51d4aa9501 100644 --- a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts @@ -1,6 +1,6 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { describe, expect, it } from 'vitest' -import type { RunningToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts' +import type { StartedToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts' import { MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree, } from '../src/client/model/tool-call-tree.ts' @@ -19,8 +19,8 @@ const settle = (seq: number, parentCallId: string, subCallId: string): SessionEv isError: false, content: [], }) -const root = (callId: string): RunningToolCall => ({ - callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1, +const root = (callId: string): StartedToolCall => ({ + phase: 'start' as const, callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1, time: 1_700_000_000_000, subCalls: [], }) diff --git a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts new file mode 100644 index 0000000000..c458a0869f --- /dev/null +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAssistantMessage, createToolResultMessage, LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm' +import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionAssistantSettlementEntry, SessionEventLikeEntry, SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client' +import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' +import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' +import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { processGroupDefinition } from '../src/client/conversation-nodes/process-groups.ts' + +const attemptId = LlmAttemptId('preparing-test') +const first = ToolCallId('write-one') +const second = ToolCallId('write-two') +const args = '{"file_path":"hello.txt","content":"hello"}' + +function entry(event: SessionEvent): SessionLiveEventEntry { + return { type: 'event', event } +} + +const opening = [ + entry({ type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } }), + entry({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }), +] + +function delta(seq: number, id = first, argumentsDelta = '', name: string | null = 'write'): SessionEventLikeEntry { + return { + type: 'transient', + event: { + type: 'assistant/live-chunk', seq, time: seq, + data: { turn: 1, step: 1, attemptId, chunk: { + type: 'tool-call-delta', index: id === first ? 0 : 1, id, argumentsDelta, + ...(name === null ? {} : { name }), + } }, + }, + } +} + +function call(seq: number, callId = first): SessionLiveEventEntry { + return entry({ type: 'tool/call', seq: SessionSeq(seq), time: seq, + data: { turn: 1, step: 1, callId, name: 'write', arguments: args } }) +} + +function result(seq: number, callId = first): SessionLiveEventEntry { + return entry({ type: 'tool/result', seq: SessionSeq(seq), time: seq, surfaceOp: 'append', data: { + turn: 1, step: 1, message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'Written' }], isError: false }), + } }) +} + +function settlement(): SessionAssistantSettlementEntry { + const stream = new AssistantStreamAccumulator() + for (const [index, id] of [first, second].entries()) { + const chunk: StreamChunk = { type: 'tool-call-delta', index, id, name: 'write', argumentsDelta: args } + stream.push({ time: 3 + index, chunk }) + } + return { type: 'event', event: { + type: 'assistant/message', seq: SessionSeq(5), time: 5, surfaceOp: 'append', data: { + turn: 1, step: 1, stream: [...stream.snapshot()], + message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [first, second].map(id => ({ + type: 'tool-call', id, name: 'write', arguments: args, + })) }), + }, + } } +} + +function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = false, tool = toolDefinition) { + const assembler = new ConversationNodeAssembler( + { entries: () => [assistantDefinition, tool], fallbackEntry: () => undefined }, + { entries: () => [chatViewDefinition] }, + { entries: () => [processGroupDefinition], forTarget: target => target === 'chat' ? processGroupDefinition : undefined }, + ) + assembler.replaceWindow(entries, hasMore) + assembler.activateTarget('chat') + const tools = () => { + assembler.flush() + const snapshot = assembler.snapshot('chat') as ChatSnapshot + return snapshot.nodes.values().filter((node): node is ChatNode<'tool-call'> => node.kind === 'tool-call' && node.visibility === 'visible') + .sort((left, right) => left.anchorSeq - right.anchorSeq) + } + const phases = () => tools().map(node => [node.id, 'kind' in node.data.root ? 'result' : node.data.root.phase]) + const material = () => tools().map(({ key, anchorSeq, data }) => ({ key, anchorSeq, data })) + return { assembler, tools, phases, material } +} + +describe('Tool preparation and durable replay', () => { + it('batches repeated named deltas and retains the unchanged Tool node', () => { + const start = vi.fn((...args) => toolDefinition.start(...args)) + const h = harness(opening, false, { ...toolDefinition, start }) + expect(h.assembler.append(delta(2.1, first, '{"file_path":"file.txt","content":"'))).toBe('animation-frame') + const original = h.tools()[0]! + for (let index = 0; index < 100; index++) { + expect(h.assembler.append(delta(2.2 + index / 1000, first, 'x'.repeat(128)))).toBe('animation-frame') + expect(h.tools()[0]).toBe(original) + } + expect(start).toHaveBeenCalledOnce() + expect(h.assembler.append(call(6))).toBe('immediate') + expect(h.phases()).toEqual([[first, 'start']]) + expect(h.tools()[0]).not.toBe(original) + expect(h.assembler.append(result(7))).toBe('immediate') + expect(h.phases()).toEqual([[first, 'result']]) + }) + + it('creates two independent live preparations and updates each call under the same key', () => { + const h = harness() + h.assembler.append(delta(2.1, first, '', null)) + expect(h.phases()).toEqual([]) + h.assembler.append(delta(2.2)) + const original = h.tools()[0]! + expect(h.phases()).toEqual([[first, 'preparing']]) + expect(original.data.root).not.toHaveProperty('argsRaw') + h.assembler.append(delta(2.3, first, '{"content":"hello', null)) + expect(h.tools()[0]?.data.root).toBe(original.data.root) + h.assembler.append(delta(2.4, second)) + expect(h.phases()).toEqual([[first, 'preparing'], [second, 'preparing']]) + const groups = h.assembler.grouped('chat')! + const group = groups.entries.find(entry => entry.kind === 'group')! + if (group.kind !== 'group') throw new Error('expected a process group') + expect(groups.groupSource(group.key).getSnapshot()?.data.summary).toMatchObject({ + counts: [{ kind: 'write', count: 2 }], preparing: true, runningDetail: '', + }) + h.assembler.append(call(6)) + expect(h.tools()[0]?.key).toBe(original.key) + expect(h.phases()).toEqual([[first, 'start'], [second, 'preparing']]) + expect(h.tools()[0]?.data.root).toMatchObject({ argsRaw: args }) + h.assembler.append(result(7)) + h.assembler.append(call(8, second)) + h.assembler.append(result(9, second)) + expect(h.phases()).toEqual([[first, 'result'], [second, 'result']]) + }) + + it('withdraws transient preparation on settlement and converges with a durable-only replacement', () => { + const h = harness() + h.assembler.append(delta(2.1)) + const key = h.tools()[0]!.key + const message = settlement() + h.assembler.settleAssistant(attemptId, message) + expect(h.phases()).toEqual([]) + const history = [...opening, message, call(6), result(7), call(8, second), result(9, second)] + for (const event of history.slice(3)) h.assembler.append(event) + expect(h.tools()[0]?.key).toBe(key) + const replay = harness(history) + expect(h.phases()).toEqual([[first, 'result'], [second, 'result']]) + expect(h.material()).toEqual(replay.material()) + }) + + it('does not create preparing rows from stored Assistant streams in history or older pages', () => { + const message = settlement() + const history = [...opening, message, call(6), result(7), call(8, second), result(9, second)] + const full = harness(history) + expect(full.phases()).toEqual([[first, 'result'], [second, 'result']]) + const paged = harness([result(9, second)], true) + expect(paged.phases()).toEqual([[second, 'result']]) + paged.assembler.prepend([call(8, second)], true) + expect(paged.phases()).toEqual([[second, 'result']]) + paged.assembler.prepend(history.slice(0, 5), false) + expect(paged.material()).toEqual(full.material()) + paged.assembler.replaceWindow([...opening, message], false) + expect(paged.phases()).toEqual([]) + paged.assembler.append(call(6)) + expect(paged.phases()).toEqual([[first, 'start']]) + }) + + it('retains a dispatched call when its earlier transient start is withdrawn', () => { + const h = harness() + h.assembler.append(delta(2.1)) + const key = h.tools()[0]!.key + h.assembler.append(call(6)) + expect(h.tools()[0]).toMatchObject({ key, anchorSeq: 2.1, data: { root: { phase: 'start', argsRaw: args } } }) + const message = settlement() + h.assembler.settleAssistant(attemptId, message) + expect(h.tools()).toHaveLength(1) + expect(h.tools()[0]).toMatchObject({ key, anchorSeq: 6, data: { root: { phase: 'start', argsRaw: args } } }) + h.assembler.append(result(7)) + expect(h.phases()).toEqual([[first, 'result']]) + const replay = harness([...opening, message, call(6), result(7)]) + expect(h.material()).toEqual(replay.material()) + }) + + it('abandons one attempt and lets a subsequent live attempt create its own preparation', () => { + const h = harness() + h.assembler.append(delta(2.1)) + expect(h.phases()).toEqual([[first, 'preparing']]) + h.assembler.settleAssistant(attemptId) + expect(h.phases()).toEqual([]) + h.assembler.append(delta(2.2, second)) + expect(h.phases()).toEqual([[second, 'preparing']]) + h.assembler.replaceWindow([...opening, delta(2.2, second)], false) + expect(h.phases()).toEqual([[second, 'preparing']]) + h.assembler.settleAssistant(attemptId) + h.assembler.append(call(8, second)) + expect(h.phases()).toEqual([[second, 'start']]) + }) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2006e11e50..015f305a4b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 4823aa0a1ba5975c4efc8b6bdddd7ca8c57c068f -README.zh.md: 5ef8a1b6621bf8bab7e98cb18722a9aedda29101 +README.md: 153c505250f5654f0aeade23c221e19554294641 +README.zh.md: c849b1bdfd033e07cac382c4b62cd0df35333c8e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4823aa0a1b..153c505250 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -27,7 +27,7 @@ English | [中文](README.zh.md) `UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. A View Definition may declare `toolCallFocus` to translate a tool-call id into its focus identity. Conversation supplies an Inspect callback only while such a target has a visible View entry; Chat consumes this callback without selecting a target. -The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. +The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Definitions use the same match/start/update interface for durable and transient events. The earliest loaded start initializes State; every later Match, including another start for that identity, updates it. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and recomputes the affected Contexts from their remaining earliest start, refreshing predecessor indexes and dependents without replacing unrelated target nodes. A Context with no remaining start has no State. Its key and previously published nodes remain available to later evidence until a full window rebuild; the owning Definition may hide those nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 5ef8a1b662..c849b1bdfd 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 View Definition 可以声明 `toolCallFocus`,将工具调用 id 转换为自身的焦点标识。仅当此目标拥有可见的 View 条目时,Conversation 才提供 Inspect 回调;Chat 直接使用回调,不选择目标。 -适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 +适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。持久与瞬态事件使用同一组 match/start/update 接口。当前最早的 start 初始化 State;后续所有 Match,包括同一身份的其他 start,都用于更新。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并从剩余最早的 start 重算受影响的 Context,刷新前序索引及 dependent,不替换无关 target node。没有剩余 start 的 Context 不保留 State,但其 key 和已发布节点仍保留,供后续证据复用,直到完整窗口重建;所属 Definition 可以隐藏这些节点。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index 333dd1e5ae..64fa04ecee 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -4,7 +4,7 @@ import type { ConversationGroupData, ConversationGroupedView, ConversationGroupInput, } from './groups.ts' -/** Definition-local identity and lifecycle role extracted from one event. */ +/** Definition-local identity; start permits initialization when no earlier start is present. */ export interface ConversationMatchResult { readonly id: string readonly role: 'start' | 'update' @@ -118,8 +118,8 @@ interface ConversationMatchOf< readonly location: ConversationLocation } -/** One scalar event accepted as a Context's unique start. */ -export type ConversationStartMatch = ConversationMatchOf +/** A durable or transient event that can initialize its Context. */ +export type ConversationStartMatch = ConversationMatchOf /** One event accepted by a Definition, with its lifecycle role and resolved Location. */ export type ConversationMatch = @@ -204,7 +204,7 @@ export interface ConversationNodeDefinition { */ match(event: SessionEventLike): ConversationMatchResult | null /** - * Create State from the unique start Match. + * Create State from the earliest currently loaded start Match. * @param context - complete evidence currently collected for the Context. * @param match - the start Match. * @param reader - strictly-backward read-only Context lookup. @@ -216,9 +216,9 @@ export interface ConversationNodeDefinition { reader: ConversationContextReader, ): State /** - * Apply one post-start update Match. + * Apply a later Match, including another event marked start. * @param context - Context with its current State. - * @param match - update Match in ascending log order. + * @param match - subsequent Match in ascending event order. * @returns the State adopted by the engine. */ update( diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index c4bd56c026..f9e0c002c6 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -257,22 +257,35 @@ export type ConversationNode = | CompactionSummaryNode | UnknownSurfaceNode -/** In-flight tool card material: tool/call seen, tool/result not yet. */ -export interface RunningToolCall { +/** Identity and placement shared by tool preparation and dispatch. */ +interface ToolCallHead { callId: string /** Parent Tool call for a PTC dispatch start; absent on a root Session call. */ parentCallId?: string name: string - argsRaw: string turn: number step: number - /** Unix epoch ms when the tool/call event was logged. */ + /** Unix epoch ms when this stage began. */ time: number /** Child calls owned by this call, in dispatch order. */ subCalls: readonly ToolCallBlock[] } -/** One running or settled call, recursively owning its child calls. */ +/** A named model call whose arguments are not yet available to tool views. */ +export interface PreparingToolCall extends ToolCallHead { + readonly phase: 'preparing' +} + +/** A dispatched tool call with complete arguments and no result yet. */ +export interface StartedToolCall extends ToolCallHead { + readonly phase: 'start' + readonly argsRaw: string +} + +/** A tool still preparing or awaiting its result. */ +export type RunningToolCall = PreparingToolCall | StartedToolCall + +/** One preparing, dispatched, or settled call, recursively owning its child calls. */ export type ToolCallBlock = RunningToolCall | ToolResultNode /** In-progress assistant output (chunk accumulator product). */ diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index d51216de32..0ab42da1c2 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -135,17 +135,10 @@ function mergeMatches( } function conversationMatch( - key: string, input: SessionEventLikeEntry, role: ConversationMatch['role'], location: ConversationMatch['location'], ): ConversationMatch { - if (role === 'start') { - if (input.type !== 'event') { - throw new Error(`conversation Context ${key} received a transient start Match`) - } - return { event: input.event, role, location } - } return { event: input.event, role, location } } @@ -269,6 +262,8 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore /** * Retire one Assistant attempt's transient matches and apply its optional durable settlement. + * Empty Contexts retain their keys and published nodes until the loaded window is rebuilt; + * Definitions may hide those nodes when no start remains instead of withdrawing their identities. * @param attemptId - process-local attempt whose transient presentation ended. * @param entry - durable message or attempt event committed for the stream. * @returns highest requested publication cadence. @@ -461,7 +456,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return this.dispatchInput(input, (definition, id, role) => { const key = conversationContextKey(definition.kind, id) const match = conversationMatch( - key, input, role, this.locationIndex.locationOf(input.event), @@ -533,12 +527,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore ): ConversationPublication { const key = conversationContextKey(definition.kind, id) let context = this.contexts.get(key) - if (role === 'start' && context?.start !== undefined) { - throw new Error(`conversation Context ${key} received more than one start Match`) - } context ??= this.createContext(definition, id, key) + const starting = role === 'start' && context.start === undefined const match = conversationMatch( - key, input, role, this.locationIndex.locationOf(input.event), @@ -547,11 +538,11 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore if (previous !== undefined && previous.event.seq >= input.event.seq) { throw new Error(`conversation Context ${key} received non-appended Match ${input.event.seq}`) } - if (role === 'start' && context.matches.length > 0) { + if (starting && context.matches.length > 0) { throw new Error(`conversation Context ${key} received an update before its start Match`) } context.matches.push(match) - if (match.role === 'start') { + if (starting && match.role === 'start') { context.startSeq = input.event.seq context.start = match this.indexStartedContext(context) @@ -560,7 +551,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore owners.add(context) this.contextsBySeq.set(input.event.seq, owners) - if (match.role === 'start') { + if (starting) { this.replayContext(context) } else if (context.state !== undefined) { const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } @@ -576,24 +567,16 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore pending: ReadonlyMap, affected: Set, ): void { - const startsByKind = new Map() for (const [key, entries] of pending) { const first = entries[0] if (first === undefined) continue let context = this.contexts.get(key) context ??= this.createContext(first.definition, first.id, key) - let discoveredStart: ConversationStartMatch | undefined const additions = entries .map((entry) => { if (entry.definition !== context.definition || entry.id !== context.id) { throw new Error(`conversation Context ${key} received inconsistent Definition identity`) } - if (entry.match.role === 'start') { - if (discoveredStart !== undefined || context.start !== undefined) { - throw new Error(`conversation Context ${key} received more than one start Match`) - } - discoveredStart = entry.match - } const owners = this.contextsBySeq.get(entry.match.event.seq) ?? new Set() owners.add(context) this.contextsBySeq.set(entry.match.event.seq, owners) @@ -601,28 +584,21 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore }) .sort((left, right) => left.event.seq - right.event.seq) context.matches = mergeMatches(context.key, additions, context.matches) - if (discoveredStart !== undefined) { - context.start = discoveredStart - context.startSeq = discoveredStart.event.seq - const starts = startsByKind.get(context.kind) ?? [] - starts.push(context) - startsByKind.set(context.kind, starts) - } - if (context.start !== undefined && context.matches[0] !== context.start) { - throw new Error(`conversation Context ${context.key} received an update before its start Match`) - } affected.add(context) this.markDirty(context) } - for (const [kind, contexts] of startsByKind) this.indexStartedContexts(kind, contexts) } private replayContexts(contexts: ReadonlySet): void { + this.refreshStarts(contexts) const ordered = [...contexts].sort((left, right) => (left.startSeq ?? Number.POSITIVE_INFINITY) - (right.startSeq ?? Number.POSITIVE_INFINITY)) for (const context of ordered) { if (context.start === undefined) { context.state = undefined + this.replaceDependencies(context, new Map()) + context.revision++ + this.revised.add(context) this.markDirty(context) continue } @@ -630,6 +606,26 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore } } + private refreshStarts(contexts: ReadonlySet): void { + const changed = new Set() + const startsByKind = new Map() + for (const context of contexts) { + const start = context.matches.find(match => match.role === 'start') + if (start === context.start) continue + context.start = start + context.startSeq = start?.event.seq + changed.add(context) + const starts = startsByKind.get(context.kind) ?? [] + if (start !== undefined) starts.push(context) + startsByKind.set(context.kind, starts) + } + for (const [kind, starts] of startsByKind) { + const existing = this.contextsByKind.get(kind) ?? [] + this.contextsByKind.set(kind, existing.filter(context => !changed.has(context))) + this.indexStartedContexts(kind, starts) + } + } + private replayContext(context: InternalContext): void { const start = context.start if (start === undefined) { @@ -650,7 +646,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.replaceDependencies(context, dependencies) for (let index = 1; index < context.matches.length; index++) { const match = context.matches[index] - if (match === undefined || match.role !== 'update') continue + if (match === undefined) continue const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } context.state = requireState( context.definition, diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index e776311040..e5825f8c70 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -30,7 +30,7 @@ export type { export type { AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig, AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, - ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, + ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode, } from './contract/records.ts' diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index aab4735316..d170ddce32 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -130,14 +130,14 @@ export const zh = { 'tool.title.queryRuntime': '查询运行时', 'tool.title.inspectPlugins': '检查动态插件', 'tool.title.workflow': '运行工作流', - 'tool.title.ralph': '运行 Ralph', + 'tool.title.ralph': '运行循环工作流', 'tool.title.readEvent': '读取事件', 'tool.title.searchEvents': '搜索事件', 'tool.title.traceEvent': '追踪事件', 'tool.title.searchSessions': '搜索会话', 'tool.title.traceSession': '追踪会话', 'tool.title.listModels': '查看可用模型', - 'tool.title.subagent': '委派任务', + 'tool.title.subagent': '创建代理', 'tool.title.listAgents': '查看代理', 'tool.title.sendMessage': '发送消息', 'tool.title.interruptAgent': '中断代理', @@ -159,7 +159,7 @@ export const zh = { 'tool.title.getTeamTask': '读取团队任务', 'tool.title.updateTeamTask': '更新团队任务', 'tool.title.listTeamTasks': '查看团队任务', - 'tool.title.waitAgent': '等待队友', + 'tool.title.waitAgent': '等待子智能体', 'detail.recordedResult': '调用结果', 'detail.empty': '暂无结果', 'detail.none': '无', @@ -243,8 +243,8 @@ export const zh = { 'detail.receipt.signal': '信号已发送', 'detail.receipt.closed': '已关闭', 'detail.receipt.closing': '关闭中', - 'detail.wait.noProgress': '没有正在运行的队友', - 'detail.wait.title': '队友状态', + 'detail.wait.noProgress': '没有正在运行的子智能体', + 'detail.wait.title': '子智能体状态', 'detail.wait.timeout': '等待超时', 'detail.wait.changed': '检测到变化', 'detail.agent.reply': '代理回复', @@ -282,6 +282,7 @@ export const zh = { 'bash.failed': '失败', 'bash.stopped': '已停止', 'row.running': '运行中', + 'row.preparing': '正在准备调用', 'row.failed': '失败', 'row.stopped': '已停止', 'row.input': '输入', @@ -294,7 +295,7 @@ export const zh = { 'tool.title.edit': '编辑', 'tool.title.code': '代码', 'tool.title.generic': '工具调用', - 'tool.title.inspect': '查看', + 'tool.title.inspect': '查询 Cordis 环境', 'tool.title.runCordis': '运行 Cordis 插件', 'tool.title.stopCordis': '停止 Cordis 插件', 'tool.title.removeCordis': '移除 Cordis 插件', @@ -482,15 +483,15 @@ export const en = { 'tool.title.queryRuntime': 'Query runtime', 'tool.title.inspectPlugins': 'Inspect plugins', 'tool.title.workflow': 'Run workflow', - 'tool.title.ralph': 'Run Ralph', + 'tool.title.ralph': 'Run ralph loop', 'tool.title.readEvent': 'Read event', 'tool.title.searchEvents': 'Search events', 'tool.title.traceEvent': 'Trace event', 'tool.title.searchSessions': 'Search sessions', 'tool.title.traceSession': 'Trace session', 'tool.title.listModels': 'List models', - 'tool.title.subagent': 'Delegate task', - 'tool.title.listAgents': 'List agents', + 'tool.title.subagent': 'Create subagent', + 'tool.title.listAgents': 'List subagents', 'tool.title.sendMessage': 'Send message', 'tool.title.interruptAgent': 'Interrupt agent', 'tool.title.listJobs': 'List background jobs', @@ -511,7 +512,7 @@ export const en = { 'tool.title.getTeamTask': 'Read team task', 'tool.title.updateTeamTask': 'Update team task', 'tool.title.listTeamTasks': 'List team tasks', - 'tool.title.waitAgent': 'Wait for teammates', + 'tool.title.waitAgent': 'Wait for subagent', 'detail.recordedResult': 'Recorded result', 'detail.empty': 'No results', 'detail.none': 'None', @@ -595,8 +596,8 @@ export const en = { 'detail.receipt.signal': 'Signal delivered', 'detail.receipt.closed': 'Closed', 'detail.receipt.closing': 'Closing', - 'detail.wait.noProgress': 'No active teammates', - 'detail.wait.title': 'Teammate activity', + 'detail.wait.noProgress': 'No active subagents', + 'detail.wait.title': 'Subagent activity', 'detail.wait.timeout': 'Wait timed out', 'detail.wait.changed': 'Change detected', 'detail.agent.reply': 'Agent response', @@ -634,6 +635,7 @@ export const en = { 'bash.failed': 'Failed', 'bash.stopped': 'Stopped', 'row.running': 'Running', + 'row.preparing': 'Preparing tool call', 'row.failed': 'Failed', 'row.stopped': 'Stopped', 'row.input': 'IN', @@ -646,7 +648,7 @@ export const en = { 'tool.title.edit': 'Edit', 'tool.title.code': 'Code', 'tool.title.generic': 'Tool call', - 'tool.title.inspect': 'Inspect', + 'tool.title.inspect': 'Query Cordis environment', 'tool.title.runCordis': 'Run Cordis Plugin', 'tool.title.stopCordis': 'Stop Cordis Plugin', 'tool.title.removeCordis': 'Remove Cordis Plugin', diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 340263e65d..c9cd56ea7a 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionEventLike, SessionEventLikeEntry, SessionLiveEventEntry, } from '@deepseek-ai/dsh-api-session-controller/client' -import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand' +import { LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' @@ -658,14 +658,17 @@ describe('ConversationNodeAssembler', () => { }) }) - it('rejects a transient event classified as a Context start', () => { - const definition: ConversationNodeDefinition = { - kind: 'invalid-transient-start', + it('initializes from a transient start and clears it when the attempt is withdrawn', () => { + const start = vi.fn((_context: ConversationNodeContext, match: ConversationMatch) => match.event.seq) + const definition: ConversationNodeDefinition = { + kind: 'transient-start', match: event => event.type === 'assistant/live-chunk' ? { id: 'one', role: 'start' } : null, - start: () => null, + start, update: context => context.state, + target: 'test', + buildViewNode: context => node(context, context.state ?? null), } const assembler = new ConversationNodeAssembler( new TestEventDefinitions([definition]), @@ -673,9 +676,13 @@ describe('ConversationNodeAssembler', () => { ) const delta = transientChunk(1, 1, 1, { type: 'text-delta', index: 0, text: 'abc' }) - expect(() => assembler.replaceWindow([delta], false)).toThrow( - 'conversation Context 23:invalid-transient-startone received a transient start Match', - ) + assembler.replaceWindow([delta], false) + assembler.flush() + expect(start).toHaveBeenCalledOnce() + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + assembler.settleAssistant(LlmAttemptId('test-attempt')) + assembler.flush() + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBeNull() }) it('merges an older page and replays its affected Context once', () => { @@ -912,6 +919,45 @@ describe('ConversationNodeAssembler', () => { expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) }) + it('reorders predecessor reads when durable calls replace transient starts after dispatch', () => { + const source: ConversationNodeDefinition = { + kind: 'reindexed-source', + match: (event) => { + if (event.type === 'assistant/live-chunk' && event.data.chunk.type === 'tool-call-delta') { + return { id: String(event.data.chunk.id), role: 'start' } + } + return event.type === 'tool/call' ? { id: String(event.data.callId), role: 'start' } : null + }, + start: (_context, match) => match.event.seq, + update: context => context.state, + target: 'test', buildViewNode: () => null, + } + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'step/end' ? { id: String(event.seq), role: 'start' } : null, + start: (_context, _match, reader) => reader.previous('reindexed-source')?.state ?? -1, + update: context => context.state, + target: 'test', buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([], false) + for (const [index, id] of ['a', 'b'].entries()) { + assembler.append(transientChunk(1.1 + index / 10, 1, 1, { + type: 'tool-call-delta', index, id: ToolCallId(id), name: 'write', argumentsDelta: '', + })) + } + assembler.append(input(at(SessionSeq(3), 'tool/call', { turn: 1, step: 1, callId: ToolCallId('b'), name: 'write', arguments: '{}' }))) + assembler.append(input(at(SessionSeq(4), 'tool/call', { turn: 1, step: 1, callId: ToolCallId('a'), name: 'write', arguments: '{}' }))) + assembler.append(input(at(SessionSeq(5), 'step/end', { turn: 1, step: 1 }))) + assembler.flush() + expect([...testSnapshot(assembler)!.nodes.values()][0]?.data).toBeCloseTo(1.2) + assembler.settleAssistant(LlmAttemptId('test-attempt')) + assembler.flush() + expect([...testSnapshot(assembler)!.nodes.values()][0]?.data).toBe(4) + }) + it('replays direct dependents when an append revises their predecessor Context', () => { const source: ConversationNodeDefinition = { kind: 'source', @@ -1537,12 +1583,14 @@ describe('ConversationNodeAssembler', () => { )).toThrow(/Definition "undefined-update" returned undefined from update/) }) - it('rejects a duplicate start before mutating the existing Context', () => { - const definition: ConversationNodeDefinition = { + it('updates the same Context for later start matches and reselects an earlier prepended start', () => { + const start = vi.fn((_context: ConversationNodeContext, match: ConversationMatch) => [match.event.seq]) + const update = vi.fn((context: { state: number[] }, match: ConversationMatch) => [...context.state, match.event.seq]) + const definition: ConversationNodeDefinition = { kind: 'single-start', match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, - start: (_context, match) => match.event.seq, - update: context => context.state, + start, + update, target: 'test', buildViewNode: context => node(context, context.state), } @@ -1555,10 +1603,16 @@ describe('ConversationNodeAssembler', () => { ], false) assembler.flush() - expect(() => assembler.append( + assembler.append( input(at(SessionSeq(2), 'command/run', { commandId: 'two', name: 'x' })), - )).toThrow(/received more than one start Match/) + ) assembler.flush() - expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + expect(start).toHaveBeenCalledOnce() + expect(update).toHaveBeenCalledOnce() + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual([1, 2]) + assembler.prepend([input(at(SessionSeq(0), 'command/run', { commandId: 'zero', name: 'x' }))], false) + assembler.flush() + expect(start).toHaveBeenCalledTimes(2) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual([0, 1, 2]) }) }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 890059d48e..048f3628f2 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 7611e613073457c7843cc2a969e2f295fc204bbf -README.zh.md: 15162229dc4adc5c0143cd141481887a5c996be7 +README.md: 8a0c51a8c88418862438342fc2a0faf41055dd00 +README.zh.md: 3c01c405d390e6fcd06665d9a53face55f5c39dc diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 7611e61307..8a0c51a8c8 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,6 +30,8 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](. ### Explicit deliveries +The `present` tool's preparing stage is one non-expandable document-icon row. It does not show filenames, create delivery cards, or enable file actions before the recorded call supplies arguments. + The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply. The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. @@ -42,7 +44,7 @@ The card renders the summary the Host serves for the turn's latest `workspace/ch ### The review tab -A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use aligned success or error markers and background tints. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. +A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use separate code and line-number backgrounds in each theme, with coloured line numbers, signs, and aligned edge markers; plain code text keeps the primary text colour. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. The selected-file title uses the shared `ui-primitives` [`PathLabel`](../ui-primitives/README.md#component-catalog): complete and left-aligned when it fits, otherwise clipped at the left with a fade that preserves trailing characters and the extension. Directories use subdued text, the filename uses primary text, and hovering reveals the complete displayed path. Resizing the pane or selecting another file updates the fade; the dropdown arrow, line counts, and toolbar actions retain their space. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 15162229dc..3c01c405d3 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,6 +30,8 @@ kind: "package-reference" ### 显式交付 +`present` 工具的准备阶段是带文档图标的不可展开单行。记录的调用提供参数前,该行不显示文件名、不创建交付卡片,也不启用文件操作。 + Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。 `present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 @@ -42,7 +44,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明 ### review tab -行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除使用对齐的成功色或错误色标记和背景色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 +行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除在各主题下使用独立的代码区与行号区底色,并为行号、增删符号和对齐的左侧色条着色;普通代码文字保留主文本色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 当前文件标题使用 `ui-primitives` 的共享组件 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog):空间足够时完整靠左显示,否则从左侧裁剪并渐隐,保留尾部字符和扩展名。目录使用弱化颜色,文件名使用主色,悬停显示完整路径。调整面板宽度或切换文件时更新渐隐;下拉箭头、行数和工具栏操作保留各自的空间。 diff --git a/packages/client/ui-deliverables/src/client/FileDiff.module.css b/packages/client/ui-deliverables/src/client/FileDiff.module.css index 21d18dad44..db25c09e87 100644 --- a/packages/client/ui-deliverables/src/client/FileDiff.module.css +++ b/packages/client/ui-deliverables/src/client/FileDiff.module.css @@ -1,6 +1,5 @@ /** Shared comparison layout, header, states, and numbered diff lines for previews and review tabs. */ -.root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } -:global(body[data-ds-dark-theme]) .root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); } +.root { --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } .header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } .status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } .body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } @@ -21,9 +20,10 @@ .text { padding-right: 16px; } .body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; } .body[data-review-wrap] .text { overflow-wrap: anywhere; } -.add { background: var(--diff-add-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); } -.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); } -.del { background: var(--diff-del-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); } -.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); } +.add { --diff-gutter-fill: var(--dsw-alias-file-diff-added-gutter); --diff-marker: var(--dsw-alias-file-diff-added-marker); background: var(--dsw-alias-file-diff-added-bg); } +.del { --diff-gutter-fill: var(--dsw-alias-file-diff-deleted-gutter); --diff-marker: var(--dsw-alias-file-diff-deleted-marker); background: var(--dsw-alias-file-diff-deleted-bg); } +.add .number, .del .number { background: var(--diff-gutter-fill); color: var(--diff-marker); } +.add .number:first-child, .del .number:first-child { box-shadow: inset 3px 0 0 var(--diff-marker); } +.add .sign, .del .sign { color: var(--diff-marker); } .context .text { color: var(--dsw-alias-label-secondary); } .empty { background: var(--diff-empty-fill); } diff --git a/packages/client/ui-deliverables/src/client/PresentRow.tsx b/packages/client/ui-deliverables/src/client/PresentRow.tsx index 04c37677fd..57d6006f3f 100644 --- a/packages/client/ui-deliverables/src/client/PresentRow.tsx +++ b/packages/client/ui-deliverables/src/client/PresentRow.tsx @@ -8,6 +8,9 @@ import css from './PresentRow.module.css' type PresentRowProps = ToolCallViewProps & PropsLocale +/* v8 ignore next -- Non-expandable rows never invoke DisclosureRow's required toggle callback. */ +const noop = (): void => undefined + /** Raw arguments can be partial while a call is streaming. */ function fileNames(raw: string): string { let args: unknown @@ -25,7 +28,18 @@ function fileNames(raw: string): string { * @param props - tool call and localized status copy. * @returns a status row with a result disclosure. */ -export function PresentRow({ block, inspect, t }: PresentRowProps) { +export function PresentRow(props: PresentRowProps) { + return props.phase === 'preparing' ? : +} + +function PreparingPresentRow({ t }: Extract) { + return
+ } + open={false} expandable={false} onToggle={noop} running /> +
+} + +function StartedPresentRow({ block, inspect, t }: Exclude) { const settled = 'kind' in block const state = !settled ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' const args = (settled ? block.call?.argsRaw : block.argsRaw) ?? '' diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 69e976fa06..eb2af5f30f 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -28,6 +28,7 @@ export const zh = { 'presented.file': '文件', 'row.title': '交付文件', 'row.running': '正在交付', + 'row.preparing': '准备交付', 'row.ok': '已交付', 'row.error': '交付失败', 'row.stopped': '已中断', @@ -90,6 +91,7 @@ export const en: Record = { 'presented.file': 'File', 'row.title': 'Present files', 'row.running': 'Delivering', + 'row.preparing': 'Preparing deliverables', 'row.ok': 'Delivered', 'row.error': 'Delivery failed', 'row.stopped': 'Interrupted', diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx index 870d54df97..ee01d9b093 100644 --- a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx @@ -2,17 +2,17 @@ /** Present UI derives statuses and details from durable tool records. */ import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { PresentRow } from '../src/client/PresentRow.tsx' import { en } from '../src/client/locales.ts' afterEach(cleanup) type Props = Parameters[0] -const running: RunningToolCall = { callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] } +const running: StartedToolCall = { phase: 'start' as const, callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] } const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'p', call: { name: 'present', argsRaw: running.argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Presented report.txt (4 bytes)' }], isError: false, subCalls: [] } function props(block: Props['block'], inspect?: () => void): Props { - return { block, callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props + return { ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props } it('discloses the saved result and offers call inspection', () => { @@ -32,6 +32,17 @@ it('discloses the saved result and offers call inspection', () => { expect(row.getAttribute('aria-expanded')).toBe('false') }) +it('shows a non-expandable preparation before delivery arguments exist', () => { + const useDisclosure = vi.fn(() => { throw new Error('preparation must not subscribe to disclosure resets') }) + const view = render() + expect(view.container.querySelector('[data-state="preparing"] svg')).not.toBeNull() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.querySelector('pre')).toBeNull() + expect(useDisclosure).not.toHaveBeenCalled() +}) + it.each([ [running, 'running', 'Delivering'], [{ ...settled, isError: true }, 'error', 'Delivery failed'], diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9d9597eea7..66c0f146c7 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 36ec3d016c1e39527fe71634157a8604e84aa3e7 -README.zh.md: 46e692b74d25e816632663490ba065cf188989fb +README.md: 5b8a16739504d8da169e04f64b0feb684f47f8c3 +README.zh.md: 291beb0cef60584ec86d349e8be8a11a1ae1694b diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 36ec3d016c..5b8a167395 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | -| `Modal` | Centered dialog over a page mask. | +| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `OnboardingSurface` | First-run stage that holds the application root inert. | | `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 46e692b74d..291beb0cef 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -49,7 +49,7 @@ kind: "package-library" | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | -| `Modal` | 页面遮罩之上的居中对话框。 | +| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | | `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 58438873a8..dcb5da4535 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import type { ReactNode } from 'react' +import type { KeyboardEventHandler, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutlineRegular } from './icons/index.tsx' @@ -14,6 +14,7 @@ interface ModalBaseProps { footer?: ReactNode className?: string contentClassName?: string + onKeyDownCapture?: KeyboardEventHandler } type ModalProps = ModalBaseProps & ( @@ -34,10 +35,11 @@ type ModalProps = ModalBaseProps & ( * @param props.contentClassName - optional class for a scrollable content region. * @param props.headless - render children directly in the card (no default * header/close/body chrome); mask, card, Escape, and aria-label remain. + * @param props.onKeyDownCapture - handle a nested dialog's keys before the document Escape listeners. * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, + open, onClose, title, closeLabel, description, children, footer, className, contentClassName, onKeyDownCapture, headless = false, }: ModalProps) { useEffect(() => { if (!open) return @@ -51,7 +53,7 @@ export function Modal({ if (!open) return null return createPortal(( -
+