diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index ca90cebb0e..bdb07d5f8a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 7ad2a2403eb9962b369b016070e8ca378ed55c60 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 90850c469f1444e7f6cd105551e6cc21920e91d9 +2026-07-19-gui-layering-and-rpc-protocol.md: 34077302c53081f6ee9171d64dce9af342710d71 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: bc51542ac8159ee7cba234b4ee8b4db47a7f9b58 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 7ad2a2403e..34077302c5 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) -> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation (HTTP+SSE) is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md). +> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation combines HTTP uplink with the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md), while the browser object layer is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md). ## Problem @@ -15,7 +15,7 @@ We need a UI integration layer. Beyond the existing ACP/stdio baseline, more pro That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. -At the same time the physical channels differ per consumer (HTTP/SSE, in-process direct calls, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. +At the same time the physical channels differ per consumer (browser HTTP/WebSocket, in-process fetch/SSE, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. ## Decision @@ -64,7 +64,7 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod |---|---|---|---| | Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | | Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | -| Carrier layer | `dsh-host-webserver` | Web-shape HTTP: static serving + `/api/*`→handler forwarding + SSE write-out + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | +| Carrier layer | `dsh-host-webserver` | Web-shape HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | | Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | | Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture RFC | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | | Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app | @@ -88,7 +88,7 @@ The sections from here down are the protocol body carried by the front layer (`d ``` client 发起 server 发起 request ① ClientRequest ③ ServerRequest - (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + (POST /api/ body) (WebSocket message:session 事件、审批/问答 requested) response ② ServerResponse ④ ClientResponse (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) ``` @@ -99,7 +99,7 @@ The sections from here down are the protocol body carried by the front layer (`d |---|---|---|---|---| | `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mints | `POST /api/` body | | `ServerResponse` | `'server-response'` | `rpcId` `result` | echoes ① | that POST's response body (always HTTP 200) | -| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | SSE `data:` line | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | WebSocket text message | | `ClientResponse` | `'client-response'` | `rpcId` `result` | echoes ③ | `POST /api/respond` body | `RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`, narrowed via `switch (message.type)`. @@ -169,7 +169,7 @@ The remaining methods (`session.create`/`session.history`/`session.rename`/`sess ### Frames (server→client, named unions) -Two SSE streams: the mux stream (`GET /api/events.mux`, all-session aggregate) and the host stream (`GET /api/events.host`, host-level events). One example frame row: +Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE to preserve the same shape; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row: | frame type | payload | when | |---|---|---| @@ -216,7 +216,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| | `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch` (same-origin `/api/*`) | the browser shape; HTTP+SSE carriage details in the web client architecture RFC | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 90850c469f..bc51542ac8 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 -> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现(HTTP+SSE)见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。 +> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。 ## Problem @@ -14,7 +14,7 @@ Status: implemented 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 -同时各消费端的物理通道不同(HTTP/SSE、进程内直调、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 +同时各消费端的物理通道不同(浏览器 HTTP/WebSocket、进程内 fetch/SSE、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 ## Decision @@ -62,7 +62,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. |---|---|---|---| | 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | | 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | -| 承载层 | `dsh-host-webserver` | Web 形态 HTTP:静态服务 + `/api/*`→handler 转发 + SSE 写出 + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | +| 承载层 | `dsh-host-webserver` | Web 形态 HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | | client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构 RFC | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | | 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app | @@ -86,7 +86,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. ``` client 发起 server 发起 request ① ClientRequest ③ ServerRequest - (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + (POST /api/ body) (WebSocket message:session 事件、审批/问答 requested) response ② ServerResponse ④ ClientResponse (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) ``` @@ -97,7 +97,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. |---|---|---|---|---| | `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mint | `POST /api/` body | | `ServerResponse` | `'server-response'` | `rpcId` `result` | 回填 ① | 该 POST 的应答体(恒 HTTP 200) | -| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | SSE `data:` 行 | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | WebSocket text message | | `ClientResponse` | `'client-response'` | `rpcId` `result` | 回填 ③ | `POST /api/respond` body | `RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`,`switch (message.type)` 窄化。 @@ -167,7 +167,7 @@ export type ResponseValue = ### 帧(server→client,具名 union) -两条 SSE 流:mux 流(`GET /api/events.mux`,全 session 聚合)与 host 流(`GET /api/events.host`,host 级事件)。帧示例一行: +两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)。帧示例一行: | 帧 type | 载荷 | 何时发 | |---|---|---| @@ -214,7 +214,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| | `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch`(同源 `/api/*`) | 浏览器形态;HTTP+SSE 承载落地见 Web 客户端架构 RFC | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 61e6a93e23..20c1d99991 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: b1f777172774f1cf8fef4d9494f15b38064d0c73 -2026-07-19-gui-web-client-architecture.zh.md: e43151b7d5ff096d574c786e3aae107523d22c96 +2026-07-19-gui-web-client-architecture.md: b306f3b155d9d9208066c3f25ad2c4fb4683b1ee +2026-07-19-gui-web-client-architecture.zh.md: 28632667c45b360eb2bc5f0d06f10b9df910770d diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index b1f7771727..b306f3b155 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. - **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. -- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory. +- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering RFC's territory. ## The React face (`packages/client/web-react`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index e43151b7d5..28632667c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 - **TranscriptAdapter / PartialAccumulator**:对话记录是按日志顺序投影的 append 来源 surface(`@deepseek-ai/dsh-session/surface` 的 `isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface,后者遮蔽被替换的范围,会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。 -- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载(HTTP POST 载两个 client→server 象限、SSE 载两个 server→client 象限)与客户端类族归分层 RFC 属地。 +- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.md)载两个 server→client 象限,客户端类族归分层 RFC 属地。 ## React 面(`packages/client/web-react`) diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml new file mode 100644 index 0000000000..2a4879b6aa --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.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-08-04-websocket-downlink-carrier.md +2026-08-04-websocket-downlink-carrier.md: b41ad687725c55acb8517fe7e93d645f007a0453 +2026-08-04-websocket-downlink-carrier.zh.md: 568240ec14592fba8444e5cc0a3bae2b35c45b94 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md new file mode 100644 index 0000000000..b41ad68772 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md @@ -0,0 +1,39 @@ +# Agent Note: WebSocket carrier for browser downlinks + +Status: implemented + +English | [中文](2026-08-04-websocket-downlink-carrier.zh.md) + +## Problem + +The browser Web GUI has long used two SSE responses for `events.mux` and `events.host`. HTTP/1.1 browsers typically allow only about six concurrent connections per origin; each page permanently occupying two makes same-origin tabs, plugin resources, and ordinary RPCs contend for connection slots, and reaching the limit causes requests to queue rather than merely slowing them down. The RPC protocol itself is channel-independent: a constraint of the browser's physical carrier must not leak into the session/runtime object layer. + +## Decision + +The real browser carrier opens one independent WebSocket for each downlink stream class: `/api/events.mux` sends only `MuxFrame`, and `/api/events.host` sends only `HostFrame`. Each text message is one complete `ServerRequest` JSON document; the client continues to validate the envelope first, then the concrete frame union for that path, and passes the narrow `RpcRequest` form to the existing `ConnectionController`. The streams retain independent lifecycles and provide no cross-stream ordering guarantee; either one ending still fails the entire connection generation and rebuilds it under the existing backoff policy. + +WebSocket carries only the host→browser downlink. All client→host unary calls and `respond` operations for server requests continue to use the existing `POST /api/*`; the WebSocket accepts no client application messages. `WebApiClient` therefore holds HTTP `fetch` for uplink and WebSocket for downlink, while the fixture and `InProcessApiClient(toFetchHandler(api))` continue to implement the same two-stream `IApiClient` abstraction. The in-process fetch carrier retains SSE encoding and decoding to verify the channel-independent protocol's isomorphism, but network GET requests to `/api/events.*` answer only Upgrade Required and do not provide a browser compatibility fallback. + +## Upgrade and lifecycle boundaries + +`dsh-host-webserver` provides an exact upgrade-route registration seam alongside ordinary routes, dispatches Node upgrade sockets by pathname only, contains raw-socket errors, and waits for surviving upgraded connections to close during server teardown; it knows nothing about Harness frames or WebSocket messages. `dsh-client-connection` owns the WebSocket handshake, frame output, and stream cancellation, and reuses the `/api` Host/Origin trust fence before upgrade. An untrusted authority or cross-origin Origin is rejected before `ctx.apiProxy.events.*` starts. + +A browser abort or socket close cancels the corresponding host stream; plugin teardown also waits for that source iterator's cleanup. If a host stream throws midway, the carrier sends one existing `stream/error` frame and then closes the socket; the client treats that frame as connection loss rather than delivering it to a business sink. Each WebSocket reports open independently, and the existing readiness handshake still waits until mux and host are both open and the `host.describe` HTTP call has succeeded before publishing connected. + +## Verification + +Webserver contract tests pin upgrade-pathname dispatch, duplicate-registration rejection, disposal, and teardown; connection real-network tests pin each WebSocket's trust check, open, schema envelope, frame order, stream error, and close cancellation; client tests also prove that downlinks create `ws:`/`wss:` URLs while unary calls and `respond` still use HTTP `fetch`. The assembled keyless browser replay continues to cover Chromium, a real host, HTTP uplink, and the full WebSocket downlink chain. + +## Alternatives considered + +**Multiplex mux and host over one WebSocket.** This would add a channel tag, a multiplexing queue, and a single-connection backpressure policy, and would change the existing two-stream readiness semantics. Two WebSockets already avoid the HTTP/1.1 six-connection limit while keeping this change in the physical carrier layer. + +**Move unary calls and respond to a full-duplex WebSocket as well.** This would rewrite timeout, cancellation, HTTP-status, trust-fence, and request-correlation behavior without adding any benefit for the current downlink connection-slot problem. HTTP uplink is an explicitly retained boundary. + +**Keep a network SSE fallback.** Two carriers would let the production browser path silently fork because of proxy or handshake differences and would leave the connection-limit problem in a supported branch. During prerelease, only WebSocket downlink ships; the existing reconnect behavior and connection state expose failures explicitly. + +**Rely on HTTP/2 for greater connection concurrency.** The built-in development server uses plaintext Node HTTP/1.1, and a deployment's fronting proxy is not a product invariant. The physical downlink directly uses a browser primitive outside that connection pool. + +## Consequences + +Each Web page still has two long-lived downlink connections, but they no longer consume the browser's six-connection HTTP/1.1 quota. The runtime continues to consume the original two streams and retains all reconnect, seam-repair, and cross-stream unordered semantics. The cost is one more upgrade-registration surface in the webserver, a WebSocket implementation dependency in the connection package's host half, and separate maintenance of the browser WebSocket and in-process SSE physical codecs. They share the same `ServerRequest`/frame schemas and `IApiClient` semantics, avoiding a second application protocol. diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md new file mode 100644 index 0000000000..568240ec14 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 浏览器下行 WebSocket 载体 + +Status: implemented + +[English](2026-08-04-websocket-downlink-carrier.md) | 中文 + +## Problem + +浏览器 Web GUI 的 `events.mux` 与 `events.host` 长期使用两条 SSE(Server-Sent Events)响应。HTTP/1.1 浏览器通常只允许每个来源约六条并发连接;每个页面永久占住两条会让同源多标签页、插件资源和普通 RPC 争抢连接槽,达到上限后不是降速而是排队阻塞。RPC 协议本身是通道无关的,约束来自浏览器物理载体,不应渗入 session/runtime 对象层。 + +## Decision + +浏览器真实载体为两类下行流各开一条独立 WebSocket:`/api/events.mux` 只发送 `MuxFrame`,`/api/events.host` 只发送 `HostFrame`。每条 text message 是一份完整的 `ServerRequest` JSON;客户端继续先校验信封,再按路径校验具体 frame union,并把窄形 `RpcRequest` 交给既有 `ConnectionController`。两条流保持独立生命周期和无跨流顺序保证,任一条结束仍使整个 connection generation 失败并按既有退避策略重建。 + +WebSocket 只承担 host→browser 下行。所有 client→host unary 调用和对 server request 的 `respond` 继续使用既有 `POST /api/*`;不在 WebSocket 上接收任何客户端业务 message。`WebApiClient` 因而同时持有 HTTP `fetch` 上行与 WebSocket 下行,而 fixture 和 `InProcessApiClient(toFetchHandler(api))` 继续实现同一 `IApiClient` 双流抽象。进程内 fetch 载体保留 SSE 编解码来检验通道无关的协议同构,但网络 `/api/events.*` GET 只回答 upgrade required,不作为浏览器兼容回退。 + +## Upgrade 与生命周期边界 + +`dsh-host-webserver` 提供与普通 route 并列的精确 upgrade-route 注册缝,只按 pathname 分发 Node upgrade socket,隔离原始 socket 错误,并在 server teardown 期间等待仍存活的升级连接关闭;它不认识 Harness 帧或 WebSocket message。`dsh-client-connection` 拥有 WebSocket handshake、frame 写出和 stream cancellation,并在 upgrade 前复用 `/api` 的 Host/Origin 信任栅栏。未受信任的 authority 或跨来源 Origin 在 `ctx.apiProxy.events.*` 启动前即被拒绝。 + +浏览器 abort 或 socket close 会取消对应的 host stream;plugin teardown 还会等待该 source iterator 完成清理。host stream 中途抛错时,载体发送一份现有的 `stream/error` frame 后关闭 socket;客户端把该 frame 收敛为连接丢失,不投递给业务 sink。每条 WebSocket 独立报告 open,既有 readiness handshake 仍等待 mux、host 都 open 且 `host.describe` HTTP 调用成功后才发布 connected。 + +## Verification + +webserver 契约测试钉住 upgrade pathname 分发、重复注册拒绝、disposer 与 teardown;connection 的真实网络测试钉住两条 WebSocket 各自的信任检查、open、schema 信封、frame 顺序、stream error 与 close cancellation;客户端测试同时证明下行创建 `ws:`/`wss:` URL,而 unary 与 `respond` 仍调用 HTTP `fetch`。组装后的 keyless browser replay 继续覆盖 Chromium、真实 host、HTTP 上行与 WebSocket 下行整链。 + +## Alternatives considered + +**用一条 WebSocket 复用 mux 与 host。** 这会新增 channel tag、复用队列与单连接背压策略,并改变现有双流 readiness 语义;两条 WebSocket 已避开 HTTP/1.1 六连接上限,同时让本次变更保持在物理载体层。 + +**把 unary 与 respond 一并迁入全双工 WebSocket。** 这会改写超时、取消、HTTP 状态、信任栅栏和请求关联面,却不能为当前的下行连接槽问题带来额外收益;上行 HTTP 是明确保留的边界。 + +**保留网络 SSE 回退。** 双载体会让生产浏览器路径可因代理或握手差异静默分叉,并让连接上限问题继续存在于一个受支持分支;预发布阶段只交付 WebSocket 下行,失败由既有重连与连接状态显式呈现。 + +**依赖 HTTP/2 扩大并发连接能力。** 内置开发服务器是明文 Node HTTP/1.1,部署前置代理也不是产品可依赖的不变式;物理下行应直接使用不受该连接池限制的浏览器原语。 + +## Consequences + +每个 Web 页面仍有两条长期下行连接,但它们不再消耗浏览器的 HTTP/1.1 六连接配额;runtime 继续消费原有双流并保留所有重连、补缝和跨流无序语义。代价是 webserver 多一个 upgrade 注册面,connection host 半依赖 WebSocket 实现,并需分别维护浏览器 WebSocket 与进程内 SSE 两种物理编解码;它们共享同一 `ServerRequest`/frame schema 和 `IApiClient` 语义,避免形成第二套业务协议。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml index 4d686731fe..7a2e0422fd 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-23-web-permission-and-approval.md: cd402a039e55e7a24a038055dab5793aa0d08438 -2026-07-23-web-permission-and-approval.zh.md: ce4964789bc94a0962796bb2f5fbf1a94e8f5145 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md +2026-07-23-web-permission-and-approval.md: a3d25f83aa6ecee5a874cef1706fd4f0b212d638 +2026-07-23-web-permission-and-approval.zh.md: fdd9d51a7771b571f436ed1a23a76c5f64f4c786 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md index cd402a039e..a3d25f83aa 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md @@ -16,13 +16,13 @@ The web host composes the same sandboxed product path as the acp-agent compositi The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. -Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. +Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Pending questions take over through ui-question, including the `plan-review` decision shape. The sidebar mirrors every blocked interaction with an amber warning dot that outranks the running ring, including during search: the manager tracks per-session approval and question request identities rather than reading Session instances, classifies only requests satisfying the plan-review composer's binary rendering constraints as plan reviews, and presents the first pending question ahead of concurrent approvals to match composer routing. Pre-instantiation buffering retains each live request identity, replaces replay duplicates, and removes resolved requests so sidebar status never outlives the answerable `PendingWait`; tracking clears per connection generation so reopen replay is authoritative. Sessions never instantiated still light their dot. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. ## Alternatives considered **Reuse the ACP `session/set_config_option` shape on the web wire.** Rejected: the web contract's unary method registry (`RpcMethodMap` + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature. -**A session event for pending approvals instead of a proxy-side registry.** Rejected: approval requests are transient interaction state, not durable session data — the `approval/asked`/`decided` audit pair already logs the durable half. Persisting requested frames would re-ask dead questions on replay. +**A session event for pending interactions instead of the live registries.** Rejected: answerable requests are transient interaction state, not durable session data — approval's `approval/asked`/`decided` audit pair already logs its durable half. Persisting requested frames would re-ask dead questions on replay. **Registering the answerer only when a mux subscriber exists.** Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window. @@ -30,4 +30,4 @@ Client-side, `Session` gained `permissions` and `setPermission`, and approval an ## Consequences -Web sessions now start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering shipped separately through the same registry pattern (ui-question over the question pending table). The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, and the keyless web smoke exercises the fixture-mode approval answer and preset switch in a real browser. +Web sessions start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering uses the same registry pattern (ui-question over the question pending table), and Session navigation identifies approval, plan-review, and ordinary question waits before the user opens them. The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, the keyless web smoke for fixture-mode approval and preset switching, and real-composition plan-review and question snapshots that pin the pending sidebar status through resolution. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md index ce4964789b..fdd9d51a77 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md @@ -16,13 +16,13 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l 权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 -在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。问题占位符仍留在消息流中。侧边栏用一枚琥珀色警示圆点同步呈现这一阻塞状态,且其优先级高于表示运行中的圆环:manager 跟踪每个会话尚未解决的 approvalId(对 mux 打开时的回放幂等,并按连接代次清除,以保证重开后的回放才是权威依据),而非读取 Session 实例,因此从未实例化过的会话也能点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 +在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。pending 问题通过 ui-question 接管 composer,包括 `plan-review` 决策形状。侧边栏用一枚优先级高于运行中圆环的琥珀色警示圆点,同步呈现每个被阻塞的交互,搜索期间也不例外:manager 跟踪每个会话的审批与问题请求标识,而非读取 Session 实例;它只把满足 plan-review composer 二元呈现约束的请求分类为计划审查,并在问题与审批并发时优先呈现第一个 pending 问题,以匹配 composer 路由。实例化前的缓冲会保留每个仍有效的请求标识,替换回放产生的重复项,并移除已解决的请求,因此侧边栏状态绝不会比可应答的 `PendingWait` 存续得更久;跟踪以连接代次为单位清除,以保证重开后的回放才是权威依据。从未实例化过的会话仍会点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 ## 曾考虑的替代方案 **在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳:Web 契约的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。 -**用一个会话事件承载 pending 审批,而非 proxy 侧注册表。** 不予采纳:审批请求是瞬态的交互状态,而非持久的会话数据——`approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 +**用一个会话事件承载 pending 交互,而非实时注册表。** 不予采纳:可应答请求是瞬态的交互状态,而非持久的会话数据——审批的 `approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 **仅在存在 mux 订阅者时才注册应答者。** 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。 @@ -30,4 +30,4 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l ## 后果 -Web 会话现在从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答已通过同一注册表模式单独交付(ui-question 基于问题 pending 表)。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖情况:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件,以及无密钥 Web 冒烟测试在真实浏览器中演练 fixture 模式的审批应答与预设切换。 +Web 会话从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-question 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖率:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件、针对 fixture 模式审批应答与预设切换的无密钥 Web 冒烟测试,以及真实组合的 plan-review 与问题快照;这些快照会固定 pending 侧边栏状态直至解决。 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index 1faf10a4c8..dade9b5d90 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.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-07-27-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: 98f9dc9bed5358e816d4324462d5ea7657f9007f -2026-07-27-native-workspace-directory-picker.zh.md: ca765778fae734fd47a05652aea7021328ed4ab6 +2026-07-27-native-workspace-directory-picker.md: a36f7b239a9115fe5eb33472ec5084818a66e9f2 +2026-07-27-native-workspace-directory-picker.zh.md: bb3e2fc6f7c77c8ace97e53435297326f937e4e8 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index 98f9dc9bed..a36f7b239a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -27,10 +27,10 @@ The workspace manager must upsert the returned workspace before the selection ca The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process. -Platform adapters invoke native tools without a shell: +Platform adapters open the dialog without a shell — spawned native tools on POSIX, an in-process COM conversation on Windows: - macOS: `osascript` and the system folder chooser. -- Windows: PowerShell in STA mode and `FolderBrowserDialog`. +- Windows: the koffi `IFileOpenDialog` child process with the best thread DPI awareness the host accepts (per-monitor-v2 when available; PMv2-less hosts cascade to per-monitor or system-aware) ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the tier has no fallback — failures surface as-is ([PowerShell chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). - Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index ca765778fa..bb3e2fc6f7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -27,10 +27,10 @@ Status: implemented 只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。 -平台适配器不经 shell,直接调用原生工具: +平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是子进程 COM 会话: - macOS:`osascript` 和系统文件夹选择器。 -- Windows:采用 STA 模式的 PowerShell 和 `FolderBrowserDialog`。 +- Windows:koffi `IFileOpenDialog` 子进程,使用宿主接受的最佳线程 DPI 感知(可用时为 per-monitor-v2;不支持 PMv2 的主机级联到 per-monitor 或 system-aware)(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));该层无回退——失败原样上报(见[PowerShell 链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。 - Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml new file mode 100644 index 0000000000..d4ec255235 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.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/feature/2026-08-01-pwsh-tool-and-executor.md +2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6 +2026-08-01-pwsh-tool-and-executor.zh.md: 5a48adb79fed209d2d2ecb9514fd51538491f04c diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md new file mode 100644 index 0000000000..7206f8ffe6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -0,0 +1,35 @@ +# Agent Note: PowerShell executor and pwsh tool + +Status: implemented + +English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md) + +## Problem + +The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry. + +## Decision + +Two new packages under `packages/bash/`: + +- **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. +- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call minus the sandbox surface: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, and the bash marker/truncation rendering story (a clean exit produces no marker). The parity decision supersedes this note's minimal-profile tool description. + +Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. + +The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md). + +## Alternatives considered + +**Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation. + +**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest. + +**Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default. + +## Consequences + +- The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. +- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground and background work (minus sandbox), with prompt guidance that states the marker contract precisely. +- Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. +- The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md new file mode 100644 index 0000000000..5a48adb79f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -0,0 +1,35 @@ +# Agent Note: PowerShell 执行器与 pwsh 工具 + +Status: implemented + +[English](2026-08-01-pwsh-tool-and-executor.md) | 中文 + +## 问题 + +harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。 + +## 决策 + +在 `packages/bash/` 下新增两个包: + +- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。parity 决策取代了本 note 的最小画像工具描述。 + +Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 + +本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。 + +## 备选方案 + +**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。 + +**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型契约保持诚实。 + +**现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。 + +## 后果 + +- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 +- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台与后台工作(减 sandbox)上与 bash 工具行为可互换,提示词指导精确陈述 marker 契约。 +- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 +- CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml new file mode 100644 index 0000000000..e17e4685e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.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/feature/2026-08-02-pwsh-tool-bash-parity.md +2026-08-02-pwsh-tool-bash-parity.md: bf40c440b9f7f330412d8949f59c54d541152d45 +2026-08-02-pwsh-tool-bash-parity.zh.md: bc67dac29900eacf9e615e76fe947e3e642a3979 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md new file mode 100644 index 0000000000..bf40c440b9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -0,0 +1,36 @@ +# Agent Note: pwsh tool bash parity + +Status: implemented + +English | [中文](2026-08-02-pwsh-tool-bash-parity.zh.md) + +## Problem + +The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately minimal profile — foreground only (a fresh process per call; no persistent PTY session), no managed-environment parity beyond three hardcoded `DSH_*` keys, and a marker story ("always `[exit code: N]`") that diverged from the bash tool's rendering without being declared. Review of that change found the model-visible contract drifting from the implementation: the description promised spill-path reporting the renderer never performed, the README claimed exports that did not exist and rendering the tool did not do, and the tool's own tests pinned the lossy behavior. The minimal profile also left the `DSH_*` contributor seam duplicated-by-absence: plugins contributing environment facts to `ctx.bashEnv` had no effect on pwsh calls. + +## Decision + +`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, minus the sandbox surface, and its model-visible text describes exactly that behavior: + +- **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. +- **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. +- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. +- **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. +- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor), persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work), and pwsh-specific TUI/GUI presentation (generic/terminal cards stay; a PowerShell-aware terminal card with an exit pill is roadmap work). + +## Alternatives considered + +**Keep the minimal profile and fix only the claims.** Rejected: the review's core finding was that text contracts copied from bash drift without the corresponding implementation; a minimal tool plus accurate claims still leaves pwsh calls without background execution, without contributor parity, and with a divergent marker story that must be re-justified forever. + +**Reject a mismatched executor dialect at load.** Attempted and reverted before merge: a `ShellDialect` marker (`bash` | `powershell`) on `BashExecutor`, with both shell tools throwing when the mounted executor speaks another shell. It forced every executor implementation — including each test and example fake — to declare a dialect, adding noise to every shell-tool test for a guard with no in-repo or plausible deployment to catch (shipped compositions always pair tool-pwsh with `dsh-pwsh-local` and tool-bash with `dsh-bash-local`). The pairing contract stays documented in each tool's README instead. + +**Extract a fully shared tool implementation base (abstract shell dialect, two thin leaves).** Considered and deferred: the bash-env extraction and the structural mirror (`render.ts`/`background.ts` twins) are the foundation it would rest on; a full base waits until a third dialect or the persistent-PTY twin makes the abstraction's shape observable. + +## Consequences + +- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. +- Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. +- `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). +- Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. +- The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor. +- The roadmap proposal's parity stage is delivered; its remaining stages are the Windows default composition and pwsh TUI/GUI rendering. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md new file mode 100644 index 0000000000..bc67dac299 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -0,0 +1,36 @@ +# Agent Note: pwsh 工具与 bash 对齐 + +Status: implemented + +[English](2026-08-02-pwsh-tool-bash-parity.md) | 中文 + +## 问题 + +首个 Windows 原生基础交付的 `dsh-tool-pwsh` 是刻意最小的画像——仅前台、无后台任务、受管环境只有三个硬编码 `DSH_*` 键、以及一个未声明就偏离 bash 工具的 marker 故事("恒打 `[exit code: N]`")。对该变更的 review 发现模型可见契约与实现脱节:描述承诺了渲染器从未执行的 spill 路径报告,README 宣称了不存在的导出与工具未做的渲染,工具自己的测试还钉死了有损行为。最小画像还让 `DSH_*` contributor seam 因缺席而重复:向 `ctx.bashEnv` 贡献环境事实的插件对 pwsh 调用毫无作用。 + +## 决策 + +`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,减去 sandbox 面,其模型可见文本精确描述这一行为: + +- **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 +- **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 +- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 +- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 +- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)、持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)、pwsh 专属 TUI/GUI 呈现(维持 generic/terminal 卡;带退出 pill 的 PowerShell 感知 terminal 卡属路线图)。 + +## 备选方案 + +**保留最小画像,只修声明。** 否决:review 的核心发现是"从 bash 复制的文本契约在缺少对应实现时会漂移";最小工具加准确声明仍让 pwsh 调用没有后台执行、没有 contributor 对等、并留下一个必须永远重新辩护的偏离 marker 故事。 + +**在加载时拒绝不匹配的执行器方言。** 合并前尝试过并撤回:在 `BashExecutor` 上加 `ShellDialect` 标记(`bash` | `powershell`),两个 shell 工具在挂载的执行器说另一种方言时抛错。它迫使每个执行器实现——包括每个测试与示例的 fake——都要声明 dialect,为一道仓内及合理部署中都没有目标可拦的护栏(交付组合总是把 tool-pwsh 配 `dsh-pwsh-local`、tool-bash 配 `dsh-bash-local`)给每个 shell 工具测试添噪。配对契约改由各工具 README 记录。 + +**提取完全共享的工具实现基座(抽象 shell 方言,两个薄叶子)。** 考虑后推迟:bash-env 提取与结构镜像(`render.ts`/`background.ts` 孪生)是它要立足的基础;在出现第三种方言或持久 PTY 孪生、让抽象的形态可观察之前,不做完整基座。 + +## 后果 + +- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 +- 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 +- `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 +- Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 +- pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。 +- 路线图提案的 parity 阶段已交付;其余阶段是 Windows 默认组合与 pwsh TUI/GUI 渲染。 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml new file mode 100644 index 0000000000..2ec7925a3e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.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/feature/2026-08-02-win32-in-process-folder-dialog.md +2026-08-02-win32-in-process-folder-dialog.md: 91a1ed0d7b1c1938a5e038ce36f1ca90bf3c9e82 +2026-08-02-win32-in-process-folder-dialog.zh.md: 6b90dc1c5fa0042b3e2bcbea8ed554f1f0ea2acf diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md new file mode 100644 index 0000000000..91a1ed0d7b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -0,0 +1,27 @@ +# Agent Note: Win32 folder picker moves to koffi in a child process + +Status: implemented + +English | [中文](2026-08-02-win32-in-process-folder-dialog.zh.md) + +## Problem + +The Windows directory picker's primary tier was a spawned PowerShell script around WinForms `FolderBrowserDialog`: the modern dialog only where PowerShell 7 happens to be installed, a review-flagged regression where PowerShell 6 resolves but has no WinForms (exit 1 is not `ENOENT`, so the 5.1 fallback never ran), a `SetProcessDPIAware` ceiling of system DPI, and a picker whose behavior depended on which shells a machine ships rather than on Windows itself. + +## Decision + +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs in a spawned child process so the modal `Show` never blocks the host event loop; the child posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), killing the child when the close budget is exhausted. The dialog is the child's first window, so Windows activates it without a foreground call. The child thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. The PowerShell chain that preceded this tier is gone (see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)): the tier has no fallback. + +## Alternatives considered + +- **A prebuilt native helper (`native/` family like `node-addon-landlock-run`).** Rejected: a mirror repository, an npm package family, MSVC provisioning, and a release handoff — all to ship ~150 lines of C the repository cannot exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. +- **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that a child process + koffi express in TypeScript. +- **Keep PowerShell primary and probe versions.** Rejected: the picker stays hostage to shell packaging (6 vs 7, Store aliases, profiles), and 5.1's legacy dialog remains the floor wherever pwsh is absent; the fallback-trigger widening alone was accepted into the fallback tier instead. +- **Blocking the main thread for the modal call.** Rejected outright: the web host must keep serving RPC while the dialog is open. + +## Consequences + +- Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not. +- Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind). +- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake risks a native access violation, contained to the dialog child process — the host Node process survives and the failure surfaces as-is (no fallback tier; see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). The mocked-koffi ABI pins and the real win32 smoke exist to catch such mistakes before shipping. +- The packaged-binary arm — the packaged executable spawning itself as the dialog entry — is not exercised by any automated test: the source plane and the built `lib/worker.cjs` under plain node are covered, and the packaged spawn remains deferred to the Windows CI roadmap. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md new file mode 100644 index 0000000000..6b90dc1c5f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Win32 文件夹选择器迁至 koffi 子进程 + +Status: implemented + +[English](2026-08-02-win32-in-process-folder-dialog.md) | 中文 + +## 问题 + +Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` 的外部 PowerShell 脚本:只有恰好安装了 PowerShell 7 的机器才有现代对话框;review 指出的回归——PowerShell 6 可解析却没有 WinForms(退出码 1 而非 `ENOENT`,5.1 回退永远不会触发);`SetProcessDPIAware` 只有系统 DPI 的上限;选择器的行为取决于机器装了哪些 shell,而不是取决于 Windows 本身。 + +## 决策 + +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 spawn 出的子进程中,模态 `Show` 永不阻塞宿主事件循环;子进程在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,关闭预算耗尽时 kill 子进程。对话框是子进程的第一个窗口,Windows 会自动激活它,无需手动前台调用。子进程线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。先于本层存在的 PowerShell 链已被删除(见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)):该层无回退。 + +## 考虑过的替代方案 + +- **预编译原生助手(`native/` 家族,如 `node-addon-landlock-run`)。** 否决:镜像仓库、npm 包家族、MSVC 供给和发布交接——只为交付约 150 行 CI 无法执行的 C(没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 +- **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而子进程 + koffi 用 TypeScript 就能表达。 +- **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。 +- **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。 + +## 后果 + +- 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell。 +- 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾)。 +- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误可能引发原生访问冲突,但被限制在对话框子进程内——宿主 Node 进程存活,失败原样上报(无回退层;见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。mocked-koffi 的 ABI 钉与真实 win32 冒烟正是为了在交付前捕获这类错误。 +- 打包二进制的臂——打包后的可执行文件以对话框入口形式自我 spawn——不受任何自动化测试覆盖:源码平面与普通 node 下构建出的 `lib/worker.cjs` 已被覆盖,打包 spawn 推迟到 Windows CI 路线图。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml new file mode 100644 index 0000000000..344dd2bf6c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.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/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md +2026-08-04-drop-windows-powershell-picker-fallback.md: 619afd31d9ec78cdb8565e29fa942b7db8749365 +2026-08-04-drop-windows-powershell-picker-fallback.zh.md: e14904db46a955d4cf40da195a39bf62cbef96ff diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md new file mode 100644 index 0000000000..619afd31d9 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md @@ -0,0 +1,38 @@ +# Agent Note: Drop the Windows PowerShell picker fallback + +Status: implemented + +English | [中文](2026-08-04-drop-windows-powershell-picker-fallback.zh.md) + +## Problem + +The win32 branch of the native directory picker kept a two-tier PowerShell fallback under the koffi `IFileOpenDialog` child process: `pwsh.exe` first, then `powershell.exe` (Windows PowerShell 5.1), both running the same WinForms script with a `SetProcessDPIAware` opt-in. The chain existed to keep a working chooser when the koffi tier was "unavailable", but every trigger it plausibly protected was a failure of our own packaging or deployment, not of the operating system: + +- koffi's native binary ships as an ordinary optional dependency (`@koromix/koffi-win32-x64`, no install script); a host that installs the package at all has the binary, and a host that cannot install it fails the package install loudly — the fallback code never loads either. +- "Ancient Windows" cannot occur: the Node versions this repo supports run on Windows generations far newer than the Vista-era `IFileOpenDialog` ABI the dialog needs. +- A koffi/COM defect crashes only the dialog child process (crash isolation); the correct response to our own bug is a surfaced failure, not a silent downgrade to a legacy dialog. + +The chain also cost real complexity: two spawn tiers running one identical script, a fallback trigger widened from `ENOENT` to any pwsh failure to close the PowerShell 6 (no WinForms) regression, a triple-miss `AggregateError` carrying all three causes, and per-tier abort re-checks. The seam already owns the only fallback that matters — the `browse` backend at the composition level, chosen once at boot by `directory-picker-auto`. + +## Decision + +The win32 tier is exactly the koffi `IFileOpenDialog` child process; any failure surfaces as-is with no fallback. The PowerShell chain — the `pwsh` → Windows PowerShell 5.1 cascade, the DPI-corrected WinForms script, the `AggregateError` aggregation — is deleted, and `pickNativeDirectory`'s win32 branch is a single call. `dsh-native-command` remains a dependency for the POSIX tiers. + +The fallback criterion the rest of the package already followed now applies uniformly: a fallback tier exists only for tools the OS/desktop environment provides and may omit (`zenity` → `kdialog` on Linux, which the boot-time probe also samples); tools our own package ships (`koffi`) fail loud. macOS `osascript` stays fallback-free as before. + +This change consolidates and deletes the pwsh-first DPI picker-fix note: its decision is fully reversed here, and its preserved rationale no longer guides future work on a koffi-only tier. What it kept that was real: PowerShell 7 renders the modern `IFileDialog`-based folder picker where 5.1's `FolderBrowserDialog` is hardwired to the legacy `SHBrowseForFolder` tree; the script's `SetProcessDPIAware` corrected the spawn's system-DPI ceiling; the pwsh→5.1 hop existed because a resolvable PowerShell 6 has no WinForms (exit 1, not `ENOENT`). Its rejected alternatives (requiring PowerShell 7, importing `resolvePwshPath`, setting DPI awareness in the harness process) are moot with the chain gone. + +## Alternatives considered + +**Keep the chain but drop the pwsh quality tier (`koffi` → Windows PowerShell 5.1).** Rejected: the remaining tier still defends our own packaged dependency, still costs the script, the widened trigger, and the aggregation, and still hides our own vtable/COM defects behind a legacy dialog. The criterion "fallback only for externally provided tools" admits no Windows tier at all. + +**Keep the chain as-is.** Rejected: it was the only two-level runtime fallback in the picker surface, its triggers were deployment-side failures that fail loud anyway, and it degraded a failed pick into an `AggregateError` whose most actionable entry was a PowerShell host. + +**Fall back to `browse` at runtime when the native pick fails.** Rejected: the seam's flow holes are `single`-kind and the `-auto` composition already picks one backend at boot; a runtime cross-kind hop would double-mount both backends and blur the capability boundary. + +## Consequences + +- The win32 picker's failure surface is one error from one tier; callers see the real cause (koffi load failure, COM refusal, dialog crash) instead of a chain-aggregated error. +- `pwsh`/`powershell.exe` are no longer invoked by this package; the WinForms script, its `SetProcessDPIAware` correction, and the `-STA` flags are gone with them. +- Tests shrink accordingly: the pwsh/5.1 cascade and triple-miss cases are replaced by one "failure surfaces with no fallback" case; the default-adapter test now drives the Linux tier. +- Reintroduction condition: a future win32 mechanism outside our packaging chain (a system-provided dialog host we do not ship) would justify a single fallback tier under the same criterion. diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md new file mode 100644 index 0000000000..e14904db46 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md @@ -0,0 +1,38 @@ +# Agent Note:删除 Windows PowerShell 选择器回退 + +Status: implemented + +[English](2026-08-04-drop-windows-powershell-picker-fallback.md) | 中文 + +## Problem + +原生目录选择器的 win32 分支在 koffi `IFileOpenDialog` 子进程之下保留了一条两级 PowerShell 回退:先 `pwsh.exe`,再 `powershell.exe`(Windows PowerShell 5.1),两者运行同一个带 `SetProcessDPIAware` 开关的 WinForms 脚本。该链的存在是为了在 koffi 层"不可用"时仍能给出一个可用的选择器,但它可能保护的每一个触发条件都是我们自己打包或部署的失败,而不是操作系统的: + +- koffi 的原生二进制作为普通 optional 依赖(`@koromix/koffi-win32-x64`,无 install script)分发;能装上该包的宿主就一定有二进制,装不上的宿主会在安装期大声失败——回退代码同样不会加载。 +- "上古 Windows"不可能出现:本仓库支持的 Node 版本运行在远比 Vista 时代 `IFileOpenDialog` ABI 新的 Windows 世代上。 +- koffi/COM 缺陷只崩对话框子进程(crash isolation);对我们自己 bug 的正确反应是上报失败,而不是静默降级到旧版对话框。 + +这条链还付出了真实的复杂度:两个 spawn 层运行同一脚本、把回退触发从 `ENOENT` 拓宽为 pwsh 的任何失败以关闭 PowerShell 6(无 WinForms)回归、携带全部三个原因的三连败 `AggregateError`,以及每层的 abort 重检。seam 早已拥有唯一重要的回退——组合层面的 `browse` 后端,由 `directory-picker-auto` 在启动时选择一次。 + +## Decision + +win32 层恰好就是 koffi `IFileOpenDialog` 子进程;任何失败原样上报,无回退。PowerShell 链——`pwsh` → Windows PowerShell 5.1 级联、DPI 修正的 WinForms 脚本、`AggregateError` 聚合——被删除,`pickNativeDirectory` 的 win32 分支成为单次调用。`dsh-native-command` 仍为 POSIX 层保留依赖。 + +本包其余部分早已遵循的回退判据现在统一适用:回退层只存在于操作系统/桌面环境提供且可能缺失的工具(Linux 的 `zenity` → `kdialog`,启动探针同样采样它们);我们自己打包的工具(`koffi`)失败即大声报错。macOS `osascript` 与之前一样保持无回退。 + +本次变更合并并删除了 pwsh 优先的 DPI 选择器修复 Note:其决策在此被完全反转,其保留的 rationale 对只含 koffi 的层不再指导未来工作。其中真实的部分:PowerShell 7 呈现基于 `IFileDialog` 的现代文件夹选择器,而 5.1 的 `FolderBrowserDialog` 被硬连到旧版 `SHBrowseForFolder` 树;脚本的 `SetProcessDPIAware` 修正了 spawn 的系统 DPI 上限;pwsh→5.1 的跳转存在是因为可解析的 PowerShell 6 没有 WinForms(退出码 1,而非 `ENOENT`)。其被拒绝的替代方案(要求 PowerShell 7、导入 `resolvePwshPath`、在 harness 进程设置 DPI 感知)随链删除而失去意义。 + +## Alternatives considered + +**保留链但去掉 pwsh 质量层(`koffi` → Windows PowerShell 5.1)。** 拒绝:剩下的层仍在为我们自己打包的依赖辩护,仍要付出脚本、拓宽的触发与聚合的代价,仍会把我们自己的 vtable/COM 缺陷藏到旧版对话框后面。"仅对外部提供的工具回退"的判据不接受任何 Windows 层。 + +**原样保留链。** 拒绝:它是选择器面上唯一的二级运行时回退,其触发条件是本就大声失败的部署侧失败,并且它把失败的 pick 降级成一个最具可操作性的条目是 PowerShell 宿主的 `AggregateError`。 + +**原生 pick 失败时在运行时回退到 `browse`。** 拒绝:seam 的流程洞是 `single` kind,`-auto` 组合已在启动时选择一个后端;运行时跨 kind 跳转会双挂两个后端并模糊能力边界。 + +## Consequences + +- win32 选择器的失败面是来自单一层的一个错误;调用方看到真实原因(koffi 加载失败、COM 拒绝、对话框崩溃),而不是链式聚合的错误。 +- 本包不再调用 `pwsh`/`powershell.exe`;WinForms 脚本、其 `SetProcessDPIAware` 修正与 `-STA` 标志随之消失。 +- 测试相应缩减:pwsh/5.1 级联与三连败用例被一个"失败原样上报、无回退"用例取代;默认适配器测试改驱动 Linux 层。 +- 重新引入条件:未来出现在我们打包链之外的 win32 机制(我们不随包分发的系统提供的对话框宿主)才值得在同一判据下保留一层回退。 diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml new file mode 100644 index 0000000000..5e7f47f278 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.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/proposed/feature/2026-08-01-windows-pwsh-default.md +2026-08-01-windows-pwsh-default.md: a310174b6864bb880070280835ccfd8623e26342 +2026-08-01-windows-pwsh-default.zh.md: 079c1e3cac789a5e3fa4d0bb889026b3fb69f78c diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md new file mode 100644 index 0000000000..a310174b68 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md @@ -0,0 +1,39 @@ +# Agent Note: Windows defaults to pwsh (roadmap) + +Status: proposed + +English | [中文](2026-08-01-windows-pwsh-default.zh.md) + +## Problem + +The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them. + +## Proposal + +Two follow-up stages, each independently shippable. The former stage 2 (bash-tool parity twin) shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface. + +1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. +2. **pwsh TUI/GUI rendering** — the TUI and Web surfaces render pwsh output with PowerShell-aware presentation (native path display, `$env:` facts), the counterpart of the bash terminal cards. This is where terminal/console rendering conventions get a PowerShell twin. + +The stages are deliberately sequenced: composition first (a Windows user gets PowerShell without choosing), then rendering. Nothing in this proposal changes POSIX behavior. + +## Alternatives considered + +**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. + +**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible. + +**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior. + +## Acceptance criteria + +- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. +- POSIX hosts are byte-for-byte unaffected (same roster, same executor). +- The shipped-composition e2es assert the platform-gated roster on both families. +- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 lands with TUI/Web rendering snapshots for pwsh output. + +## Risks + +- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. +- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. +- **Rendering conventions** — a PowerShell twin for terminal cards is a UI design decision with snapshot surface; deferring it (stage 2) keeps stage 1 shippable without UI churn. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md new file mode 100644 index 0000000000..079c1e3cac --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Windows 默认改用 pwsh(路线图) + +Status: proposed + +[English](2026-08-01-windows-pwsh-default.md) | 中文 + +## 问题 + +harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 + +## 提案 + +两个阶段,各自可独立交付。原阶段 2(bash 工具对等孪生)已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在在前台与后台工作(减 sandbox 面)上镜像 `tool-bash`,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装表面的 keyless 应用快照。 + +1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 +2. **pwsh TUI/GUI 渲染**——TUI 与 Web 表面以 PowerShell 感知的呈现渲染 pwsh 输出(原生路径显示、`$env:` 实情),即 bash 终端卡片的对应物。这是终端/控制台渲染约定获得 PowerShell 孪生的地方。 + +各阶段刻意排序:先组合(Windows 用户无需选择即获得 PowerShell),再渲染。本提案不改变任何 POSIX 行为。 + +## 备选方案 + +**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 + +**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于带批准/PTY 表面可见的组合决策。 + +**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。 + +## 验收标准 + +- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 +- POSIX 主机逐字节不受影响(清单相同,执行器相同)。 +- 交付组合 e2e 在两个平台族上断言按平台门控的清单。 +- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 附带 pwsh 输出的 TUI/Web 渲染快照落地。 + +## 风险 + +- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 +- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。 +- **渲染约定**——终端卡片的 PowerShell 孪生是带快照表面的 UI 设计决策;把它延期(阶段 2)让阶段 1 无需 UI 翻动即可交付。 diff --git a/AGENTS.md b/AGENTS.md index e03b128876..a42f39084a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) - bash/ bash executor seam + local impl + model-facing bash tools + bash/ bash executor seam + local/pwsh impls + model-facing shell tools subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fc0600a4d4..d6910daedc 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -81,6 +81,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`turndown`](https://github.com/mixmark-io/turndown) | MIT | | [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | | [`use-sync-external-store`](https://github.com/facebook/react) | MIT | +| [`ws`](https://github.com/websockets/ws) | MIT | | [`yaml`](https://github.com/eemeli/yaml) | ISC | | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | @@ -130,6 +131,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT | | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0bede25716..28f58bcf4d 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -54,6 +54,8 @@ flowchart LR cfg --> plugin_dsh_base_approval plugin_dsh_base_permission["permission
@deepseek-ai/dsh-permission"] cfg --> plugin_dsh_base_permission + plugin_dsh_base_bash_env["bash-env
@deepseek-ai/dsh-bash-env"] + cfg --> plugin_dsh_base_bash_env plugin_dsh_base_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] cfg --> plugin_dsh_base_tool_bash plugin_dsh_base_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] @@ -171,6 +173,7 @@ flowchart LR | `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | +| `bash-env` | `@deepseek-ai/dsh-bash-env` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` | | `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 26f59fe46c..dddf2fc1b5 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -173,6 +173,9 @@ sandbox: danger-full-access approval: never +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' diff --git a/apps/cli/package.json b/apps/cli/package.json index 0ccf4b2197..9d0b0d9700 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -22,6 +22,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", @@ -75,6 +76,7 @@ "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -115,6 +117,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-subagent-report": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 08b1c323ba..d2186e097a 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -11,7 +11,7 @@ import type { Context } from 'cordis' import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tool-bash' +import type {} from '@deepseek-ai/dsh-bash-env' import { AppCLIEntry } from './app-cli-entry.ts' import { createProcessShutdown } from './process-shutdown.ts' diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index b9b2eeea1f..44730e9f37 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../packages/ui/app-boot" }, + { + "path": "../../packages/bash/bash-env" + }, { "path": "../../packages/bash/tool-bash" }, diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts index 717cebdd36..3d48442747 100644 --- a/apps/web/tests/bash-abort-row.e2e.ts +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () const row = page.locator('[data-sample="bash"]').first() const call = row.locator('xpath=..') await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') - await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1) + await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(1) await row.click() await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') @@ -64,7 +64,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () await call.getByText('OUT', { exact: true }).waitFor() await call.getByText('Wait until cancellation', { exact: false }).waitFor() await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor() - await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2) + await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) // The borrowed fixture's UTC date is still the previous day in PDT; diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index c452295d23..12487d0c43 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -7,7 +7,7 @@ // content from the keyless FixtureApiClient transport. // // Component behavior remains owned by per-package suites (SlotTestRuntime -// benches over src). This smoke additionally pins the resident approval +// benches over src). This smoke additionally pins the resident interaction // fixture's cross-plugin projection because only the built connection/runtime/ // workspace graph can prove that transport-to-row path end to end. import { readFileSync } from 'node:fs' @@ -105,14 +105,15 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) await within(tree).findByText('4 sessions') - // The resident approval fixture proves the assembled workspace plugin - // distinguishes a blocked running session from an ordinarily busy one. + // The resident fixture has both a question and an approval; composer routing + // exposes the question first, and the assembled workspace plugin mirrors that + // actionable wait instead of the underlying running state. const waitingTitle = await within(tree).findByText('Fixture 历史会话') const waitingRow = waitingTitle.closest('[role="treeitem"]') if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() - within(waitingRow).getByText('Waiting for approval') + within(waitingRow).getByText('Waiting for answer') // Opening a session reaches chat content through the fixture transport. fireEvent.click(waitingTitle) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index e37e3954ab..8c2462d7ee 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -25,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // The waiting golden owns the decision card; the approved golden owns the // transcript the approval leaves behind — the state the card cannot see. const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md') +const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md') const MODE = webSnapshotMode() @@ -82,9 +83,15 @@ describe('web e2e: plan review takeover round trip', () => { expect(await page.locator('[data-question-key]').count()).toBe(0) await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0) + const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + if (MODE !== 'record') { const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd) await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE) + const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } await card.getByRole('button', { name: 'Approve' }).click() @@ -100,6 +107,7 @@ describe('web e2e: plan review takeover round trip', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Card gone; regular input restored. expect(await page.locator('[data-plan-review-key]').count()).toBe(0) + expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE) @@ -108,6 +116,8 @@ describe('web e2e: plan review takeover round trip', () => { }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'review.expected.md', 'approved.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md', + ]) }) }) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index ebbaf759c4..6f865567bb 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -23,6 +23,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') // Final golden: the answered transcript — the question resolved into its tool // round trip and the final reply, the state the composer goldens cannot see. @@ -76,11 +77,17 @@ describe('web e2e: resident question composer round trip', () => { await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0) + const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + if (MODE !== 'record') { // This golden owns the stable question surface; the answered-state // golden below owns the resulting transcript. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } // Squeezed card: the option rows are the capped card's scroll content, so @@ -155,6 +162,7 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Composer gone; regular input restored. expect(await page.locator('[data-question-key]').count()).toBe(0) + expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) // Golden of the answered transcript: the ask_user_question round trip // rendered as history (question tool row + DONE), composer takeover gone. @@ -168,6 +176,7 @@ describe('web e2e: resident question composer round trip', () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', 'ui.expected.md', + 'sidebar.expected.md', 'composed.expected.md', 'answered.expected.md', ]) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6b8067235f..ac6c92d5f0 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -2,8 +2,8 @@ // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). // Boots the REAL web composition — the shipped base plus web overlay through // the vendored Loader (the same include boot AppCLIEntry drives), patched the -// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the -// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: +// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket +// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: // replay (default, keyless: normally disables the llm-deepseek row and // inserts dsh-llm-replay in providers mode), record (real adapter + key, // harvests fixtures from live session memory), refresh (keyless replay that diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index 8f09d36efd..c95c6b8de6 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -14,10 +14,10 @@ - img - img - text: Context injection -- 'button "Failed Bash Error: command aborted" [expanded]': +- 'button "Failed Bash Error: tool call aborted" [expanded]': - img - - text: "Failed Bash Error: command aborted" -- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted" + - text: "Failed Bash Error: tool call aborted" +- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted" - button "Inspect" - 'button "Failed Bash Error: tool call aborted before dispatch"': - img diff --git a/apps/web/tests/snapshots/plan-review/sidebar.expected.md b/apps/web/tests/snapshots/plan-review/sidebar.expected.md new file mode 100644 index 0000000000..0e184c2f6f --- /dev/null +++ b/apps/web/tests/snapshots/plan-review/sidebar.expected.md @@ -0,0 +1 @@ +- 'treeitem "Plan awaiting review Plan a small change: add now" [selected]' diff --git a/apps/web/tests/snapshots/question-composer/sidebar.expected.md b/apps/web/tests/snapshots/question-composer/sidebar.expected.md new file mode 100644 index 0000000000..fcc2849e0f --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/sidebar.expected.md @@ -0,0 +1 @@ +- treeitem "Waiting for answer Use the ask_user_question tool to now" [selected] diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 0dad00c0e5..886eb52e1c 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -108,6 +108,9 @@ flowchart LR pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] + pkg_pwsh_local["pwsh-local"] + pkg_tool_pwsh["tool-pwsh"] + pkg_bash_env["bash-env"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] pkg_pty["pty"] svc_pty["ctx.pty
Persistent PTY session registry"] @@ -170,6 +173,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_approval --> svc_approval pkg_bash --> svc_bash + pkg_bash_env --> svc_bashEnv pkg_bash_local --> svc_bash pkg_bash_sandbox --> svc_bash pkg_code_runtime --> svc_codeRuntime @@ -197,6 +201,7 @@ flowchart LR pkg_plan_mode --> svc_planMode pkg_pty --> svc_pty pkg_pty_local --> svc_pty + pkg_pwsh_local --> svc_bash pkg_sandbox --> svc_sandbox pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy @@ -237,7 +242,6 @@ flowchart LR pkg_tasks --> svc_tasks pkg_tasks_local --> svc_tasks pkg_token_meter --> svc_tokenMeter - pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools pkg_typert_registry --> svc_typert pkg_user_interaction --> svc_userInteraction @@ -260,6 +264,9 @@ flowchart LR svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash + svc_bash --> pkg_tool_pwsh + svc_bashEnv --> pkg_tool_bash + svc_bashEnv --> pkg_tool_pwsh svc_clientModuleHost --> pkg_hmr svc_codeRuntime --> pkg_tools svc_compact --> pkg_compact_basic @@ -380,8 +387,8 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, Claude Code, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | -| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | -| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | +| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`pwsh-local`](../packages/bash/pwsh-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. | +| `ctx.bashEnv` | `core` | [`bash-env`](../packages/bash/bash-env) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox), [`pty-local`](../packages/pty/pty-local) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`pty-local`](../packages/pty/pty-local) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a74d1493a4..245ff31e52 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -192,7 +192,19 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:89`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) + +## `@deepseek-ai/dsh-bash-env` + +```ts config-catalog +/** Plugin config (all optional — the built-in facts resolve without defaults). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} +``` + +Source: [`packages/bash/bash-env/src/index.ts:29`](../packages/bash/bash-env/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -296,7 +308,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:21`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -574,7 +586,7 @@ export interface Config { } ``` -Source: [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:47`](../packages/host/webserver/src/index.ts) ## `@deepseek-ai/dsh-invariants` @@ -963,6 +975,37 @@ export interface Config { Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +## `@deepseek-ai/dsh-pwsh-local` + +Requires: `subprocess` + +```ts config-catalog +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** Default working directory for commands (default: process.cwd()). */ + cwd?: string + /** Default foreground timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for per-call timeout overrides. */ + maxTimeoutMs?: number + /** Per-stream in-memory output cap; overflow spills to a temp file. */ + maxOutputBytes?: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ + graceMs?: number + /** + * Explicit pwsh executable. When omitted, well-known Windows install + * locations and PATH entries are probed in order (PowerShell 7 install, + * PATH entries such as the Microsoft Store install, then Windows + * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH. + */ + pwshPath?: string +} +``` + +Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -1701,19 +1744,17 @@ Source: [`packages/llm/token-meter/src/types.ts:12`](../packages/llm/token-meter ## `@deepseek-ai/dsh-tool-bash` -Requires: `tools` · `bash` · `systemPrompt` +Requires: `tools` · `bash` · `systemPrompt` · `bashEnv` ```ts config-catalog -/** Configuration for the bash tool and its managed child environment. */ +/** Configuration for the bash tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string } ``` -Source: [`packages/bash/tool-bash/src/index.ts:41`](../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:34`](../packages/bash/tool-bash/src/index.ts) ## `@deepseek-ai/dsh-tool-bash-persistent` @@ -1851,6 +1892,20 @@ export interface Config { Source: [`packages/pty/tool-pty/src/index.ts:35`](../packages/pty/tool-pty/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh` + +Requires: `tools` · `bash` · `systemPrompt` · `bashEnv` + +```ts config-catalog +/** Configuration for the pwsh tool. */ +export interface Config { + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ + enableRunInBackground?: boolean +} +``` + +Source: [`packages/bash/tool-pwsh/src/index.ts:41`](../packages/bash/tool-pwsh/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a713efa09c..93d6d267a4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -297,7 +297,7 @@ Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/inde ## `ctx.bashEnv` — `BashEnvRegistry` -Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. +Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model shell call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. ```ts cordis-catalog /** @@ -309,7 +309,7 @@ Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The names register(contributor: BashEnvContributor): () => void /** - * Build the trusted `DSH_*` snapshot for one bash tool execution. + * Build the trusted `DSH_*` snapshot for one shell tool execution. * @param execution - the current tool execution. * @returns an immutable environment overlay containing built-ins and current contributions. */ @@ -324,7 +324,7 @@ list(): BashEnvVariableInfo[] Types: [DshEnvironment](../core-data-structures/subprocess.md) · [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/bash-env/src/index.ts:89`](../../packages/bash/bash-env/src/index.ts) ## `ctx.clientModuleHost` — `ClientModuleHostService` @@ -770,6 +770,14 @@ The web-shape HTTP carrier service. Activation listens immediately (route regist */ register(route: WebRoute): () => void +/** + * Register an exact-path HTTP upgrade route. Duplicate paths throw because + * one socket can have only one protocol owner. + * @param route - pathname and handler owning negotiation plus socket use. + * @returns the disposer removing the route. + */ +registerUpgrade(route: WebUpgradeRoute): () => void + /** * Register an index.html transform, applied to every index response in * registration order. @@ -779,7 +787,7 @@ register(route: WebRoute): () => void tapIndex(transform: (html: string) => string): () => void ``` -Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:63`](../../packages/host/webserver/src/index.ts) ## `ctx.invariants` — `InvariantService` @@ -2097,7 +2105,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:88`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:91`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/module-graph.md b/docs/module-graph.md index 341655c57f..fb0457958c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -38,9 +38,12 @@ flowchart TD end subgraph group_bash["packages/bash"] pkg_bash["bash"] + pkg_bash_env["bash-env"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_pwsh_local["pwsh-local"] pkg_tool_bash["tool-bash"] + pkg_tool_pwsh["tool-pwsh"] end subgraph group_fs["packages/fs"] pkg_fs["fs"] @@ -505,6 +508,10 @@ flowchart TD pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants pkg_fs_policy --> pkg_fs @@ -692,18 +699,11 @@ flowchart TD pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_bash - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_paths - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_session_persistence - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tasks - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval + pkg_bash_env --> pkg_bash + pkg_bash_env --> pkg_invariants + pkg_bash_env --> pkg_paths + pkg_bash_env --> pkg_session_persistence + pkg_bash_env --> pkg_tools pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -887,6 +887,25 @@ flowchart TD pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_bash + pkg_tool_bash --> pkg_bash_env + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tasks + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_bash + pkg_tool_pwsh --> pkg_bash_env + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tasks + pkg_tool_pwsh --> pkg_tools pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -971,27 +990,6 @@ flowchart TD pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter - pkg_agent_spine_demo --> pkg_agent - pkg_agent_spine_demo --> pkg_agent_loop - pkg_agent_spine_demo --> pkg_goal - pkg_agent_spine_demo --> pkg_goal_session - pkg_agent_spine_demo --> pkg_invariants - pkg_agent_spine_demo --> pkg_llm - pkg_agent_spine_demo --> pkg_llm_retry - pkg_agent_spine_demo --> pkg_paths - pkg_agent_spine_demo --> pkg_scope - pkg_agent_spine_demo --> pkg_session - pkg_agent_spine_demo --> pkg_session_title - pkg_agent_spine_demo --> pkg_skill - pkg_agent_spine_demo --> pkg_skill_local - pkg_agent_spine_demo --> pkg_system_prompt - pkg_agent_spine_demo --> pkg_tasks_local - pkg_agent_spine_demo --> pkg_tool_bash - pkg_agent_spine_demo --> pkg_tool_goal - pkg_agent_spine_demo --> pkg_tool_skill - pkg_agent_spine_demo --> pkg_tool_tasks - pkg_agent_spine_demo --> pkg_tools - pkg_agent_spine_demo --> pkg_workspace_context pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1033,6 +1031,39 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_bash_env + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session + pkg_agent_spine_demo --> pkg_invariants + pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry + pkg_agent_spine_demo --> pkg_paths + pkg_agent_spine_demo --> pkg_scope + pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_title + pkg_agent_spine_demo --> pkg_skill + pkg_agent_spine_demo --> pkg_skill_local + pkg_agent_spine_demo --> pkg_system_prompt + pkg_agent_spine_demo --> pkg_tasks_local + pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal + pkg_agent_spine_demo --> pkg_tool_skill + pkg_agent_spine_demo --> pkg_tool_tasks + pkg_agent_spine_demo --> pkg_tools + pkg_agent_spine_demo --> pkg_workspace_context + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1053,17 +1084,6 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess ``` | Package | Group | Depends on | @@ -1152,6 +1172,7 @@ flowchart TD | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | @@ -1192,7 +1213,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1223,6 +1244,8 @@ flowchart TD | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | @@ -1235,7 +1258,6 @@ flowchart TD | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1243,7 +1265,8 @@ flowchart TD | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index e91b6e3c34..81929ba150 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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/testing.md -testing.md: 89d495e05c7521becea052ad67ac602ba28c22ef -testing.zh.md: 8e19734d09d5b0bdbeffe9426bed7c12f23fbc41 +testing.md: 728c65f1911cbaa098f653af9436a92336fbe068 +testing.zh.md: 72b7c3bcfa69f4c65fc128dbcbb19c080fb0fbc4 diff --git a/docs/testing.md b/docs/testing.md index 89d495e05c..728c65f191 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,9 +7,9 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). -- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. +- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header. In-flight branches carrying older fixture edits merge current `master` and run the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) through `pnpm run migrate:packed-session-fixtures`; the [removal proposal](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) retires that command and these links after all affected branches converge. @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 8e19734d09..72b7c3bcfa 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -7,9 +7,9 @@ ## 层级 - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 -- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 +- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture。仍携带旧版 fixture 改动的在途分支应合并当前 `master`,并通过 `pnpm run migrate:packed-session-fixtures` 运行[临时迁移器](../scripts/migrate-packed-session-fixtures.ts);待所有受影响分支收敛后,[移除提案](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会移除该命令及这些链接。 @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。浏览器渲染的 Web GUI 旅程使用 `apps/web/tests/snapshots/`。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f93aedd658..38125bcd4a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -18,7 +18,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | -| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | @@ -207,6 +208,48 @@ Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. +## `@deepseek-ai/dsh-tool-pwsh` + +### `pwsh` + +Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] +} +``` + +Source: [`packages/bash/tool-pwsh/src/index.ts`](../packages/bash/tool-pwsh/src/index.ts) + +The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. + ## `@deepseek-ai/dsh-tool-cordis` ### `cordis_inspect` diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 5509012e3e..9c79aa8a99 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -web-styling.md: af05faca30fc968828f5a850f59d9d48ae382b05 -web-styling.zh.md: d0838cd8a6ee4290cdddff16b979950bec396314 +# pnpm run verify-translation-pairing --write docs/web-styling.md +web-styling.md: 173ce4482ec01f95cf38c62ebc2879e03a2d8192 +web-styling.zh.md: b38ba267cc7cc66ed12f000a93744dc81d940d06 diff --git a/docs/web-styling.md b/docs/web-styling.md index af05faca30..173ce4482e 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -62,14 +62,14 @@ Font sizes and spacing are **not tokenized** (matching the baseline repository's - Input card: floats centered at the same width as the conversation column (840px, reduced to 712px below 1024px) with bottom spacing; radius `--radius-xl`, border `--border-l2`, background `--bg-base`, shadow `--shadow-card`; two internal vertical sections = textarea (16px/24px, minimum 2 lines, maximum 14 lines = 336px, auto-growing through a mirror div) + action row (a 34px primary round button nested at bottom right); focus does not change the border or shadow (matching the baseline). - Primary input button (the three decisions made on 2026-07-20, visually based on the Codex App): a 32px solid circular icon button (inline SVG). Idle = `--accent` background with a white ↑ “Send” arrow; while running it changes in place to an accent ■ “Stop” icon on `--accent-soft` (the same color family, not a warning, and not red). **Input is locked while running** (decision 3, replacing the earlier hover-menu design): the textarea is disabled (gray, with draft content still visible), there is no queue/interjection menu, and Stop is the only action. When the turn ends, input unlocks and regains focus. Enter sends; Ctrl/Meta+Enter inserts a newline (the keyboard path is disabled with the locked input while running). - Scrollbars: nearly invisible, darkening on hover, with `scrollbar-gutter: stable` so they do not consume layout space (always use `.scrollable`; see § 3.9). -- Four-quadrant RPC direction symbols (the official visual vocabulary, using the spatial metaphor that up goes to the server, down comes from the server; single line = unary, double line = SSE): +- Four-quadrant RPC direction symbols (the official visual vocabulary, using the spatial metaphor that up goes to the server, down comes from the server; single line = client-initiated exchange, double line = server-initiated exchange): | Symbol | Quadrant | Badge colors | | --- | --- | --- | | `↑` | client-request (unary outbound) | `--accent` / `--accent-soft` | | `↓` | server-response (unary response) | ok `--ok`/`--ok-soft`, error `--error`/`--error-soft` | -| `⇟` | server-request (SSE frame push) | mux `--color-frame-mux`/`--frame-mux-soft`, host `--color-frame-host`/`--frame-host-soft` | -| `⇞` | client-response (SSE-side response) | `--accent`/`--accent-soft` at reduced opacity | +| `⇟` | server-request (downlink stream) | mux `--color-frame-mux`/`--frame-mux-soft`, host `--color-frame-host`/`--frame-host-soft` | +| `⇞` | client-response (reply to server request) | `--accent`/`--accent-soft` at reduced opacity | ## 3. Style implementation rules (review checklist) diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index d0838cd8a6..b38ba267cc 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -62,14 +62,14 @@ - 输入卡片:与会话列同宽(840px,<1024px 降 712px)居中悬浮(距底留白带);圆角 `--radius-xl`、边框 `--border-l2`、底 `--bg-base`、阴影 `--shadow-card`;内部上下两段=textarea(16px/24px,min 2 行 max 14 行=336px,镜像 div 自增高)+ 操作行(右下嵌 34px 主圆钮);focus 无边框/阴影变化(基线同款)。 - 输入主按钮(拍板 2026-07-20 三连,视觉参照 Codex App):32px 实心正圆图标钮(内联 SVG)——空闲=`--accent` 底白↑箭头「发送」,运行中原地变 `--accent-soft` 底 accent ■「停止」(同色系不告警、不用红)。**运行中锁输入**(拍板 3,取代早先 hover 菜单方案):textarea disabled(灰、草稿内容保留可见)、无任何排队/插话菜单,停止是唯一动作;turn 结束解禁并 refocus。键盘 Enter=发送、Ctrl/Meta+Enter=换行(运行中键盘路径随锁失效)。 - 滚动条:近隐形、hover 加深、`scrollbar-gutter: stable` 不占布局(统一走 `.scrollable`,见 §3-9)。 -- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE): +- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=客户端发起的交互、双线=服务端发起的交互): | 符号 | 象限 | 徽章配色 | | --- | --- | --- | | `↑` | client-request(unary 出站) | `--accent` / `--accent-soft` | | `↓` | server-response(unary 回包) | ok `--ok`/`--ok-soft`,error `--error`/`--error-soft` | -| `⇟` | server-request(SSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` | -| `⇞` | client-response(SSE 侧回应) | `--accent`/`--accent-soft` 降透明度 | +| `⇟` | server-request(下行流) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` | +| `⇞` | client-response(对 server request 的回应) | `--accent`/`--accent-soft` 降透明度 | ## 3. 样式编码规范(review 对照打勾) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 198daf015d..c107a1e2b0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,10 +1,12 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { mkdir, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' /** @@ -47,6 +49,7 @@ const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) +const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -157,6 +160,22 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, + // The pwsh overlay (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the + // bundle's bash tool for the PowerShell twin, so its header class pins its + // own prompt/tool sidecars and a recorded transcript. + { + name: 'pwsh-tool-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'pwsh', + configPath: PWSH_CONFIG, + // The composition boots the real pwsh executor; hosts without a `pwsh` + // binary skip the run (fixtures stay guarded). The recorded turn writes + // PWSH_OK via [Console]::Out.Write so the fixture carries no platform + // newline and one recording replays on every host. + pwshOnly: true, + }, { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', @@ -419,11 +438,17 @@ const SCENARIOS: Scenario[] = [ }, ] +// Hosts without a usable PowerShell skip the pwsh-tool-turn run (its fixtures +// stay guarded); the probe follows the executor's own resolution so a Windows +// host with only an install-location pwsh still runs the scenario. +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: SNAPSHOTS_DIR, scenarios: SCENARIOS, mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT), + hasPwsh, }) it('packed ACP fixture retains every chunk row kind without changing the logical session', () => { diff --git a/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml new file mode 100644 index 0000000000..c152b6ca67 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml @@ -0,0 +1,27 @@ +# Minimal tool-pwsh composition: real app boot path, real pwsh executor, real +# foreground + background tool calls; driven by the package's loader.spec.ts. +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + +- id: tools + name: '@deepseek-ai/dsh-tools' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + graceMs: 200 + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: tasks + name: '@deepseek-ai/dsh-tasks-local' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts new file mode 100644 index 0000000000..9899bfcd17 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Test driver: boot the tool-pwsh Loader composition, execute one real + * foreground and one real background pwsh command through the tool registry, + * and persist the observed model-visible output to `./pwsh-loader-report.json` + * for the package spec's inspect step. + */ + +import { writeFile } from 'node:fs/promises' +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { CallId } from '@deepseek-ai/dsh-llm' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('tool-pwsh driver requires a config path') + +const ctx = await boot('tool-pwsh-loader-smoke', resolveConfigPath(configPath, undefined)) +try { + const schema = ctx.tools.schemas().find(tool => tool.name === 'pwsh') + if (schema === undefined) throw new Error('pwsh tool not registered by the composition') + const prompt = (await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:pwsh') + + const foreground = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-fg'), + name: 'pwsh', + arguments: { command: 'Write-Output loader-ok', description: 'loader foreground' }, + }) + const foregroundText = foreground.content.filter(block => block.type === 'text').map(block => block.text).join('') + + const background = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-bg'), + name: 'pwsh', + arguments: { + command: 'Start-Sleep -Milliseconds 200; Write-Output loader-bg-ok', + description: 'loader background', + run_in_background: true, + }, + }) + const taskId = (background.value as { taskId: string }).taskId + + // The output delta and the terminal status can land in separate reads + // (Windows flushes the child pipe at exit), so accumulate both. + let backgroundText = '' + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const read = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-bg-read'), + name: 'task_output', + arguments: { task_id: taskId }, + }) + backgroundText += read.content.filter(block => block.type === 'text').map(block => block.text).join('') + if (backgroundText.includes('loader-bg-ok') && backgroundText.includes('[status: completed')) break + await new Promise(resolve => setTimeout(resolve, 50)) + } + + await writeFile('./pwsh-loader-report.json', JSON.stringify({ + schemaHasRunInBackground: Object.hasOwn(schema.parameters.properties as object, 'run_in_background'), + promptHasMarkerSection: prompt?.text.includes('Non-zero exits are reported as `[exit code: N]` markers') === true, + // Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). + foregroundText: foregroundText.replace(/\r\n/g, '\n'), + backgroundText: backgroundText.replace(/\r\n/g, '\n'), + })) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml new file mode 100644 index 0000000000..9daab43aff --- /dev/null +++ b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Minimal keyless composition: real app, pwsh executor, and pwsh tool; replayed model. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. + goals: false + # The pwsh tool replaces the bundle's bash tool in this composition. + toolBash: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml new file mode 100644 index 0000000000..7021ae2116 --- /dev/null +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -0,0 +1,37 @@ +# Minimal live counterpart for the pwsh-tool-turn snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: false + skills: + enabled: false + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. + goals: false + # The pwsh tool replaces the bundle's bash tool in this composition. + toolBash: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index 5fe449ae48..d66020b594 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":14,"time":1785487611319,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785487611319,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bd630f41-b45d-4183-a785-1ff6e7049b62"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[7,8,9,10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785487611319,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} -{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e"},"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"tool/call","seq":18,"time":1785487611378,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} {"type":"tool/result","seq":19,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skipped"},"content":[{"type":"tool-result","toolCallId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true}],"role":"user","id":"282c5c8c-7296-4545-8014-e6393b351436"},"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","seq":20,"time":1785487611378,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json new file mode 100644 index 0000000000..653e9a346c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl new file mode 100644 index 0000000000..9dc17ef799 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl @@ -0,0 +1,32 @@ +{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785678162244,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785678162245,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"6efbdc24-7abe-4f34-ac6d-15f93d49ad9a"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785678162246,"data":{"title":"Use the pwsh tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785678162261,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":5,"time":1785678162262,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":6,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1785678162968,"data":{"turn":1,"step":1,"index":0,"dt":[393,0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}} +{"type":"assistant/chunk","seq":29,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":30,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,0,0,0,0,275,0,1,0,0,0,0,0,0,0],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"",", ","\"","description","\"",": ","\"","Write"," P","WS","H","_OK"," to"," console","\"","}"]}} +{"type":"assistant/chunk","seq":65,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}} +{"type":"assistant/chunk","seq":66,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}}}} +{"type":"assistant/chunk","seq":67,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":68,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":69,"time":1785678164126,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"6e968eea-46b8-4489-8005-5e898d53c1a9"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"} +{"type":"tool/call","seq":70,"time":1785678164127,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}} +{"type":"tool/result","seq":71,"time":1785678164405,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"964dfad5-651e-47f0-90a5-fe5bc711a3ff"}},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"step/end","seq":72,"time":1785678164405,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":73,"time":1785678164410,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":74,"time":1785678165135,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":75,"time0":1785678165136,"data":{"turn":1,"step":2,"index":0,"dt":[176,44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":100,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":101,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":102,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":103,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":104,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":105,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":106,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":107,"time":1785678165693,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"91f35706-53e9-4fcd-891e-2c9eafccde98"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106],"surfaceOp":"append"} +{"type":"step/end","seq":108,"time":1785678165694,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":109,"time":1785678165694,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md new file mode 100644 index 0000000000..f354648c41 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md @@ -0,0 +1,7 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a concise snapshot agent working in {{cwd}}. + +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json new file mode 100644 index 0000000000..611de722e3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json @@ -0,0 +1,90 @@ +{ + "initial": [ + { + "name": "pwsh", + "description": "Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + } + ], + "changes": [] +} diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 1f708ab601..b8362fc6f8 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -57,6 +58,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) await harness.plugin(LocalSubprocessService) + await harness.plugin(BashEnvPlugin) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -117,6 +119,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) await harness.plugin(LocalSubprocessService) + await harness.plugin(BashEnvPlugin) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index c354205388..756cc58e39 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -61,6 +62,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/package.json b/examples/package.json index 513d0e4a2f..6a8a1c0d78 100644 --- a/examples/package.json +++ b/examples/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-bash": "workspace:*", + "@deepseek-ai/dsh-bash-env": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -42,6 +43,7 @@ "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", + "@deepseek-ai/dsh-pwsh-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-repository-plugin": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", @@ -83,6 +85,7 @@ "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-pty": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-skill": "workspace:*", diff --git a/knip.json b/knip.json index 5c0978d01c..dd217ab169 100644 --- a/knip.json +++ b/knip.json @@ -80,6 +80,16 @@ "tests/**/*.ts" ] }, + "packages/host/directory-picker-native": { + "entry": [ + "tests/**/*.spec.{ts,tsx}", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.{ts,tsx}", + "tests/**/*.{ts,tsx}" + ] + }, "packages/client/web-ui": { "entry": [ "tests/**/*.spec.{ts,tsx}" diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 0af14fda76..66cc852e04 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/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/bash/README.md -README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b -README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea +README.md: ef82e9f4684ecf551ac7701d812088dd6b2ef6d0 +README.zh.md: 84ff244ec3d1ff5d385a3eb334e4cf9f1fe31e03 diff --git a/packages/bash/README.md b/packages/bash/README.md index e60ad9b0e4..ef82e9f468 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -2,13 +2,16 @@ English | [中文](README.zh.md) -The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages. +The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All **product** packages. | Package | Role | ctx key | |---|---|---| | `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` | | `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | +| `pwsh-local/` | Local PowerShell `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (executable resolution, UTF-8-pinned spawn, Windows termination semantics) | (registers `ctx.bash`) | +| `bash-env/` | Tool-independent managed `DSH_*` shell environment registry shared by the shell tools (built-in facts + effect-scoped contributors) | (registers `ctx.bashEnv`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | +| `tool-pwsh/` | Model-facing PowerShell-dialect `pwsh` schema (behavior mirrors `tool-bash` minus the sandbox surface); background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)). diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index deb23ea820..84ff244ec3 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -2,13 +2,16 @@ [English](README.md) | 中文 -规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。 +能力家族横跨规范执行器 seam、其实现、共享 shell 环境与面向模型的工具。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| | `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` | | `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) | | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | +| `pwsh-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 PowerShell `BashExecutor` 实现(可执行文件解析、UTF-8 固定 spawn、Windows 终止语义) | (注册 `ctx.bash`) | +| `bash-env/` | 工具无关的受管 `DSH_*` shell 环境注册表,由 shell 工具共享(内置事实 + 受 effect 作用域约束的 contributor) | (注册 `ctx.bashEnv`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | +| `tool-pwsh/` | 面向模型的 PowerShell 方言 `pwsh` schema(行为镜像 `tool-bash`,减去 sandbox 面);后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | 接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 diff --git a/packages/bash/bash-env/README.i18n.yaml b/packages/bash/bash-env/README.i18n.yaml new file mode 100644 index 0000000000..90d43daa7b --- /dev/null +++ b/packages/bash/bash-env/README.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 packages/bash/bash-env/README.md +README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f +README.zh.md: aeb33629def3fcc10294bbca19b37d43dcc73a0c diff --git a/packages/bash/bash-env/README.md b/packages/bash/bash-env/README.md new file mode 100644 index 0000000000..7b939326d4 --- /dev/null +++ b/packages/bash/bash-env/README.md @@ -0,0 +1,51 @@ +# @deepseek-ai/dsh-bash-env + +English | [中文](README.zh.md) + +The tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of trusted, per-execution `DSH_*` variables that the model-facing shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`) collect into every shell call's environment. Built-in shell facts (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`) are owned by the registry itself; other plugins register additional enumerable facts with effect-scoped disposal, and duplicate ownership or undeclared runtime keys fail loudly. + +The package root exports the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the `BashEnvRegistry` service class and its contributor types; consumers use `ctx.bashEnv` after loading this plugin. + +## Config + +```yaml +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + config: + dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh +``` + +## Managed environment + +Every foreground and background model shell call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. + +`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-bash-env' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executors remove all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The shell tools' descriptions teach the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. + +## Model Experience + +Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **`list()` enumerates contributor-declared variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog. diff --git a/packages/bash/bash-env/README.zh.md b/packages/bash/bash-env/README.zh.md new file mode 100644 index 0000000000..aeb33629de --- /dev/null +++ b/packages/bash/bash-env/README.zh.md @@ -0,0 +1,51 @@ +# @deepseek-ai/dsh-bash-env + +[English](README.md) | 中文 + +工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`)归注册表自身所有;其他插件可以注册额外的可枚举事实,注册随插件纤维(fiber)释放,重复所有权或未声明的运行时键会响亮失败。 + +包根导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`。 + +## Config + +```yaml +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + config: + dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh +``` + +## Managed environment + +每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent 的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。 + +`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。 + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-bash-env' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +覆盖层根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器在合并该快照前移除所有继承的 `DSH_*`,因此嵌套 harness 与并发的父子 agent 无法泄漏过期的身份。`process.env` 永不被修改。shell 工具的描述只教授通用的 `$DSH_*` 约定,而不是点名持久化相关的变量或添加常驻的 system-prompt 段落。 + +## Model Experience + +Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **`list()` 只枚举 contributor 声明的变量** — 注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`)不包含在内,因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。 diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json new file mode 100644 index 0000000000..9ea29ad57c --- /dev/null +++ b/packages/bash/bash-env/package.json @@ -0,0 +1,50 @@ +{ + "name": "@deepseek-ai/dsh-bash-env", + "description": "Tool-independent managed DSH_* shell environment registry", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/bash-env/src/index.ts b/packages/bash/bash-env/src/index.ts new file mode 100644 index 0000000000..c7caa89f08 --- /dev/null +++ b/packages/bash/bash-env/src/index.ts @@ -0,0 +1,217 @@ +/** + * Tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of + * trusted, per-execution `DSH_*` variables consumed by the model-facing shell + * tools (`dsh-tool-bash`, `dsh-tool-pwsh`). Built-in shell facts are owned by + * the registry itself while plugins can register additional, enumerable facts + * with effect-scoped disposal. + * + * @module @deepseek-ai/dsh-bash-env + */ + +import { Service, type Context } from 'cordis' +import z from 'schemastery' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' +import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-persistence' + +declare module 'cordis' { + interface Context { + bashEnv: BashEnvRegistry + } +} + +export const name = 'bash-env' +export const inject: string[] = [] + +/** Plugin config (all optional — the built-in facts resolve without defaults). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} + +/** Runtime configuration schema for the bash-env plugin. */ +export const Config: z = z.object({ + dshHome: z.string(), +}) + +/** Model-visible metadata for one managed `DSH_*` environment variable. */ +export interface BashEnvVariable { + /** Concise description of the environment fact represented by the variable. */ + description: string +} + +/** + * A plugin contribution to the managed environment of each model shell call. + * Declared keys make ownership conflicts detectable before the first command; + * `resolve` computes only the values available for the current execution. + */ +export interface BashEnvContributor { + /** Stable contributor name used in diagnostics and duplicate detection. */ + name: string + /** Complete set of `DSH_*` keys this contributor may return. */ + variables: Readonly> + /** + * Resolve this contributor's available values for one tool execution. + * @param execution - the shell tool execution and its optional calling agent. + * @returns a partial map containing only keys declared in {@link variables}. + */ + resolve(execution: ToolExecution): Readonly>> +} + +/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ +export interface BashEnvVariableInfo extends BashEnvVariable { + /** Contributor that owns the variable. */ + contributor: string + /** Declared `DSH_*` environment variable name. */ + key: DshEnvironmentKey +} + +const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const +const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const +const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const +const RESERVED_BASH_ENV_KEYS = new Set([ + DSH_HOME_ENV, + DSH_SHELL_KEY, + DSH_SESSION_ID_KEY, +]) +const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ + +/** + * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. + * The namespace is rebuilt for every model shell call: ambient `DSH_*` values + * are discarded by the executor, then the registry's current snapshot is + * injected. Built-in shell facts remain owned by the registry itself while + * plugins can register additional, enumerable facts with effect-scoped + * disposal. + */ +export class BashEnvRegistry extends Service { + private readonly contributors = new Map() + private readonly keyOwners = new Map() + private readonly dshHome: string + + /** + * Create and install the `ctx.bashEnv` service. + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'bashEnv') + this.dshHome = resolveDshHome(config.dshHome) + } + + /** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ + register(contributor: BashEnvContributor): () => void { + const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { + if (contributor.name.trim().length === 0) { + throw new Error('bash env contributor name must be non-empty') + } + if (this.contributors.has(contributor.name)) { + throw new Error(`bash env contributor "${contributor.name}" is already registered`) + } + + const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] + for (const [key, variable] of variables) { + if (!key.startsWith(DSH_ENV_PREFIX) + || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { + throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) + } + if (RESERVED_BASH_ENV_KEYS.has(key)) { + throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) + } + if (variable.description.trim().length === 0) { + throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) + } + const owner = this.keyOwners.get(key) + if (owner !== undefined) { + throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) + } + } + + this.contributors.set(contributor.name, contributor) + for (const [key] of variables) this.keyOwners.set(key, contributor.name) + yield () => { + this.contributors.delete(contributor.name) + for (const [key] of variables) this.keyOwners.delete(key) + } + }.bind(this), 'bashEnv.register()') + return () => void dispose() + } + + /** + * Build the trusted `DSH_*` snapshot for one shell tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ + collect(execution: ToolExecution): DshEnvironment { + const values: Record = { + [DSH_HOME_ENV]: this.dshHome, + [DSH_SHELL_KEY]: '1', + } + if (execution.agent !== undefined) { + values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id + } + + for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { + const resolved = contributor.resolve(execution) + for (const [rawKey, value] of Object.entries(resolved)) { + const key = rawKey as DshEnvironmentKey + if (!Object.hasOwn(contributor.variables, key)) { + throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) + } + if (typeof value !== 'string') { + throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) + } + values[key] = value + } + } + + return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) + } + + // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, + // prompt, or UI code treats list() as an exhaustive environment catalog. + /** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ + list(): BashEnvVariableInfo[] { + return [...this.contributors.values()] + .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ + contributor: contributor.name, + description: variable.description, + key: key as DshEnvironmentKey, + }))) + .sort((left, right) => left.key.localeCompare(right.key)) + } +} + +/** + * Load the bash-env plugin: register the `ctx.bashEnv` service and the + * shell-agnostic persistence contributor (`DSH_SESSION_JSONL`). + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ +export function apply(ctx: Context, config: Config = {}): void { + const registry = new BashEnvRegistry(ctx, config) + registry.register({ + name: 'session-persistence', + variables: { + [DSH_SESSION_JSONL_KEY]: { + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + }, + }, + resolve(execution) { + const agent = execution.agent + if (agent === undefined) return {} + const location = ctx.get('sessionPersistence')?.locate(agent.session.header) + return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} + }, + }) +} diff --git a/packages/bash/bash-env/src/invariant.ts b/packages/bash/bash-env/src/invariant.ts new file mode 100644 index 0000000000..31f842c56d --- /dev/null +++ b/packages/bash/bash-env/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-bash-env`. + * @module @deepseek-ai/dsh-bash-env/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env' + +/** Cordis companion plugin name. */ +export const name = 'bash-env-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the environment registry validates ownership and collected values at each + * registration/collection; it publishes no independent snapshot that a companion could cross-check. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-bash/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts similarity index 79% rename from packages/bash/tool-bash/tests/bash-env.spec.ts rename to packages/bash/bash-env/tests/bash-env.spec.ts index d988075c5b..c93a768f80 100644 --- a/packages/bash/tool-bash/tests/bash-env.spec.ts +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -1,3 +1,9 @@ +/** + * Registry tests for `@deepseek-ai/dsh-bash-env`: built-in facts, contributor + * ownership and validation, collection ordering, effect-scoped disposal, and + * the explicit disposer contract. + */ + import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -5,7 +11,8 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash' +import { BashEnvRegistry } from '@deepseek-ai/dsh-bash-env' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' const testToolSignal = new AbortController().signal @@ -190,4 +197,41 @@ describe('BashEnvRegistry', () => { dispose() expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL') }) + + it('the plugin registers the service and the persistence contributor on load', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + expect(ctx.bashEnv).toBeInstanceOf(BashEnvRegistry) + expect(ctx.bashEnv.list()).toEqual([ + { + contributor: 'session-persistence', + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + key: 'DSH_SESSION_JSONL', + }, + ]) + }) + + it('the persistence contributor resolves DSH_SESSION_JSONL only for a jsonl backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + ctx.provide('sessionPersistence', { + locate: () => ({ kind: 'jsonl' as const, path: 'C:\\sessions\\s.jsonl' }), + }) + expect(ctx.bashEnv.collect(execution('sess-p')).DSH_SESSION_JSONL).toBe('C:\\sessions\\s.jsonl') + }) + + it('the persistence contributor omits the variable for a non-jsonl backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + ctx.provide('sessionPersistence', { + locate: () => ({ kind: 'sqlite' as const, path: 'C:\\sessions\\s.db' }), + }) + expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') + }) + + it('the persistence contributor omits the variable without a persistence backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') + }) }) diff --git a/packages/bash/bash-env/tsconfig.json b/packages/bash/bash-env/tsconfig.json new file mode 100644 index 0000000000..bcf5eb5229 --- /dev/null +++ b/packages/bash/bash-env/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml new file mode 100644 index 0000000000..1b78ca75f9 --- /dev/null +++ b/packages/bash/pwsh-local/README.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 packages/bash/pwsh-local/README.md +README.md: 9deba9c1b63ccfdb9e1805b9896db33f144839bf +README.zh.md: e45c820e1d5e31aebd9ed365c6130850f1db2a62 diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md new file mode 100644 index 0000000000..9deba9c1b6 --- /dev/null +++ b/packages/bash/pwsh-local/README.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-pwsh-local + +English | [中文](README.zh.md) + +Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command ` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. + +The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged. + +The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, the pure `resolvePwshPath`/`candidatePwshPaths` helpers, and the `ENV_OVERRIDES`/`ENCODING_PREAMBLE` constants the executor injects into every spawn. + +## Config + +```yaml +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + cwd: C:\path\to\workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace + pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH +``` + +## Behavior (and where it came from) + +The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call: + +- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. +- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. +- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. +- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. +- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. + +## Model Experience + +Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas (through the generic task runtime), spill-file paths, and infrastructure failures. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead. +- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; interactive terminal sessions remain deferred until the roadmap's pwsh TUI/GUI rendering work lands. +- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. +- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. +- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead. +- **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected. + +Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md new file mode 100644 index 0000000000..e45c820e1d --- /dev/null +++ b/packages/bash/pwsh-local/README.zh.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-pwsh-local + +[English](README.md) | 中文 + +`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。 + +命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。 + +包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`、纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。 + +## 配置 + +```yaml +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + cwd: C:\path\to\workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace + pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH +``` + +## 行为(及其由来) + +作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义: + +- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 +- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 +- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。 +- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 +- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 +- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 + +## 模型体验 + +间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量(经通用任务运行时)、spill 文件路径与基础设施失败。 + +#### KV Cache 影响 + +无直接失效;具名消费方拥有请求前缀的任何变更。 + +## 已知局限与延期工作 + +- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。 +- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`;交互式终端会话在路线图的 pwsh TUI/GUI 渲染工作落地之前保持延期。 +- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 +- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 +- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。 +- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。 +- **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。 + +清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。 diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json new file mode 100644 index 0000000000..b7a63188b9 --- /dev/null +++ b/packages/bash/pwsh-local/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-pwsh-local", + "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts new file mode 100644 index 0000000000..316d2c8651 --- /dev/null +++ b/packages/bash/pwsh-local/src/index.ts @@ -0,0 +1,288 @@ +/** + * Local PowerShell implementation of the bash executor seam. Each command runs + * as `pwsh -NoLogo -NoProfile -NonInteractive -Command ` in a managed + * process spawned through `ctx.subprocess`; the executor owns command + * defaulting, deadlines and cause classification, the model-friendly terminal + * environment, and the model-facing stdout/stderr merge for background reads. + * + * The command string is passed as ONE argv element to `-Command`: PowerShell + * itself parses the text, and no intermediate shell exists, so there is no + * shell-quoting layer to escape (the `bash -c` string domain has no + * equivalent here). Native Win32 paths (`C:\...`) pass through unchanged. + * + * @module @deepseek-ai/dsh-pwsh-local + */ + +import { Context } from 'cordis' +import z from 'schemastery' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { resolvePwshPath } from './resolve.ts' + +/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */ +/** + * Model-friendly environment overrides for PowerShell: disable colors and + * pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is + * deliberately absent; `NO_COLOR` is honored by modern pwsh renderers. + */ +export const ENV_OVERRIDES = { + NO_COLOR: '1', + PAGER: 'cat', + GIT_PAGER: 'cat', +} as const + +/** + * UTF-8 output pinning prepended to every command. The subprocess collector + * decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort + * executable fallback) writes the console/OEM code page by default, which + * garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The + * statements ride on line 1 after `; ` separators so PowerShell error line + * numbers stay accurate. + */ +export const ENCODING_PREAMBLE = + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' + +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */ +const DEFAULT_GRACE_MS = 3_000 + +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 + +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** Default working directory for commands (default: process.cwd()). */ + cwd?: string + /** Default foreground timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for per-call timeout overrides. */ + maxTimeoutMs?: number + /** Per-stream in-memory output cap; overflow spills to a temp file. */ + maxOutputBytes?: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ + graceMs?: number + /** + * Explicit pwsh executable. When omitted, well-known Windows install + * locations and PATH entries are probed in order (PowerShell 7 install, + * PATH entries such as the Microsoft Store install, then Windows + * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH. + */ + pwshPath?: string +} + +/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */ +type ResolvedConfig = Required> & Pick + +// Resolution lives in its own dependency-free module so the repository's +// coverage-gate probe shares the exact definition the suites use. +export { candidatePwshPaths, resolvePwshPath } from './resolve.ts' + +/** Project a settled collect-mode reader into the final CollectedOutput shape. */ +function finalOutput(reader: SubprocessOutputReader): CollectedOutput { + const read = reader.readFrom(0) + return { + text: read.text, + truncated: read.lossy, + ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, + } +} + +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`pwsh-local: ${name} must be a positive finite number`) + } +} + +/** + * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill + * files, and process-tree termination are the subprocess service's mechanics; + * this executor supplies their configured budgets per spawn. + */ +export class PwshLocalExecutor extends BashExecutor { + static inject = ['subprocess'] + + static Config: z = z.object({ + cwd: z.string(), + timeoutMs: z.number().default(120_000), + maxTimeoutMs: z.number().default(600_000), + maxOutputBytes: z.number().default(64_000), + maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES), + graceMs: z.number().default(DEFAULT_GRACE_MS), + pwshPath: z.string(), + }) + + /** Validated config (schemastery applied the defaults before construction). */ + readonly config: ResolvedConfig + + /** The pwsh executable resolved once at construction. */ + readonly pwshPath: string + + constructor(ctx: Context, config: Config) { + super(ctx) + // Schemastery fills these fields before construction; the type does not encode that step. + this.config = config as ResolvedConfig + assertPositiveFinite('timeoutMs', this.config.timeoutMs) + assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) + assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) + assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) + assertPositiveFinite('graceMs', this.config.graceMs) + this.pwshPath = resolvePwshPath(this.config.pwshPath) + } + + /** + * Resolve a request into a fully-specified spec: fill `workdir` from + * `config.cwd` (else `process.cwd()`), and `timeoutMs` from + * `config.timeoutMs`, capped at `config.maxTimeoutMs`. + */ + resolve(request: BashExecRequest): BashExecSpec { + const timeoutMs = clampTimeout( + request.timeoutMs, + this.config.timeoutMs, + this.config.maxTimeoutMs, + 'pwsh-local: request.timeoutMs', + ) + const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes + assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes) + return { + command: request.command, + workdir: request.workdir ?? this.config.cwd ?? process.cwd(), + timeoutMs, + stdoutMaxBytes, + ...request.signal ? { signal: request.signal } : {}, + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) + return { + argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`], + cwd: spec.workdir, + stdio: { + stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', + stdout: collect(stdoutMaxBytes), + stderr: collect(this.config.maxOutputBytes), + }, + graceMs: this.config.graceMs, + signal, + env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, + } + } + + /** The collect-mode readers the executor itself requested (present by construction). */ + private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { + const { stdout, stderr } = handle.collected + /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ + if (stdout === undefined || stderr === undefined) { + throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream') + } + /* v8 ignore stop */ + return { stdout, stderr } + } + + async run(spec: BashExecSpec): Promise { + // One deadline combines timeout and upstream cancellation; disposal clears its timer. + using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const outcome = await handle.done + const collected = PwshLocalExecutor.collected(handle) + // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. + const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined + const aborted = d.signal.aborted && !timedOut + return { + ...outcome, + timedOut, + aborted, + timeoutMs: spec.timeoutMs, + stdout: finalOutput(collected.stdout), + stderr: finalOutput(collected.stderr), + } + } + + start(spec: BashExecSpec): BashProcess { + // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const collected = PwshLocalExecutor.collected(running) + + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined + return note + } + + let stdoutOffset = 0 + let stderrOffset = 0 + const proc: BashProcess = { + status: 'running', + exitCode: null, + signal: null, + done: running.done.then((outcome) => { + // Any signal termination is killed, including a command signaling itself. + if (proc.status === 'running') { + proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed' + } + proc.exitCode = outcome.exitCode + proc.signal = outcome.signal + this.onProcessDone(proc, collected.stderr.readFrom(0).text) + }, (error: unknown) => { + // Background spawn failures settle as killed and surface through the read path. + proc.status = 'killed' + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote) + }), + readOutput: (): BashProcessRead => { + const out = collected.stdout.readFrom(stdoutOffset) + const err = collected.stderr.readFrom(stderrOffset) + stdoutOffset = out.nextOffset + stderrOffset = err.nextOffset + + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() + // Single newline between sections: stdout chunks usually end with one + // already; add it only when missing. + const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' + const delta = out.text + + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') + return { + delta, + lossy: out.lossy || err.lossy, + ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {}, + ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {}, + } + }, + kill: (): boolean => { + if (proc.status !== 'running') return false + proc.status = 'killed' + running.terminate() + return true + }, + } + return proc + } + + /** + * Settlement hook for subclasses that attach execution facts to a process. + * The base implementation is intentionally empty. Mirrored from + * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is + * the declared seam for a future pwsh-confining subclass and has no consumer + * in this package yet. + * @param _proc - the settled process handle. + * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + */ + protected onProcessDone(_proc: BashProcess, _stderr: string): void {} +} +/* jscpd:ignore-end */ + +export default PwshLocalExecutor diff --git a/packages/bash/pwsh-local/src/invariant.ts b/packages/bash/pwsh-local/src/invariant.ts new file mode 100644 index 0000000000..4bb1c1ea30 --- /dev/null +++ b/packages/bash/pwsh-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`. + * @module @deepseek-ai/dsh-pwsh-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local' + +/** Cordis companion plugin name. */ +export const name = 'pwsh-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts new file mode 100644 index 0000000000..c6ded2f883 --- /dev/null +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -0,0 +1,60 @@ +/** + * PowerShell executable resolution, dependency-free so non-package consumers + * (the repository's coverage-gate probe in `vitest.config.ts`) can share the + * ONE resolution definition with the executor and its suites — a probe that + * resolved differently from the code under test could exempt a file whose + * suites actually run. + * + * @module @deepseek-ai/dsh-pwsh-local/resolve + */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Well-known Windows PowerShell install locations plus PATH entries, newest + * first. Explicitly parameterized (env) so resolution is a pure function of + * its inputs on every platform. + * @param env - the environment to probe; defaults to the process environment. + * @returns candidate `pwsh` executable paths in resolution order. + */ +export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] { + const programFiles = env.ProgramFiles ?? 'C:\\Program Files' + const systemRoot = env.SystemRoot ?? 'C:\\Windows' + const candidates = [ + join(programFiles, 'PowerShell', '7', 'pwsh.exe'), + ] + // Microsoft Store installs (and any user-added location) live on PATH; + // entries may carry surrounding quotes from `setx`-style definitions. + for (const entry of (env.PATH ?? '').split(';')) { + const trimmed = entry.trim().replace(/^"|"$/g, '') + if (trimmed.length === 0) continue + candidates.push(join(trimmed, 'pwsh.exe')) + } + // Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts. + candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) + return candidates +} + +/** + * Resolve the pwsh executable this executor spawns. + * @param configured - an explicit `pwshPath` config value, trusted as-is. + * @param env - the environment to probe on Windows; defaults to the process environment. + * @param platform - the platform to resolve for; defaults to the process platform. + * @returns the first existing well-known location on Windows (PowerShell 7 + * install, a PATH entry such as the Microsoft Store install, then Windows + * PowerShell 5.1), else `pwsh` for PATH resolution. + */ +export function resolvePwshPath( + configured?: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string { + if (configured !== undefined && configured.length > 0) return configured + if (platform === 'win32') { + for (const candidate of candidatePwshPaths(env)) { + if (existsSync(candidate)) return candidate + } + } + return 'pwsh' +} diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts new file mode 100644 index 0000000000..4552f2eeec --- /dev/null +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -0,0 +1,454 @@ +/** + * Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess + * service plus a REAL pwsh executable, exercised through the executor seam + * (`resolve` → `run`/`start`). These verify the world — actual PowerShell + * runs, output capture, truncation and spill, deadlines, kill escalation, and + * the background-handle contract. The suite self-skips when no usable `pwsh` + * resolves (a CI accommodation for hosts without PowerShell); the pure unit tests + * (config validation, executable resolution) run on every platform. PowerShell + * writes CRLF on Windows, so exact text assertions normalize line endings. + */ + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import SubprocessService from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-')) + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + +/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */ +function samePath(actual: string, expected: string): boolean { + const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value) + return norm(actual) === norm(expected) +} + +async function setup(config: ConstructorParameters[1] = {}) { + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + // A short kill grace via the REAL config path, so escalation tests stay fast. + await ctx.plugin(PwshLocalExecutor, { graceMs: 200, ...config }) + const bash = ctx.bash as PwshLocalExecutor + return { ctx, bash } +} + +/** + * Poll a handle's consuming readOutput until the ACCUMULATED delta contains + * `expected`; returns the accumulation (reads never re-deliver, so the caller + * gets everything produced up to the match). + */ +async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + let all = '' + while (Date.now() < deadline) { + all += proc.readOutput().delta + if (lf(all).includes(expected)) return lf(all) + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(lf(all))}`) +} + +describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => { + it('trusts an explicit configured path verbatim', () => { + expect(resolvePwshPath('C:\\custom\\pwsh.exe')).toBe('C:\\custom\\pwsh.exe') + expect(resolvePwshPath('pwsh')).toBe('pwsh') + }) + + it('falls through an empty configured path to platform resolution', () => { + // SystemRoot points at a non-existent tree so the Windows PowerShell 5.1 + // fallback candidate cannot exist either. + expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh') + }) + + it('returns pwsh on non-Windows platforms regardless of the environment', () => { + expect(resolvePwshPath(undefined, { ProgramFiles: 'P:\\Program Files' }, 'linux')).toBe('pwsh') + expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh') + }) + + it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => { + const candidates = candidatePwshPaths({ + ProgramFiles: 'P:\\Program Files', + SystemRoot: 'S:\\Windows', + PATH: ';"Q:\\quoted store";' + ';', + }) + expect(candidates).toEqual([ + join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('Q:\\quoted store', 'pwsh.exe'), + join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + // A missing PATH contributes no entries (the empty-string fallback). + expect(candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', SystemRoot: 'S:\\Windows' })) + .toEqual([ + join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + }) + + it('returns the first EXISTING win32 candidate, else pwsh', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-')) + const store = join(dir, 'store') + mkdirSync(store, { recursive: true }) + writeFileSync(join(store, 'pwsh.exe'), '') + // The existing PATH entry wins over the non-existent Program Files install. + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) + .toBe(join(store, 'pwsh.exe')) + // No candidate exists anywhere (SystemRoot points at a non-existent tree, + // so even the Windows PowerShell 5.1 fallback cannot exist) → the + // PATH-resolution fallback. + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32')) + .toBe('pwsh') + }) +}) + +describe('spawn construction (pure, every platform)', () => { + /** A subprocess service that records spawn specs and settles instantly. */ + class CapturingSubprocessService extends SubprocessService { + specs: SubprocessSpawnSpec[] = [] + private readonly reader: SubprocessOutputReader = { + readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), + } + override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + this.specs.push(spec) + return { + pid: -1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: this.reader, stderr: this.reader }, + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate: () => {}, + waitForExit: async () => true, + } + } + } + + it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => { + const ctx = new Context() + const subprocess = new CapturingSubprocessService(ctx) + await ctx.plugin(PwshLocalExecutor) + await ctx.bash.run(ctx.bash.resolve({ command: 'Write-Output 你好' })) + expect(subprocess.specs).toHaveLength(1) + const { argv } = subprocess.specs[0]! + expect(argv.slice(0, 5)).toEqual([expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command']) + expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`) + expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding') + expect(ENCODING_PREAMBLE).toContain('$OutputEncoding') + }) +}) + +describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { + it('resolves with output and the effective timeout', async () => { + const { bash } = await setup({ timeoutMs: 5_000 }) + const result = await bash.run(bash.resolve({ command: 'Write-Output hi' })) + expect(result.exitCode).toBe(0) + expect(lf(result.stdout.text)).toBe('hi\n') + expect(result.timeoutMs).toBe(5_000) + }) + + it('uses config cwd, overridable per call', async () => { + const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-')) + const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-')) + const { bash } = await setup({ cwd: first }) + const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) + expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true) + const fromCall = await bash.run(bash.resolve({ command: '(Get-Location).Path', workdir: second })) + expect(samePath(fromCall.stdout.text.trim(), second)).toBe(true) + }) + + it('defaults cwd to process.cwd()', async () => { + const { bash } = await setup() + const result = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) + expect(samePath(result.stdout.text.trim(), process.cwd())).toBe(true) + }) + + it('caps per-call timeouts at maxTimeoutMs', async () => { + const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 }) + const result = await bash.run(bash.resolve({ command: 'Write-Output ok', timeoutMs: 99_999 })) + expect(result.timeoutMs).toBe(2_000) + }) + + it('rejects invalid numeric config and timeout overrides', async () => { + await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) + await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) + await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) + await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) + await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) + + const { bash } = await setup() + expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) + expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: -1 })).toThrow(/request\.timeoutMs/) + expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/) + expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/) + }) + + it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + expect(bash.resolve({ command: 'Write-Output ok' }).stdoutMaxBytes).toBe(100) + + // Raw Console writes avoid PowerShell's own line-ending and formatting + // layers, so the byte counts are exact on every platform. + const result = await bash.run(bash.resolve({ + command: '[Console]::Out.Write("x" * 500); [Console]::Error.WriteLine("e" * 500)', + stdoutMaxBytes: 500, + })) + + expect(result.stdout.text).toBe('x'.repeat(500)) + expect(result.stdout.truncated).toBe(false) + expect(result.stderr.truncated).toBe(true) + expect(result.stderr.text.length).toBeLessThanOrEqual(100) + }) + + it('per-call timeout takes precedence under the cap and kills on expiry', async () => { + const { bash } = await setup({ timeoutMs: 60_000 }) + const result = await bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', timeoutMs: 100 })) + expect(result.timedOut).toBe(true) + // Mutually exclusive: a timeout classifies as timedOut, never also aborted. + expect(result.aborted).toBe(false) + expect(result.timeoutMs).toBe(100) + }) + + it('propagates abort signals', async () => { + const { bash } = await setup() + const controller = new AbortController() + const pending = bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal })) + setTimeout(() => { controller.abort() }, 50) + const result = await pending + expect(result.aborted).toBe(true) + // Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut. + expect(result.timedOut).toBe(false) + }) + + it('classifies a self-killed command as neither timed out nor aborted', async () => { + const { bash } = await setup({ timeoutMs: 60_000 }) + const result = await bash.run(bash.resolve({ command: 'Stop-Process -Id $PID' })) + expect(result.timedOut).toBe(false) + expect(result.aborted).toBe(false) + // Windows reports a forced termination without a signal; POSIX reports the + // terminating signal PowerShell chose (SIGTERM, or SIGKILL for the hard kill). + if (process.platform === 'win32') { + expect(result.signal).toBeNull() + } else { + expect(['SIGTERM', 'SIGKILL']).toContain(result.signal) + } + }) + + it('rejects on spawn failure (bad workdir)', async () => { + const { bash } = await setup() + await expect(bash.run(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/) + }) + + it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => { + const { bash } = await setup() + const spec = bash.resolve({ + command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:SEAM_VAR][$env:DSH_SEAM_VAR]"', + stdin: 'piped\n', + env: { SEAM_VAR: 'env-ok' }, + dshEnv: { DSH_SEAM_VAR: 'dsh-ok' }, + }) + // resolve() keeps the optional input/environment fields verbatim. + expect(spec.stdin).toBe('piped\n') + expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' }) + expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' }) + const result = await bash.run(spec) + expect(lf(result.stdout.text)).toBe('piped\n[env-ok][dsh-ok]\n') + }) + + it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => { + const { bash } = await setup() + const spec = bash.resolve({ command: 'Write-Output ok' }) + expect('stdin' in spec).toBe(false) + expect('env' in spec).toBe(false) + expect('dshEnv' in spec).toBe(false) + }) +}) + +describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)', () => { + it('start returns immediately with a running handle that settles as completed', async () => { + const { bash } = await setup() + const before = Date.now() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' })) + expect(Date.now() - before).toBeLessThan(150) + expect(proc.status).toBe('running') + await proc.done + expect(proc.status).toBe('completed') + expect(proc.exitCode).toBe(0) + }) + + it('threads stdin and extra env into a background process', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ + command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"', + stdin: 'bg-stdin\n', + env: { BG_VAR: 'bg-env' }, + dshEnv: { DSH_BG_VAR: 'bg-dsh-env' }, + })) + const output = await readUntil(proc, '[bg-env][bg-dsh-env]') + expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n') + await proc.done + expect(proc.exitCode).toBe(0) + }) + + it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output first; Start-Sleep -Seconds 1; Write-Output second' })) + const first = await readUntil(proc, 'first\n') + expect(lf(first)).toBe('first\n') + await proc.done + // Read-after-exit returns the remaining buffered output — once. + const second = proc.readOutput() + expect(lf(second.delta)).toBe('second\n') + expect(second.lossy).toBe(false) + expect(proc.readOutput().delta).toBe('') + }) + + it('readOutput marks stderr sections', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n') + }) + + it('readOutput reports stderr-only deltas without a leading newline', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('[stderr]\nerr\n') + }) + + it('readOutput adds a separator only when stdout lacks a trailing newline', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n') + }) + + it('readOutput flags lossy reads and reports stdout spill paths', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' })) + await proc.done + const read = proc.readOutput() + // Window slid past offset 0 → lossy, spill path points at the full stream. + expect(read.lossy).toBe(true) + expect(read.stdoutSpillPath).toBeDefined() + }) + + it('readOutput reports stderr spill paths', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' })) + await proc.done + const read = proc.readOutput() + expect(read.lossy).toBe(true) + expect(read.stderrSpillPath).toBeDefined() + expect(lf(read.delta)).toContain('[stderr]') + }) + + it('kill() terminates the process tree: true once, false after settlement', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) + expect(proc.kill()).toBe(true) + await proc.done + expect(proc.status).toBe('killed') + expect(proc.kill()).toBe(false) + }) + + it('kill() returns false for a naturally completed process', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output ok' })) + await proc.done + expect(proc.status).toBe('completed') + expect(proc.kill()).toBe(false) + }) + + it('a spec.signal abort settles the handle as killed, not completed', async () => { + const { bash } = await setup() + const controller = new AbortController() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal })) + controller.abort() + await proc.done + expect(proc.status).toBe('killed') + }) + + it.skipIf(process.platform === 'win32')('a self-signal exit settles the handle as killed, not completed (POSIX)', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' })) + await proc.done + expect(proc.status).toBe('killed') + expect(proc.exitCode).toBeNull() + // PowerShell picks SIGTERM for Stop-Process, SIGKILL for the hard kill. + expect(['SIGTERM', 'SIGKILL']).toContain(proc.signal) + }) + + it('a background spawn failure settles as killed with the error readable on stderr', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' })) + // done resolves (never rejects) even though the process never ran. + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + expect(proc.readOutput().delta).toContain('spawn failed:') + }) +}) + +describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => { + const ctx = new Context() + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 }) + const bash = ctx.bash as PwshLocalExecutor + + // The child prints its own pid so the test can probe liveness through the + // public read surface alone. + const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' })) + const pid = Number((await readUntil(proc, '\n')).trim()) + expect(Number.isInteger(pid) && pid > 0).toBe(true) + + // Executor reload/disposal leaves background work running — the + // handle stays live and readable, mirroring the task runtime's + // registrations-outlive-producer-fibers contract. + await executorFiber.dispose() + expect(proc.status).toBe('running') + expect(() => process.kill(pid, 0)).not.toThrow() + + // Service disposal kills the group and AWAITS its exit (no orphans). + await managerFiber.dispose() + expect(() => process.kill(pid, 0)).toThrow() + await proc.done + // POSIX reports the kill as a signal; Windows reports a forced + // termination as exit 1 with no signal (indistinguishable from a crash), + // so the status stamp follows the platform's exit facts. + expect(proc.status).toBe(process.platform === 'win32' ? 'completed' : 'killed') + }) + + it('service disposal settles running handles and leaves settled ones untouched', async () => { + const ctx = new Context() + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(PwshLocalExecutor, { graceMs: 200 }) + const bash = ctx.bash as PwshLocalExecutor + + const finished = bash.start(bash.resolve({ command: 'Write-Output done' })) + await finished.done + expect(finished.status).toBe('completed') + const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) + + await managerFiber.dispose() + // A settled process was untouched; the live one was terminated and joined. + expect(finished.status).toBe('completed') + await running.done + expect(running.status).toBe(process.platform === 'win32' ? 'completed' : 'killed') + }) +}) diff --git a/packages/bash/pwsh-local/tsconfig.json b/packages/bash/pwsh-local/tsconfig.json new file mode 100644 index 0000000000..53ccc94926 --- /dev/null +++ b/packages/bash/pwsh-local/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index 9d53b52d02..fb33cffefb 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/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/bash/tool-bash/README.md -README.md: 29b9fba369e1fc6a4b8bb7bdd6543b7678df627d -README.zh.md: 31f691f7bfb8d2cb905751663151c3f6a6bc6c57 +README.md: 35a5647365dab6daa903c14cba8b83702b50305d +README.zh.md: eb7901f3445117988d77e6d39b73681480b7a754 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 29b9fba369..35a5647365 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`. -Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). +Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor. The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests. @@ -28,26 +28,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th ### Managed shell environment -Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. - -`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. +Every foreground and background model bash call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/README.md) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. The registry contract — contributor registration, loud duplicate/undeclared-key failure, the built-in reservations, and the contributor example — lives in that package's README. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; the local executor removes all inherited `DSH_*` before merging it, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. @@ -141,7 +122,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. +Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `tool call aborted`. #### Token effect diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index 31f691f7bf..eb7901f344 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -4,7 +4,7 @@ 模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。 -需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`);在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`)。 +需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器。 包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 @@ -28,26 +28,7 @@ ### 托管 shell 环境 -每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。 - -`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。 - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 +每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表契约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。 @@ -141,7 +122,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr #### 模型看到的内容 -验证和策略失败统一为 `Error: `。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "" is not strictly wider than this call's current "" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`。 +验证和策略失败统一为 `Error: `。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "" is not strictly wider than this call's current "" mode`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。 #### Token 影响 diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index c34e1c6e7b..c9734b46eb 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -29,12 +29,11 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -49,15 +48,14 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 3b3874bc59..91a88c9cca 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -8,205 +8,39 @@ * @module @deepseek-ai/dsh-tool-bash */ -import { Service, type Context } from 'cordis' +import type { Context } from 'cordis' import z from 'schemastery' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-bash-env' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' -import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' -declare module 'cordis' { - interface Context { - bashEnv: BashEnvRegistry - } -} - export const name = 'tool-bash' -export const inject = ['tools', 'bash', 'systemPrompt'] +export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv'] -/** Configuration for the bash tool and its managed child environment. */ +/** Configuration for the bash tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - dshHome: z.string(), }) -/** Model-visible metadata for one managed `DSH_*` environment variable. */ -export interface BashEnvVariable { - /** Concise description of the environment fact represented by the variable. */ - description: string -} - -/** - * A plugin contribution to the managed environment of each model bash call. - * Declared keys make ownership conflicts detectable before the first command; - * `resolve` computes only the values available for the current execution. - */ -export interface BashEnvContributor { - /** Stable contributor name used in diagnostics and duplicate detection. */ - name: string - /** Complete set of `DSH_*` keys this contributor may return. */ - variables: Readonly> - /** - * Resolve this contributor's available values for one tool execution. - * @param execution - the bash tool execution and its optional calling agent. - * @returns a partial map containing only keys declared in {@link variables}. - */ - resolve(execution: ToolExecution): Readonly>> -} - -/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ -export interface BashEnvVariableInfo extends BashEnvVariable { - /** Contributor that owns the variable. */ - contributor: string - /** Declared `DSH_*` environment variable name. */ - key: DshEnvironmentKey -} - -const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const -const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const -const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const -const RESERVED_BASH_ENV_KEYS = new Set([ - DSH_HOME_ENV, - DSH_SHELL_KEY, - DSH_SESSION_ID_KEY, -]) -const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ - -/** - * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. - * The namespace is rebuilt for every model bash call: ambient `DSH_*` values - * are discarded by the executor, then the registry's current snapshot is - * injected. Built-in shell facts remain owned by the registry itself while - * plugins can register additional, enumerable facts with effect-scoped - * disposal. - */ -export class BashEnvRegistry extends Service { - private readonly contributors = new Map() - private readonly keyOwners = new Map() - private readonly dshHome: string - - /** - * Create and install the `ctx.bashEnv` service. - * @param ctx - Cordis context that owns the service and registrations. - * @param config - home-directory configuration for the built-in variables. - */ - constructor(ctx: Context, config: Config = {}) { - super(ctx, 'bashEnv') - this.dshHome = resolveDshHome(config.dshHome) - } - - /** - * Register one environment contributor. Names and keys are unique; built-in - * keys are reserved. Registration is disposed with the calling plugin fiber. - * @param contributor - declared key ownership and per-execution resolver. - * @returns the disposer that unregisters the contribution. - */ - register(contributor: BashEnvContributor): () => void { - const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { - if (contributor.name.trim().length === 0) { - throw new Error('bash env contributor name must be non-empty') - } - if (this.contributors.has(contributor.name)) { - throw new Error(`bash env contributor "${contributor.name}" is already registered`) - } - - const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] - for (const [key, variable] of variables) { - if (!key.startsWith(DSH_ENV_PREFIX) - || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { - throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) - } - if (RESERVED_BASH_ENV_KEYS.has(key)) { - throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) - } - if (variable.description.trim().length === 0) { - throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) - } - const owner = this.keyOwners.get(key) - if (owner !== undefined) { - throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) - } - } - - this.contributors.set(contributor.name, contributor) - for (const [key] of variables) this.keyOwners.set(key, contributor.name) - yield () => { - this.contributors.delete(contributor.name) - for (const [key] of variables) this.keyOwners.delete(key) - } - }.bind(this), 'bashEnv.register()') - return () => void dispose() - } - - /** - * Build the trusted `DSH_*` snapshot for one bash tool execution. - * @param execution - the current tool execution. - * @returns an immutable environment overlay containing built-ins and current contributions. - */ - collect(execution: ToolExecution): DshEnvironment { - const values: Record = { - [DSH_HOME_ENV]: this.dshHome, - [DSH_SHELL_KEY]: '1', - } - if (execution.agent !== undefined) { - values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id - } - - for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { - const resolved = contributor.resolve(execution) - for (const [rawKey, value] of Object.entries(resolved)) { - const key = rawKey as DshEnvironmentKey - if (!Object.hasOwn(contributor.variables, key)) { - throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) - } - if (typeof value !== 'string') { - throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) - } - values[key] = value - } - } - - return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) - } - - // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, - // prompt, or UI code treats list() as an exhaustive environment catalog. - /** - * Enumerate plugin-contributed variables without executing their resolvers. - * @returns declarations sorted by environment variable name. - */ - list(): BashEnvVariableInfo[] { - return [...this.contributors.values()] - .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ - contributor: contributor.name, - description: variable.description, - key: key as DshEnvironmentKey, - }))) - .sort((left, right) => left.key.localeCompare(right.key)) - } -} - /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ interface BashToolArgs { command: string @@ -354,24 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const export function apply(ctx: Context, config: Config = {}): void { - // FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell - // environment plugin; replacing this tool with persistent Bash must not - // remove the managed DSH_* contributor seam. - const bashEnv = new BashEnvRegistry(ctx, config) - bashEnv.register({ - name: 'session-persistence', - variables: { - [DSH_SESSION_JSONL_KEY]: { - description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', - }, - }, - resolve(execution) { - const agent = execution.agent - if (agent === undefined) return {} - const location = ctx.get('sessionPersistence')?.locate(agent.session.header) - return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} - }, - }) const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS @@ -522,7 +338,7 @@ export function apply(ctx: Context, config: Config = {}): void { ? standingPolicy : { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode } const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot) - const dshEnv = bashEnv.collect(exec) + const dshEnv = ctx.bashEnv.collect(exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, @@ -565,7 +381,11 @@ export function apply(ctx: Context, config: Config = {}): void { ...request, signal: exec.signal, })) - if (result.aborted) throw new Error('command aborted') + if (result.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } return { kind: 'foreground' as const, ...canonicalBashResult(result) } }, presentCall: presentBashCall, diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 5616afcdef..b76f24bc04 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -14,6 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** @@ -32,8 +33,9 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(ToolBash) ctx.llm.registerAdapter(['mock'], adapter) return ctx } diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index a52b3f16f1..4f913d78ee 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -20,6 +20,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { processOutcome } from '../src/background.ts' import { renderProcessRead, renderResult } from '../src/render.ts' @@ -35,6 +36,7 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -50,6 +52,7 @@ async function setupWithTasks() { await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -188,6 +191,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) return { ctx, bash: ctx.bash as RecordingSandboxExecutor } } @@ -281,6 +285,7 @@ describe('bash tool', () => { await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -300,7 +305,7 @@ describe('bash tool', () => { expect(text(result)).toMatch(/ENOENT/) }) - it('surfaces foreground aborts as isError', async () => { + it('surfaces foreground aborts as the structured TOOL_ABORTED error', async () => { const ctx = await setup() const controller = new AbortController() const pending = ctx.tools.execute({ @@ -312,7 +317,10 @@ describe('bash tool', () => { setTimeout(() => { controller.abort() }, 50) const result = await pending expect(result.isError).toBe(true) - expect(text(result)).toMatch(/aborted/) + expect(result.error).toMatchObject({ + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }) }) // Type and required-key violations are rejected by the harness @@ -389,6 +397,7 @@ describe('bash tool', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) + await ctx.plugin(BashEnvPlugin) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash']) @@ -403,6 +412,7 @@ describe('bash tool', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) await ctx.plugin(LocalSubprocessService) @@ -493,6 +503,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(CountingStartExecutor) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const controller = new AbortController() @@ -520,6 +531,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(CountingStartExecutor) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) @@ -534,6 +546,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) @@ -568,6 +581,7 @@ describe('sandbox escalation through the generic task producer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(RecordingSandboxExecutor) + await ctx.plugin(BashEnvPlugin) await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing') }) @@ -1003,9 +1017,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { // not renderResult output, so a generic fenced card, no terminal output/exit. const out = ctx.tools.get('bash')!.presentResult!( { command: 'x', description: 'x' }, - { content: [{ type: 'text', text: 'command aborted' }], isError: true }, + { content: [{ type: 'text', text: 'tool call aborted' }], isError: true }, ) - expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] }) + expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] }) }) it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => { @@ -1097,8 +1111,9 @@ describe('the model-facing bash tool builds its request from named args only (no } await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(BashEnvPlugin, { dshHome: recordingDshHome }) await ctx.plugin(RecordingBashExecutor) - await ctx.plugin(ToolBash, { dshHome: recordingDshHome }) + await ctx.plugin(ToolBash) return { ctx, bash: ctx.bash as RecordingBashExecutor } } diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index 00e9195f9f..b122ed58ca 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -26,21 +26,18 @@ { "path": "../../core/agent" }, - { - "path": "../../session-persistence/session-persistence" - }, { "path": "../../bash/bash" }, - { - "path": "../../util/paths" - }, { "path": "../../tasks/tasks" }, { "path": "../../core/system-prompt" }, + { + "path": "../../bash/bash-env" + }, { "path": "../../ui/user-approval" }, diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml new file mode 100644 index 0000000000..030d24c7c2 --- /dev/null +++ b/packages/bash/tool-pwsh/README.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 packages/bash/tool-pwsh/README.md +README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7 +README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md new file mode 100644 index 0000000000..dfe26a6368 --- /dev/null +++ b/packages/bash/tool-pwsh/README.md @@ -0,0 +1,125 @@ +# @deepseek-ai/dsh-tool-pwsh + +English | [中文](README.zh.md) + +The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker). + +Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). + +The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export. + +The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker. + +## Tools + +### `pwsh` + +| Arg | Type | Notes | +|---|---|---| +| `command` | string (required) | Run via `pwsh -Command`. No state persists between calls — use `workdir`, not `cd`. | +| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. | +| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. | +| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. | +| `run_in_background` | boolean | Return a task id immediately; no timeout applies. | + +`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. + +### Managed shell environment + +Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables. + +Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. + +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. + +When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. + +## UI presentation + +The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe. + +## Model Experience + +### System prompt + +#### What the model sees + +Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section. + +##### Pwsh guidance + +```markdown +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. +``` + +#### Token effect + +Small fixed input cost per request while the plugin is active. + +#### KV Cache effect + +Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section. + +### Tool schemas + +#### What the model sees + +The model sees the generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh). Agent-scoped tool restrictions can remove the definition for that agent. + +#### Token effect + +Fixed schema cost on every request where the tool is visible. + +#### KV Cache effect + +Prefix-stable while visibility and the tool definition are unchanged. A restriction or config change may invalidate reuse from the first changed token. + +### Foreground result + +#### What the model sees + +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: ]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]` (nonzero exits only); an empty body renders as `(no output)`. + +#### Token effect + +Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +### Background result + +#### What the model sees + +A background start renders exactly `started background task `; subsequent reads and status flow through the generic `task_output`/`task_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes. + +#### Token effect + +The ack is a fixed short line; task output is bounded per read. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +### Tool errors + +#### What the model sees + +Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. + +#### Token effect + +Only the failing call adds these retained tokens; an aborted call adds no command output. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored). +- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. +- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. +- **Generic UI presentation** — results use the generic card; a PowerShell-aware terminal card with exit-status pill is roadmap work. +- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md new file mode 100644 index 0000000000..2344f8477e --- /dev/null +++ b/packages/bash/tool-pwsh/README.zh.md @@ -0,0 +1,125 @@ +# @deepseek-ai/dsh-tool-pwsh + +[English](README.md) | 中文 + +注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 + +需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 + +包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。 + +插件还贡献 `tool:pwsh` prompt section(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。 + +## 工具 + +### `pwsh` + +| Arg | Type | Notes | +|---|---|---| +| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 | +| `description` | string (required) | 命令的一行主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 | +| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 | +| `workdir` | string | 本次调用的工作目录。默认取调用 agent 的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | +| `run_in_background` | boolean | 立即返回任务 id;不适用超时。 | + +`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。 + +### Managed shell environment + +每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。 + +结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 + +规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task `;编程消费者使用类型化字段而不解析渲染文本。 + +当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 + +## UI presentation + +工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。 + +## Model Experience + +### System prompt + +#### What the model sees + +本插件注册作用域内的每个请求都包含下面的 pwsh 指引。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。 + +##### Pwsh guidance + +```markdown +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. +``` + +#### Token effect + +插件激活期间每次请求的固定小额输入成本。 + +#### KV Cache effect + +注册作用域与 prompt 文本不变时前缀稳定。插件激活或释放可能使该 prompt 段落的复用失效。 + +### Tool schemas + +#### What the model sees + +模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。 + +#### Token effect + +工具可见的每个请求上的固定 schema 成本。 + +#### KV Cache effect + +可见性与工具定义不变时前缀稳定。限制或配置变更可能从首个变化 token 起使复用失效。 + +### Foreground result + +#### What the model sees + +渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: ]`、`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`(仅非零退出);空体渲染为 `(no output)`。 + +#### Token effect + +调用前零结果 token。每个流的输出有界,而每条已发出的行保留在历史中直到压缩。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +### Background result + +#### What the model sees + +后台启动精确渲染为 `started background task `;随后的读取与状态通过通用 `task_output`/`task_kill` 工具流转,包括内存截断丢弃未读字节时的 lossy 读取 spill 通知。 + +#### Token effect + +ack 是固定短行;任务输出按读取有界。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +### Tool errors + +#### What the model sees + +校验与基础设施失败规范化为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 + +#### Token effect + +只有失败的调用会新增这些保留 token;被中止的调用不产生命令输出。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +## Known Limitations and Deferred Work + +- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。 +- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 +- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 +- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。 +- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。 diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json new file mode 100644 index 0000000000..7f43cd57e8 --- /dev/null +++ b/packages/bash/tool-pwsh/package.json @@ -0,0 +1,59 @@ +{ + "name": "@deepseek-ai/dsh-tool-pwsh", + "description": "Model-facing pwsh tool over the bash executor seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/tool-pwsh/src/background.ts b/packages/bash/tool-pwsh/src/background.ts new file mode 100644 index 0000000000..5e3464f76b --- /dev/null +++ b/packages/bash/tool-pwsh/src/background.ts @@ -0,0 +1,31 @@ +/** + * Generic-task adaptation for background pwsh process handles — the shell-agnostic + * twin of `dsh-tool-bash`'s background adaptation. + * + * @module @deepseek-ai/dsh-tool-pwsh/background + */ + +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */ + +/** + * Map a settled background process onto the generic task-outcome vocabulary: + * `killed` stays `killed` (detail: the signal when one is known), everything + * else is `completed` with the exit code as detail. A nonzero command exit is + * reported, not failed, exactly like the foreground rendering. + * @param proc - the settled process handle. + * @returns the outcome for the `ctx.tasks` registration. + */ +export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } { + // TODO(background-infrastructure-outcome): widen BashProcess with an explicit + // infrastructure-failure outcome, then map spawn failures and + // sandbox.runnerFailed to task `failed`. The current seam aliases a spawn + // failure with a signal-less kill and a runner failure with an ordinary + // wrapper exit; real nonzero command exits must remain `completed`. + if (proc.status === 'killed') { + return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + } + return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts new file mode 100644 index 0000000000..9423fe36e6 --- /dev/null +++ b/packages/bash/tool-pwsh/src/index.ts @@ -0,0 +1,306 @@ +/** + * Model-facing `pwsh` tool over the `ctx.bash` executor seam. Intended for + * Windows compositions where a PowerShell executor (e.g. + * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is + * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. + * + * Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface: + * foreground and `run_in_background` execution (background handles register + * with the generic `ctx.tasks` runtime), the managed `DSH_*` environment + * through the shared `bash-env` registry, and the bash marker/truncation + * rendering story. UI presentation stays on the existing generic/terminal + * cards; a pwsh-specific rendering twin is roadmap work. + * + * @module @deepseek-ai/dsh-tool-pwsh + */ + +import { isAbsolute, resolve as resolvePath } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tasks' +import type {} from '@deepseek-ai/dsh-bash-env' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import { processOutcome } from './background.ts' +import { renderPwshProcessRead, renderPwshResult } from './render.ts' + +declare module '@deepseek-ai/dsh-tasks' { + interface TaskKindMap { + pwsh: 'pwsh' + } +} + +export const name = 'tool-pwsh' +export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv'] + +/** Configuration for the pwsh tool. */ +export interface Config { + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ + enableRunInBackground?: boolean +} + +/** Runtime configuration schema for the pwsh tool plugin. */ +export const Config: z = z.object({ + enableRunInBackground: z.boolean().default(true), +}) + +/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ +interface PwshToolArgs { + command: string + description: string + timeoutMs?: number + workdir?: string + run_in_background?: boolean +} + +/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */ +interface PwshForegroundResult { + kind: 'foreground' + exitCode: number | null + signal: NodeJS.Signals | null + timedOut: boolean + aborted: boolean + timeoutMs: number + stdout: { text: string; truncated: boolean; spillPath?: string } + stderr: { text: string; truncated: boolean; spillPath?: string } +} + +/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */ +function validatePwshArgs(args: PwshToolArgs): void { + if (args.command.trim().length === 0) { + throw new Error('invalid command: expected a non-empty string') + } + if (args.description.trim().length === 0) { + throw new Error('invalid description: expected a non-empty string') + } + if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { + throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) + } +} +/* jscpd:ignore-end */ + +function pwshDescription(backgroundEnabled: boolean): string { + const background = backgroundEnabled + ? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.' + : 'Background execution is not available; long-running commands must finish within the timeout.' + return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — ' + + 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment ' + + 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. ' + + 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. ' + + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. ' + + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + + background +} + +/** + * Resolve an explicit workdir first, making a relative one session-workspace-relative; + * otherwise use the session header cwd and leave executor defaulting as the fallback. + */ +function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined { + const headerCwd = exec.agent?.session.header.cwd + if (modelWorkdir === undefined) return headerCwd + if (headerCwd !== undefined && !isAbsolute(modelWorkdir)) { + return resolvePath(headerCwd, modelWorkdir) + } + return modelWorkdir +} + +/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */ +function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { + const output = (stream: BashRunResult['stdout']) => ({ + text: stream.text, + truncated: stream.truncated, + ...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {}, + }) + return { + kind: 'foreground', + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + aborted: result.aborted, + timeoutMs: result.timeoutMs, + /* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */ + stdout: output(result.stdout), + stderr: output(result.stderr), + } +} + +/** Canonical background-handle properties shared by the pwsh output union. */ +const BACKGROUND_OUTPUT_PROPERTIES = { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, +} as const +/* jscpd:ignore-end */ + +export function apply(ctx: Context, config: Config = {}): void { + const backgroundEnabled = config.enableRunInBackground ?? true + + ctx.systemPrompt.section({ + name: 'tool:pwsh', + order: 105, + text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. ' + + 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.', + }) + + ctx.tools.register(defineTool({ + name: 'pwsh', + description: pwshDescription(backgroundEnabled), + parameters: { + command: { type: 'string', required: true, description: 'The PowerShell command to execute.' }, + description: { + type: 'string', + required: true, + description: 'Clear, concise description of what this command does in active voice, ' + + '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; ' + + '"git status" → "Show working tree status"; "Get-Process" → "List running processes".', + }, + timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' }, + workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' }, + ...backgroundEnabled ? { + run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' }, + } : {}, + }, + output: { + // The foreground result wire shape mirrors dsh-tool-bash's by contract — + // consumers of one must accept the other (see the pwsh-tool-and-executor + // Agent Note). + /* jscpd:ignore-start -- deliberate result-schema symmetry with dsh-tool-bash. */ + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: BACKGROUND_OUTPUT_PROPERTIES, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, + aborted: { type: 'boolean', required: true }, + timeoutMs: { type: 'number', required: true }, + stdout: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + stderr: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + }, + }, + ], + }, + /* jscpd:ignore-end */ + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderPwshResult(value), + }], + }, + /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ + async execute(args: PwshToolArgs, exec) { + validatePwshArgs(args) + const workdir = resolveWorkdir(args.workdir, exec) + const request = { + command: args.command, + ...workdir !== undefined ? { workdir } : {}, + ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, + dshEnv: ctx.bashEnv.collect(exec), + } + if (args.run_in_background === true) { + // Undeclared keys are allowed, so schema omission also needs enforcement. + if (!backgroundEnabled) { + throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)') + } + const tasks = ctx.get('tasks') + if (tasks === undefined) { + throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + } + // The caller owns cancellation until ctx.tasks commits detached ownership. + /* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort; + pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts + already-aborted signals first, so this mirror-only guard has no reachable trigger. */ + if (exec.signal.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } + /* v8 ignore end */ + // Task preflight finishes before the starter can spawn a process. + const id = tasks.start({ + kind: 'pwsh', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + run: () => { + const proc = ctx.bash.start(ctx.bash.resolve(request)) + return { + cancel: () => void proc.kill(), + done: proc.done.then(() => processOutcome(proc)), + readOutput: () => renderPwshProcessRead(proc.readOutput()), + } + }, + }) + return { kind: 'background' as const, taskId: id } + } + const result = await ctx.bash.run(ctx.bash.resolve({ + ...request, + signal: exec.signal, + })) + if (result.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } + return canonicalPwshResult(result) + }, + /* jscpd:ignore-end */ + /* jscpd:ignore-start -- the background call card mirrors presentBashCall's by design (Agent Note). */ + presentCall: (args: PwshToolArgs): TerminalCallView | GenericCallView => { + // Background acknowledgements carry no terminal exit status; the generic + // card mirrors the bash tool's background presentation. + if (args.run_in_background === true) { + return { + card: 'generic', + title: args.command, + kind: 'execute', + rawInput: args.command, + content: [{ type: 'text', text: args.description }], + } + } + return { + card: 'terminal', + title: args.command, + description: args.description, + ...args.workdir !== undefined ? { cwd: args.workdir } : {}, + } + }, + /* jscpd:ignore-end */ + presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => { + const block = result.content.length === 1 ? result.content[0] : undefined + if (block === undefined || block.type !== 'text') return undefined + return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }] } + }, + })) +} diff --git a/packages/bash/tool-pwsh/src/invariant.ts b/packages/bash/tool-pwsh/src/invariant.ts new file mode 100644 index 0000000000..dd6370b490 --- /dev/null +++ b/packages/bash/tool-pwsh/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`. + * @module @deepseek-ai/dsh-tool-pwsh/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh' + +/** Cordis companion plugin name. */ +export const name = 'tool-pwsh-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/src/render.ts b/packages/bash/tool-pwsh/src/render.ts new file mode 100644 index 0000000000..42f4bc696c --- /dev/null +++ b/packages/bash/tool-pwsh/src/render.ts @@ -0,0 +1,81 @@ +/** + * Model-facing result rendering for the pwsh tool — the PowerShell twin of + * `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked + * stderr section, truncation notices with spill paths, then exit-status + * markers. Non-zero exits are reported, not errored — the model decides how to + * react; only infrastructure failures (spawn errors, aborts) surface as + * isError results. + * + * @module @deepseek-ai/dsh-tool-pwsh/render + */ + +import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */ + +/** Append the truncation notice (with the full-output spill path) to a stream's text. */ +function streamText(output: CollectedOutput): string { + if (!output.truncated) return output.text + return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` +} + +/** The renderable foreground result shape (the schema-derived value, no `kind`). */ +export interface RenderablePwshResult { + exitCode: number | null + signal: string | null + timedOut: boolean + timeoutMs: number + stdout: CollectedOutput + stderr: CollectedOutput +} + +/** + * Shape one finished run into the text the model sees: stdout, then a marked + * stderr section, then exit-status markers, matching the bash tool's story — + * a clean exit (0, no signal) produces no marker. + * @param result - the completed foreground run from the executor. + * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. + */ +export function renderPwshResult(result: RenderablePwshResult): string { + const out = streamText(result.stdout) + const err = streamText(result.stderr) + + let body = out + if (err.length > 0) { + // Single newline between sections (stdout usually ends with one already). + if (body.length > 0 && !body.endsWith('\n')) body += '\n' + body += `[stderr]\n${err}` + } + if (body.length === 0) body = '(no output)' + + const markers: string[] = [] + // A command may trap the termination and exit 0 after timeout; still report interruption. + if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) + if (result.signal !== null) { + markers.push(`[killed by signal: ${result.signal}]`) + } else if (result.exitCode !== 0) { + markers.push(`[exit code: ${result.exitCode}]`) + } + if (markers.length === 0) return body + + if (!body.endsWith('\n')) body += '\n' + return body + markers.join('\n') +} + +/** + * Shape one background-process read into the `task_output` delta the model + * sees: the incremental delta, plus the lossy-read notice (with full-stream + * spill paths) when in-memory truncation dropped unread bytes. + * @param read - one incremental read from the process handle. + * @returns the delta text with any loss notice appended. + */ +export function renderPwshProcessRead(read: BashProcessRead): string { + const notices: string[] = [] + if (read.lossy) { + const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) + notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) + } + if (notices.length === 0) return read.delta + return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/tests/integration.spec.ts b/packages/bash/tool-pwsh/tests/integration.spec.ts new file mode 100644 index 0000000000..c347866f50 --- /dev/null +++ b/packages/bash/tool-pwsh/tests/integration.spec.ts @@ -0,0 +1,154 @@ +/** + * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the + * `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell + * process. These verify the world — actual commands run, stdout/stderr come + * back, exit codes render, timeouts abort, background tasks settle through the + * generic task runtime, and per-session cwd resolution works. The suite + * self-skips when no usable `pwsh` resolves (a CI accommodation for hosts without + * PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage + * gate. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { PwshLocalExecutor, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' + +const testToolSignal = new AbortController().signal + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + +let dir: string +let ctx: Context + +let callCounter = 0 +function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) { + return ctx.tools.execute({ + signal: signal ?? testToolSignal, + callId: CallId(`it-${++callCounter}`), + name, + arguments: args, + ...agentObj ? { agent: agentObj as never } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-pwsh-')) + await writeFile(join(dir, 'greeting.txt'), 'hello pwsh\n') + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 }) + await ctx.plugin(ToolPwsh) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } }) + + it('runs a command and returns stdout with no marker on a clean exit', async () => { + const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent()) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pwsh success') + expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 }) + expect(lf(text(result))).toBe('hi\n') + }) + + it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => { + const result = await call('pwsh', { + command: '[Console]::Error.WriteLine("boom"); exit 3', + description: 'fail loudly', + }, agent()) + expect(result.isError).toBe(false) + expect(lf(text(result))).toBe('[stderr]\nboom\n[exit code: 3]') + }) + + it('resolves relative paths in the session workspace', async () => { + const result = await call('pwsh', { + command: 'Get-Content greeting.txt', + description: 'read greeting', + }, agent()) + expect(result.isError).toBe(false) + expect(lf(text(result))).toBe('hello pwsh\n') + }) + + it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => { + const result = await call('pwsh', { + command: 'Start-Sleep -Seconds 60', + description: 'sleep forever', + timeoutMs: 100, + }, agent()) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected a timed-out foreground result') + expect(result.value).toMatchObject({ kind: 'foreground', timedOut: true, aborted: false }) + // Windows reports the forced termination as exit 1 without a signal; + // POSIX reports SIGTERM — the timeout marker is the stable fact. + expect(lf(text(result))).toContain('[timed out after 100ms]') + }) + + it('an upstream cancellation aborts the run', async () => { + const controller = new AbortController() + const pending = call('pwsh', { + command: 'Start-Sleep -Seconds 60', + description: 'sleep forever', + }, agent(), controller.signal) + setTimeout(() => { controller.abort() }, 50) + const result = await pending + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) + }) + + it('a background run settles through the REAL task_output tool', async () => { + const started = await call('pwsh', { + command: 'Start-Sleep -Milliseconds 300; Write-Output bg-done', + description: 'background greeting', + run_in_background: true, + }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toMatchObject({ kind: 'background' }) + const taskId = (started.value as { taskId: string }).taskId + + // The output delta and the terminal status can land in separate reads + // (Windows flushes the child pipe at exit), so collect incrementally — + // the same two-step shape as the bash background suite. + const deadline = Date.now() + 10_000 + let output = '' + while (Date.now() < deadline) { + const read = await call('task_output', { task_id: taskId }) + output += text(read) + if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break + await new Promise(resolve => setTimeout(resolve, 50)) + } + expect(output).toContain('bg-done') + expect(output).toContain('[status: completed, exit code: 0]') + }) +}) diff --git a/packages/bash/tool-pwsh/tests/loader.spec.ts b/packages/bash/tool-pwsh/tests/loader.spec.ts new file mode 100644 index 0000000000..7037162579 --- /dev/null +++ b/packages/bash/tool-pwsh/tests/loader.spec.ts @@ -0,0 +1,63 @@ +/** + * REAL-composition tier (packages/AGENTS.md): boot the examples-owned + * tool-pwsh Loader fixture as a subprocess through the same app/boot path a + * deployment uses, execute real foreground and background pwsh commands + * through the tool registry, and assert the assembled model-visible surface: + * schema, prompt section, and rendered results. Self-skips when no `pwsh` + * executable exists (a CI accommodation for hosts without PowerShell). + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +const driver = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +interface PwshLoaderReport { + schemaHasRunInBackground: boolean + promptHasMarkerSection: boolean + foregroundText: string + backgroundText: string +} + +describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => { + it('registers the pwsh surface and renders real foreground and background results', async () => { + let report: PwshLoaderReport | undefined + const { stderr } = await runLoaderSmoke({ + label: 'tool-pwsh loader smoke', + tempDirPrefix: 'tool-pwsh-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + report = JSON.parse(await readFile(join(cwd, 'pwsh-loader-report.json'), 'utf8')) as PwshLoaderReport + }, + }) + expect(stderr).not.toContain('UNHANDLED') + expect(report).toBeDefined() + expect(report).toMatchObject({ + schemaHasRunInBackground: true, + promptHasMarkerSection: true, + }) + expect(report?.foregroundText).toBe('loader-ok\n') + expect(report?.backgroundText).toContain('loader-bg-ok') + expect(report?.backgroundText).toContain('[status: completed, exit code: 0]') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts new file mode 100644 index 0000000000..218099326f --- /dev/null +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -0,0 +1,637 @@ +/** + * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor, + * exercised through `ctx.tools.execute()` so nothing bypasses the tool + * registry. The fake executor makes every seam outcome scriptable — output + * text, truncation, timeout, abort, nonzero exits, background handles — so + * these tests verify the schema, argument validation, workdir derivation, + * managed `DSH_*` collection, abort translation, canonical result projection, + * rendering, background task wiring, and the UI presenters. Real-pwsh behavior + * is pinned separately in integration.spec.ts. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve as resolvePath } from 'node:path' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' +import type { BashProcessRead } from '@deepseek-ai/dsh-bash' +import { processOutcome } from '../src/background.ts' +import { renderPwshProcessRead } from '../src/render.ts' + +const testToolSignal = new AbortController().signal + +/** + * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()` + * returns the armed foreground script, `start()` returns the armed background + * handle. + */ +class FakeBash extends BashExecutor { + requests: BashExecRequest[] = [] + specs: BashExecSpec[] = [] + startCalls = 0 + handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') + backgroundHandler: (spec: BashExecSpec) => BashProcess = () => fakeProcess('bg-ok\n') + + override resolve(request: BashExecRequest): BashExecSpec { + this.requests.push(request) + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(spec: BashExecSpec): Promise { + this.specs.push(spec) + return this.handler(spec) + } + + override start(spec: BashExecSpec): BashProcess { + this.startCalls++ + this.specs.push(spec) + return this.backgroundHandler(spec) + } +} + +/** A successful run result over the given stdout; overrides script the failure shapes. */ +function runResult(stdout: string, overrides?: Partial): BashRunResult { + return { + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: stdout, truncated: false }, + stderr: { text: '', truncated: false }, + ...overrides, + } +} + +/** A settled successful background handle; overrides script failure shapes. */ +function fakeProcess(delta = 'bg-ok\n'): BashProcess { + let consumed = false + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => { + if (consumed) return { delta: '', lossy: false } + consumed = true + return { delta, lossy: false } + }, + kill: () => false, + } +} + +/** A running background handle whose kill() settles it as killed (like a real task_kill). */ +function killableProcess(): BashProcess { + let resolveDone: () => void = () => {} + const done = new Promise((resolve) => { resolveDone = resolve }) + const proc: BashProcess = { + status: 'running', + exitCode: null, + signal: null, + done, + readOutput: () => ({ delta: '', lossy: false }), + kill: () => { + if (proc.status !== 'running') return false + proc.status = 'killed' + proc.signal = 'SIGTERM' + resolveDone() + return true + }, + } + return proc +} + +async function setup(toolConfig: Partial = {}, dshHome?: string) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as FakeBash + return { ctx, bash } +} + +/** Full harness: the generic task runtime + its control surface, then the pwsh tool. */ +async function setupWithTasks(toolConfig: Partial = {}, dshHome?: string) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as FakeBash + return { ctx, bash } +} + +/** + * Build a fake {@link Agent} with the shared agent/session identity, give it a + * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. + */ +function registerFakeAgent(ctx: Context, sessionId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) + const agent = { + id, + ctx: scopeFiber.ctx, + session: { id, header: { version: 0, id, createdAt: 0 } }, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} + +let callCounter = 0 +function call(ctx: Context, name: string, args: unknown, agent?: Agent) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + ...agent ? { agent } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +async function callUntilText( + ctx: Context, + name: string, + args: unknown, + expected: string, + timeoutMs = 5_000, +): Promise>> { + const deadline = Date.now() + timeoutMs + let last: Awaited> | undefined + while (Date.now() < deadline) { + last = await call(ctx, name, args) + if (text(last).includes(expected)) return last + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`) +} + +describe('registration', () => { + it('registers the pwsh tool with its prompt section and schema', async () => { + const { ctx } = await setup() + const schema = ctx.tools.schemas().find(s => s.name === 'pwsh') + expect(schema).toBeDefined() + expect(schema?.description).toContain('PowerShell command') + expect(schema?.parameters.properties).toMatchObject({ + command: { type: 'string' }, + description: { type: 'string' }, + timeoutMs: { type: 'number' }, + workdir: { type: 'string' }, + run_in_background: { type: 'boolean' }, + }) + expect(schema?.parameters.required).toEqual(['command', 'description']) + const prompt = renderPrompt(await ctx.systemPrompt.assemble()) + expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers') + expect(prompt).toContain('without a signal marker') + }) + + it('stays pending until ctx.bash exists (inject)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolPwsh) + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('unregisters everything on fiber disposal (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + const fiber = await ctx.plugin(ToolPwsh) + expect(ctx.tools.schemas()).toHaveLength(1) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) + +describe('argument validation', () => { + it('rejects a blank command or description and a non-positive timeoutMs', async () => { + const { ctx } = await setup() + expect(text(await call(ctx, 'pwsh', { command: ' ', description: 'd' }))).toContain('expected a non-empty string') + expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string') + expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 }))) + .toContain('invalid timeoutMs: expected a positive number') + }) +}) + +describe('execution through the bash seam', () => { + it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => { + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-')) + const { ctx, bash } = await setup({}, dshHome) + bash.handler = () => runResult('hi\n') + const agent = registerFakeAgent(ctx, 'session-1') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) + const result = await call(ctx, 'pwsh', { + command: 'Write-Output hi', + description: 'say hi', + timeoutMs: 1234, + }, agent) + expect(result.isError).toBe(false) + const request = bash.requests[0] + expect(request?.command).toBe('Write-Output hi') + expect(request?.workdir).toBe('/sessions/s1') + expect(request?.timeoutMs).toBe(1234) + expect(request?.dshEnv).toEqual({ + DSH_HOME: dshHome, + DSH_SHELL: '1', + DSH_SESSION_ID: 'session-1', + }) + expect(bash.specs[0]?.workdir).toBe('/sessions/s1') + }) + + it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('ok\n') + const agent = registerFakeAgent(ctx, 'session-cwd') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent) + expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir')) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent) + expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path')) + }) + + it('omits workdir and the session id without an agent, so executor defaulting applies', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('ok\n') + await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }) + expect(bash.requests[0]).not.toHaveProperty('workdir') + const dshEnv = bash.requests[0]?.dshEnv + expect(dshEnv).toBeDefined() + expect(dshEnv?.['DSH_SHELL']).toBe('1') + expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String)) + expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID') + }) + + it('forwards exec.signal into the resolved request', async () => { + const { ctx, bash } = await setup() + const controller = new AbortController() + bash.handler = () => runResult('ok\n') + await ctx.tools.execute({ + signal: controller.signal, + callId: CallId('call-signal'), + name: 'pwsh', + arguments: { command: 'Write-Output ok', description: 'ok' }, + }) + expect(bash.requests[0]?.signal).toBe(controller.signal) + }) + + it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('out\n', { + exitCode: 2, + stderr: { text: 'err\n', truncated: false }, + timeoutMs: 5000, + }) + const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' }) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pwsh success') + expect(result.value).toEqual({ + kind: 'foreground', + exitCode: 2, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 5000, + stdout: { text: 'out\n', truncated: false }, + stderr: { text: 'err\n', truncated: false }, + }) + expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]') + }) + + it('renders a clean exit without a marker and an empty body as (no output)', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('hi\n') + const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(text(clean)).toBe('hi\n') + + bash.handler = () => runResult('') + const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' }) + expect(text(empty)).toBe('(no output)') + }) + + it('renders stderr-only output without a stdout prefix', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]') + }) + + it('inserts the separating newline before the stderr section when stdout lacks one', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('out', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]') + }) + + it('renders the truncation notice with the spill path, then markers', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('tail', { + stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' }, + stderr: { text: '', truncated: false }, + }) + const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) + expect(text(result)).toBe('tail\n[output truncated; full output: /spill/out.log]') + + bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 }) + const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' }) + // A timeout kill carries both facts, mirroring the bash tool's markers. + expect(text(timedOut)).toBe('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]') + }) + + it('renders the truncation notice with (unavailable) when no spill path exists', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('tail', { + stdout: { text: 'tail', truncated: true }, + stderr: { text: '', truncated: false }, + }) + const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) + expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]') + }) + + it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }) + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) + }) +}) + +describe('background execution through the task runtime', () => { + it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { + const { ctx } = await setupWithTasks() + const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toEqual({ kind: 'background', taskId: 'pwsh-1' }) + expect(text(started)).toBe('started background task pwsh-1') + + const read = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, 'bg-ok') + expect(text(read)).toContain('bg-ok') + // A later read reports the terminal outcome in the generic status line. + const final = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, '[status: completed, exit code: 0]') + expect(final.isError).toBe(false) + }) + + it('a running background task is killable through the REAL task_kill tool', async () => { + const { ctx, bash } = await setupWithTasks() + bash.backgroundHandler = () => killableProcess() + await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }) + expect(text(killed)).toBe('requested cancellation of task pwsh-1') + // The cancel reached the process handle; the task settles as killed with + // the signal detail mapped by processOutcome. + const final = await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }) + expect(text(final)).toContain('[status: killed, signal: SIGTERM]') + }) + + it('a background task started by an agent is registered with that agent as owner', async () => { + const { ctx } = await setupWithTasks() + const agent = registerFakeAgent(ctx, 'sess-owner') + const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent) + expect(text(started)).toBe('started background task pwsh-1') + + const anon = await call(ctx, 'task_output', { task_id: 'pwsh-1' }) + expect(anon.isError).toBe(true) + expect(text(anon)).toMatch(/belongs to another session/) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }, agent) + expect(killed.isError).toBe(false) + await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan + }) + + it('fails loud when the task runtime is not loaded', async () => { + const { ctx } = await setup() // no LocalTaskService / ToolTasks + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + }) + + it('a pre-aborted call is skipped before the process starts', async () => { + const { ctx, bash } = await setupWithTasks() + const controller = new AbortController() + controller.abort() + const result = await ctx.tools.execute({ + callId: CallId('call-pre-aborted'), + name: 'pwsh', + arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, + signal: controller.signal, + }) + expect(result.isError).toBe(true) + expect(result.error).toEqual({ + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(bash.startCalls).toBe(0) + }) + + it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => { + // With no control surface, task preflight fails before the executor can spawn. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh) + const bash = ctx.bash as FakeBash + + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no control surface is attached') + // Declare-then-execute: the failed preflight means no process ever ran. + expect(bash.startCalls).toBe(0) + }) + + it('enableRunInBackground: false removes the parameter and flips the description', async () => { + const { ctx } = await setup({ enableRunInBackground: false }) + const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')! + expect(Object.keys(schema.parameters.properties as Record)) + .toEqual(['command', 'description', 'timeoutMs', 'workdir']) + expect(schema.description).toContain('Background execution is not available') + expect(schema.description).not.toContain('run_in_background') + + // Schema omission is advertising; execution must also enforce the opt-out. + const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true }) + expect(forced.isError).toBe(true) + expect(text(forced)).toContain('run_in_background is disabled for this deployment') + const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' }) + expect(foreground.isError).toBe(false) + }) + + it('applies the built-in background default when apply() receives a bare config', async () => { + // Bypasses the schemastery defaults on purpose: apply() must stand on its + // own `?? true` fallback when embedded programmatically without the schema. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + ToolPwsh.apply(ctx, {}) + const schema = ctx.tools.schemas()[0]! + expect(schema.parameters.properties).toHaveProperty('run_in_background') + expect(schema.description).toContain('task_output') + }) +}) + +describe('UI presentation', () => { + it('a real execute renders the console view through the tool definition presenter', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('hi\n') + const args = { command: 'Write-Output hi', description: 'say hi' } + const result = await call(ctx, 'pwsh', args) + const view = ctx.tools.get('pwsh')?.presentResult?.(args, result) + expect(view).toEqual({ + card: 'generic', + content: [{ type: 'text', text: '```console\nhi\n```' }], + }) + }) + + it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' })) + .toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' }) + expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' })) + .toMatchObject({ cwd: 'C:\\work' }) + }) + + it('a background pending call renders the generic card like the bash tool', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + expect(definition?.presentCall?.({ + command: 'Start-Sleep -Seconds 60', + description: 'long wait', + run_in_background: true, + })).toEqual({ + card: 'generic', + title: 'Start-Sleep -Seconds 60', + kind: 'execute', + rawInput: 'Start-Sleep -Seconds 60', + content: [{ type: 'text', text: 'long wait' }], + }) + }) + + it('presentResult falls back to undefined for multi-block or non-text content', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + const args = { command: 'Write-Output hi', description: 'say hi' } + const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false } + expect(definition?.presentResult?.(args, multi as never)).toBeUndefined() + const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false } + expect(definition?.presentResult?.(args, image as never)).toBeUndefined() + }) +}) + +describe('renderPwshProcessRead', () => { + const base: BashProcessRead = { delta: 'out\n', lossy: false } + + it('returns the delta verbatim for a lossless read', () => { + expect(renderPwshProcessRead(base)).toBe('out\n') + expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('') + }) + + it('appends the loss notice with the available spill paths', () => { + expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]') + expect(renderPwshProcessRead({ + ...base, + lossy: true, + stdoutSpillPath: 'C:\\spill\\out.log', + stderrSpillPath: 'C:\\spill\\err.log', + })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]') + }) + + it('reports (unavailable) when a lossy read has no safe spill path', () => { + expect(renderPwshProcessRead({ ...base, lossy: true })) + .toBe('out\n[some output was dropped from memory; full output: (unavailable)]') + }) + + it('an empty lossy delta is the notice alone', () => { + expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' })) + .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]') + }) + + it('inserts the separating newline only when the delta lacks one', () => { + expect(renderPwshProcessRead({ delta: 'tail', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + }) +}) + +describe('processOutcome', () => { + function settled(over: Partial): BashProcess { + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => ({ delta: '', lossy: false }), + kill: () => false, + ...over, + } + } + + it('maps a signal-killed process to killed with the signal detail', () => { + expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' }))) + .toEqual({ status: 'killed', detail: 'signal: SIGTERM' }) + }) + + it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => { + expect(processOutcome(settled({ status: 'killed', exitCode: null }))) + .toEqual({ status: 'killed', detail: 'killed before exit' }) + }) + + it('maps a completed process to its exit code', () => { + expect(processOutcome(settled({ exitCode: 3 }))) + .toEqual({ status: 'completed', detail: 'exit code: 3' }) + }) + + it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => { + expect(processOutcome(settled({ exitCode: null }))) + .toEqual({ status: 'completed', detail: 'exit code: 0' }) + }) +}) diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json new file mode 100644 index 0000000000..61b2c69448 --- /dev/null +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../bash/bash-env" + }, + { + "path": "../../tasks/tasks" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index a636d8bc49..a09f0fa0cd 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 -README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca +README.md: faf093964a740092983e13bf88f2cccd853c3e36 +README.zh.md: b06ab245dedbde13957aa416be044ef107b2753c diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f537fee327..faf093964a 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,11 +2,15 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The real browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the fixture and in-process carriers continue to satisfy the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). + +## `/api` WebSocket downlinks + +`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier. ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index a29d2c00e7..b06ab245de 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,11 +2,15 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。真实浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;fixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 + +## `/api` WebSocket 下行 + +`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。 ## 无密钥 fixture diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index d86b2bdf2a..b14dfc80e3 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-connection", - "description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)", + "description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api", "version": "0.0.1", "private": true, "type": "module", @@ -34,7 +34,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "ws": "^8.21.0" }, "files": [ "lib/index.js", @@ -52,6 +53,7 @@ "devDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/ws": "^8.18.1", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/client/connection/src/api-path.ts b/packages/client/connection/src/api-path.ts index 30e91522a2..f34aa231d4 100644 --- a/packages/client/connection/src/api-path.ts +++ b/packages/client/connection/src/api-path.ts @@ -1,8 +1,14 @@ /** * The /api URL prefix — single source for both halves of the web transport. - * The node half registers this prefix on the web server; browser-side path - * literals currently live in the apiproxy client layer (out of scope here). + * The node half registers this prefix on the web server; both halves share the + * event paths below for the browser WebSocket downlinks. */ /** Route prefix owning every api request (`/api` and `/api/`). */ export const API_PATH = '/api' + +/** Browser mux-frame WebSocket pathname. */ +export const MUX_EVENTS_PATH = `${API_PATH}/events.mux` + +/** Browser host-frame WebSocket pathname. */ +export const HOST_EVENTS_PATH = `${API_PATH}/events.host` diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index ecb180dca7..4e897ccf87 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -4,7 +4,7 @@ * the attacker's domain while the socket reaches this server) and cross-site * requests fired from a malicious page. The Host fence binds every request, * browser-looking or not: over plain HTTP a browser attaches neither Origin - * nor Fetch-Metadata to reads (EventSource, images, navigations — those + * nor Fetch-Metadata to reads (images and navigations — those * headers go only to trustworthy destinations), so an unmarked request may * still be a rebound browser read and Host is the one header rebinding cannot * forge. Non-browser and remote clients pass the same fence via loopback, the @@ -97,7 +97,7 @@ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: read // fills Host from the URL it believes it is talking to, so a rebound page // carries the attacker's domain here even though the socket lands on this // server. There is no marker shortcut — a browser read over plain HTTP - // (EventSource, images, navigations) arrives with neither Origin nor + // (images and navigations) arrives with neither Origin nor // Fetch-Metadata, indistinguishable from curl, and its response is readable // by the rebound page. const host = header(request.headers, 'host') diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 6eb6491e2f..18d22f878d 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -126,7 +126,7 @@ export class ConnectionController { try { // Strict readiness handshake (audit C2): describe proves unary reachability, onOpen - // proves each SSE transport is established (response headers in, before any frame) — + // proves each physical stream is established before any frame — // only then may onConnected fire, so the resync it triggers cannot outrun the // subscribed baseline. The timeout guards against a carrier that never fires onOpen // (see ConnectionConfig.streamOpenTimeoutMs). diff --git a/packages/client/connection/src/client/web-api-client.ts b/packages/client/connection/src/client/web-api-client.ts index 9ae6eeae7d..a2c2d95b7b 100644 --- a/packages/client/connection/src/client/web-api-client.ts +++ b/packages/client/connection/src/client/web-api-client.ts @@ -1,12 +1,91 @@ -// WebApiClient: the browser platform subclass — transport = global fetch over same-origin -// /api/* (base resolution handled by AbstractApiClient). Envelope observation comes from the -// base batching aspect; subscribers attach via subscribeEnvelopes (see boot). +/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */ +import type { ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest } from './api.ts' import { AbstractApiClient } from './api.ts' +import { hostFrameSchema, muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema' +import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema' +import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../api-path.ts' -/** Browser platform subclass: transport = global fetch over same-origin /api/*. */ +type SocketItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } +type Parser = { parse(value: unknown): F } + +/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */ export class WebApiClient extends AbstractApiClient { protected doFetch(input: URL, init?: RequestInit): Promise { return globalThis.fetch(input, init) } + + protected override openMux( + _payload: Parameters[0]['payload'], + signal: AbortSignal, + onOpen?: () => void, + ): AsyncIterable> { + return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen) + } + + protected override openHost( + _payload: Parameters[0]['payload'], + signal: AbortSignal, + onOpen?: () => void, + ): AsyncIterable> { + return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen) + } + + private async *readWebSocket( + path: string, + signal: AbortSignal, + frameSchema: Parser, + onOpen?: () => void, + ): AsyncGenerator> { + const url = new URL(path, this.resolveBase()) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + const socket = new WebSocket(url) + const inbox: SocketItem[] = [] + let wake: (() => void) | undefined + const enqueue = (item: SocketItem): void => { + inbox.push(item) + wake?.() + wake = undefined + } + const handleOpen = (): void => { onOpen?.() } + const handleMessage = (event: MessageEvent): void => { + let full: ServerRequest + let frame: F + try { + if (typeof event.data !== 'string') throw new Error('binary WebSocket frame') + full = serverRequestSchema.parse(JSON.parse(event.data)) + frame = frameSchema.parse(full.payload) + } catch (error) { + console.error(`[client-connection] dropping malformed WebSocket frame on ${path}:`, error) + return + } + this.onEnvelope(full) + enqueue({ kind: 'frame', envelope: { rpcId: full.rpcId, payload: frame } }) + } + const handleClose = (): void => { enqueue({ kind: 'end' }) } + const handleAbort = (): void => { + if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close() + } + socket.addEventListener('open', handleOpen) + socket.addEventListener('message', handleMessage) + socket.addEventListener('close', handleClose, { once: true }) + signal.addEventListener('abort', handleAbort, { once: true }) + if (signal.aborted) handleAbort() + try { + while (true) { + while (inbox.length > 0) { + const item = inbox.shift() as SocketItem + if (item.kind === 'end') return + yield item.envelope + } + await new Promise((resolve) => { wake = resolve }) + } + } finally { + signal.removeEventListener('abort', handleAbort) + socket.removeEventListener('open', handleOpen) + socket.removeEventListener('message', handleMessage) + socket.removeEventListener('close', handleClose) + handleAbort() + } + } } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index ed4af2d21f..d3107ed037 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -2,13 +2,14 @@ import type { Context } from 'cordis' import z from 'schemastery' // Activates the httpServer Context merge used below. -import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { API_PATH } from './api-path.ts' +import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' +import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' -export { API_PATH } from './api-path.ts' +export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' @@ -76,6 +77,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) + const downlinks = new WebSocketDownlinks(ctx.apiProxy) const route: WebRoute = { kind: 'prefix', path: API_PATH, @@ -92,8 +94,31 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { res.end('forbidden') return } + if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { + res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) + res.end('upgrade required') + return + } await bridge(req, res, apiHandler) }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') + const registerDownlink = ( + path: string, + handle: WebUpgradeRoute['handler'], + ): void => { + ctx.effect(() => ctx.httpServer.registerUpgrade({ + path, + handler: (req, socket, head) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + rejectWebSocketUpgrade(socket) + return + } + return handle(req, socket, head) + }, + }), `client-connection: ${path} WebSocket`) + } + ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') + registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) + registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) } diff --git a/packages/client/connection/src/websocket-downlink.ts b/packages/client/connection/src/websocket-downlink.ts new file mode 100644 index 0000000000..72ae5e94ef --- /dev/null +++ b/packages/client/connection/src/websocket-downlink.ts @@ -0,0 +1,153 @@ +/** Host-side WebSocket carrier for the two server-to-browser event streams. */ + +import { randomUUID } from 'node:crypto' +import type { IncomingMessage } from 'node:http' +import type { Duplex } from 'node:stream' +import WebSocket, { WebSocketServer } from 'ws' +import type { + ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' + +type Frame = MuxFrame | HostFrame + +function serverRequest(frame: RpcRequest): ServerRequest { + return { + type: 'server-request', + rpcId: frame.rpcId, + method: frame.payload.type, + payload: frame.payload, + } +} + +function send(socket: WebSocket, frame: RpcRequest): Promise { + return new Promise((resolve, reject) => { + if (socket.readyState !== WebSocket.OPEN) { + reject(new Error('websocket downlink closed before frame delivery')) + return + } + socket.send(JSON.stringify(serverRequest(frame)), (error) => { + if (error) reject(error) + else resolve() + }) + }) +} + +function failureFrame(error: unknown): RpcRequest { + return { + rpcId: RpcId(randomUUID()), + payload: { + type: 'stream/error', + error: { code: 'internal', message: String(error), details: {} }, + }, + } +} + +/** + * Owns WebSocket negotiation and frame pumping for the connection plugin's + * two downlinks. Client messages are a protocol violation: upstream traffic + * remains on HTTP. + */ +export class WebSocketDownlinks { + private readonly server = new WebSocketServer({ noServer: true }) + private readonly pumps = new Set>() + + /** @param api - host API supplying the typed event streams. */ + constructor(private readonly api: ApiProxy) {} + + /** + * Upgrade one socket and pump the mux stream until either side closes. + * @param req - HTTP upgrade request. + * @param socket - Raw socket transferred by the HTTP server. + * @param head - Bytes already read after the upgrade headers. + */ + handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void { + this.upgrade(req, socket, head, signal => this.api.events.mux({ + rpcId: RpcId(randomUUID()), + payload: {}, + }, signal)) + } + + /** + * Upgrade one socket and pump the host stream until either side closes. + * @param req - HTTP upgrade request. + * @param socket - Raw socket transferred by the HTTP server. + * @param head - Bytes already read after the upgrade headers. + */ + handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void { + this.upgrade(req, socket, head, signal => this.api.events.host({ + rpcId: RpcId(randomUUID()), + payload: {}, + }, signal)) + } + + /** + * Terminate owned sockets and await the no-server acceptor plus frame pumps. + * @returns A promise resolving after every socket and source iterator stops. + */ + async close(): Promise { + for (const socket of this.server.clients) socket.terminate() + await new Promise((resolve, reject) => { + this.server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + await Promise.all(this.pumps) + } + + private upgrade( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + open: (signal: AbortSignal) => AsyncIterable>, + ): void { + this.server.handleUpgrade(req, socket, head, (websocket) => { + const abort = new AbortController() + websocket.once('close', () => { abort.abort() }) + websocket.once('error', () => { abort.abort() }) + websocket.once('message', () => { + websocket.close(1008, 'downlink only') + }) + const pump = this.pump(websocket, open(abort.signal), abort) + this.pumps.add(pump) + void pump.then(() => { this.pumps.delete(pump) }) + }) + } + + private async pump( + socket: WebSocket, + frames: AsyncIterable>, + abort: AbortController, + ): Promise { + try { + for await (const frame of frames) await send(socket, frame) + } catch (error) { + if (!abort.signal.aborted) { + try { + await send(socket, failureFrame(error)) + } catch { + // Socket loss won the race; no downstream remains to receive the failure frame. + } + } + } finally { + abort.abort() + if (socket.readyState === WebSocket.OPEN) socket.close() + } + } +} + +/** + * Reject an untrusted upgrade before protocol negotiation. + * @param socket - Raw HTTP socket that remains owned by the caller. + */ +export function rejectWebSocketUpgrade(socket: Duplex): void { + socket.end([ + 'HTTP/1.1 403 Forbidden', + 'Connection: close', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Length: 9', + '', + 'forbidden', + ].join('\r\n')) +} diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 43c71dffb7..524983fb4f 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -3,15 +3,55 @@ * selection off the page URL, and the single-consumer stream-loop ownership. */ import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { apply, type ConnectionHandle } from '../src/client/index.ts' +import type { RpcMessage } from '../src/client/api.ts' +import { RpcId } from '../src/client/api.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { hostname: string; search: string } } +type Win = { location?: { hostname: string; search: string; origin?: string } } +type WebSocketGlobal = { WebSocket?: typeof WebSocket } + +const originalWebSocket = globalThis.WebSocket +const sockets: FakeWebSocket[] = [] + +class FakeWebSocket extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + + readonly url: string + readyState = FakeWebSocket.CONNECTING + + constructor(url: string | URL) { + super() + this.url = String(url) + sockets.push(this) + queueMicrotask(() => { + if (this.readyState !== FakeWebSocket.CONNECTING) return + this.readyState = FakeWebSocket.OPEN + this.dispatchEvent(new Event('open')) + }) + } + + close(): void { + if (this.readyState === FakeWebSocket.CLOSED) return + this.readyState = FakeWebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + receive(data: unknown): void { + this.dispatchEvent(new MessageEvent('message', { data })) + } +} afterEach(() => { delete (globalThis as Win).location + sockets.length = 0 + if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket + else globalThis.WebSocket = originalWebSocket }) async function mount(): Promise { @@ -53,7 +93,7 @@ describe('connection client apply', () => { loop.stop() // teardown must not throw; the fixture streams abort quietly }) - it('WebApiClient carries requests over globalThis.fetch', async () => { + it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch @@ -65,9 +105,102 @@ describe('connection client apply', () => { try { // Schema rejection is fine — the transport hop is the assertion. await (handle.api as WebApiClient).host.describe({}).catch(() => undefined) + await handle.api.respond({ + type: 'client-response', + rpcId: RpcId('response-over-http'), + result: { ok: true, value: {} }, + }).catch(() => undefined) } finally { globalThis.fetch = original } - expect(seen.some(u => u.includes('/api/'))).toBe(true) + expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true) + expect(seen.some(u => u.includes('/api/respond'))).toBe(true) + }) + + it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => { + ;(globalThis as Win).location = { + hostname: 'localhost', search: '', origin: 'http://localhost:3080', + } + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + const fetch = vi.spyOn(globalThis, 'fetch') + const client = (await mount()).api as WebApiClient + const envelopes: RpcMessage[][] = [] + client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) }) + const opened: string[] = [] + const muxAbort = new AbortController() + const hostAbort = new AbortController() + const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]() + const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]() + const muxFrame = mux.next() + const hostFrame = host.next() + await vi.waitFor(() => { expect(sockets).toHaveLength(2) }) + expect(sockets.map(socket => socket.url)).toEqual([ + 'ws://localhost:3080/api/events.mux', + 'ws://localhost:3080/api/events.host', + ]) + await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) }) + + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + sockets[0]!.receive(new Uint8Array([1, 2, 3])) + sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} })) + sockets[0]!.receive(JSON.stringify({ + type: 'server-request', + rpcId: 'mux-browser', + method: 'session/subscribed', + payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 }, + })) + sockets[1]!.receive(JSON.stringify({ + type: 'server-request', + rpcId: 'host-browser', + method: 'host/commands-changed', + payload: { type: 'host/commands-changed' }, + })) + expect(await muxFrame).toMatchObject({ + value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } }, + }) + expect(await hostFrame).toMatchObject({ + value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } }, + }) + expect(errors).toHaveBeenCalledTimes(2) + await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) }) + expect(fetch).not.toHaveBeenCalled() + + const muxEnd = mux.next() + const hostEnd = host.next() + muxAbort.abort() + hostAbort.abort() + await expect(muxEnd).resolves.toMatchObject({ done: true }) + await expect(hostEnd).resolves.toMatchObject({ done: true }) + expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true) + errors.mockRestore() + fetch.mockRestore() + }) + + it('maps an HTTPS page origin to a secure WebSocket URL', async () => { + ;(globalThis as Win).location = { + hostname: 'harness.example', search: '', origin: 'https://harness.example', + } + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + const client = (await mount()).api + const abort = new AbortController() + const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() + const pending = iterator.next() + await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') }) + abort.abort() + await expect(pending).resolves.toMatchObject({ done: true }) + }) + + it('closes a WebSocket immediately when its signal was already aborted', async () => { + ;(globalThis as Win).location = { + hostname: 'localhost', search: '', origin: 'http://localhost:3080', + } + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + const client = (await mount()).api + const abort = new AbortController() + abort.abort() + const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + expect(sockets).toHaveLength(1) + expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 08c65de2ba..551902f42e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,22 +1,29 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ -import { EventEmitter } from 'node:events' +import { EventEmitter, once } from 'node:events' import { createServer, request as httpRequest } from 'node:http' -import { Readable } from 'node:stream' +import { PassThrough, Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, inject } from '../src/index.ts' +import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts' -/** Structural httpServer fake: the plugin only touches register(). */ -function fakeHttpServer(routes: WebRoute[]): Pick { +/** Structural httpServer fake recording both route registries. */ +function fakeHttpServer( + routes: WebRoute[], + upgrades: WebUpgradeRoute[], +): Pick { return { register(route) { routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, + registerUpgrade(route) { + upgrades.push(route) + return () => { upgrades.splice(upgrades.indexOf(route), 1) } + }, tapIndex: () => () => {}, port: 0, } @@ -45,33 +52,67 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { +async function mounted(config?: { trustedHosts?: string[] }): Promise<{ + routes: WebRoute[] + upgrades: WebUpgradeRoute[] + dispose: () => Promise +}> { const ctx = new Context() const routes: WebRoute[] = [] - ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + const upgrades: WebUpgradeRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, dispose: () => fiber.dispose() } + return { routes, upgrades, dispose: () => fiber.dispose() } } describe('connection node half', () => { it('fails the load on a trustedHosts entry that is not a bare authority', async () => { const routes: WebRoute[] = [] + const upgrades: WebUpgradeRoute[] = [] const ctx = new Context() - ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) expect(routes).toHaveLength(0) + expect(upgrades).toHaveLength(0) }) - it('registers the /api prefix route and removes it with the fiber', async () => { - const { routes, dispose } = await mounted() + it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => { + const { routes, upgrades, dispose } = await mounted() expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH]) await dispose() expect(routes).toHaveLength(0) + expect(upgrades).toHaveLength(0) + }) + + it('requires WebSocket upgrade for network GETs to either event path', async () => { + const { routes, dispose } = await mounted() + for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) { + const { response, state } = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response) + expect(state.status).toBe(426) + expect(state.body).toBe('upgrade required') + } + await dispose() + }) + + it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => { + const { upgrades, dispose } = await mounted() + const socket = new PassThrough() + const chunks: Buffer[] = [] + socket.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + const ended = once(socket, 'end') + await upgrades[0]!.handler(fakeRequest({ + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }, MUX_EVENTS_PATH), socket, Buffer.alloc(0)) + await ended + expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden') + await dispose() }) it('refuses an untrusted Host on any /api path before the bridge runs', async () => { diff --git a/packages/client/connection/tests/websocket-downlink.spec.ts b/packages/client/connection/tests/websocket-downlink.spec.ts new file mode 100644 index 0000000000..9d53a82820 --- /dev/null +++ b/packages/client/connection/tests/websocket-downlink.spec.ts @@ -0,0 +1,308 @@ +import { once } from 'node:events' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import type { + ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' +import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts' +import { WebSocketDownlinks } from '../src/websocket-downlink.ts' + +type MuxSource = (signal: AbortSignal) => AsyncIterable> +type HostSource = (signal: AbortSignal) => AsyncIterable> + +const running: (() => Promise)[] = [] + +afterEach(async () => { + await Promise.all(running.splice(0).map(close => close())) +}) + +function untilAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +async function * idle(signal: AbortSignal): AsyncGenerator> { + await untilAbort(signal) +} + +function api(mux: MuxSource, host: HostSource): ApiProxy { + return { + events: { + mux: (_request, signal) => mux(signal), + host: (_request, signal) => host(signal), + }, + } as ApiProxy +} + +async function serve(downlinks: WebSocketDownlinks): Promise<{ + origin: string + close: () => Promise +}> { + const server = createServer() + server.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname + if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head) + else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head) + else socket.destroy() + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { + origin: `ws://127.0.0.1:${String(port)}`, + close: async () => { + await downlinks.close() + await new Promise(resolve => server.close(() => { resolve() })) + }, + } +} + +function read(socket: WebSocket): Promise { + return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest) +} + +async function acceptedSocket(downlinks: WebSocketDownlinks): Promise { + const server = (downlinks as unknown as { server: { clients: Set } }).server + let accepted: WebSocket | undefined + await vi.waitFor(() => { + accepted = server.clients.values().next().value + expect(accepted).toBeDefined() + }) + return accepted as WebSocket +} + +describe('WebSocket downlinks', () => { + it('carries mux and host over independent downstream sockets and cancels each source on close', async () => { + let muxAborted = false + let hostAborted = false + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + try { + yield { + rpcId: RpcId('mux-1'), + payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 }, + } + await untilAbort(signal) + } finally { + muxAborted = true + } + }, + async function * (signal) { + try { + yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } } + await untilAbort(signal) + } finally { + hostAborted = true + } + }, + )) + const host = await serve(downlinks) + running.push(host.close) + + const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`) + const muxFrame = read(mux) + const hostFrame = read(hostSocket) + expect(await muxFrame).toEqual({ + type: 'server-request', + rpcId: 'mux-1', + method: 'session/subscribed', + payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 }, + }) + expect(await hostFrame).toEqual({ + type: 'server-request', + rpcId: 'host-1', + method: 'host/commands-changed', + payload: { type: 'host/commands-changed' }, + }) + + const muxClosed = once(mux, 'close') + const hostClosed = once(hostSocket, 'close') + mux.close() + hostSocket.close() + await Promise.all([muxClosed, hostClosed]) + await vi.waitFor(() => { + expect(muxAborted).toBe(true) + expect(hostAborted).toBe(true) + }) + }) + + it('rejects client messages because upstream remains HTTP', async () => { + let aborted = false + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + try { + await untilAbort(signal) + } finally { + aborted = true + } + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + const closed = once(socket, 'close') + socket.send('upstream payload') + const [code, reason] = await closed as [number, Buffer] + expect(code).toBe(1008) + expect(String(reason)).toBe('downlink only') + await vi.waitFor(() => { expect(aborted).toBe(true) }) + }) + + it('sends stream/error before closing when a source fails', async () => { + const downlinks = new WebSocketDownlinks(api( + async function * () { + throw new Error('mux source failed') + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + const failure = read(socket) + const closed = once(socket, 'close') + expect((await failure).payload).toEqual({ + type: 'stream/error', + error: { code: 'internal', message: 'Error: mux source failed', details: {} }, + }) + await closed + }) + + it('aborts the source when an accepted socket reports a transport error', async () => { + let aborted = false + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + try { + await untilAbort(signal) + } finally { + aborted = true + } + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + const accepted = await acceptedSocket(downlinks) + const closed = once(socket, 'close') + accepted.emit('error', new Error('transport failed')) + await closed + expect(aborted).toBe(true) + }) + + it('drops a source frame that races after the client has closed', async () => { + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let finish!: () => void + const finished = new Promise((resolve) => { finish = resolve }) + let sourceSignal: AbortSignal | undefined + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + sourceSignal = signal + try { + await gate + yield { + rpcId: RpcId('late'), + payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 }, + } + } finally { + finish() + } + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + const closed = once(socket, 'close') + socket.close() + await closed + await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) + release() + await finished + }) + + it('contains socket send callback failures and closes the downlink', async () => { + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const downlinks = new WebSocketDownlinks(api( + async function * () { + await gate + yield { + rpcId: RpcId('send-failure'), + payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 }, + } + }, + idle, + )) + const host = await serve(downlinks) + running.push(host.close) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + const accepted = await acceptedSocket(downlinks) + const send = vi.spyOn(accepted, 'send').mockImplementation((( + _data: unknown, + optionsOrCallback?: unknown, + callback?: (error?: Error) => void, + ) => { + const done = typeof optionsOrCallback === 'function' + ? optionsOrCallback as (error?: Error) => void + : callback + done?.(new Error('socket send failed')) + }) as WebSocket['send']) + const closed = once(socket, 'close') + release() + await closed + expect(send).toHaveBeenCalledTimes(2) + send.mockRestore() + }) + + it('rejects when its acceptor has already closed', async () => { + const downlinks = new WebSocketDownlinks(api(idle, idle)) + await downlinks.close() + await expect(downlinks.close()).rejects.toThrow('The server is not running') + }) + + it('waits for source cleanup before teardown resolves', async () => { + let cleanupStarted!: () => void + const started = new Promise((resolve) => { cleanupStarted = resolve }) + let releaseCleanup!: () => void + const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve }) + let cleaned = false + const downlinks = new WebSocketDownlinks(api( + async function * (signal) { + try { + await untilAbort(signal) + } finally { + cleanupStarted() + await cleanupGate + cleaned = true + } + }, + idle, + )) + const host = await serve(downlinks) + const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) + await once(socket, 'open') + let closed = false + const closing = host.close().then(() => { closed = true }) + try { + await started + expect(closed).toBe(false) + releaseCleanup() + await closing + expect(cleaned).toBe(true) + } finally { + releaseCleanup() + await closing + } + }) +}) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index c3850804ff..7a785911c3 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 -README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a +README.md: dd780369a1d888dde2579e436afe2ce1e6dcfdd1 +README.zh.md: 5574ad6452c6a2d94fb63da7e53b98e8d074f1c9 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 89e58f967f..dd780369a1 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -8,6 +8,8 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending. + `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. `WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 960e2fceed..5574ad6452 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -8,6 +8,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 + `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 `WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 0a384fffa2..2336ec9d9c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -59,7 +59,9 @@ export type { export type { ConversationHistoryProjection } from './session-history/history-fold.ts' export type { SessionHistoryInspection } from './sessions/history.ts' export { PendingWait } from './sessions/pending.ts' -export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' +export type { + PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads, +} from './sessions/pending.ts' // Projection value store (session-projection RFC, push model): host-computed // whole values per key; domains ship projection support with zero client code. export type { diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 4f26674420..115370488f 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -4,6 +4,7 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { PendingInteractionStatus } from './pending.ts' /** Host list summary enriched with the latest mux-projected durable title. */ export interface TitledSessionSummary extends SessionSummary { @@ -12,7 +13,7 @@ export interface TitledSessionSummary extends SessionSummary { projectionValues?: Readonly> } -/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */ +/** One flattened session-list row with lineage depth and live pending interaction. */ export interface SessionListEntry { sessionId: SessionId title?: string @@ -26,8 +27,8 @@ export interface SessionListEntry { cwd?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> - /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ - waitingApproval: boolean + /** User interaction currently blocking this session, derived from live mux frames. */ + pendingInteraction?: PendingInteractionStatus /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } @@ -37,10 +38,13 @@ export interface SessionListEntry { * follows the established input order; this projection never re-sorts a * hydrated list from mutable timestamps. * @param summaries - the host's session.list items. - * @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false). + * @param pendingInteractions - current manager-owned interaction status by session. * @returns display rows in render order. */ -export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet): SessionListEntry[] { +export function flattenLineage( + summaries: readonly TitledSessionSummary[], + pendingInteractions?: ReadonlyMap, +): SessionListEntry[] { const byId = new Map() for (const s of summaries) byId.set(s.sessionId, s) @@ -64,7 +68,12 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[], waiti return } visited.add(s.sessionId) - out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth }) + const pendingInteraction = pendingInteractions?.get(s.sessionId) + out.push({ + ...s, + ...(pendingInteraction === undefined ? {} : { pendingInteraction }), + depth, + }) const kids = children.get(s.sessionId) if (kids === undefined) return for (const kid of kids) walk(kid, depth + 1) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b4bf12a0e3..c9961592ba 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -12,6 +12,7 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' +import type { PendingInteractionStatus } from './pending.ts' // Type-only merge edge: the title domain's client-namespace outlet declares // the 'title' projection key this manager projects into list rows (and any // useProjection('title') consumer reads). Zero value imports by construction. @@ -70,23 +71,44 @@ type SessionListMutation = /** Local first-send flip: the sender clears blank without waiting for a host frame. */ | { kind: 'engaged'; sessionId: SessionId } -/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */ -const PENDING_BUFFER_CAP = 32 +/** Stable identity of a frame retained until an uninstantiated Session can consume it. */ +function bufferedRequestKey(envelope: RpcRequest): string | undefined { + const frame = envelope.payload + switch (frame.type) { + case 'approval/requested': return `a:${frame.approvalId}` + case 'question/requested': return `q:${envelope.rpcId}` + case 'session/queue': return 'queue' + /* v8 ignore next -- pendingBuffers contains only the three frame types above. */ + default: return undefined + } +} +/** Match ui-question's binary plan-review routing at the wire boundary. */ +function questionInteractionStatus( + questions: Extract['questions'], +): PendingInteractionStatus { + if (questions.length !== 1) return 'question' + const question = questions[0] as typeof questions[number] + const intent = question.intent + if (intent?.kind !== 'plan-review' || question.detail === undefined) return 'question' + if (question.multiSelect === true) return 'question' + const options = question.options ?? [] + if (options.length > 2) return 'question' + return options.some(option => option.label === intent.approve) ? 'plan-review' : 'question' +} /** Instance cluster + frame entry + the session list (see the web client architecture RFC). */ export class SessionManager { private readonly sessions = new Map() - /** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit - * history (cannot be backfilled on open), the one frame class that must not take the - * drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these - * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ + /** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history + * cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates + * compact by identity. Instantiation replays and clears it, while removal drops it (audit S7). */ private readonly pendingBuffers = new Map[]>() - /** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open - * replays of the same requested frame). Manager-owned rather than read off Session instances - * because the sidebar must light up for sessions never instantiated. Cleared per connection - * generation — the reopen replay re-adds still-pending questions — and on session-removed. */ - private readonly waitingApprovals = new Map>() + /** Outstanding answerable interactions per session, keyed by their stable request identity. + * Manager-owned rather than read off Session instances because the sidebar must light up for + * sessions never instantiated. Cleared per connection generation — the reopen replay re-adds + * still-pending requests — and on session-removed. */ + private readonly pendingInteractions = new Map>() /** Per-session projection value stores, retained independently of instance arrival (the * title-snapshot precedent, generalized): push frames land here whether or not the Session * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the @@ -567,6 +589,26 @@ export class SessionManager { return this.listSnapshotCache } + /** Add or refresh one stable pending-interaction identity. */ + private trackPending(sessionId: SessionId, key: string, status: PendingInteractionStatus): void { + let interactions = this.pendingInteractions.get(sessionId) + if (interactions === undefined) { + interactions = new Map() + this.pendingInteractions.set(sessionId, interactions) + } + if (interactions.get(key) === status) return + interactions.set(key, status) + this.notifier.markDirty() + } + + /** Settle one pending-interaction identity without disturbing sibling waits. */ + private resolvePending(sessionId: SessionId, key: string): void { + const interactions = this.pendingInteractions.get(sessionId) + if (interactions === undefined || !interactions.delete(key)) return + if (interactions.size === 0) this.pendingInteractions.delete(sessionId) + this.notifier.markDirty() + } + // ---- ConnectionController sinks (wired by boot) ---- /** @@ -592,11 +634,10 @@ export class SessionManager { // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) this.notifier.markDirty() - // New mux-generation baseline: buffered session/queue frames belong to - // the previous generation and the host is about to resend the live - // snapshot — drop them, or every reconnect appends a duplicate batch - // (and enough reconnects push real approval/question frames past the - // cap). Same re-baseline signal Session uses for its own mirror. + // New mux-generation baseline: discard the previous queue snapshot. + // The host omits session/queue when the live queue is empty, so retaining + // it could replay stale work when the Session is instantiated later. + // This is the same re-baseline signal Session uses for its own mirror. const buffered = this.pendingBuffers.get(frame.sessionId) if (buffered !== undefined) { const kept = buffered.filter(item => item.payload.type !== 'session/queue') @@ -606,43 +647,54 @@ export class SessionManager { } } } - // List-level waiting-approval bit (the sidebar amber dot): tracked here for - // every session, instantiated or not; approvalId keys make replays idempotent. + // List-level pending-interaction status (the sidebar amber dot): tracked + // for every session, instantiated or not; stable keys make replays idempotent. if (frame.type === 'approval/requested') { - let ids = this.waitingApprovals.get(frame.sessionId) - if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set()) - if (!ids.has(frame.approvalId)) { - ids.add(frame.approvalId) - this.notifier.markDirty() - } + this.trackPending(frame.sessionId, `a:${frame.approvalId}`, 'approval') } else if (frame.type === 'approval/resolved') { - const ids = this.waitingApprovals.get(frame.sessionId) - if (ids !== undefined && ids.delete(frame.approvalId)) { - if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId) - this.notifier.markDirty() - } + this.resolvePending(frame.sessionId, `a:${frame.approvalId}`) + } else if (frame.type === 'question/requested') { + this.trackPending( + frame.sessionId, + `q:${envelope.rpcId}`, + questionInteractionStatus(frame.questions), + ) + } else if (frame.type === 'question/resolved') { + this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`) } const session = this.sessions.get(frame.sessionId) if (session === undefined) { - // Approval/question/queue frames never hit history: buffer for replay on - // instantiation; everything else drops (not instantiated — history fully - // backfills on open). + // Answerable requests never hit history: retain each live identity until + // instantiation, compacting replay duplicates and resolutions so list + // status cannot outlive the PendingWait the user would need to answer. + // Queue is a latest-value snapshot; everything else drops because open + // backfills it from history. switch (frame.type) { case 'approval/requested': - case 'approval/resolved': case 'question/requested': - case 'question/resolved': case 'session/queue': { const buffer = this.pendingBuffers.get(frame.sessionId) ?? [] - const prior = frame.type === 'session/queue' - ? buffer.findIndex(item => item.payload.type === 'session/queue') - : -1 - if (prior !== -1) buffer.splice(prior, 1) - buffer.push(envelope) - if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP) + const key = frame.type === 'approval/requested' + ? `a:${frame.approvalId}` + : frame.type === 'question/requested' ? `q:${envelope.rpcId}` : 'queue' + const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) + if (prior === -1) buffer.push(envelope) + else buffer[prior] = envelope this.pendingBuffers.set(frame.sessionId, buffer) return } + case 'approval/resolved': + case 'question/resolved': { + const buffer = this.pendingBuffers.get(frame.sessionId) + if (buffer === undefined) return + const key = frame.type === 'approval/resolved' + ? `a:${frame.approvalId}` + : `q:${frame.questionRpcId}` + const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) + if (prior !== -1) buffer.splice(prior, 1) + if (buffer.length === 0) this.pendingBuffers.delete(frame.sessionId) + return + } default: return } @@ -689,7 +741,7 @@ export class SessionManager { this.sessions.get(frame.sessionId)?.handleRemoved() } this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation - this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone + this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone if (!durableSubagent) this.projectionStores.delete(frame.sessionId) // A pull already in flight was requested before this removal and can // carry the pre-removal parentAvailable:true, which would resurrect @@ -735,20 +787,19 @@ export class SessionManager { * The moment a connection generation dies (before any next-generation frame * can arrive — onConnected waits for the readiness handshake while replayed * frames flow from stream open, so clearing there would race the replay): - * drop generation-scoped live state. Approvals resolved while disconnected - * send no frame, so the stale bits and the buffered answerable frames must - * not survive into the next generation — the mux-open replay re-adds every - * still-pending question with its live rpcId. - */ + * drop generation-scoped live state. Interactions resolved while disconnected + * send no frame, so stale statuses and buffered answerable frames must not + * survive into the next generation — mux-open replay re-adds every still-pending + * request with its live rpcId. + */ handleDisconnected(): void { - if (this.waitingApprovals.size > 0) { - this.waitingApprovals.clear() + if (this.pendingInteractions.size > 0) { + this.pendingInteractions.clear() this.notifier.markDirty() } for (const [sessionId, buffer] of [...this.pendingBuffers]) { const kept = buffer.filter(item => - item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved' - && item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved') + item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested') if (kept.length === buffer.length) continue if (kept.length === 0) this.pendingBuffers.delete(sessionId) else this.pendingBuffers.set(sessionId, kept) @@ -855,7 +906,15 @@ export class SessionManager { ...(projectionValues === undefined ? {} : { projectionValues }), } }) - const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys())) + const pendingInteractions = new Map() + for (const [sessionId, interactions] of this.pendingInteractions) { + const statuses = [...interactions.values()] + // The composer selects the first question ahead of approval. Mirror that + // answer order so the sidebar names the interaction the user can act on. + const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0] + if (status !== undefined) pendingInteractions.set(sessionId, status) + } + const fresh = flattenLineage(merged, pendingInteractions) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( @@ -863,7 +922,7 @@ export class SessionManager { && prev.blank === entry.blank && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth - && prev.waitingApproval === entry.waitingApproval + && prev.pendingInteraction === entry.pendingInteraction && prev.projectionValues === entry.projectionValues ) return prev this.entryCache.set(entry.sessionId, entry) diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts index ba69a6951e..1383faea35 100644 --- a/packages/client/runtime/src/client/sessions/pending.ts +++ b/packages/client/runtime/src/client/sessions/pending.ts @@ -15,6 +15,9 @@ export interface PendingPayloads { /** Pending-interaction discriminant (the keys of PendingPayloads). */ export type PendingKind = keyof PendingPayloads +/** Session-list summary of the user action currently blocking progress. */ +export type PendingInteractionStatus = 'approval' | 'plan-review' | 'question' + /** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */ export type PendingInteraction = { [K in PendingKind]: PendingWait }[PendingKind] diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 47ee641053..9399f594d3 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -33,6 +33,7 @@ import type { ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' +import type { PendingInteractionStatus } from './pending.ts' import { SessionProvideChannel } from './provide.ts' import type { Session } from './session.ts' @@ -48,8 +49,8 @@ export interface SessionSummary { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' running: boolean - /** An approval question is pending on this session (sidebar amber-dot state). */ - waitingApproval: boolean + /** User interaction currently blocking this session (sidebar amber-dot state). */ + pendingInteraction?: PendingInteractionStatus /** * Empty-log bit (host summary derivation mirror). New Session reuses a blank * one targeting the same workspace. Filtering stays with the consumer: the @@ -613,9 +614,11 @@ export class SessionsService implements ISessions { id: entry.sessionId, displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, - waitingApproval: entry.waitingApproval, blank: entry.blank, updatedAt: entry.updatedAt, + ...(entry.pendingInteraction === undefined + ? {} + : { pendingInteraction: entry.pendingInteraction }), ...(entry.projectionValues === undefined ? {} : { projectionValues: entry.projectionValues }), @@ -643,7 +646,6 @@ export class SessionsService implements ISessions { parentId: address.parentSessionId, origin: 'subagent', running: child.activity === 'running', - waitingApproval: false, blank: false, updatedAt: 0, } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index f6d7baf318..909a293b3e 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -40,6 +40,7 @@ describe('instances', () => { const manager = new SessionManager(api) // Uninstantiated: approval buffers, plain session/event drops. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } }) const session = manager.get(S1) expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }]) @@ -47,16 +48,26 @@ describe('instances', () => { expect(manager.get(S2).getSnapshot().pending).toEqual([]) }) - it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => { + it('retains every live answerable request and compacts resolutions before instantiation', () => { const api = new FakeApiClient() const manager = new SessionManager(api) - // 40 distinct question frames for an uninstantiated session: only the newest 32 survive. + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) for (let i = 0; i < 40; i++) { manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) } - const pending = manager.get(S1).getSnapshot().pending - expect(pending).toHaveLength(32) - expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + for (let i = 0; i < 40; i++) { + manager.handleMuxEnvelope({ + rpcId: `r${i}` as never, + payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' }, + }) + } + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + expect(manager.get(S1).getSnapshot().pending).toEqual([]) + }) + + it('drops buffered answerable requests on session removal', () => { + const manager = new SessionManager(new FakeApiClient()) // Removed session: buffered frames must not replay on a future instantiation. manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) @@ -862,48 +873,102 @@ describe('connected generation', () => { }) }) -describe('waiting-approval list bit', () => { - it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => { +describe('pending-interaction list status', () => { + it('tracks approval requests through replay and resolution without instantiation', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') // Mux-open replay of the same question (same approvalId) is idempotent. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() }) - it('clears only when the last outstanding question resolves; session-removed drops the bit', () => { + it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + manager.handleMuxEnvelope({ + rpcId: 'q1' as never, + payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + + manager.handleMuxEnvelope({ + rpcId: 'q2' as never, + payload: { + type: 'question/requested', + sessionId: S1, + questions: [{ + id: 'plan', question: 'Approve?', detail: '# Plan', + options: [{ label: 'Approve' }, { label: 'Refuse' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + }], + }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review') + manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + }) + + it.each([ + ['missing detail', {}], + ['multi-select', { detail: '# Plan', multiSelect: true }], + ['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }], + ['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }], + ])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + manager.handleMuxEnvelope({ + rpcId: 'q-plan' as never, + payload: { + type: 'question/requested', sessionId: S1, + questions: [{ + id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + ...over, + }], + }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + }) + + it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ + rpcId: 'q1' as never, + payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) - manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) - // Removed sessions drop their bit outright. - manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + + manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) expect(manager.getListSnapshot().items).toHaveLength(0) }) - it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => { + it('drops stale status at generation death before replay re-adds live interactions', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') // Generation death clears (resolved-while-disconnected questions send no frame)… manager.handleDisconnected() - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() // …and a replayed frame arriving before onConnected (stream open precedes // the readiness handshake) survives the later handleConnected untouched. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleConnected() - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') }) it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 1d20a512f5..e313b63fd2 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -222,7 +222,6 @@ export class TestSessions implements ISessions { id, displayTitle: fixture.id, running: false, - waitingApproval: false, blank: false, updatedAt: this.records.size + 1, ...fixture.summary, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bf1e21a541..6ebbbed744 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: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d -README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246 +README.md: 0d00eac1db5aed7feec9bb714fe3d8976cd39943 +README.zh.md: 4219950a9d51eb1037051ca00d61fa0d5ffa6fd2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7d3a4b5fe0..0d00eac1db 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d93af91381..4219950a9d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index f07104ba7b..8c4af6a921 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -90,7 +90,7 @@ async function bench(snapshot: ConversationSnapshot) { const session = createSnapshotStore(snapshot) const list = createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, current: SID, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 928a7f0658..06dfd03498 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -244,7 +244,7 @@ describe('bash sample row', () => { return createSnapshotStore({ ids: [SID], byId: { - [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 }, + [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 }, }, current: undefined, phase: 'ready', diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 445b875109..c92e43db6c 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -93,7 +93,7 @@ describe('tails', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], - byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } }, + byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 0d0fd46407..60103ec1f8 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -153,7 +153,7 @@ describe('chat row diff body', () => { describe('FileMutationRow diff card', () => { const list = () => createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', subagentsByParent: {}, @@ -306,7 +306,7 @@ describe('DetailsPanel diff Output section', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx index f8ecb8b73c..4dcdb6c766 100644 --- a/packages/client/ui-conversation/tests/read-card.spec.tsx +++ b/packages/client/ui-conversation/tests/read-card.spec.tsx @@ -167,7 +167,7 @@ describe('GenericToolCard read body', () => { describe('ReadRow keyed toolview', () => { const list = () => createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', subagentsByParent: {}, @@ -254,7 +254,7 @@ describe('DetailsPanel Output section (read)', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 312ec88642..f9bd3fa3c1 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -92,10 +92,10 @@ function mount( } = {}, ) { const root = sid('root') - const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 } + const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 } const childRow = { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', - running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2, + running: false, blank: options.summaryBlank ?? false, updatedAt: 2, ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }), } const listed = options.omitSummaryRow !== true diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index d4be802a34..f655979518 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -342,7 +342,7 @@ describe('chat row terminal body', () => { describe('BashRow terminal card', () => { const list = () => createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', subagentsByParent: {}, @@ -448,7 +448,7 @@ describe('DetailsPanel Output section', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index 77f9c0a794..e83851edfc 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import css from './StateDot.module.css' -/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */ +/** Four-color state semantic (green done / amber user-attention / blue running ring / red error). */ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' /** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */ diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index b6c02fcfb7..e0610d3379 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -56,7 +56,6 @@ function props( displayTitle: 'worker', running: true, blank: false, - waitingApproval: false, updatedAt: Date.now(), }, }, @@ -83,7 +82,6 @@ function summary(id: SessionId, updatedAt: number): SessionSummary { displayTitle: id, running: false, blank: false, - waitingApproval: false, updatedAt, } } diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 374efd0f58..43d753fae5 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469 -README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab +README.md: 855bcdc7fa1850ee30a1887add10dc019d9887e2 +README.zh.md: acb35ab169f10444c26ae6d68aefda9c5b8703df diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 17105f9d70..855bcdc7fa 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -12,7 +12,7 @@ Workspace and Session hover cards copy the value their row clips: activating a W The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. -Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`. +Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator; ordinary rows repeat the localized status in their hover card, and both ordinary and search-result rows carry the same text as a visually hidden label for assistive technology. Running uses the blue indicator and its hidden label; an idle row leaves the reserved status slot empty. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -30,5 +30,5 @@ None; this package neither assembles nor sends a provider request. - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. - **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions. -- **Approval waiting is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. +- **Pending user interaction is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index b40b946927..acb35ab169 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -12,7 +12,7 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 -Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。 +Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点,优先级高于运行指示器;普通行的悬浮卡片重复显示本地化状态,普通行和搜索结果行则都以相同文本提供面向辅助技术的视觉隐藏标签。运行状态使用蓝色指示器及其隐藏标签;空闲行会保留空的状态槽位。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -30,5 +30,5 @@ Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原 - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 - **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 -- **待审批状态不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 +- **待处理的用户交互不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index e697ddf907..fd4f31f78e 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -308,6 +308,7 @@ function SearchResults({ result={result} currentId={list.current} onOpen={open} + t={t} /> ))} diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index e64127a70d..b9e06a6ae2 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -47,6 +47,8 @@ export const zh = { 'status.running': '进行中', 'status.idle': '空闲', 'status.waitingApproval': '等待审批', + 'status.planReview': '计划待审', + 'status.waitingAnswer': '等待回答', 'hover.created': '创建于 {time}', 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', @@ -105,6 +107,8 @@ export const en = { 'status.running': 'Running', 'status.idle': 'Idle', 'status.waitingApproval': 'Waiting for approval', + 'status.planReview': 'Plan awaiting review', + 'status.waitingAnswer': 'Waiting for answer', 'hover.created': 'Created {time}', 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index e961919498..836325076b 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -166,14 +166,29 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { ) } -/** Session status presentation; approval waiting outranks the underlying running state. */ -function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } { - if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') } +/* v8 ignore next 3 -- closed-union backstop; only reached if the status is forged */ +function assertNever(value: never): never { + throw new Error(`unknown pending interaction: ${String(value)}`) +} + +/** Session status presentation; pending user interaction outranks the running state. */ +function sessionStatus( + node: Pick, + t: RowTranslate, +): { state: StateDotState; label: string } { + switch (node.pendingInteraction) { + case 'approval': return { state: 'warning', label: t('status.waitingApproval') } + case 'plan-review': return { state: 'warning', label: t('status.planReview') } + case 'question': return { state: 'warning', label: t('status.waitingAnswer') } + case undefined: break + /* v8 ignore next -- closed PendingInteractionStatus union */ + default: return assertNever(node.pendingInteraction) + } if (node.running) return { state: 'ongoing', label: t('status.running') } return { state: 'done', label: t('status.idle') } } -/** Hover-card body: full title, relative time, and approval/running/idle status. */ +/** Hover-card body: full title, relative time, and interaction/running/idle status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { const status = sessionStatus(node, t) return ( @@ -215,14 +230,17 @@ export interface RowDragProps { * @param props.result - merged local/content search row. * @param props.currentId - selected session id. * @param props.onOpen - open the selected session. + * @param props.t - Workspace-browser translation seat. * @returns the result button. */ -export function SearchResultItem({ result, currentId, onOpen }: { +export function SearchResultItem({ result, currentId, onOpen, t }: { result: SearchResultNode currentId: string | undefined onOpen: (id: SearchResultNode['id']) => void + t: RowTranslate }) { const selected = result.id === currentId + const status = sessionStatus(result, t) return (