Merge remote-tracking branch 'upstream/master' into feat/web-workspace-file-links
# Conflicts: # apps/web/tsconfig.json # packages/client/connection/README.i18n.yaml # packages/client/connection/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/ToolRow.module.css # packages/client/ui-conversation/src/client/chat/chat-flow.ts # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/host/apiproxy/src/native-path-opener.ts # tsconfig.host.json
This commit is contained in:
@@ -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/host/README.md
|
||||
README.md: 391adb7009a01d1ec95c8dcb809e8a2065aa0b31
|
||||
README.zh.md: 7fc730ed9ec3a067589b277733eb5bb2c42f8b4e
|
||||
README.md: 7cd331f113eeec6c0a56f0ebc60554d9647aee75
|
||||
README.zh.md: 07b0e1569e17b9f0465a43f77fa2dbddcb1bae91
|
||||
|
||||
@@ -6,11 +6,11 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` |
|
||||
| `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` |
|
||||
| `directory-picker/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` |
|
||||
| `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (registers `ctx.directoryPicker`) |
|
||||
| `directory-picker-browse/` | Dual-face browse interaction: listing/creation primitives over Node stdlib (remote-capable) + the browser half rendering the in-app Select Workspace Directory dialog | (registers `ctx.directoryPicker`) |
|
||||
| `directory-picker-auto/` | Adaptive chooser: resolves the host's situation once at boot (bind host, SSH, display) and mounts the matching dual-face backend as an in-memory Loader entry | (mounts a backend row) |
|
||||
| [`apiproxy/`](apiproxy/README.md) | Shared host API gateway and wire contract | `ctx.apiProxy` |
|
||||
| [`webserver/`](webserver/README.md) | HTTP route carrier | `ctx.httpServer` |
|
||||
| [`directory-picker/`](directory-picker/README.md) | Workspace-directory picking seam | `ctx.directoryPicker` |
|
||||
| [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` |
|
||||
| [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` |
|
||||
| [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend |
|
||||
|
||||
`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire.
|
||||
`apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam.
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# host/ — web GUI 宿主半侧
|
||||
# host/ — Web GUI 宿主侧
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
|
||||
dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承载它的普通 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),由它提供 [`apps/web`](../../apps/web/)。这些全是**产品**包。
|
||||
|
||||
| 包 | 角色 | ctx 键 |
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`)、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents`/`ctx.workspace` 的宿主实现 | `ctx.apiProxy` |
|
||||
| `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` |
|
||||
| `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `native`/`browse` 能力 | `ctx.directoryPicker` |
|
||||
| `directory-picker-native/` | 双面原生交互:OS 选择器后端(osascript/PowerShell/Zenity+KDialog,仅宿主屏幕可用)+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `ctx.directoryPicker`) |
|
||||
| `directory-picker-browse/` | 双面浏览交互:基于 Node 标准库的列举/创建原语(可远程)+ 渲染应用内选择工作区目录对话框的 browser half | (注册 `ctx.directoryPicker`) |
|
||||
| `directory-picker-auto/` | 自适应选择器:启动时一次性判定宿主处境(绑定宿主、SSH、显示),并把匹配的双面后端挂载为内存中的 Loader 条目 | (挂载一个后端行) |
|
||||
| [`apiproxy/`](apiproxy/README.md) | 共享宿主 API 网关和协议契约 | `ctx.apiProxy` |
|
||||
| [`webserver/`](webserver/README.md) | HTTP 路由载体 | `ctx.httpServer` |
|
||||
| [`directory-picker/`](directory-picker/README.md) | workspace 目录选择 seam | `ctx.directoryPicker` |
|
||||
| [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` |
|
||||
| [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` |
|
||||
| [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 |
|
||||
|
||||
`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。
|
||||
`apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器/HTTP 载体。选择器实现可在共享 seam 后互相替换。
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5
|
||||
README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f
|
||||
README.md: 00b9ea075af7eb55041d48ceb3a1363e9de90397
|
||||
README.zh.md: e62e3fa99c2908afeca85b7182701f6fcdf19de9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml).
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml).
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
@@ -10,7 +10,9 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
|
||||
|
||||
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
|
||||
|
||||
`session.history` pages on append-origin message boundaries: `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
|
||||
Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`.
|
||||
|
||||
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
@@ -18,9 +20,9 @@ Session titles ride the generic projection pair like every other domain — the
|
||||
|
||||
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
|
||||
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
|
||||
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
|
||||
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
@@ -30,11 +32,11 @@ A stale continuation discards every partial result, deduplication entry, and cur
|
||||
|
||||
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
@@ -50,8 +52,8 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations (the former `host.listModels` reservation shipped as `llm.models`); an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **Pending-interaction state is host-side** — the wire shape is POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries.
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
|
||||
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
|
||||
|
||||
@@ -2,39 +2,41 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。
|
||||
|
||||
## 契约层(`/api`)
|
||||
|
||||
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站"简单请求"(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。
|
||||
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站「简单请求」(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。
|
||||
|
||||
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
|
||||
|
||||
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。
|
||||
|
||||
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
|
||||
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
|
||||
待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
|
||||
陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始,但不会丢弃探测所得的提供方页面大小。上限探测与陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。
|
||||
|
||||
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
@@ -44,14 +46,14 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
无。该包定义客户端与宿主间的协议契约和载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项(先前预留的 `host.listModels` 已作为 `llm.models` 交付);未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **待处理交互状态位于宿主侧**:协议形状为 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
|
||||
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -56,6 +54,7 @@
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* commands domain contract: the web catalog/dispatch face of the host command
|
||||
* registry (`ctx.commands`). Both methods address one session's agent via
|
||||
* `sessionId` — every served session has an Agent (Session+Agent are born
|
||||
* together), so there is no agent-less surface on this wire.
|
||||
* registry (`ctx.commands`). Both methods address an ordinary session's Agent
|
||||
* via `sessionId`, resuming it when cold. Session-backed subagents reject with
|
||||
* `agent-busy` and retain their dedicated continuation owner.
|
||||
*/
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
@@ -27,7 +27,8 @@ export interface CommandDescriptor {
|
||||
export interface CommandsApi {
|
||||
/**
|
||||
* Lists the addressed agent's effective command catalog (name-sorted,
|
||||
* globals plus its scoped shadows).
|
||||
* globals plus its scoped shadows). Session-backed subagents reject with
|
||||
* `agent-busy`.
|
||||
*/
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
|
||||
|
||||
@@ -42,6 +43,7 @@ export interface CommandsApi {
|
||||
* pairing id, letting the issuing client correlate this acknowledgment
|
||||
* with that flow node. The signal rides beside the request, never on the
|
||||
* wire: the fetch carrier's request signal cancels the running handler.
|
||||
* Session-backed subagents reject with `agent-busy` before dispatch.
|
||||
*/
|
||||
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, inboxItemIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
@@ -53,7 +53,8 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
type: z.literal('session/queue'),
|
||||
sessionId: sessionIdSchema,
|
||||
items: z.array(z.object({
|
||||
id: inboxItemIdSchema,
|
||||
id: messageIdSchema,
|
||||
placement: z.union([z.literal('queued'), z.literal('steering'), z.literal('context')]),
|
||||
message: messageSchema,
|
||||
})),
|
||||
}),
|
||||
@@ -65,7 +66,14 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
|
||||
/** HostFrame union (payload slot of a host-stream ServerRequest). */
|
||||
export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
|
||||
z.object({
|
||||
type: z.literal('host/session-added'),
|
||||
sessionId: sessionIdSchema,
|
||||
blank: z.boolean(),
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
origin: z.literal('subagent').optional(),
|
||||
cwd: z.string().optional(),
|
||||
}),
|
||||
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
|
||||
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
|
||||
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* events domain contract: signatures and frame unions for the two SSE
|
||||
* events domain contract: signatures and frame unions for the two logical
|
||||
* streams. Four-quadrant: streams yield the narrow form `RpcRequest<Frame>` (server-request
|
||||
* view) — rpcId must be exposed to the business layer, because responses to answerable frames
|
||||
* (approval/question requested) echo it; for pure pushes it identifies that one push.
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
|
||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
@@ -32,15 +32,17 @@ export type ToolEventView =
|
||||
| { for: 'call'; view: ToolCallView }
|
||||
| { for: 'result'; view: ToolResultView }
|
||||
|
||||
/** One pending queued occurrence in an authoritative queue snapshot. */
|
||||
/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedInboxItem {
|
||||
/** Agent-owned occurrence identity used by queue mutations. */
|
||||
id: InboxItemId
|
||||
/** Message identity used by inbox mutations. */
|
||||
id: MessageId
|
||||
/** Agent-resolved FIFO placement; queued and steering items render on different surfaces, context items stay invisible until claimed. */
|
||||
placement: 'queued' | 'steering' | 'context'
|
||||
/** Complete pending message; it is not durable until the Agent claims it. */
|
||||
message: Message
|
||||
}
|
||||
|
||||
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
|
||||
/** Streaming face of the contract: the two logical stream openers (mux + host). */
|
||||
export interface EventsApi {
|
||||
/**
|
||||
* All-session aggregated mux stream. On open, emits a subscribed control frame for every
|
||||
@@ -71,11 +73,12 @@ export type MuxFrame =
|
||||
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
|
||||
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
|
||||
/**
|
||||
* Complete transient queue state after every enqueue, mutation, claim, or
|
||||
* Complete transient inbox state after every enqueue, mutation, claim, or
|
||||
* discard. Pending work is not model-visible and therefore has no durable
|
||||
* session event; the whole snapshot makes edit, deletion, cancel, and
|
||||
* reconnect converge through one authoritative signal. Pending steering is
|
||||
* outside this Web queue projection.
|
||||
* reconnect converge through one authoritative signal. `session/queue`
|
||||
* covers both resolved placements: queued items render
|
||||
* in QueueDock, while pending steering renders at the conversation tail.
|
||||
*/
|
||||
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
|
||||
/**
|
||||
@@ -90,9 +93,9 @@ export type MuxFrame =
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
/**
|
||||
* Host stream frames. session-added carries the lineage anchor, the project
|
||||
* cwd, and the blank bit (the list-summary fields a client cannot wait for a
|
||||
* refresh to learn); the frame fires at session/created, so blank is
|
||||
* Host stream frames. session-added carries the lineage anchor, product
|
||||
* origin, project cwd, and blank bit (the list-summary fields a client cannot
|
||||
* wait for a refresh to learn); the frame fires at session/created, so blank is
|
||||
* constantly true — clients flip it on the session's first
|
||||
* `host/session-status(running:true)` (a blank session never runs), and a
|
||||
* reconnecting client takes `session.list`'s summary.blank as authoritative.
|
||||
@@ -106,7 +109,14 @@ export type MuxFrame =
|
||||
* workspace-changed — `workspace.list` re-baselines it on reconnect).
|
||||
*/
|
||||
export type HostFrame =
|
||||
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
|
||||
| {
|
||||
type: 'host/session-added'
|
||||
sessionId: SessionId
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
}
|
||||
| { type: 'host/session-removed'; sessionId: SessionId }
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
|
||||
|
||||
@@ -22,7 +22,11 @@ export interface GoalRef {
|
||||
readonly revision: number
|
||||
}
|
||||
|
||||
/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */
|
||||
/**
|
||||
* Goal-domain unary methods. Every mutation resolves an ordinary session's
|
||||
* Agent and applies one CAS-guarded verb; session-backed subagents reject with
|
||||
* `agent-busy`.
|
||||
*/
|
||||
export interface GoalsApi {
|
||||
/** Create and arm a goal. */
|
||||
create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* apiproxy contract-layer barrel. api/ has zero Node dependencies and is
|
||||
* importable from the browser; the TS interfaces are the authoritative contract, HTTP/SSE are
|
||||
* merely physical channels (four-quadrant message model).
|
||||
* importable from the browser; the TS interfaces are the authoritative contract, while HTTP,
|
||||
* WebSocket, and in-process SSE are merely physical channels (four-quadrant message model).
|
||||
*/
|
||||
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
@@ -9,6 +9,7 @@ import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { SubagentsApi } from './subagents.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
@@ -19,6 +20,7 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
|
||||
export interface ApiProxy {
|
||||
sessions: SessionsApi
|
||||
subagents: SubagentsApi
|
||||
host: HostApi
|
||||
workspace: WorkspaceApi
|
||||
commands: CommandsApi
|
||||
@@ -39,6 +41,9 @@ export type {
|
||||
SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type {
|
||||
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
|
||||
} from './subagents.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
@@ -46,7 +51,7 @@ export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, Too
|
||||
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
export type { CredentialsApi, CredentialView } from './credentials.ts'
|
||||
export type { ConfigurableProviderView, LlmApi } from './llm.ts'
|
||||
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
@@ -67,7 +72,6 @@ export type {
|
||||
// ---- Errors and ids ----
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
|
||||
// ---- Fixed session-search product bounds ----
|
||||
export {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { ConfigurableProviderView } from './llm.ts'
|
||||
import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts'
|
||||
import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts'
|
||||
|
||||
/** ConfigurableProviderView row of llm.providers. */
|
||||
@@ -34,3 +34,30 @@ export const llmModelsValueSchema = z.object({
|
||||
groups: z.array(modelProviderGroupSchema),
|
||||
failures: z.array(modelCatalogFailureSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'llm.models'>>>
|
||||
|
||||
/** DiscoveredModelView row of llm.discoverModels. */
|
||||
export const discoveredModelViewSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
}) satisfies z.ZodType<Wire<DiscoveredModelView>>
|
||||
|
||||
/** llm.discoverModels request payload. */
|
||||
export const llmDiscoverModelsRequestSchema = z.object({
|
||||
settingsNs: z.string().min(1),
|
||||
provider: z.string().min(1).optional(),
|
||||
baseURL: z.string().min(1).optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
// Write-only at the host: used for this one interrogation, never stored and
|
||||
// never returned. It does ride the client's outgoing envelope like every
|
||||
// other secret-bearing payload (`credentials.set`, `settings.update`), which
|
||||
// `subscribeEnvelopes()` observers can see — redacting that tap is a
|
||||
// configuration-plane-wide change, not this method's to make alone.
|
||||
apiKey: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'llm.discoverModels'>>>
|
||||
|
||||
/** llm.discoverModels response value. */
|
||||
export const llmDiscoverModelsValueSchema = z.object({
|
||||
models: z.array(discoveredModelViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'llm.discoverModels'>>>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* surfaces. `llm.providers` merges the configurable-provider directory
|
||||
* (which providers CAN be configured, and where their settings live) with the
|
||||
* live route registry; `llm.models` is the session-independent model catalog
|
||||
* (`session.models` minus the per-session current/unlisted logic). Both
|
||||
* invalidate on the `host/models-changed` frame.
|
||||
* (the same groups as `session.models`, without the per-session current
|
||||
* target). Both invalidate on the `host/models-changed` frame.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
@@ -40,4 +40,43 @@ export interface LlmApi {
|
||||
* failures ride `failures` without failing the sound groups.
|
||||
*/
|
||||
models(request: RpcRequest<{}>): Promise<RpcResponse<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }>>
|
||||
|
||||
/**
|
||||
* Interrogate a provider endpoint the configuration surface is still
|
||||
* drafting, and return the models it advertises for the user to adopt.
|
||||
*
|
||||
* The payload is the draft, not a stored route: `settingsNs` selects the
|
||||
* adapter family that answers, and the rest comes from the form. `provider`
|
||||
* names the route being edited when there is one — an adapter that already
|
||||
* describes that route answers from its own registry, with better metadata
|
||||
* and no network call, and needs no endpoint. A route it does not describe is
|
||||
* asked over the wire, which is what `baseURL`, `api`, and `apiKey` are for.
|
||||
*
|
||||
* Nothing is written — the reply is candidates, and only a later
|
||||
* `settings.mutate` decides what a route serves. `apiKey` is accepted here
|
||||
* but never stored or returned; a provider whose key is already stored omits
|
||||
* it and the endpoint answers unauthenticated or refuses.
|
||||
*/
|
||||
discoverModels(
|
||||
request: RpcRequest<{
|
||||
settingsNs: string
|
||||
provider?: string
|
||||
baseURL?: string
|
||||
api?: string
|
||||
apiKey?: string
|
||||
}>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<{ models: DiscoveredModelView[] }>>
|
||||
}
|
||||
|
||||
/** Wire view of one model an interrogated endpoint advertises. */
|
||||
export interface DiscoveredModelView {
|
||||
/** Model id the endpoint accepts. */
|
||||
id: string
|
||||
/** Human-readable name when the endpoint supplies one. */
|
||||
name?: string
|
||||
/** Maximum combined request and response context, when disclosed. */
|
||||
contextWindow?: number
|
||||
/** Maximum output tokens, when disclosed. */
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { CredentialsApi } from './credentials.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { SubagentsApi } from './subagents.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,9 @@ export interface RpcMethodMap {
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.updateQueue': SessionsApi['updateQueue']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
'subagent.list': SubagentsApi['list']
|
||||
'subagent.history': SubagentsApi['history']
|
||||
'subagent.prompt': SubagentsApi['prompt']
|
||||
'host.describe': HostApi['describe']
|
||||
'host.pickDirectory': HostApi['pickDirectory']
|
||||
'host.listDirectory': HostApi['listDirectory']
|
||||
@@ -53,6 +57,7 @@ export interface RpcMethodMap {
|
||||
'goal.complete': GoalsApi['complete']
|
||||
'goal.clear': GoalsApi['clear']
|
||||
'settings.describe': SettingsApi['describe']
|
||||
'settings.openDocument': SettingsApi['openDocument']
|
||||
'settings.update': SettingsApi['update']
|
||||
'settings.replace': SettingsApi['replace']
|
||||
'settings.mutate': SettingsApi['mutate']
|
||||
@@ -61,6 +66,7 @@ export interface RpcMethodMap {
|
||||
'credentials.unset': CredentialsApi['unset']
|
||||
'llm.providers': LlmApi['providers']
|
||||
'llm.models': LlmApi['models']
|
||||
'llm.discoverModels': LlmApi['discoverModels']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -48,14 +48,26 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
|
||||
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
|
||||
z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({
|
||||
parentSessionId: z.string(),
|
||||
childSessionId: z.string(),
|
||||
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
|
||||
}) }),
|
||||
z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Four-quadrant RPC message model. Channels and messages are
|
||||
* decoupled: HTTP is the client→server physical channel, SSE the server→client one; logical
|
||||
* messages are channel-independent, and the wire full form is a four-member discriminated union.
|
||||
* Four-quadrant RPC message model. Channels and messages are decoupled: HTTP,
|
||||
* WebSocket, and in-process SSE are physical carriers, while logical messages
|
||||
* are channel-independent and form a four-member discriminated union.
|
||||
* api/ contract layer: zero Node dependencies, importable from the browser.
|
||||
*/
|
||||
|
||||
import type { z as zCore } from 'zod'
|
||||
type ZodIssue = zCore.core.$ZodIssue
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
|
||||
/**
|
||||
* Message correlation id: the initiator mints it on a request; a response
|
||||
@@ -45,7 +45,8 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-create-failed': { path: string }
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-busy': { reason: string }
|
||||
'queue-item-not-found': { itemId: InboxItemId }
|
||||
'queue-item-not-found': { itemId: MessageId }
|
||||
'steer-unavailable': { itemId: MessageId }
|
||||
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
||||
'command-error': {}
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
@@ -69,8 +70,27 @@ export interface RpcErrorDetailsMap {
|
||||
'settings-conflict': { ns: string; expected: number; actual: number }
|
||||
/** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */
|
||||
'credential-rejected': { ref: string }
|
||||
/**
|
||||
* Interrogating a draft provider endpoint did not produce a model listing:
|
||||
* no adapter family serves the namespace, the protocol has no listing this
|
||||
* build can read, or the endpoint was unreachable, refused the credential,
|
||||
* or answered with something else. The message is the adapter's own text —
|
||||
* it is what the form shows before falling back to hand-entry — and the
|
||||
* details name the endpoint asked, never the credential offered.
|
||||
*/
|
||||
'model-discovery-failed': { settingsNs: string; baseURL?: string }
|
||||
'title-invalid': { sessionId: SessionId }
|
||||
'fork-unavailable': { sessionId: SessionId }
|
||||
'subagent-parent-unavailable': { parentSessionId: SessionId }
|
||||
'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
|
||||
'subagent-catalog-diagnostic': {
|
||||
parentSessionId: SessionId
|
||||
childSessionId: SessionId
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
'subagent-not-resumable': { childSessionId: SessionId }
|
||||
'subagent-unauthorized': { childSessionId: SessionId }
|
||||
'subagent-delivery-unavailable': { childSessionId: SessionId }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
@@ -136,7 +156,7 @@ export interface ServerResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Message initiated by the server (wire carrier: SSE frame). Answerable interactions
|
||||
* Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions
|
||||
* (approval/question requested — stable rpcId, reused on replay) and pure pushes
|
||||
* (session/event etc. — rpcId identifies that one push) share this shape; whether a
|
||||
* response is expected is determined statically by method (a strict dichotomy, no third kind).
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type {
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
|
||||
/** InboxItemId: one brand cast after non-empty string validation. */
|
||||
export const inboxItemIdSchema = z.string().min(1) as unknown as z.ZodType<InboxItemId>
|
||||
/** MessageId: one brand cast after non-empty string validation. */
|
||||
export const messageIdSchema = z.string().min(1) as unknown as z.ZodType<MessageId>
|
||||
|
||||
/**
|
||||
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
|
||||
@@ -53,6 +53,7 @@ export const sessionSummarySchema = z.object({
|
||||
running: z.boolean(),
|
||||
blank: z.boolean(),
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
origin: z.literal('subagent').optional(),
|
||||
cwd: z.string().optional(),
|
||||
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
|
||||
}) as unknown as z.ZodType<Wire<SessionSummary>>
|
||||
@@ -164,7 +165,6 @@ export const modelCatalogModelSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
unlisted: z.literal(true).optional(),
|
||||
reasoning: modelReasoningSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
|
||||
|
||||
@@ -194,10 +194,10 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
|
||||
]) as unknown as z.ZodType<ToolEventView>
|
||||
|
||||
/** One session.history item: the session event plus its optional host-computed tool view. */
|
||||
export const historyEntrySchema = z.object({
|
||||
export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({
|
||||
event: sessionEventSchema,
|
||||
view: toolEventViewSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<HistoryEntry>>
|
||||
}) as unknown as z.ZodType<Wire<HistoryEntry>>
|
||||
|
||||
/**
|
||||
* Projection baseline passthrough: `values` stays a wide record — each value
|
||||
@@ -211,11 +211,11 @@ export const sessionProjectionsBlockSchema = z.object({
|
||||
}) as unknown as z.ZodType<SessionProjectionsBlock>
|
||||
|
||||
/** session.history response value (projections rides the tail page only). */
|
||||
export const sessionHistoryValueSchema = z.object({
|
||||
export const sessionHistoryValueSchema: z.ZodType<Wire<ResponseValue<'session.history'>>> = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
projections: sessionProjectionsBlockSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
|
||||
})
|
||||
|
||||
/** session.models request payload. */
|
||||
export const sessionModelsRequestSchema = z.object({
|
||||
@@ -264,10 +264,11 @@ export const sessionPromptValueSchema = z.object({
|
||||
/** session.updateQueue request payload. */
|
||||
export const sessionUpdateQueueRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
itemId: inboxItemIdSchema,
|
||||
itemId: messageIdSchema,
|
||||
action: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }),
|
||||
z.object({ kind: z.literal('remove') }),
|
||||
z.object({ kind: z.literal('steer') }),
|
||||
]),
|
||||
}) as unknown as z.ZodType<RequestPayload<'session.updateQueue'>>
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* else references RequestPayload<'session.*'> / ResponseValue<'session.*'>.
|
||||
*/
|
||||
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// The pure-type outlet: api/ is browser-importable, and the package root's
|
||||
// cordis Context merge (via dsh-agent) must not enter client aggregates.
|
||||
@@ -89,8 +89,6 @@ export interface ModelCatalogModel {
|
||||
name: string
|
||||
/** Optional provider-supplied description. */
|
||||
description?: string
|
||||
/** The current model was inserted because the advisory catalog omitted it. */
|
||||
unlisted?: true
|
||||
/** Exact-route reasoning metadata when the adapter exposes it. */
|
||||
reasoning?: ModelReasoning
|
||||
}
|
||||
@@ -129,6 +127,7 @@ export interface SessionModels {
|
||||
export type QueueAction =
|
||||
| { kind: 'edit'; content: ContentBlock[] }
|
||||
| { kind: 'remove' }
|
||||
| { kind: 'steer' }
|
||||
|
||||
/** Session list entry (v1 builds no index: list does readdir+stat). */
|
||||
export interface SessionSummary {
|
||||
@@ -153,6 +152,8 @@ export interface SessionSummary {
|
||||
blank: boolean
|
||||
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
|
||||
parentSessionId?: SessionId
|
||||
/** Coarse durable origin used by navigation surfaces; never proves resumability. */
|
||||
origin?: 'subagent'
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
@@ -217,17 +218,22 @@ export interface SessionsApi {
|
||||
* the client needs a fresh baseline already pulls the tail page, and
|
||||
* loadOlder (the only beforeSeq path) is the only path that never needs one.
|
||||
* A deployment without the registry serves histories without the block.
|
||||
* Reading history uses an attached Session or persistence inspection and
|
||||
* never resumes or publishes an Agent.
|
||||
*/
|
||||
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
|
||||
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
|
||||
|
||||
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
|
||||
/**
|
||||
* Reads a fresh advisory model directory for an ordinary session. Provider
|
||||
* lookups run independently; subagents reject with `agent-busy`.
|
||||
*/
|
||||
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
|
||||
|
||||
/**
|
||||
* Selects the complete target for this session. Exact model metadata
|
||||
* validates an optional reasoning effort, while catalog membership remains
|
||||
* advisory.
|
||||
* advisory. Session-backed subagents reject with `agent-busy`.
|
||||
*/
|
||||
selectModel(request: RpcRequest<{
|
||||
sessionId: SessionId
|
||||
@@ -243,6 +249,7 @@ export interface SessionsApi {
|
||||
* normalized accepted title and the title event's seq return so the caller
|
||||
* can settle its projection cell without waiting for the push frame. A
|
||||
* title that normalizes to empty fails with `title-invalid`.
|
||||
* Session-backed subagents reject with `agent-busy`.
|
||||
*/
|
||||
rename(request: RpcRequest<{ sessionId: SessionId; title: string }>):
|
||||
Promise<RpcResponse<{ title: string; seq: number }>>
|
||||
@@ -263,23 +270,31 @@ export interface SessionsApi {
|
||||
* falls back to the source's last completed turn. An in-log anchor whose
|
||||
* turn is still open fails with `fork-unavailable` instead of clipping to
|
||||
* an earlier turn. The child inherits the source cwd, latest logged model
|
||||
* target, workspace attachment, and `parentSessionId` lineage; the seed
|
||||
* prefix carries the source title.
|
||||
* target and `parentSessionId` lineage; the seed prefix carries the source
|
||||
* title. Reading the source uses attached state or persistence inspection
|
||||
* without acquiring an Agent. Workspace attachment follows the source
|
||||
* directly, or the nearest workspace-owning ancestor when the source is a
|
||||
* subagent.
|
||||
*/
|
||||
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
|
||||
/** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
/**
|
||||
* Edits or removes one pending queued occurrence.
|
||||
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
|
||||
* Session-backed subagents reject with `agent-busy`.
|
||||
*/
|
||||
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
|
||||
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: MessageId; action: QueueAction }>):
|
||||
Promise<RpcResponse<{ accepted: true }>>
|
||||
|
||||
/** Stops the active turn, preserving pending inbox work that resumes in FIFO order after cancellation settles. */
|
||||
/**
|
||||
* Stops an ordinary session's active turn, preserving pending inbox work
|
||||
* that resumes in FIFO order after cancellation settles. Session-backed
|
||||
* subagents reject with `agent-busy`.
|
||||
*/
|
||||
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
|
||||
|
||||
}
|
||||
|
||||
@@ -32,9 +32,18 @@ export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wi
|
||||
/** settings.describe response value. */
|
||||
export const settingsDescribeValueSchema = z.object({
|
||||
writable: z.boolean(),
|
||||
hasDocument: z.boolean(),
|
||||
namespaces: z.array(settingsNamespaceViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'settings.describe'>>>
|
||||
|
||||
/** settings.openDocument request payload. */
|
||||
export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.openDocument'>>>
|
||||
|
||||
/** settings.openDocument response value. */
|
||||
export const settingsOpenDocumentValueSchema = z.object({
|
||||
opened: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'settings.openDocument'>>>
|
||||
|
||||
/** settings.update request payload. */
|
||||
export const settingsUpdateRequestSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
|
||||
@@ -53,10 +53,26 @@ export type SettingsPathOpView =
|
||||
export interface SettingsApi {
|
||||
/**
|
||||
* Describe every registered namespace: redacted layered values plus the
|
||||
* serialized schema a client renders its form from. `writable: false`
|
||||
* (read-only provider) tells the client to disable every write control.
|
||||
* serialized schema a client renders its form from. `hasDocument` reports
|
||||
* whether a file-backed provider owns a local document without exposing its
|
||||
* Host path. This method is loopback-only; `writable: false` (read-only
|
||||
* provider) tells the client to disable every write control.
|
||||
*/
|
||||
describe(request: RpcRequest<{}>): Promise<RpcResponse<{ writable: boolean; namespaces: SettingsNamespaceView[] }>>
|
||||
describe(request: RpcRequest<{}>): Promise<RpcResponse<{
|
||||
writable: boolean
|
||||
hasDocument: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>
|
||||
|
||||
/**
|
||||
* Materialize the configured local document when absent and ask the Host to
|
||||
* hand it to the platform text-document opener. macOS forces a text editor;
|
||||
* Linux and Windows use the desktop file association. The request carries
|
||||
* no path, so the browser cannot choose an arbitrary Host filesystem target.
|
||||
*/
|
||||
openDocument(
|
||||
request: RpcRequest<{}>, signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>>
|
||||
|
||||
/**
|
||||
* Merge a patch into one namespace's user layer (validate → persist →
|
||||
|
||||
77
packages/host/apiproxy/src/api/subagents.schema.ts
Normal file
77
packages/host/apiproxy/src/api/subagents.schema.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/** Zod schemas for the browser-safe subagent domain. */
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, historyEntrySchema, sessionIdSchema, sessionProjectionsBlockSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import type { SubagentListEntry } from './subagents.ts'
|
||||
|
||||
/** Healthy and diagnostic durable catalog rows. */
|
||||
export const subagentListEntrySchema = z.union([
|
||||
z.object({
|
||||
kind: z.literal('child'),
|
||||
id: sessionIdSchema,
|
||||
mode: z.literal('one-shot'),
|
||||
activity: z.union([z.literal('running'), z.literal('inactive')]),
|
||||
hasChildren: z.boolean(),
|
||||
label: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('child'),
|
||||
id: sessionIdSchema,
|
||||
mode: z.literal('continuable'),
|
||||
activity: z.union([z.literal('running'), z.literal('inactive')]),
|
||||
hasChildren: z.boolean(),
|
||||
label: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('diagnostic'),
|
||||
id: sessionIdSchema,
|
||||
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
|
||||
}),
|
||||
]) satisfies z.ZodType<Wire<SubagentListEntry>>
|
||||
|
||||
/** subagent.list request payload. */
|
||||
export const subagentListRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.list'>>>
|
||||
|
||||
/** subagent.list response value. */
|
||||
export const subagentListValueSchema = z.object({
|
||||
entries: z.array(subagentListEntrySchema),
|
||||
parentAvailable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.list'>>>
|
||||
|
||||
/** subagent.history request payload. */
|
||||
export const subagentHistoryRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
childSessionId: sessionIdSchema,
|
||||
mode: z.union([z.literal('one-shot'), z.literal('continuable')]),
|
||||
beforeSeq: z.number().int().nonnegative().optional(),
|
||||
maxMessages: z.number().int().positive().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.history'>>>
|
||||
|
||||
/** subagent.history response value. */
|
||||
export const subagentHistoryValueSchema = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
projections: sessionProjectionsBlockSchema.optional(),
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'subagent.history'>>>
|
||||
|
||||
/** subagent.prompt request payload. */
|
||||
export const subagentPromptRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
childSessionId: sessionIdSchema,
|
||||
mode: z.literal('continuable'),
|
||||
content: z.array(contentBlockSchema),
|
||||
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
|
||||
|
||||
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
|
||||
|
||||
/** subagent.prompt response value. */
|
||||
export const subagentPromptValueSchema = z.object({
|
||||
messageId: messageIdSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.prompt'>>>
|
||||
96
packages/host/apiproxy/src/api/subagents.ts
Normal file
96
packages/host/apiproxy/src/api/subagents.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Browser-safe subagent domain contract. Persisted transcript reads never
|
||||
* activate an Agent, while continuable prompts route through the exact live
|
||||
* direct parent into the child's Agent inbox.
|
||||
*/
|
||||
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { HistoryEntry, SessionProjectionsBlock } from './sessions.ts'
|
||||
|
||||
/** Complete durable direct-child catalog row. */
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
kind: 'child'
|
||||
id: SessionId
|
||||
/** Whether the child Agent driver is running at the Host sampling boundary. */
|
||||
activity: 'running' | 'inactive'
|
||||
/** Whether a direct descendant has durable `origin: 'subagent'`. */
|
||||
hasChildren: boolean
|
||||
} & (
|
||||
| {
|
||||
mode: 'one-shot'
|
||||
label?: string
|
||||
}
|
||||
| {
|
||||
mode: 'continuable'
|
||||
label: string
|
||||
}
|
||||
)
|
||||
| {
|
||||
kind: 'diagnostic'
|
||||
id: SessionId
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/** Inbox identity returned once the continuation accepts one human message. */
|
||||
export interface SubagentPromptReceipt {
|
||||
messageId: MessageId
|
||||
}
|
||||
|
||||
/** Durable parent/child address that selects subagent transport in the client. */
|
||||
export type SubagentAddress =
|
||||
& {
|
||||
parentSessionId: SessionId
|
||||
childSessionId: SessionId
|
||||
}
|
||||
& (
|
||||
| { mode: 'one-shot' }
|
||||
| { mode: 'continuable' }
|
||||
)
|
||||
|
||||
/** Complete direct-child catalog plus the delivery-time parent availability hint. */
|
||||
export interface SubagentCatalog {
|
||||
entries: SubagentListEntry[]
|
||||
parentAvailable: boolean
|
||||
}
|
||||
|
||||
/** Subagent-domain unary methods. */
|
||||
export interface SubagentsApi {
|
||||
/**
|
||||
* Lists direct session-backed children without loading either side. Parent
|
||||
* availability is a hint; continuable prompt performs the authoritative
|
||||
* check.
|
||||
*/
|
||||
list(
|
||||
request: RpcRequest<{ parentSessionId: SessionId }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<SubagentCatalog>>
|
||||
|
||||
/**
|
||||
* Reads one healthy catalog child's persisted raw log with ordinary
|
||||
* message-aligned pagination and render intents, without Agent activation.
|
||||
*/
|
||||
history(
|
||||
request: RpcRequest<SubagentAddress & { beforeSeq?: number; maxMessages?: number }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<{
|
||||
events: HistoryEntry[]
|
||||
hasMore: boolean
|
||||
projections?: SessionProjectionsBlock
|
||||
}>>
|
||||
|
||||
/**
|
||||
* Delivers human content to a continuable child through the exact live
|
||||
* parent's continuation owner. Success identifies the message accepted by
|
||||
* the child's FIFO inbox; later execution is independent of this request.
|
||||
*/
|
||||
prompt(
|
||||
request: RpcRequest<
|
||||
Extract<SubagentAddress, { mode: 'continuable' }> & { content: ContentBlock[] }
|
||||
>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<SubagentPromptReceipt>>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting,
|
||||
* four-quadrant envelope wrap/unwrap, zod parsing, SSE frame decoding, and the payload-direct
|
||||
* four-quadrant envelope wrap/unwrap, zod parsing, in-process SSE frame decoding, and the payload-direct
|
||||
* IApiClient domain methods (business code never mints). Platform differences ride two aspects:
|
||||
* abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
|
||||
*/
|
||||
@@ -49,12 +49,18 @@ import {
|
||||
goalClearValueSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
import {
|
||||
settingsDescribeValueSchema, settingsMutateValueSchema, settingsReplaceValueSchema, settingsUpdateValueSchema,
|
||||
settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema,
|
||||
settingsReplaceValueSchema, settingsUpdateValueSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import {
|
||||
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
|
||||
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
|
||||
import {
|
||||
subagentHistoryValueSchema,
|
||||
subagentListValueSchema,
|
||||
subagentPromptValueSchema,
|
||||
} from '../api/subagents.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -64,8 +70,8 @@ import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema
|
||||
* Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
|
||||
* carry only that external signal. In both cases the signal rides beside the request, never
|
||||
* on the wire, like the stream signatures.
|
||||
* Stream methods accept an optional onOpen callback: it fires once the SSE transport is
|
||||
* readable (response headers received, before any frame) — the "stream established" signal
|
||||
* Stream methods accept an optional onOpen callback: it fires once the physical transport is
|
||||
* readable (before any frame) — the "stream established" signal
|
||||
* connection controllers need for the readiness handshake. Generators are lazy, so the
|
||||
* underlying fetch (and therefore onOpen) only happens once iteration starts.
|
||||
* Relationship: ApiProxy is the narrow-form signature contract the impl side implements;
|
||||
@@ -86,6 +92,11 @@ export interface IApiClient {
|
||||
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
}
|
||||
subagents: {
|
||||
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
|
||||
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
|
||||
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
|
||||
}
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
|
||||
@@ -122,6 +133,7 @@ export interface IApiClient {
|
||||
}
|
||||
settings: {
|
||||
describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
|
||||
openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.openDocument'>>>
|
||||
update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.update'>>>
|
||||
replace(payload: RequestPayload<'settings.replace'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.replace'>>>
|
||||
mutate(payload: RequestPayload<'settings.mutate'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.mutate'>>>
|
||||
@@ -134,6 +146,7 @@ export interface IApiClient {
|
||||
llm: {
|
||||
providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.providers'>>>
|
||||
models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.models'>>>
|
||||
discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.discoverModels'>>>
|
||||
}
|
||||
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
|
||||
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
|
||||
@@ -155,6 +168,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.updateQueue': sessionUpdateQueueValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'subagent.list': subagentListValueSchema,
|
||||
'subagent.history': subagentHistoryValueSchema,
|
||||
'subagent.prompt': subagentPromptValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'host.pickDirectory': hostPickDirectoryValueSchema,
|
||||
'host.listDirectory': hostListDirectoryValueSchema,
|
||||
@@ -176,6 +192,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'goal.complete': goalCompleteValueSchema,
|
||||
'goal.clear': goalClearValueSchema,
|
||||
'settings.describe': settingsDescribeValueSchema,
|
||||
'settings.openDocument': settingsOpenDocumentValueSchema,
|
||||
'settings.update': settingsUpdateValueSchema,
|
||||
'settings.replace': settingsReplaceValueSchema,
|
||||
'settings.mutate': settingsMutateValueSchema,
|
||||
@@ -184,6 +201,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'credentials.unset': credentialsUnsetValueSchema,
|
||||
'llm.providers': llmProvidersValueSchema,
|
||||
'llm.models': llmModelsValueSchema,
|
||||
'llm.discoverModels': llmDiscoverModelsValueSchema,
|
||||
}
|
||||
|
||||
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -385,6 +403,12 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||
}
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
|
||||
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
// A native system dialog is user-paced and may legitimately stay open
|
||||
@@ -430,6 +454,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: (payload, signal) => this.callUnary('settings.describe', payload, signal),
|
||||
openDocument: (payload, signal) => this.callUnary('settings.openDocument', payload, signal),
|
||||
update: (payload, signal) => this.callUnary('settings.update', payload, signal),
|
||||
replace: (payload, signal) => this.callUnary('settings.replace', payload, signal),
|
||||
mutate: (payload, signal) => this.callUnary('settings.mutate', payload, signal),
|
||||
@@ -444,6 +469,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: (payload, signal) => this.callUnary('llm.providers', payload, signal),
|
||||
models: (payload, signal) => this.callUnary('llm.models', payload, signal),
|
||||
discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal),
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
|
||||
@@ -51,12 +51,18 @@ import {
|
||||
goalClearRequestSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
import {
|
||||
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsReplaceRequestSchema, settingsUpdateRequestSchema,
|
||||
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema,
|
||||
settingsReplaceRequestSchema, settingsUpdateRequestSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import {
|
||||
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
|
||||
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
|
||||
import {
|
||||
subagentHistoryRequestSchema,
|
||||
subagentListRequestSchema,
|
||||
subagentPromptRequestSchema,
|
||||
} from '../api/subagents.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -64,9 +70,8 @@ import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.sc
|
||||
* payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
|
||||
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
|
||||
* documented on Wire); the dispatch point carries the one Wire→exact cast.
|
||||
* Every invoke receives the carrier Request's signal; methods whose contract
|
||||
* declares a signal parameter (session.search and command.execute) forward it,
|
||||
* the rest ignore it.
|
||||
* Every invoke receives the carrier Request's signal; routes whose contract
|
||||
* declares a signal parameter forward it, and the rest ignore it.
|
||||
*/
|
||||
type UnaryRoutes = {
|
||||
[K in keyof RpcMethodMap]: {
|
||||
@@ -87,6 +92,9 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
|
||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
|
||||
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
|
||||
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
|
||||
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
|
||||
@@ -108,6 +116,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) },
|
||||
'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) },
|
||||
'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) },
|
||||
'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) },
|
||||
'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) },
|
||||
'settings.replace': { schema: settingsReplaceRequestSchema, invoke: (api, r) => api.settings.replace(r) },
|
||||
'settings.mutate': { schema: settingsMutateRequestSchema, invoke: (api, r) => api.settings.mutate(r) },
|
||||
@@ -116,6 +125,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'credentials.unset': { schema: credentialsUnsetRequestSchema, invoke: (api, r) => api.credentials.unset(r) },
|
||||
'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) },
|
||||
'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) },
|
||||
'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* platform subclasses on the client side), and the host-side implementation
|
||||
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
|
||||
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
|
||||
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
|
||||
* routes — physical carriers wrap `ctx.apiProxy` themselves.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
@@ -45,7 +45,10 @@ export interface Config {
|
||||
* project directory and the fallback parent for name-created Workspaces.
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = ['agents', 'directoryPicker', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
|
||||
static inject = [
|
||||
'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
|
||||
'tools', 'userInteraction', 'workspace',
|
||||
]
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
@@ -54,6 +57,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
readonly subagents: ApiProxy['subagents']
|
||||
readonly workspace: ApiProxy['workspace']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly commands: ApiProxy['commands']
|
||||
@@ -75,6 +79,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.subagents = api.subagents
|
||||
this.workspace = api.workspace
|
||||
this.host = api.host
|
||||
this.commands = api.commands
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* Cross-platform open-with-default-application used by the local GUI carrier.
|
||||
* Cross-platform native path and text-document openers used by the local GUI
|
||||
* carrier.
|
||||
*
|
||||
* A document a browser RENDERS is opened with the user's default browser
|
||||
* rather than the default application for its type, when the platform can name
|
||||
* one: a developer who binds `.html` to an editor would otherwise click a
|
||||
* produced page and get source code. The contract is uniform — prefer the
|
||||
* default browser, fall back to the default application — while how completely
|
||||
* a platform can answer "which browser" differs, and every failure falls back
|
||||
* rather than surfacing.
|
||||
* Under the default intent, a document a browser RENDERS is opened with the
|
||||
* user's default browser rather than the default application for its type,
|
||||
* when the platform can name one: a developer who binds `.html` to an editor
|
||||
* would otherwise click a produced page and get source code. The contract is
|
||||
* uniform — prefer the default browser, fall back to the default application —
|
||||
* while how completely a platform can answer "which browser" differs, and
|
||||
* every failure falls back rather than surfacing. The text-editor intent never
|
||||
* consults the browser: it exists to open the document's TEXT.
|
||||
*/
|
||||
|
||||
import { extname } from 'node:path'
|
||||
@@ -76,32 +78,30 @@ async function openInBrowser(
|
||||
return false
|
||||
}
|
||||
|
||||
/** Native path-open intent; macOS distinguishes text editing from file association. */
|
||||
type PathOpenIntent = 'default' | 'text-editor'
|
||||
|
||||
/** PowerShell single-quoted literal (doubles embedded quotes). */
|
||||
function powershellLiteral(path: string): string {
|
||||
return `'${path.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application, or
|
||||
* with the default browser when the path names a document a browser renders.
|
||||
* @param path - absolute or host-resolvable path (caller owns resolution).
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform, environment, and runner seam for deterministic tests.
|
||||
*/
|
||||
export async function openNativePath(
|
||||
/** Dispatch one shell-free platform command for the requested open intent. */
|
||||
async function openNativePathWithIntent(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
intent: PathOpenIntent,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
const env = internals.env ?? process.env
|
||||
|
||||
if (BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
|
||||
if (intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
|
||||
&& await openInBrowser(path, signal, platform, run, env)) return
|
||||
|
||||
if (platform === 'darwin') {
|
||||
await run('open', [path], signal)
|
||||
await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,3 +121,33 @@ export async function openNativePath(
|
||||
|
||||
throw new Error(`native path opener is unsupported on ${platform}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application, or
|
||||
* with the default browser when the path names a document a browser renders.
|
||||
* @param path - absolute or host-resolvable path (caller owns resolution).
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform, environment, and runner seam for deterministic tests.
|
||||
*/
|
||||
export function openNativePath(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
return openNativePathWithIntent(path, signal, 'default', internals)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a text document for editing; macOS bypasses the file-type association
|
||||
* so a YAML association with a browser cannot consume the gesture.
|
||||
* @param path - absolute or host-resolvable text-document path.
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
*/
|
||||
export function openNativeTextFile(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
return openNativePathWithIntent(path, signal, 'text-editor', internals)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
/** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */
|
||||
function agentOf(ctx: Context): Agent {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
return { session } as unknown as Agent
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('approval pending registry', () => {
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' })
|
||||
const agent = { session } as unknown as Agent
|
||||
const cancelled = new AbortController()
|
||||
@@ -308,7 +308,7 @@ describe('approval pending registry', () => {
|
||||
// Bypass ApprovalService: a log whose sole asked event already has its
|
||||
// decided partner must not be re-claimed — the answerer delegates.
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' })
|
||||
session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' })
|
||||
const agent = { session } as unknown as Agent
|
||||
@@ -322,7 +322,7 @@ describe('approval pending registry', () => {
|
||||
// Bypass ApprovalService: dispatch the waterfall directly with a session
|
||||
// that has no approval/asked event — the proxy answerer must call next().
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = { session } as unknown as Agent
|
||||
const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const))
|
||||
expect(outcome).toBe('unavailable')
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('summary blank = conversation not started', () => {
|
||||
const session = ctx.sessions.create()
|
||||
attach(session)
|
||||
appendStandalone(session)
|
||||
session.append('turn/start', { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 0 })
|
||||
expect(await listBlank(api, session.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
/**
|
||||
* Cold-session and degenerate-composition paths of the host ApiProxy:
|
||||
* sessions.list merging persisted-but-unattached summaries (mtime source,
|
||||
* createdAt fallbacks, lineage projection), the resume error split when
|
||||
* the composition has no persistence gate and no agent factory, and the
|
||||
* agent-busy mapping of a synchronous prompt rejection.
|
||||
* metadata-only listing, Agent-free history reads, subagent ownership
|
||||
* isolation, and prompt failure mapping.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
PersistenceCoordinator,
|
||||
SessionPersistenceRevision,
|
||||
type PersistenceBackend,
|
||||
type StoredPrefix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
@@ -42,7 +47,7 @@ describe('sessions.list cold merge', () => {
|
||||
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
|
||||
const metas = [
|
||||
header('session-a', 1000),
|
||||
header('session-b', 2000, { parentSession: sid('session-parent') }),
|
||||
header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
|
||||
header('session-c', 1500),
|
||||
]
|
||||
// Structural fake of the persistence face list() consumes: list + locate.
|
||||
@@ -74,6 +79,7 @@ describe('sessions.list cold merge', () => {
|
||||
expect(a?.parentSessionId).toBeUndefined()
|
||||
expect(b?.updatedAt).toBe(2000)
|
||||
expect(b?.parentSessionId).toBe('session-parent')
|
||||
expect(b?.origin).toBe('subagent')
|
||||
expect(c?.updatedAt).toBe(1500)
|
||||
})
|
||||
})
|
||||
@@ -90,7 +96,7 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
const worked = 1_000_000
|
||||
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
],
|
||||
meta: { cwd: '/proj', createdAt: 500 },
|
||||
@@ -106,7 +112,7 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
expect(summary?.updatedAt).toBe(worked)
|
||||
|
||||
// Real work appended after end-seed does move it.
|
||||
resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
resumed.append('turn/start', { turn: 2 })
|
||||
const after = await api.sessions.list(request({}))
|
||||
if (!after.result.ok) throw new Error('list failed')
|
||||
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
@@ -114,8 +120,220 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cold history recovery view', () => {
|
||||
it('shows in-memory interruption repair without activating the session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('session-interrupted')
|
||||
const meta = header(sessionId, 1000)
|
||||
const stored: StoredPrefix<never> = {
|
||||
meta,
|
||||
events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }],
|
||||
revision: SessionPersistenceRevision('history-recovery-test:1'),
|
||||
}
|
||||
const backend: PersistenceBackend<never> = {
|
||||
name: 'history-recovery-test',
|
||||
loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined),
|
||||
readStoredRevision: id => Promise.resolve(
|
||||
id === sessionId ? SessionPersistenceRevision('history-recovery-test:1') : undefined,
|
||||
),
|
||||
appendBatch: () => Promise.resolve(),
|
||||
commitRepair: () => Promise.resolve(),
|
||||
list: () => Promise.resolve([structuredClone(meta)]),
|
||||
}
|
||||
const coordinator = new PersistenceCoordinator(ctx, backend)
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: (signal?: AbortSignal) => backend.list(signal),
|
||||
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
|
||||
if (!history.result.ok) throw new Error('history failed')
|
||||
expect(history.result.value.events.map(entry => entry.event)).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"data": {
|
||||
"turn": 1,
|
||||
},
|
||||
"seq": 0,
|
||||
"time": 1,
|
||||
"type": "turn/start",
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"reason": {
|
||||
"kind": "interrupted",
|
||||
},
|
||||
"turn": 1,
|
||||
},
|
||||
"seq": 1,
|
||||
"time": 1,
|
||||
"type": "turn/end",
|
||||
},
|
||||
]
|
||||
`)
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent ownership fence', () => {
|
||||
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('session-child')
|
||||
const meta = header('session-child', 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
seedLength: 0,
|
||||
})
|
||||
const events = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: 3,
|
||||
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
|
||||
},
|
||||
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta, events }))
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const history = await api.sessions.history(request({ sessionId }))
|
||||
expect(history.result.ok).toBe(true)
|
||||
if (history.result.ok) {
|
||||
expect(history.result.value.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type))
|
||||
}
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
|
||||
const prompt = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'follow up' }],
|
||||
}))
|
||||
expect(prompt.result.ok).toBe(false)
|
||||
if (!prompt.result.ok) {
|
||||
expect(prompt.result.error).toMatchObject({
|
||||
code: 'agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
})
|
||||
}
|
||||
|
||||
const create = await api.sessions.create(request({ sessionId, cwd: '/proj' }))
|
||||
expect(create.result.ok).toBe(false)
|
||||
if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(inspect).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
|
||||
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(parent)
|
||||
|
||||
const originSession = ctx.sessions.create(sid('session-origin-child'), {
|
||||
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
const cancel = vi.fn()
|
||||
const updateInbox = vi.fn(() => 'applied' as const)
|
||||
const originChild = {
|
||||
id: originSession.id,
|
||||
session: originSession,
|
||||
status: 'idle',
|
||||
ctx,
|
||||
cancel,
|
||||
updateInbox,
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(originChild)
|
||||
|
||||
const startingSession = ctx.sessions.create(sid('session-starting-child'), {
|
||||
meta: { cwd: '/proj', parentSession: parent.id },
|
||||
})
|
||||
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.enter(startingChild, parent)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
|
||||
expect(stopped.result.ok).toBe(false)
|
||||
if (!stopped.result.ok) expect(stopped.result.error.code).toBe('agent-busy')
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
|
||||
const queued = await api.sessions.updateQueue(request({
|
||||
sessionId: originChild.id,
|
||||
itemId: MessageId('queued-item'),
|
||||
action: { kind: 'remove' },
|
||||
}))
|
||||
expect(queued.result.ok).toBe(false)
|
||||
if (!queued.result.ok) expect(queued.result.error.code).toBe('agent-busy')
|
||||
expect(updateInbox).not.toHaveBeenCalled()
|
||||
|
||||
const models = await api.sessions.models(request({ sessionId: startingChild.id }))
|
||||
expect(models.result.ok).toBe(false)
|
||||
if (!models.result.ok) expect(models.result.error.code).toBe('agent-busy')
|
||||
|
||||
const create = await api.sessions.create(request({ sessionId: originChild.id, cwd: '/proj' }))
|
||||
expect(create.result.ok).toBe(false)
|
||||
if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
|
||||
|
||||
const history = await api.sessions.history(request({ sessionId: originChild.id }))
|
||||
expect(history.result.ok).toBe(true)
|
||||
expect(ctx.agents.get(originChild.id)).toBe(originChild)
|
||||
})
|
||||
|
||||
it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const session = ctx.sessions.create(sid('session-ordinary-fork'), {
|
||||
seed: [{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' },
|
||||
}],
|
||||
meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 },
|
||||
})
|
||||
const followup = vi.fn()
|
||||
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const response = await api.sessions.prompt(request({
|
||||
sessionId: agent.id,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'ordinary work' }],
|
||||
}))
|
||||
expect(response.result.ok).toBe(true)
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('degenerate composition (no persistence, no factory)', () => {
|
||||
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
|
||||
it('list skips the cold merge and history reports missing persistence as internal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -126,15 +344,32 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
expect(listed.result.ok).toBe(true)
|
||||
if (listed.result.ok) expect(listed.result.value.items).toEqual([])
|
||||
|
||||
// No persistence → the servable gate passes silently; the factory-less
|
||||
// registry then rejects resume, which is NOT a SessionNotFound.
|
||||
// No persistence means cold history cannot inspect a transcript.
|
||||
const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) {
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
|
||||
expect(response.result.error.message).toMatch(/history unavailable for session "session-ghost"/)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps a persistence catalog miss to session-not-found without inspection', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const inspect = vi.fn()
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([]),
|
||||
inspect,
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.prompt synchronous rejection', () => {
|
||||
@@ -170,4 +405,43 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a raced cold-resume ID collision as agent-busy', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('race-resume')
|
||||
const meta: SessionHeader = header('race-resume', 1000)
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
// The raced winner: a live parent-owned subagent publishes the identity
|
||||
// while the generic cold resume is in flight, so the resume collides.
|
||||
const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
|
||||
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(parent)
|
||||
const childSession = ctx.sessions.create(sessionId, {
|
||||
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent
|
||||
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
|
||||
// The parent's `enter()` wins the identity between the pre-resume
|
||||
// re-check and publication; the generic resume then collides.
|
||||
ctx.agents.register(child)
|
||||
throw new Error('session id already published')
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const models = await api.sessions.models(request({ sessionId }))
|
||||
expect(models.result.ok).toBe(false)
|
||||
if (!models.result.ok) {
|
||||
expect(models.result.error).toMatchObject({
|
||||
code: 'agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,8 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -20,7 +20,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import type { HostFrame, MuxFrame } from '../src/api/index.ts'
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
@@ -63,7 +63,14 @@ async function harness(options: { commands?: boolean; skills?: boolean } = {}):
|
||||
/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
|
||||
function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
|
||||
const session = ctx.sessions.create(sessionId)
|
||||
const agent = { id: session.id, session, status: 'idle', ctx } as Agent
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
const agent = {
|
||||
id: session.id,
|
||||
session,
|
||||
inbox,
|
||||
status: 'idle',
|
||||
ctx,
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
@@ -78,6 +85,13 @@ async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number,
|
||||
return frames
|
||||
}
|
||||
|
||||
/** Read the next payload from an open stream. */
|
||||
async function nextFrame<F>(iterator: AsyncIterator<RpcRequest<F>>): Promise<F> {
|
||||
const result = await iterator.next()
|
||||
if (result.done) throw new Error('stream ended')
|
||||
return result.value.payload
|
||||
}
|
||||
|
||||
describe('command.list', () => {
|
||||
it('serves the addressed agent\'s name-sorted catalog', async () => {
|
||||
const ctx = await harness()
|
||||
@@ -264,7 +278,7 @@ describe('host/commands-changed frame', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** Build one frozen inbox message for the live `agent/inbox/*` events. */
|
||||
/** Build one frozen inbox message. */
|
||||
function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
|
||||
return freezeMessage({
|
||||
id: MessageId(id),
|
||||
@@ -274,27 +288,19 @@ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
|
||||
})
|
||||
}
|
||||
|
||||
/** Build one addressable inbox occurrence around a frozen message. */
|
||||
function inboxItem(id: string, message: UserMessage, placement: InboxPlacement): InboxItem {
|
||||
return { id: InboxItemId(id), message, placement }
|
||||
}
|
||||
|
||||
describe('session.updateQueue', () => {
|
||||
it('routes an addressable action and reports a lost claim race', async () => {
|
||||
it('splices a queued message and reports a lost claim race', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = stubAgent(ctx)
|
||||
const seen: unknown[] = []
|
||||
agent.updateInbox = (id, action) => {
|
||||
seen.push({ id, action })
|
||||
return id === InboxItemId('present') ? 'applied' : 'not-found'
|
||||
}
|
||||
const present = inboxMessage('present', 'before')
|
||||
agent.inbox.splice('next-turn', 0, 0, [present])
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const applied = await api.sessions.updateQueue({
|
||||
rpcId: RpcId('q-apply'),
|
||||
payload: {
|
||||
sessionId: agent.id,
|
||||
itemId: InboxItemId('present'),
|
||||
itemId: MessageId('present'),
|
||||
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
|
||||
},
|
||||
})
|
||||
@@ -303,15 +309,15 @@ describe('session.updateQueue', () => {
|
||||
rpcId: RpcId('q-missing'),
|
||||
payload: {
|
||||
sessionId: agent.id,
|
||||
itemId: InboxItemId('claimed'),
|
||||
itemId: MessageId('claimed'),
|
||||
action: { kind: 'remove' },
|
||||
},
|
||||
})
|
||||
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
|
||||
expect(seen).toEqual([
|
||||
{ id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } },
|
||||
{ id: 'claimed', action: { kind: 'remove' } },
|
||||
])
|
||||
expect(agent.inbox.nextTurn[0]).toMatchObject({
|
||||
id: 'present',
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a stale occurrence without resuming a cold agent', async () => {
|
||||
@@ -322,7 +328,7 @@ describe('session.updateQueue', () => {
|
||||
rpcId: RpcId('q-cold'),
|
||||
payload: {
|
||||
sessionId: 'cold-session' as SessionId,
|
||||
itemId: InboxItemId('stale-item'),
|
||||
itemId: MessageId('stale-item'),
|
||||
action: { kind: 'remove' },
|
||||
},
|
||||
})
|
||||
@@ -333,100 +339,64 @@ describe('session.updateQueue', () => {
|
||||
})
|
||||
|
||||
describe('session/queue frames', () => {
|
||||
it('folds nested mutations observed before their outer enqueue', async () => {
|
||||
it('publishes authoritative inbox snapshots without duplicating message identity', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const original = inboxItem('i-edit', inboxMessage('m-edit', 'before'), 'queued')
|
||||
const edited = inboxItem('i-edit', inboxMessage('m-edit', 'after'), 'queued')
|
||||
const removed = inboxItem('i-remove', inboxMessage('m-remove', 'remove me'), 'queued')
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent) return
|
||||
if (item.id === original.id) ctx.emit('agent/inbox/update', agent, edited)
|
||||
if (item.id === removed.id) ctx.emit('agent/inbox/discard', agent, [removed])
|
||||
const queued = inboxMessage('m-1', 'queued prompt')
|
||||
const edited = inboxMessage('m-1', 'edited prompt')
|
||||
const steering = inboxMessage('m-2', 'steering prompt')
|
||||
agent.inbox.splice('next-turn', 0, 0, [queued])
|
||||
agent.inbox.splice('next-step', 0, 0, [steering])
|
||||
|
||||
const abort = new AbortController()
|
||||
const iterator = api.events.mux({
|
||||
rpcId: RpcId('t-mux-baseline'),
|
||||
payload: {},
|
||||
}, abort.signal)[Symbol.asyncIterator]()
|
||||
const frames = [
|
||||
await nextFrame(iterator),
|
||||
await nextFrame(iterator),
|
||||
]
|
||||
agent.inbox.splice('next-turn', 0, 1, [edited])
|
||||
frames.push(await nextFrame(iterator), await nextFrame(iterator))
|
||||
const injected = freezeMessage({
|
||||
id: MessageId('m-3'),
|
||||
role: 'user',
|
||||
content: [{ type: 'text' as const, text: 'injected context' }],
|
||||
source: { kind: 'plugin' as const, plugin: 'approval' },
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const live = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-reentrant'), payload: {} }, live.signal), 2, live)
|
||||
agent.inbox.splice('next-step', 0, 0, [injected])
|
||||
frames.push(await nextFrame(iterator), await nextFrame(iterator))
|
||||
abort.abort()
|
||||
await iterator.return?.()
|
||||
|
||||
ctx.emit('agent/inbox/enqueue', agent, original)
|
||||
ctx.emit('agent/inbox/enqueue', agent, removed)
|
||||
|
||||
const liveFrames = (await collected).filter(frame => frame.type === 'session/queue')
|
||||
expect(liveFrames.map(frame => frame.items)).toEqual([
|
||||
[{ id: edited.id, message: edited.message }],
|
||||
])
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-reentrant-replay'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames)
|
||||
})
|
||||
|
||||
it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const live = new AbortController()
|
||||
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
|
||||
// subscribed baseline + one queued snapshot; pending steering stays off this wire.
|
||||
const liveCollected = collect<MuxFrame>(liveStream, 2, live)
|
||||
|
||||
const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued')
|
||||
const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued)
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
|
||||
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue')
|
||||
expect(liveFrames).toEqual([
|
||||
expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([
|
||||
{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [{ id: queued.id, message: queued.message }],
|
||||
items: [
|
||||
{ id: queued.id, placement: 'queued', message: queued },
|
||||
{ id: steering.id, placement: 'steering', message: steering },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [
|
||||
{ id: edited.id, placement: 'queued', message: edited },
|
||||
{ id: steering.id, placement: 'steering', message: steering },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [
|
||||
{ id: edited.id, placement: 'queued', message: edited },
|
||||
{ id: injected.id, placement: 'context', message: injected },
|
||||
{ id: steering.id, placement: 'steering', message: steering },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// A fresh mux connection replays only the current authoritative snapshot.
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]])
|
||||
})
|
||||
|
||||
it('publishes edits in place in the authoritative order', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const abort = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 5, abort)
|
||||
const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued')
|
||||
const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued')
|
||||
const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, first)
|
||||
ctx.emit('agent/inbox/enqueue', agent, second)
|
||||
ctx.emit('agent/inbox/update', agent, edited)
|
||||
ctx.emit('agent/inbox/dequeue', agent, edited)
|
||||
|
||||
const frames = (await collected).filter(frame => frame.type === 'session/queue')
|
||||
expect(frames.map(frame => frame.items)).toEqual([
|
||||
[{ id: first.id, message: first.message }],
|
||||
[{ id: first.id, message: first.message }, { id: second.id, message: second.message }],
|
||||
[{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }],
|
||||
[{ id: first.id, message: first.message }],
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes an empty snapshot after terminal discard', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const doomed = inboxItem('i-doomed', inboxMessage('m-5', 'doomed'), 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, doomed)
|
||||
ctx.emit('agent/inbox/discard', agent, [doomed])
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 1, abort)
|
||||
expect(frames.filter(frame => frame.type === 'session/queue')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* invalidation frames (settings/credentials/models changed).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
@@ -47,18 +47,35 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown>
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown>; readOnly?: boolean }) {
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
|
||||
doc?: Record<string, unknown>
|
||||
readOnly?: boolean
|
||||
documentPath?: string
|
||||
preparedPath?: string
|
||||
}) {
|
||||
super(ctx)
|
||||
this.doc = structuredClone(options?.doc ?? {})
|
||||
this.readOnly = options?.readOnly ?? false
|
||||
this.path = options?.documentPath
|
||||
this.preparedPath = options?.preparedPath
|
||||
}
|
||||
|
||||
private readonly readOnly: boolean
|
||||
private readonly path: string | undefined
|
||||
private readonly preparedPath: string | undefined
|
||||
|
||||
get writable(): boolean {
|
||||
return !this.readOnly
|
||||
}
|
||||
|
||||
override get documentPath(): string | undefined {
|
||||
return this.path
|
||||
}
|
||||
|
||||
override prepareDocument(): Promise<string | undefined> {
|
||||
return Promise.resolve(this.preparedPath ?? this.documentPath)
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
@@ -146,7 +163,12 @@ const AdapterConfig = z.object({
|
||||
})
|
||||
|
||||
async function harness(options?: {
|
||||
settings?: false | { doc?: Record<string, unknown>; readOnly?: boolean }
|
||||
settings?: false | {
|
||||
doc?: Record<string, unknown>
|
||||
readOnly?: boolean
|
||||
documentPath?: string
|
||||
preparedPath?: string
|
||||
}
|
||||
credentials?: false | { shadowed?: string[] }
|
||||
/** Skip the directory registration to exercise a namespace the proxy does not expose. */
|
||||
configurableProviders?: false
|
||||
@@ -205,11 +227,15 @@ describe('settings domain', () => {
|
||||
})
|
||||
|
||||
it('describes layered redacted namespaces with their secret slots', async () => {
|
||||
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } } } })
|
||||
const ctx = await harness({ settings: {
|
||||
doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } },
|
||||
documentPath: '/tmp/custom-settings.yaml',
|
||||
} })
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.writable).toBe(true)
|
||||
expect(value.hasDocument).toBe(true)
|
||||
expect(value.namespaces).toHaveLength(1)
|
||||
const view = value.namespaces[0]!
|
||||
expect(view.ns).toBe('llm-deepseek')
|
||||
@@ -222,6 +248,62 @@ describe('settings domain', () => {
|
||||
expect(JSON.stringify(value)).not.toContain('user-secret')
|
||||
})
|
||||
|
||||
it('opens the provider-resolved document without accepting a browser path', async () => {
|
||||
const ctx = await harness({ settings: {
|
||||
documentPath: '/tmp/described-settings.yaml',
|
||||
preparedPath: '/tmp/custom-settings.yaml',
|
||||
} })
|
||||
const opened: string[] = []
|
||||
const api = createApiProxy(ctx, {
|
||||
...DEFAULTS,
|
||||
openTextFile: (path) => {
|
||||
opened.push(path)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
expect(expectOk(await api.settings.openDocument(request({}), new AbortController().signal)))
|
||||
.toEqual({ opened: true })
|
||||
expect(opened).toEqual(['/tmp/custom-settings.yaml'])
|
||||
})
|
||||
|
||||
it('refuses to open settings when the provider has no local document', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
expect(expectOk(await api.settings.describe(request({}))).hasDocument).toBe(false)
|
||||
const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('no local document')
|
||||
})
|
||||
|
||||
it('does not prepare or open a settings document after cancellation', async () => {
|
||||
const ctx = await harness({ settings: { documentPath: '/tmp/settings.yaml' } })
|
||||
const opened: string[] = []
|
||||
const api = createApiProxy(ctx, {
|
||||
...DEFAULTS,
|
||||
openTextFile: (path) => {
|
||||
opened.push(path)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
const prepare = vi.spyOn(ctx.settings, 'prepareDocument')
|
||||
const cancelled = new AbortController()
|
||||
cancelled.abort()
|
||||
expect(expectErr(await api.settings.openDocument(request({}), cancelled.signal)).code)
|
||||
.toBe('cancelled')
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
|
||||
const pending = Promise.withResolvers<string | undefined>()
|
||||
prepare.mockReturnValueOnce(pending.promise)
|
||||
const duringPrepare = new AbortController()
|
||||
const opening = api.settings.openDocument(request({}), duringPrepare.signal)
|
||||
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
|
||||
duringPrepare.abort()
|
||||
pending.resolve('/tmp/settings.yaml')
|
||||
expect(expectErr(await opening).code).toBe('cancelled')
|
||||
expect(opened).toEqual([])
|
||||
})
|
||||
|
||||
it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
|
||||
// The settings seam is general: any plugin may register a namespace for
|
||||
// its own configuration. The Web configuration plane remains opt-in, so a
|
||||
@@ -441,11 +523,16 @@ describe('llm domain', () => {
|
||||
])
|
||||
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash']))
|
||||
ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1']))
|
||||
// Only one namespace can answer an interrogation, so the flag follows the
|
||||
// entry's namespace rather than being assumed for every row.
|
||||
ctx.llm.registerModelDiscovery('llm-pi-ai', () => Promise.resolve([]))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const value = expectOk(await api.llm.providers(request({})))
|
||||
expect(value.providers).toEqual([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false },
|
||||
// An undeclared live route has no settings address, so nothing can be
|
||||
// interrogated on its behalf either.
|
||||
{ provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true },
|
||||
])
|
||||
})
|
||||
@@ -478,3 +565,108 @@ describe('llm domain', () => {
|
||||
expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('llm.discoverModels', () => {
|
||||
it('carries a draft to its namespace and returns candidates without storing anything', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: unknown[] = []
|
||||
ctx.llm.registerModelDiscovery('llm-pi-ai', (probe) => {
|
||||
seen.push({ baseURL: probe.baseURL, api: probe.api, apiKey: probe.apiKey })
|
||||
return Promise.resolve([
|
||||
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
|
||||
{ id: 'acme-small' },
|
||||
])
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const value = expectOk(await api.llm.discoverModels(request({
|
||||
settingsNs: 'llm-pi-ai',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
api: 'openai-completions',
|
||||
apiKey: 'probe-key',
|
||||
})))
|
||||
|
||||
expect(value.models).toEqual([
|
||||
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
|
||||
{ id: 'acme-small' },
|
||||
])
|
||||
expect(seen).toEqual([{
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
api: 'openai-completions',
|
||||
apiKey: 'probe-key',
|
||||
}])
|
||||
// Interrogating a draft is a read: no namespace gained a section, and no
|
||||
// credential reference was written.
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
||||
.not.toContain('llm-pi-ai')
|
||||
})
|
||||
|
||||
it('carries the route being edited so an adapter can answer from its own registry', async () => {
|
||||
const ctx = await harness()
|
||||
let probe: unknown
|
||||
ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
|
||||
probe = request_
|
||||
return Promise.resolve([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const value = expectOk(await api.llm.discoverModels(request({
|
||||
settingsNs: 'llm-pi-ai',
|
||||
provider: 'deepseek',
|
||||
})))
|
||||
|
||||
// No endpoint at all: a route the adapter already describes needs none.
|
||||
expect(probe).toEqual({ provider: 'deepseek' })
|
||||
expect(value.models).toEqual([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
|
||||
})
|
||||
|
||||
it('omits a credential and protocol the draft does not name', async () => {
|
||||
const ctx = await harness()
|
||||
let probe: unknown
|
||||
ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
|
||||
probe = request_
|
||||
return Promise.resolve([])
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
expectOk(await api.llm.discoverModels(request({
|
||||
settingsNs: 'llm-pi-ai',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
})))
|
||||
|
||||
// Absent fields stay absent rather than crossing as explicit undefined:
|
||||
// the adapter distinguishes "no protocol named" from "protocol undefined".
|
||||
expect(probe).toEqual({ baseURL: 'https://gateway.acme.example/v1' })
|
||||
})
|
||||
|
||||
it('reports a failed interrogation as the form\'s next move, naming no credential', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.llm.registerModelDiscovery('llm-pi-ai', () =>
|
||||
Promise.reject(new Error('https://gateway.acme.example/v1/models answered 401; check the API key')))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const error = expectErr(await api.llm.discoverModels(request({
|
||||
settingsNs: 'llm-pi-ai',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
apiKey: 'wrong',
|
||||
})))
|
||||
|
||||
expect(error.code).toBe('model-discovery-failed')
|
||||
expect(error.message).toContain('answered 401; check the API key')
|
||||
expect(error.details).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1' })
|
||||
expect(JSON.stringify(error)).not.toContain('wrong')
|
||||
})
|
||||
|
||||
it('reports a namespace no adapter family serves', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const error = expectErr(await api.llm.discoverModels(request({
|
||||
settingsNs: 'llm-deepseek',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
})))
|
||||
|
||||
expect(error.code).toBe('model-discovery-failed')
|
||||
expect(error.message).toContain('no model discovery is registered')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/** Session-fork boundaries, lineage, and inherited model routing. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { Workspace } from '@deepseek-ai/dsh-workspace'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
@@ -21,13 +22,13 @@ function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
async function composed(): Promise<Context> {
|
||||
async function composed(workspaces: readonly Workspace[] = []): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
ctx.provide('workspace', { list: () => workspaces } as never)
|
||||
ctx.agents.setFactory({
|
||||
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
|
||||
const session = ctx.sessions.create(options.sessionId, {
|
||||
@@ -49,10 +50,16 @@ async function composed(): Promise<Context> {
|
||||
/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
|
||||
type Tail = 'none' | 'open' | 'aborted'
|
||||
|
||||
function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none'): Session {
|
||||
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
|
||||
function liveAgent(
|
||||
ctx: Context,
|
||||
id: string,
|
||||
turns: number,
|
||||
tail: Tail = 'none',
|
||||
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
|
||||
): Session {
|
||||
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
|
||||
for (let turn = 1; turn <= turns; turn++) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
|
||||
source: { kind: 'user' },
|
||||
@@ -60,12 +67,15 @@ function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none')
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
if (tail !== 'none') {
|
||||
session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: turns + 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'open prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
if (tail === 'aborted') session.append('turn/end', { turn: turns + 1, reason: { kind: 'aborted' } })
|
||||
if (tail === 'aborted') session.append('turn/end', {
|
||||
turn: turns + 1,
|
||||
reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
})
|
||||
}
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
return session
|
||||
@@ -94,6 +104,103 @@ describe('sessions.fork', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('attaches a subagent fork to its nearest workspace-owning ancestor', async () => {
|
||||
const accounted: SessionId[] = []
|
||||
const attachSession = vi.fn<(sessionId: SessionId) => Promise<void>>()
|
||||
.mockResolvedValue(undefined)
|
||||
const workspace = {
|
||||
sessionIds: accounted,
|
||||
attachSession,
|
||||
} as unknown as Workspace
|
||||
const ctx = await composed([workspace])
|
||||
const owner = liveAgent(ctx, 'session-owner', 1)
|
||||
accounted.push(owner.id)
|
||||
const child = liveAgent(ctx, 'session-child', 1, 'none', {
|
||||
parentSession: owner.id,
|
||||
origin: 'subagent',
|
||||
})
|
||||
const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', {
|
||||
parentSession: child.id,
|
||||
origin: 'subagent',
|
||||
})
|
||||
ctx.provide('sessionQuery', {
|
||||
traceSession: vi.fn(() => Promise.resolve({
|
||||
target: { header: grandchild.header, live: true, persisted: false },
|
||||
ancestors: [
|
||||
{ header: child.header, live: true, persisted: false },
|
||||
{ header: owner.header, live: true, persisted: false },
|
||||
],
|
||||
descendants: [],
|
||||
complete: true,
|
||||
root: { header: owner.header, live: true, persisted: false },
|
||||
})),
|
||||
} as never)
|
||||
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: grandchild.id }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) return
|
||||
expect(attachSession).toHaveBeenCalledWith(response.result.value.sessionId)
|
||||
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
|
||||
parentSession: grandchild.id,
|
||||
cwd: '/proj',
|
||||
})
|
||||
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forks a persisted subagent without resuming its Agent', async () => {
|
||||
const ctx = await composed()
|
||||
const sourceId = sid('session-cold-subagent')
|
||||
const parentId = sid('session-cold-parent')
|
||||
const header: SessionHeader = {
|
||||
version: 0,
|
||||
id: sourceId,
|
||||
createdAt: 1,
|
||||
cwd: '/proj',
|
||||
parentSession: parentId,
|
||||
origin: 'subagent',
|
||||
}
|
||||
const events = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([header]),
|
||||
inspect: () => Promise.resolve({ meta: header, events }),
|
||||
} as never)
|
||||
ctx.provide('sessionQuery', {
|
||||
traceSession: () => Promise.resolve({
|
||||
target: { header, live: false, persisted: true },
|
||||
ancestors: [],
|
||||
descendants: [],
|
||||
complete: true,
|
||||
root: { header, live: false, persisted: true },
|
||||
}),
|
||||
} as never)
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: sourceId }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) return
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(ctx.agents.get(sourceId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
|
||||
parentSession: sourceId,
|
||||
cwd: '/proj',
|
||||
})
|
||||
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses the last completed turn only for omitted and past-end anchors', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-tail', 2, 'open')
|
||||
@@ -173,7 +280,7 @@ describe('sessions.fork', () => {
|
||||
})
|
||||
const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' }
|
||||
await expect(agentEvents(child.ctx, child).waterfall(
|
||||
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback),
|
||||
'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback),
|
||||
)).resolves.toMatchObject({
|
||||
provider: 'inherited-provider',
|
||||
model: 'inherited-model',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Web session model-directory and selection behavior: dynamic provider grouping,
|
||||
* provider-local catalog failures, logged-target restoration, advisory unlisted
|
||||
* models, and the prompt-assembly boundary for a running selection change.
|
||||
* provider-local catalog failures, logged-target restoration without stale
|
||||
* catalog injection, advisory pass-through models, and the prompt-assembly
|
||||
* boundary for a running selection change.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -118,7 +119,7 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
|
||||
}
|
||||
|
||||
describe('Web session model selection', () => {
|
||||
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
|
||||
it('groups successful providers and leaves an unlisted current target out of the catalog', async () => {
|
||||
const { ctx, sessionId } = await harness({
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
@@ -143,12 +144,6 @@ describe('Web session model selection', () => {
|
||||
description: 'Reasoning model',
|
||||
reasoning: REASONING,
|
||||
},
|
||||
{
|
||||
id: 'private-preview',
|
||||
name: 'private-preview',
|
||||
unlisted: true,
|
||||
reasoning: REASONING,
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(catalog.failures).toEqual([
|
||||
@@ -186,13 +181,13 @@ describe('Web session model selection', () => {
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
|
||||
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
|
||||
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables)
|
||||
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
|
||||
'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed),
|
||||
)).resolves.toMatchObject({
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -53,9 +53,8 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
|
||||
const session = ctx.sessions.create()
|
||||
// history resolves the agent first; a live structural stub is enough (only
|
||||
// .session is read on this path).
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
// The gateway reads both the session and durable inbox baseline.
|
||||
ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent)
|
||||
return { ctx, session }
|
||||
}
|
||||
|
||||
@@ -225,7 +224,7 @@ describe('session/projection push frame', () => {
|
||||
|
||||
seedMessages(session, 1)
|
||||
// Same-reference apply: turn/start does not concern the unit — no frame.
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
seedMessages(session, 1)
|
||||
|
||||
const frames = await collected
|
||||
|
||||
116
packages/host/apiproxy/tests/api-proxy-question.spec.ts
Normal file
116
packages/host/apiproxy/tests/api-proxy-question.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
return {
|
||||
ctx,
|
||||
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
|
||||
}
|
||||
}
|
||||
|
||||
function agent(id: string): Agent {
|
||||
return { id } as unknown as Agent
|
||||
}
|
||||
|
||||
function openMux(api: ApiProxy, abort: AbortController): {
|
||||
envelopes: RpcRequest<MuxFrame>[]
|
||||
waitForQuestion(): Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>
|
||||
} {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
let resolveQuestion!: (value: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>) => void
|
||||
const question = new Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>((resolve) => {
|
||||
resolveQuestion = resolve
|
||||
})
|
||||
void (async () => {
|
||||
for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelope.payload.type === 'question/requested') {
|
||||
resolveQuestion(envelope as RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return { envelopes, waitForQuestion: () => question }
|
||||
}
|
||||
|
||||
function answer(
|
||||
envelope: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>,
|
||||
selected: string[],
|
||||
custom?: string,
|
||||
): Parameters<ApiProxy['respond']>[0] {
|
||||
return {
|
||||
type: 'client-response',
|
||||
rpcId: envelope.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: envelope.payload.sessionId,
|
||||
answer: {
|
||||
answers: [{
|
||||
id: envelope.payload.questions[0]?.id,
|
||||
selected,
|
||||
...custom === undefined ? {} : { custom },
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('question response validation', () => {
|
||||
it('accepts selected options with custom text for multi-select questions', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const asked = ctx.userInteraction.ask({
|
||||
agent: agent('session-multi'),
|
||||
questions: [{
|
||||
id: 'targets',
|
||||
question: 'Choose targets and add another',
|
||||
multiSelect: true,
|
||||
options: [{ label: 'Code' }, { label: 'Docs' }],
|
||||
}],
|
||||
})
|
||||
const envelope = await mux.waitForQuestion()
|
||||
|
||||
expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes')))
|
||||
.toEqual({ accepted: true })
|
||||
await expect(asked).resolves.toEqual({
|
||||
answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }],
|
||||
})
|
||||
expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true)
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('keeps selected options and custom text mutually exclusive for single-select questions', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const asked = ctx.userInteraction.ask({
|
||||
agent: agent('session-single'),
|
||||
questions: [{
|
||||
id: 'target',
|
||||
question: 'Choose one target',
|
||||
options: [{ label: 'Code' }, { label: 'Docs' }],
|
||||
}],
|
||||
})
|
||||
const envelope = await mux.waitForQuestion()
|
||||
|
||||
expect(await api.respond(answer(envelope, ['Code'], 'Release notes')))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
expect(await api.respond(answer(envelope, [], 'Release notes')))
|
||||
.toEqual({ accepted: true })
|
||||
await expect(asked).resolves.toEqual({
|
||||
answers: [{ id: 'target', selected: [], custom: 'Release notes' }],
|
||||
})
|
||||
abort.abort()
|
||||
})
|
||||
})
|
||||
@@ -57,7 +57,7 @@ async function composed(withTitles = true): Promise<Context> {
|
||||
function liveAgent(ctx: Context, id: string, turns: number): Session {
|
||||
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
|
||||
for (let turn = 1; turn <= turns; turn++) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
|
||||
source: { kind: 'user' },
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('session.search', () => {
|
||||
eventFilters: [
|
||||
{
|
||||
kind: 'type',
|
||||
values: ['user/message', 'assistant/message', 'steering/message'],
|
||||
values: ['user/message', 'assistant/message'],
|
||||
},
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
@@ -182,7 +182,7 @@ describe('session.search', () => {
|
||||
withBestMatch(0, { sessionId: sid('hidden') }),
|
||||
withBestMatch(1, { surface: 'shadowed' }),
|
||||
withBestMatch(2, { type: 'tool/result' }),
|
||||
withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }),
|
||||
withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }),
|
||||
],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
226
packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
Normal file
226
packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import type { RpcRequest } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
const PARENT = sid('parent')
|
||||
const CHILD = sid('child')
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId('subagent-rpc'), payload }
|
||||
}
|
||||
|
||||
function bench(options: {
|
||||
parentLive?: boolean
|
||||
childStatus?: 'idle' | 'running'
|
||||
entries?: object[]
|
||||
followupError?: Error
|
||||
listError?: Error
|
||||
readError?: Error
|
||||
historyParent?: SessionId
|
||||
} = {}) {
|
||||
const parent = { id: PARENT }
|
||||
const child = options.childStatus === undefined
|
||||
? undefined
|
||||
: { id: CHILD, status: options.childStatus }
|
||||
const getAgent = vi.fn((id: SessionId) => {
|
||||
if (options.parentLive !== false && id === PARENT) return parent
|
||||
if (id === CHILD) return child
|
||||
return undefined
|
||||
})
|
||||
const listChildren = vi.fn(() => options.listError === undefined
|
||||
? Promise.resolve(options.entries ?? [
|
||||
{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
: Promise.reject(options.listError))
|
||||
const followup = vi.fn((
|
||||
_parent: unknown,
|
||||
_childId: SessionId,
|
||||
_content: unknown,
|
||||
_delivery: { source: { kind: string; rpcId: RpcId }; signal: AbortSignal },
|
||||
) => options.followupError === undefined
|
||||
? Promise.resolve('message-1')
|
||||
: Promise.reject(options.followupError))
|
||||
const readSession = vi.fn(() => options.readError === undefined
|
||||
? Promise.resolve({
|
||||
session: {
|
||||
version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT,
|
||||
} satisfies SessionHeader,
|
||||
events: [
|
||||
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
|
||||
] as unknown as SessionEvent[],
|
||||
})
|
||||
: Promise.reject(options.readError))
|
||||
const ctx = new Context()
|
||||
ctx.provide('agents', { get: getAgent })
|
||||
ctx.provide('subagents', { listChildren, followup })
|
||||
ctx.provide('sessionQuery', { readSession })
|
||||
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
|
||||
})
|
||||
return { api, getAgent, listChildren, readSession, followup, parent }
|
||||
}
|
||||
|
||||
describe('subagent gateway', () => {
|
||||
it('lists the complete catalog and reports exact live-parent availability', async () => {
|
||||
const { api, listChildren } = bench({ parentLive: false, entries: [
|
||||
{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: sid('one-shot'), mode: 'one-shot',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
{ kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' },
|
||||
] })
|
||||
const response = await api.subagents.list(request({ parentSessionId: PARENT }))
|
||||
expect(response.rpcId).toBe('subagent-rpc')
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
parentAvailable: false,
|
||||
entries: [
|
||||
{ kind: 'child', mode: 'continuable' },
|
||||
{ kind: 'child', mode: 'one-shot' },
|
||||
{ kind: 'diagnostic' },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
|
||||
})
|
||||
|
||||
it('derives catalog activity from the live child Agent rather than Session residency', async () => {
|
||||
const residentIdle = bench({ childStatus: 'idle', entries: [{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] })
|
||||
expect((await residentIdle.api.subagents.list(request({ parentSessionId: PARENT }))).result)
|
||||
.toMatchObject({ ok: true, value: { entries: [{ activity: 'inactive' }] } })
|
||||
|
||||
const running = bench({ childStatus: 'running' })
|
||||
expect((await running.api.subagents.list(request({ parentSessionId: PARENT }))).result)
|
||||
.toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
|
||||
})
|
||||
|
||||
it('reads a healthy direct child without looking up or activating any Agent', async () => {
|
||||
const { api, getAgent, readSession } = bench()
|
||||
const response = await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
expect(readSession).toHaveBeenCalledWith(CHILD)
|
||||
expect(getAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads one-shot history and rejects an address with the wrong mode', async () => {
|
||||
const oneShot = {
|
||||
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}
|
||||
const { api, readSession } = bench({ entries: [oneShot] })
|
||||
expect((await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
|
||||
}))).result).toMatchObject({ ok: true })
|
||||
expect((await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } })
|
||||
expect(readSession).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a diagnostic address before reading history', async () => {
|
||||
const { api, readSession } = bench({ entries: [
|
||||
{ kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
|
||||
] })
|
||||
const response = await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-catalog-diagnostic',
|
||||
details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
|
||||
},
|
||||
})
|
||||
expect(readSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes human content through the exact live parent with rpc attribution', async () => {
|
||||
const { api, parent, followup } = bench()
|
||||
const content = [{ type: 'text' as const, text: '继续' }]
|
||||
const signal = new AbortController().signal
|
||||
const response = await api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content,
|
||||
}), signal)
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true, value: { messageId: 'message-1' },
|
||||
})
|
||||
expect(followup).toHaveBeenCalledWith(
|
||||
parent,
|
||||
CHILD,
|
||||
content,
|
||||
{ source: { kind: 'user', rpcId: RpcId('subagent-rpc') }, signal },
|
||||
)
|
||||
})
|
||||
|
||||
it('fails before delivery when the parent is absent and maps continuation failures', async () => {
|
||||
const absent = bench({ parentLive: false })
|
||||
expect((await absent.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
|
||||
}), new AbortController().signal)).result).toMatchObject({
|
||||
ok: false, error: { code: 'subagent-parent-unavailable' },
|
||||
})
|
||||
expect(absent.listChildren).not.toHaveBeenCalled()
|
||||
|
||||
const failed = bench({ followupError: new SubagentError('draining', 'DRAINING') })
|
||||
expect((await failed.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
|
||||
}), new AbortController().signal)).result).toMatchObject({
|
||||
ok: false, error: { code: 'subagent-delivery-unavailable' },
|
||||
})
|
||||
})
|
||||
|
||||
it('maps history disappearance and hides unexpected backend details', async () => {
|
||||
const disappeared = bench({
|
||||
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
|
||||
})
|
||||
expect((await disappeared.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))).result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-not-found',
|
||||
message: 'subagent disappeared during history read',
|
||||
details: { parentSessionId: PARENT, childSessionId: CHILD },
|
||||
},
|
||||
})
|
||||
|
||||
const catalog = bench({ listError: new Error('secret descriptor') })
|
||||
expect((await catalog.api.subagents.list(request({
|
||||
parentSessionId: PARENT,
|
||||
}))).result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'subagent catalog read failed' },
|
||||
})
|
||||
|
||||
const prompt = bench({ followupError: new Error('secret provider') })
|
||||
expect((await prompt.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
|
||||
}), new AbortController().signal)).result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'subagent prompt failed' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -112,7 +112,7 @@ describe('mux live view computation', () => {
|
||||
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
|
||||
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
|
||||
@@ -175,7 +175,7 @@ describe('mux live view computation', () => {
|
||||
// history resolves the agent first; a live structural stub is enough (only
|
||||
// .session is read on this path).
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
|
||||
// meta rides through to presentResult's ToolResult (the spread arm).
|
||||
session.append('tool/result', {
|
||||
@@ -241,7 +241,7 @@ describe('mux live view computation', () => {
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const first = appendUserText(session, 'first prompt')
|
||||
appendAssistantText(session, 'first reply', 1)
|
||||
const third = appendUserText(session, 'second prompt')
|
||||
@@ -295,7 +295,7 @@ describe('mux live view computation', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create('session-doomed' as SessionId)
|
||||
}, { inject: ['sessions'] }))
|
||||
session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session?.append('turn/start', { turn: 1 })
|
||||
session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
|
||||
// Disposing the owning fiber detaches the session mid-stream; the
|
||||
// session/disposed listener must clear its open-call table entry.
|
||||
@@ -314,7 +314,7 @@ describe('mux live view computation', () => {
|
||||
const collected = collect(stream, 4, abort)
|
||||
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The turn/end above cleared the live table; pairing must fall back to
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
@@ -44,16 +44,15 @@ function stubAgent(session: Session): Agent {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
reserveTurnAdmission: () => undefined,
|
||||
followup: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
@@ -364,6 +363,36 @@ describe('session creation and Workspace membership', () => {
|
||||
})
|
||||
|
||||
describe('Host Workspace increments', () => {
|
||||
it('projects subagent origin in attached summaries and creation increments', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const abort = new AbortController()
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const pending = nextHostFrame(stream)
|
||||
const childId = SessionId('session-subagent-child')
|
||||
|
||||
ctx.sessions.create(childId, {
|
||||
meta: {
|
||||
cwd: '/tmp',
|
||||
parentSession: SessionId('session-parent'),
|
||||
origin: 'subagent',
|
||||
},
|
||||
})
|
||||
|
||||
expect(await pending).toMatchObject({
|
||||
payload: {
|
||||
type: 'host/session-added',
|
||||
sessionId: childId,
|
||||
parentSessionId: 'session-parent',
|
||||
origin: 'subagent',
|
||||
},
|
||||
})
|
||||
expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual(
|
||||
expect.objectContaining({ sessionId: childId, origin: 'subagent' }),
|
||||
)
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('streams committed Workspace and Session increments after empty baselines', async () => {
|
||||
const { api } = await harness()
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
|
||||
@@ -19,6 +19,7 @@ function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>>
|
||||
/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
|
||||
function scriptedApi(overrides: {
|
||||
sessions?: Partial<ApiProxy['sessions']>
|
||||
subagents?: Partial<ApiProxy['subagents']>
|
||||
host?: Partial<ApiProxy['host']>
|
||||
commands?: Partial<ApiProxy['commands']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
@@ -57,6 +58,12 @@ function scriptedApi(overrides: {
|
||||
cancel: r => ok(r, { accepted: true as const }),
|
||||
...overrides.sessions,
|
||||
},
|
||||
subagents: {
|
||||
list: r => ok(r, { entries: [], parentAvailable: false }),
|
||||
history: r => ok(r, { events: [], hasMore: false }),
|
||||
prompt: r => ok(r, { messageId: 'message-1' as never }),
|
||||
...overrides.subagents,
|
||||
},
|
||||
host: {
|
||||
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
|
||||
pickDirectory: r => ok(r, { path: null }),
|
||||
@@ -89,7 +96,8 @@ function scriptedApi(overrides: {
|
||||
...overrides.goals,
|
||||
},
|
||||
settings: {
|
||||
describe: r => ok(r, { writable: true, namespaces: [] }),
|
||||
describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }),
|
||||
openDocument: r => ok(r, { opened: true as const }),
|
||||
update: err,
|
||||
replace: err,
|
||||
mutate: err,
|
||||
@@ -104,6 +112,7 @@ function scriptedApi(overrides: {
|
||||
llm: {
|
||||
providers: r => ok(r, { providers: [] }),
|
||||
models: r => ok(r, { groups: [], failures: [] }),
|
||||
discoverModels: err,
|
||||
...overrides.llm,
|
||||
},
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
@@ -672,7 +681,8 @@ describe('config unary surface', () => {
|
||||
const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
|
||||
const api = scriptedApi({
|
||||
settings: {
|
||||
describe: record('settings.describe', r => ok(r, { writable: true, namespaces: [view] })),
|
||||
describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })),
|
||||
openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })),
|
||||
update: record('settings.update', r => ok(r, view)),
|
||||
replace: record('settings.replace', r => ok(r, view)),
|
||||
mutate: record('settings.mutate', r => ok(r, view)),
|
||||
@@ -685,12 +695,14 @@ describe('config unary surface', () => {
|
||||
llm: {
|
||||
providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
|
||||
models: record('llm.models', r => ok(r, { groups: [group], failures: [] })),
|
||||
discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })),
|
||||
},
|
||||
})
|
||||
const c = client(api)
|
||||
|
||||
const described = await c.settings.describe({})
|
||||
expect(described.result).toEqual({ ok: true, value: { writable: true, namespaces: [view] } })
|
||||
expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } })
|
||||
expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } })
|
||||
const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
|
||||
expect(updated.result).toEqual({ ok: true, value: view })
|
||||
const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} })
|
||||
@@ -709,16 +721,31 @@ describe('config unary surface', () => {
|
||||
expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
|
||||
const models = await c.llm.models({})
|
||||
expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } })
|
||||
const discovered = await c.llm.discoverModels({
|
||||
settingsNs: 'llm-pi-ai',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
api: 'openai-completions',
|
||||
apiKey: 'probe-key',
|
||||
})
|
||||
expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } })
|
||||
|
||||
expect(seen.map(call => call.method)).toEqual([
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.providers', 'llm.models',
|
||||
'llm.providers', 'llm.models', 'llm.discoverModels',
|
||||
])
|
||||
expect(seen[1]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
|
||||
expect(seen[3]?.payload)
|
||||
expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
|
||||
expect(seen[4]?.payload)
|
||||
.toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
|
||||
expect(seen[5]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
|
||||
expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
|
||||
// The draft crosses whole, credential included: the host needs it for this
|
||||
// one interrogation and stores none of it.
|
||||
expect(seen[10]?.payload).toEqual({
|
||||
settingsNs: 'llm-pi-ai',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
api: 'openai-completions',
|
||||
apiKey: 'probe-key',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an invalid credential reference name at the carrier boundary', async () => {
|
||||
|
||||
@@ -103,6 +103,31 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
},
|
||||
subagents: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } }
|
||||
},
|
||||
async history(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }
|
||||
},
|
||||
async prompt(request, signal) {
|
||||
if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) {
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled' as const, message: 'aborted', details: {} } },
|
||||
}
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { messageId: 'message-1' as never } },
|
||||
}
|
||||
},
|
||||
},
|
||||
host: {
|
||||
async describe(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
|
||||
@@ -195,7 +220,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
},
|
||||
settings: {
|
||||
async describe(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, namespaces: [] } } }
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } }
|
||||
},
|
||||
async openDocument(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
|
||||
},
|
||||
async update(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
|
||||
@@ -225,6 +253,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async models(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } }
|
||||
},
|
||||
async discoverModels(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } }
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: (_request, signal) => stream(muxFrames, signal),
|
||||
@@ -386,6 +417,23 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips the subagent domain through the wire form', async () => {
|
||||
const c = client()
|
||||
expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result)
|
||||
.toEqual({ ok: true, value: { entries: [], parentAvailable: false } })
|
||||
expect((await c.subagents.history({
|
||||
parentSessionId: 'parent' as never,
|
||||
childSessionId: 'child' as never,
|
||||
mode: 'one-shot',
|
||||
})).result).toEqual({ ok: true, value: { events: [], hasMore: false } })
|
||||
expect((await c.subagents.prompt({
|
||||
parentSessionId: 'parent' as never,
|
||||
childSessionId: 'child' as never,
|
||||
mode: 'continuable',
|
||||
content: [],
|
||||
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
|
||||
})
|
||||
|
||||
it('keeps caller and connection aborts on command.execute', async () => {
|
||||
const api = fakeApi()
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
@@ -451,6 +499,34 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into subagent.prompt', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'r-subagent-sig',
|
||||
method: 'subagent.prompt',
|
||||
payload: {
|
||||
parentSessionId: 'parent',
|
||||
childSessionId: 'child',
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: 'hang' }],
|
||||
},
|
||||
})
|
||||
const pending = handler.fetch(new Request(
|
||||
'http://x/api/subagent.prompt',
|
||||
{ method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
|
||||
))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as {
|
||||
rpcId: string
|
||||
result: { error?: { code: string } }
|
||||
}
|
||||
expect(parsed.rpcId).toBe('r-subagent-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into host.pickDirectory', async () => {
|
||||
const api = fakeApi()
|
||||
api.host.pickDirectory = async (request, signal) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openNativePath, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
@@ -26,6 +26,18 @@ describe('native path opener', () => {
|
||||
expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('bypasses macOS file associations for text documents', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativeTextFile('/Users/test/settings.yaml', signal(), { platform: 'darwin', run })
|
||||
expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('uses the Linux desktop association for text documents', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run })
|
||||
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
|
||||
@@ -36,6 +48,16 @@ describe('native path opener', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the Windows desktop association for text documents', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run })
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\settings.yaml'"],
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
})
|
||||
|
||||
it('opens with Linux xdg-open', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })
|
||||
|
||||
@@ -204,7 +204,6 @@ describe('sessions domain schemas', () => {
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek V4 Flash',
|
||||
description: 'fast',
|
||||
unlisted: true,
|
||||
reasoning: {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
@@ -421,7 +420,11 @@ describe('events frame schemas', () => {
|
||||
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
|
||||
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
|
||||
{ type: 'session/queue', sessionId: 's', items: [
|
||||
{ id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } },
|
||||
{
|
||||
id: 'm1',
|
||||
placement: 'queued',
|
||||
message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } },
|
||||
},
|
||||
] },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
@@ -451,10 +454,21 @@ describe('events frame schemas', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts every queue placement and rejects unknown placements', () => {
|
||||
const item = (placement: string) => ({ type: 'session/queue', sessionId: 's', items: [{
|
||||
id: 'm', placement,
|
||||
message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } },
|
||||
}] })
|
||||
for (const placement of ['queued', 'steering', 'context']) {
|
||||
expect(() => muxFrameSchema.parse(item(placement))).not.toThrow()
|
||||
}
|
||||
expect(() => muxFrameSchema.parse(item('bogus'))).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a queue snapshot with malformed items', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} } }] })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'm', role: 'assistant', content: [], source: { kind: 'user' } }] })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts every host frame branch', () => {
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
|
||||
@@ -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/host/directory-picker-auto/README.md
|
||||
README.md: 10d1784590b79fdfef3cf6683d389182cd8437b6
|
||||
README.zh.md: 86ec9f2c3a87557e86038ce7d3f89887c5bb3546
|
||||
README.md: f1715566c8aff8be90cab381bcedd4732d0b41f6
|
||||
README.zh.md: 9fc8e539d40a126b30be6dce02257bd9abe37944
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The **adaptive chooser** of the [directory-picker seam](../directory-picker/README.md): a node-half-only plugin that resolves the host's situation once at boot and mounts the matching dual-face backend — [`-native`](../directory-picker-native/README.md) or [`-browse`](../directory-picker-browse/README.md) — as a real Loader entry in the in-memory root tree (never persisted to a config file; the root tree's `write()` is a no-op). Because the backend arrives as an ordinary entry, its browser half is discovered by the client module table exactly as a config-row's would be, so the seam's one-row-swaps-both-faces invariant holds for the resolved choice. Unloading the chooser removes the entry again, unloading both faces with it.
|
||||
|
||||
Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse and tests. `native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes).
|
||||
Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse. `native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
|
||||
[目录选择 seam](../directory-picker/README.md) 的**自适应选择器**:一个只有 node 半侧的插件,在启动时一次性判定宿主处境,并把匹配的双面后端——[`-native`](../directory-picker-native/README.md) 或 [`-browse`](../directory-picker-browse/README.md)——作为真实的 Loader 条目挂进内存根树(绝不持久化到配置文件;根树的 `write()` 是 no-op)。由于后端以普通条目的形式到达,其 browser half 被 client 模块表发现的方式与配置行完全相同,因此对判定出的选择,seam 的“一行同时换两面”不变式依然成立。卸载该选择器会再次移除该条目,连同两面一起卸载。
|
||||
|
||||
判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用与测试。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上);以及可服务的显示会话——darwin/win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwin/win32/linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native` 或 `-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。
|
||||
判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上);以及可服务的显示会话——darwin/win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwin/win32/linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native` 或 `-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该选择器仅组合 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。
|
||||
- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -61,11 +61,9 @@ export async function apply(ctx: Context): Promise<void> {
|
||||
// nothing is left to unmount or await then.
|
||||
const entry = ctx.loader.store[id]
|
||||
if (entry === undefined) return
|
||||
const fiber = entry.fiber
|
||||
ctx.loader.remove(id)
|
||||
// remove() only starts the fiber's dispose; join it so the chooser's
|
||||
// unload signals completion only after the backend quiesced.
|
||||
await fiber?.dispose()
|
||||
// remove() disposes the entry transactionally, so the chooser's unload
|
||||
// signals completion only after the backend quiesced.
|
||||
await ctx.loader.remove(id)
|
||||
}
|
||||
}, 'directory-picker-auto: backend entry')
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('real Loader composition', () => {
|
||||
const { ctx, configPath } = await loadComposition('127.0.0.1')
|
||||
|
||||
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
|
||||
ctx.loader.remove(backendEntry.id)
|
||||
await ctx.loader.remove(backendEntry.id)
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
|
||||
@@ -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/host/directory-picker-browse/README.md
|
||||
README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
|
||||
README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f
|
||||
README.md: 11ddece6f68a752f8afc0392029c752013389d4a
|
||||
README.zh.md: c73ddeb52264bf7e2d2b9a74f0a7da9194e27c16
|
||||
|
||||
@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
|
||||
|
||||
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -20,4 +20,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost.
|
||||
- **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here.
|
||||
- **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it.
|
||||
- **Whole-filesystem scope** — there is no per-deployment browse-root restriction. `workspace.create` accepts arbitrary paths, so a root here would be UX scoping rather than a security boundary.
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。
|
||||
[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务原生后端无法触及的远程客户端。
|
||||
|
||||
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即「不可进入」),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空白段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**:浏览器侧(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染(「Loading…」胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的「显示隐藏」footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流程扩展位,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。
|
||||
- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值得付出相应成本为止。
|
||||
- **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。
|
||||
- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。
|
||||
- **全盘可浏览**——没有按部署限定的浏览根。`workspace.create` 接受任意路径,因此这里的根会限定 UX 范围,而不是安全边界。
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -49,11 +49,29 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The bar IS the editor's box in both modes: it carries the rounded outline
|
||||
* and the inner padding, the crumbs and the input sit inside it, and hovering
|
||||
* the edit zone lights the whole row rather than the remainder right of the
|
||||
* crumbs. The negative left margin pays back the border and padding, so the
|
||||
* crumb (and input) text keeps the column the title sits in. */
|
||||
.crumbBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 20px;
|
||||
box-sizing: border-box;
|
||||
min-height: 24px;
|
||||
margin-left: -9px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Lit by the affordance the row belongs to, never by a crumb: a crumb's hover
|
||||
* offers navigation, not path entry. Editing keeps the outline standing. */
|
||||
.crumbBar:has(.crumbEditZone:enabled:hover),
|
||||
.crumbBar:has(.crumbEditZone:focus-visible),
|
||||
.crumbBar:has(.pathInput) {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Deep chains scroll inside the trail (the effect pins the tail into view)
|
||||
@@ -118,25 +136,50 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The empty remainder of the bar: invisible, but a real click target that
|
||||
* flips the bar into path-edit mode. */
|
||||
/* The empty remainder of the bar: a real click target that flips the bar into
|
||||
* path-edit mode. The pencil glyph seated at its right edge is the standing
|
||||
* affordance; the outline the gesture lights belongs to the bar, so the whole
|
||||
* row reads as the box the input will occupy. */
|
||||
.crumbEditZone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1 0 34px;
|
||||
min-width: 34px;
|
||||
align-self: stretch;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.crumbEditGlyph {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.crumbEditZone:enabled:hover .crumbEditGlyph,
|
||||
.crumbEditZone:focus-visible .crumbEditGlyph {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.crumbEditZone:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.crumbEditZone:disabled .crumbEditGlyph {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Chrome-free: the bar around it draws the box (border, radius, padding). */
|
||||
.pathInput {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -18,15 +18,27 @@
|
||||
* owning flow decides what "Open" means and owns the workspace-creation
|
||||
* error surface. Hidden entries are host-flagged and hidden by default; the
|
||||
* footer's fixed-label "Show hidden files" toggle (aria-pressed, check when
|
||||
* on) reveals them (client-side only). The path editor opens seeded with a
|
||||
* trailing separator, and while the draft's directory part names a listed
|
||||
* level, its final segment prefix-filters that level's rows (a dot-led
|
||||
* prefix also reveals the hidden entries it names).
|
||||
* on) reveals them (client-side only). The path editor announces itself with
|
||||
* a pencil glyph and a bar-wide hover-lit outline, opens seeded with a
|
||||
* trailing separator, and keeps the panes under the draft: the final segment
|
||||
* prefix-filters the LAST pane while that pane's level is the one the draft's
|
||||
* directory part names (a dot-led prefix also reveals the hidden entries it
|
||||
* names, and a prefix nobody matches releases the filter), while any other
|
||||
* directory part is scanned after a short debounce and lands like any other
|
||||
* navigation — selection-anchored and two-pane away from the display root,
|
||||
* both legs waited out so one keystroke moves the view once. The pane arity
|
||||
* holds throughout: the last pane is the level the path names and the one
|
||||
* beside it is its parent, so typing deeper descends and erasing segments
|
||||
* walks back up, moving the Miller view without leaving the editor. Panes the
|
||||
* draft walked to stay put when the editor closes (cancellation included):
|
||||
* the crumbs name where the walk ended, and Open's fallback target follows
|
||||
* them.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
|
||||
Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconPlusOutline16, Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -75,6 +87,15 @@ const SLOW_SCAN_DELAY_MS = 300
|
||||
*/
|
||||
const PARENT_LEG_WAIT_MS = 200
|
||||
|
||||
/**
|
||||
* How long a typed draft rests before the panes follow it to a directory no
|
||||
* pane lists. The window absorbs the keystrokes that walk through
|
||||
* intermediate directory parts (every character of `/usr/lo` past the
|
||||
* separator would otherwise be its own scan) while staying short enough that
|
||||
* a pause reads as "the list moved with me".
|
||||
*/
|
||||
const DRAFT_PREVIEW_DEBOUNCE_MS = 250
|
||||
|
||||
/**
|
||||
* Breadcrumb rows for display: inside the home subtree the chain starts at a
|
||||
* localized Home crumb; outside it the full ancestry shows, the root labeled
|
||||
@@ -100,21 +121,89 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' {
|
||||
return listing.home.includes('\\') ? '\\' : '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* The path draft's final segment, when its directory part is exactly the
|
||||
* level `listing` lists — the segment the level prefix-filters on while the
|
||||
* user types. Any other draft (no separator yet, or naming some other
|
||||
* directory) leaves the level unfiltered. The directory part compares
|
||||
* exactly (it is the host's own path text, reached by seeding or erasing);
|
||||
* only the name filter downstream is case-insensitive.
|
||||
*/
|
||||
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
|
||||
if (draft === null) return null
|
||||
/** The listed level as a directory part: its own path, separator-terminated (the root already is). */
|
||||
function levelDirectory(listing: DirectoryListing): string {
|
||||
const sep = separatorOf(listing)
|
||||
const cut = draft.lastIndexOf(sep)
|
||||
if (cut === -1) return null
|
||||
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
|
||||
return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null
|
||||
return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
|
||||
}
|
||||
|
||||
/** The directory text a draft-following scan last sent, with the level path the host answered it with. */
|
||||
interface ScannedDirectory {
|
||||
/** The draft's directory part, verbatim as it went to the host. */
|
||||
readonly directory: string
|
||||
/** `path` of the listing that came back. */
|
||||
readonly landed: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft's directory part — everything through its last separator — or
|
||||
* null while no separator has been typed at all (nothing addresses a
|
||||
* directory yet). The platform comes from `listing`: on Windows a forward
|
||||
* slash separates too (the host's `resolve` accepts either), while on POSIX a
|
||||
* backslash is a legal name character and never separates.
|
||||
*/
|
||||
function draftDirectory(listing: DirectoryListing, draft: string): string | null {
|
||||
const cut = separatorOf(listing) === '\\'
|
||||
? Math.max(draft.lastIndexOf('\\'), draft.lastIndexOf('/'))
|
||||
: draft.lastIndexOf('/')
|
||||
return cut === -1 ? null : draft.slice(0, cut + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* How the draft reads against one level: the directory part it names, and —
|
||||
* when `listing` is the level that directory part addresses — the final
|
||||
* segment that prefix-filters it while the user types (case-insensitively,
|
||||
* downstream). A level answers a directory part when its own path is that
|
||||
* part, or when it is the level that very text just produced (`scanned`): the
|
||||
* host resolves what it is given, so `..` segments and Windows forward
|
||||
* slashes reach a level whose path spells the request differently.
|
||||
* @param listing - the level to read the draft against.
|
||||
* @param draft - the current path draft.
|
||||
* @param scanned - the last draft-following scan's directory and landing.
|
||||
* @returns the draft's directory part (null with no separator typed) and its
|
||||
* filtering tail (null when this level does not answer that directory).
|
||||
*/
|
||||
function readDraft(
|
||||
listing: DirectoryListing,
|
||||
draft: string,
|
||||
scanned: ScannedDirectory | null,
|
||||
): { directory: string | null; tail: string | null } {
|
||||
const directory = draftDirectory(listing, draft)
|
||||
if (directory === null) return { directory: null, tail: null }
|
||||
const answers = directory === levelDirectory(listing)
|
||||
|| (scanned !== null && scanned.directory === directory && scanned.landed === listing.path)
|
||||
return { directory, tail: answers ? draft.slice(directory.length) : null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows one column renders. The selection is exempt from every filter: it
|
||||
* anchors the two-pane view (crumbs and the child pane point at it), so
|
||||
* neither the hidden filter after a dot-reveal pick nor a prefix miss may
|
||||
* orphan it. A prefix narrows the level only while some row it would actually
|
||||
* show matches — a tail nobody matches is a name being spelled, not a demand
|
||||
* for an empty pane, so the level shows whole and its hidden rows return to
|
||||
* obeying the toggle. Counting only displayable rows is what keeps that true:
|
||||
* were a hidden row ever to match a prefix that does not reveal it (today
|
||||
* `hidden` means dot-prefixed, so it cannot), the level would narrow to
|
||||
* nothing.
|
||||
*/
|
||||
function visibleEntries(
|
||||
entries: readonly DirectoryEntry[],
|
||||
selectedPath: string | null,
|
||||
showHidden: boolean,
|
||||
filterPrefix: string | null,
|
||||
): readonly DirectoryEntry[] {
|
||||
const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase()
|
||||
// A dot-led prefix names hidden entries explicitly, so matching ones
|
||||
// surface even while the toggle keeps the rest hidden.
|
||||
const displayable = (entry: DirectoryEntry): boolean => showHidden || !entry.hidden || needle.startsWith('.')
|
||||
const matches = (entry: DirectoryEntry): boolean => displayable(entry) && entry.name.toLowerCase().startsWith(needle)
|
||||
const narrowing = needle !== '' && entries.some(matches)
|
||||
return entries.filter((entry) => {
|
||||
if (entry.path === selectedPath) return true
|
||||
if (narrowing) return matches(entry)
|
||||
return showHidden || !entry.hidden
|
||||
})
|
||||
}
|
||||
|
||||
/** One column of folder rows (the Miller view renders one or two of these). */
|
||||
@@ -127,16 +216,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr
|
||||
filterPrefix: string | null
|
||||
pathEditing: boolean
|
||||
}) {
|
||||
const visible = entries.filter((entry) => {
|
||||
// The selection is exempt from both filters: it anchors the two-pane
|
||||
// view (crumbs and the child pane point at it), so neither the hidden
|
||||
// filter after a dot-reveal pick nor a prefix miss may orphan it.
|
||||
if (entry.path === selectedPath) return true
|
||||
if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false
|
||||
// A dot-led prefix names hidden entries explicitly, so matching ones
|
||||
// surface even while the toggle keeps the rest hidden.
|
||||
return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true
|
||||
})
|
||||
const visible = visibleEntries(entries, selectedPath, showHidden, filterPrefix)
|
||||
return (
|
||||
<div className={css.column} role="list">
|
||||
{visible.map((entry) => {
|
||||
@@ -264,27 +344,83 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
}, [restartSlowScanWindow, listDirectory])
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly navigated level. Away from the
|
||||
* Enter owns the view from submission until its navigation lands, so the
|
||||
* debounce timer the same keystrokes armed must not supersede it. Cleared
|
||||
* by the next edit (and by opening the editor); a failed submission leaves
|
||||
* it set until the operator edits again, so the rejected path is not
|
||||
* immediately re-scanned as a preview.
|
||||
*/
|
||||
const previewSuspended = useRef(false)
|
||||
|
||||
// The panes as the draft-following scan must read them when its wait
|
||||
// fires: current, but NOT a dependency of the wait (see the effect below).
|
||||
const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null })
|
||||
useEffect(() => { viewRef.current = { parent, child } }, [parent, child])
|
||||
|
||||
// What the last draft-following scan asked for and what came back, so a
|
||||
// level still answers the text that produced it after the host respelled
|
||||
// it. Stale entries are harmless: a match needs both the directory text and
|
||||
// that level's own path, which together already mean the same directory.
|
||||
const scanned = useRef<ScannedDirectory | null>(null)
|
||||
|
||||
/**
|
||||
* A landed preview replaced the pane a keyboard operator may have Tabbed
|
||||
* onto, so the focus it drops is re-parked on the still-open editor (the
|
||||
* Modal has no focus trap). Consumed by the refocus effect below.
|
||||
*/
|
||||
const refocusPathInput = useRef(false)
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly scanned level. Away from the
|
||||
* display root — the same collapse the crumb header renders, so crumbs and
|
||||
* pane shape never disagree — the landing is two-pane: the target's ACTUAL
|
||||
* parent-level entry re-selected (left pane = parent, right pane = the
|
||||
* target), so a crumb jump reads as stepping back one pane. Both legs land
|
||||
* as one frame when the parent leg settles within
|
||||
* {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the
|
||||
* target commits alone — single wide level, the editor closes, loading
|
||||
* ends — and a late parent leg still upgrades the landing in place. A
|
||||
* failed parent leg, or a truncated parent window that lacks the target,
|
||||
* leaves the single-pane landing — the upgrade must never orphan the
|
||||
* selection it exists to anchor. Until whichever commit comes first, the
|
||||
* previous view keeps rendering: navigation swaps the panes, it never
|
||||
* blanks them.
|
||||
* target commits alone — single wide level, loading ends — and a late
|
||||
* parent leg still upgrades the landing in place. A failed parent leg, or a
|
||||
* truncated parent window that lacks the target, leaves the single-pane
|
||||
* landing — the upgrade must never orphan the selection it exists to
|
||||
* anchor. Until whichever commit comes first, the previous view keeps
|
||||
* rendering: a landing swaps the panes, it never blanks them.
|
||||
*
|
||||
* Two callers, one landing shape. A submitted path (Enter, a crumb) closes
|
||||
* the editor on arrival, announces its failure, and takes the wait bound —
|
||||
* it is answering a gesture, so it may not hang on a stalled parent. The
|
||||
* editor's own draft-following scan keeps all three to itself: it is
|
||||
* speculative, nothing waits on it, and the stale view keeps rendering, so
|
||||
* it waits for BOTH legs rather than flashing a single pane it would then
|
||||
* upgrade — one keystroke must move the view once. A failure leaves the
|
||||
* last readable panes standing and says nothing, while an arrival clears
|
||||
* the stale message and re-parks focus the swap dropped.
|
||||
* @param path - the level to list; absent lists the Host home directory.
|
||||
* @param options - `closeEditor` retires the path draft on arrival and
|
||||
* bounds the wait for the parent leg; `announce` surfaces a failure as the
|
||||
* dialog's alert.
|
||||
*/
|
||||
const navigate = useCallback((path?: string) => {
|
||||
const land = useCallback((path: string | undefined, options: { closeEditor: boolean; announce: boolean }) => {
|
||||
const { seq, scan } = launchListing(path)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
if (options.announce) setError(null)
|
||||
// What every landing does once its panes are committed, whichever shape
|
||||
// committed them.
|
||||
const settle = (): void => {
|
||||
setLoading(false)
|
||||
if (options.closeEditor) {
|
||||
setPathDraft(null)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
refocusPathInput.current = true
|
||||
}
|
||||
scan.then((target) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
// The level the panes will present as current answers this exact
|
||||
// directory text, however the host respelled it (`..`, a Windows
|
||||
// forward slash): the tail filters, and the same text asks for no
|
||||
// second scan.
|
||||
if (!options.closeEditor && path !== undefined) scanned.current = { directory: path, landed: target.path }
|
||||
// The single-pane landing; `landed` makes it first-commit-only, while
|
||||
// the two-pane commit below may still upgrade an already-landed view.
|
||||
let landed = false
|
||||
@@ -294,8 +430,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
settle()
|
||||
}
|
||||
// Arity is label-independent: only the collapsed chain's depth decides.
|
||||
if (displayCrumbs(target, '').length < 2) { landSingle(); return }
|
||||
@@ -316,23 +451,29 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setChild(target)
|
||||
// Idempotent on a late upgrade of a timed-out landing: reopening the
|
||||
// editor or starting a newer scan supersedes this seq, so reaching
|
||||
// here means the draft is closed and the loading flag is this
|
||||
// navigation's own.
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
// here means the settlement is still this landing's own.
|
||||
settle()
|
||||
}, () => {
|
||||
// The parent-leg failure (its abort included) never surfaces: the
|
||||
// target listed fine, and nobody asked to see the parent level.
|
||||
landSingle()
|
||||
})
|
||||
window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
|
||||
// Only a submitted navigation is bounded: the walk waits both legs out
|
||||
// (see the contract above), and a keystroke aborts it if the operator
|
||||
// moves on first.
|
||||
if (options.closeEditor) window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
|
||||
}, (reason: unknown) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
setError(failureText(reason))
|
||||
if (options.announce) setError(failureText(reason))
|
||||
})
|
||||
}, [launchListing, continueScan])
|
||||
|
||||
/** Commit a submitted path (Enter, a crumb, the initial home listing): the editor closes, failures surface. */
|
||||
const navigate = useCallback((path?: string) => {
|
||||
land(path, { closeEditor: true, announce: true })
|
||||
}, [land])
|
||||
|
||||
// Editor-close focus parking (consumed by the refocus effect below the
|
||||
// miller-row ref): a pick parks on the selection's row, Enter and an
|
||||
// input-focused Escape park on the crumb edit zone that replaces the
|
||||
@@ -381,6 +522,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
})
|
||||
}, [launchListing, pathDraft])
|
||||
|
||||
/**
|
||||
* Walk the panes to the directory the draft addresses, WITHOUT closing the
|
||||
* editor. The landing is an ordinary one — selection-anchored and two-pane
|
||||
* away from the display root — so typing a path moves the Miller view
|
||||
* exactly as a crumb jump does, and the draft's final segment
|
||||
* prefix-filters the arrival from the next render on.
|
||||
*/
|
||||
const previewDraftLevel = useCallback((directory: string) => {
|
||||
land(directory, { closeEditor: false, announce: false })
|
||||
}, [land])
|
||||
|
||||
/** Abandon path editing (Escape or clicking away) and restore the crumb view. */
|
||||
const cancelPathEdit = useCallback(() => {
|
||||
// Cancel also withdraws a navigation the editor already launched: its
|
||||
@@ -499,8 +651,40 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [loading, scanWindow])
|
||||
|
||||
// The panes follow the draft: EVERY keystroke replaces the pending timer,
|
||||
// and the target is decided when it fires, off the panes as they stand
|
||||
// then. Keying the wait on the draft (not on the directory part it names)
|
||||
// is what makes a keystroke that superseded an in-flight scan re-arm one,
|
||||
// and what lets an edit after a rejected submission release the hold the
|
||||
// submission took. The panes are read through a ref for the converse
|
||||
// reason: were they dependencies, the landing this commits would re-arm the
|
||||
// wait, and a host answering with a differently spelled path would scan
|
||||
// forever.
|
||||
useEffect(() => {
|
||||
if (pathDraft === null) return
|
||||
const timer = window.setTimeout(() => {
|
||||
if (previewSuspended.current) return
|
||||
// The level the panes present as current: it alone may answer the
|
||||
// draft, so anything else it names is a level to walk to.
|
||||
const current = viewRef.current.child ?? viewRef.current.parent
|
||||
if (current === null) return
|
||||
const { directory, tail } = readDraft(current, pathDraft, scanned.current)
|
||||
if (directory === null || tail !== null) return
|
||||
previewDraftLevel(directory)
|
||||
}, DRAFT_PREVIEW_DEBOUNCE_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [pathDraft, previewDraftLevel])
|
||||
|
||||
// After the hooks: a closed dialog renders nothing and evaluates no copy.
|
||||
const crumbSource = child ?? parent
|
||||
// The draft's tail filters the level it names, which by the pane invariant
|
||||
// is the LAST pane — never a pane the draft has already walked away from.
|
||||
// Narrowing that stale pane would move the view twice for one keystroke:
|
||||
// once as it narrows, again as its landing replaces it. It holds still
|
||||
// instead, and the filter arrives with the level it belongs to.
|
||||
const typedPrefix = crumbSource === null || pathDraft === null
|
||||
? null
|
||||
: readDraft(crumbSource, pathDraft, scanned.current).tail
|
||||
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
|
||||
const crumbTail = crumbs.at(-1)?.path
|
||||
useEffect(() => {
|
||||
@@ -523,6 +707,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// replacing the picked button's column — while Enter and an input-focused
|
||||
// Escape land on the crumb edit zone that replaces the input.
|
||||
useEffect(() => {
|
||||
if (refocusPathInput.current) {
|
||||
refocusPathInput.current = false
|
||||
// Only when the swap actually dropped focus to body: focus the operator
|
||||
// still holds (the input itself, a surviving row) stays theirs.
|
||||
if (document.activeElement === document.body) pathInputRef.current?.focus()
|
||||
}
|
||||
if (pathDraft !== null) return
|
||||
if (refocusPick.current) {
|
||||
refocusPick.current = false
|
||||
@@ -637,11 +827,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||
{/* The empty zone right of the crumbs is the path-edit
|
||||
* affordance: the whole remainder of the bar clicks into
|
||||
* the editor, and the pencil glyph parked at its right
|
||||
* edge (with the same tooltip) is what says so — an
|
||||
* invisible target the operator must guess at is the one
|
||||
* way into typing a path. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumbEditZone}
|
||||
aria-label={t('browser.editPath')}
|
||||
title={t('browser.editPath')}
|
||||
// Stays available with no listed level: when the home
|
||||
// listing itself fails, typing an absolute path is the one
|
||||
// remaining way forward.
|
||||
@@ -653,6 +849,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// otherwise close the editor via navigate's draft reset.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
previewSuspended.current = false
|
||||
// Seed with a trailing separator so typing immediately
|
||||
// continues into child names (and prefix-filters below).
|
||||
// No listed level means nothing to seed from (the editor
|
||||
@@ -665,7 +862,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const sep = separatorOf(parent)
|
||||
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<IconEditOutline16 size={14} className={css.crumbEditGlyph} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
@@ -682,6 +881,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// repopulate the view with the older path.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
// A fresh edit releases the submission hold: the panes
|
||||
// may follow the new text wherever it points.
|
||||
previewSuspended.current = false
|
||||
setPathDraft(event.target.value)
|
||||
}}
|
||||
{...compositionGuard}
|
||||
@@ -699,6 +901,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// focus on the returning crumb edit zone (a failure
|
||||
// keeps the editor, so the flag waits until close).
|
||||
refocusEditZone.current = true
|
||||
// The submitted path owns the view now: a debounce
|
||||
// timer still pending from these keystrokes would
|
||||
// otherwise supersede this navigation and land the
|
||||
// draft's parent directory instead.
|
||||
previewSuspended.current = true
|
||||
navigate(pathDraft)
|
||||
}
|
||||
}
|
||||
@@ -716,7 +923,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
busy={parentInert}
|
||||
onPick={select}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(parent, pathDraft)}
|
||||
filterPrefix={child === null ? typedPrefix : null}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
@@ -728,7 +935,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
busy={parentInert}
|
||||
onPick={advance}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(child, pathDraft)}
|
||||
filterPrefix={typedPrefix}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* cordis.yml row; no client code branches on a capability kind. The dialog's
|
||||
* copy is locale-registered here — the flow package owns its own strings.
|
||||
*/
|
||||
import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -22,8 +21,8 @@ export const inject = ['slots', 'workspaces', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dialog's dictionaries and the browse flow
|
||||
* into both directory-flow holes (declaration-aware deferral — the declaring
|
||||
* ui-workspace entries may activate later, and an HMR collapse re-declares).
|
||||
* into both directory-flow holes through `slots.inject()` because the
|
||||
* ui-workspace entries may activate later or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -78,16 +77,16 @@ export function apply(ctx: ClientContext): void {
|
||||
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
|
||||
t: ctx.locale.bind(LOCALE_NS),
|
||||
})
|
||||
ctx.effect(() => {
|
||||
// One occupant, both holes, as a unit: construction or late conflicts
|
||||
// (holes declared after rival providers activated) roll the whole pair
|
||||
// back and fail loud — semantics owned by deferGroupRegistration.
|
||||
const group = deferGroupRegistration(
|
||||
ctx.slots,
|
||||
['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const,
|
||||
BrowseDirectoryFlow,
|
||||
name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow),
|
||||
)
|
||||
return () => { group.dispose() }
|
||||
}, 'directory-picker-browse: flow registrations')
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, BrowseDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, BrowseDirectoryFlow)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -73,11 +73,11 @@ describe('directory-picker-browse client half', () => {
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back the first deferral when the second hole is already occupied', async () => {
|
||||
it('rolls back the outer injection when the second hole is already occupied', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
// Foreign occupant in the SECOND registered hole: the pair construction
|
||||
// throws after the first deferral installed its subscription.
|
||||
// throws after the outer injection installed its subscription.
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
@@ -98,19 +98,21 @@ describe('directory-picker-browse client half', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
|
||||
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// This provider activates BEFORE any hole exists: both deferrals wait.
|
||||
// The rival subscribes first, so synchronous declaration notifications
|
||||
// let it occupy the pair before this provider's waiting injection runs.
|
||||
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
|
||||
yield b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
yield b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
}))
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
// A rival occupies both holes ahead of the pending microtask flush.
|
||||
b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
// The rival keeps both holes; this provider rolled back wholesale and
|
||||
// surfaced the conflict on the fail-loud channel — no partial mix.
|
||||
@@ -198,10 +200,11 @@ describe('directory-picker-browse client half', () => {
|
||||
/>,
|
||||
)
|
||||
// The dialog opened at home; its confirm (browser.open) adopts the listed level.
|
||||
const openButton = await screen.findByRole('button', { name: 'browser.open' })
|
||||
openButton.click()
|
||||
const openButton = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' })
|
||||
await waitFor(() => { expect(openButton.disabled).toBe(false) })
|
||||
fireEvent.click(openButton)
|
||||
expect(props.onPicked).toHaveBeenCalledWith(HOME)
|
||||
screen.getByRole('button', { name: 'browser.cancel' }).click()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
|
||||
expect(props.onCancel).toHaveBeenCalled()
|
||||
expect(props.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -11,9 +11,14 @@ const HOME = '/home/u'
|
||||
const DOCS = `${HOME}/Documents`
|
||||
const HARNESS = `${DOCS}/harness`
|
||||
|
||||
/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */
|
||||
/**
|
||||
* Listing fake over a tiny fixed tree; unknown paths reject like the Host.
|
||||
* A trailing separator is dropped the way the Host's own `resolve` drops it,
|
||||
* so a directory part typed into the path editor addresses its level.
|
||||
*/
|
||||
function listingFor(path?: string): DirectoryListing {
|
||||
const target = path ?? HOME
|
||||
const asked = path ?? HOME
|
||||
const target = asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked
|
||||
const tree: Record<string, DirectoryListing> = {
|
||||
[HOME]: {
|
||||
path: HOME,
|
||||
@@ -647,7 +652,7 @@ describe('DirectoryBrowser', () => {
|
||||
})
|
||||
|
||||
it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => {
|
||||
mount()
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
@@ -659,16 +664,29 @@ describe('DirectoryBrowser', () => {
|
||||
// A dot-led prefix names hidden entries, so it reveals the match.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/.co` } })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('.config')
|
||||
// A prefix matching nothing empties the level (no stale rows linger).
|
||||
// A prefix nobody matches releases the filter: the level shows whole
|
||||
// (hidden rows back under the toggle) instead of emptying under a name
|
||||
// the operator is still spelling.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/zzz` } })
|
||||
expect(screen.queryByRole('listitem')).toBeNull()
|
||||
// A draft naming some other directory (or none) leaves the level whole.
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
// Its dot-led reveal lapses with it.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/.zzz` } })
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
// A tail inside the listed level names no level to walk to: the wait
|
||||
// fires and finds nothing to scan.
|
||||
const settled = b.listDirectory.mock.calls.length
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(b.listDirectory.mock.calls).toHaveLength(settled)
|
||||
// A draft naming some other directory (or none) leaves the level whole —
|
||||
// and a draft with no separator at all addresses no directory either.
|
||||
fireEvent.change(input, { target: { value: 'no-separator' } })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(b.listDirectory.mock.calls).toHaveLength(settled)
|
||||
})
|
||||
|
||||
it('filters the child pane in two-pane mode and follows the draft back up a level', async () => {
|
||||
mount()
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
@@ -679,15 +697,301 @@ describe('DirectoryBrowser', () => {
|
||||
expect(input.value).toBe(`${DOCS}/`)
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
// The child pane already lists that directory: no scan follows, and both
|
||||
// panes stay.
|
||||
const settled = b.listDirectory.mock.calls.length
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(b.listDirectory.mock.calls).toHaveLength(settled)
|
||||
expect(columns()).toHaveLength(2)
|
||||
// A miss releases the right pane's filter rather than emptying it.
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/zzz` } })
|
||||
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
// Erasing back into the parent's own path moves the filter to the LEFT
|
||||
// pane and releases the right one. The selected row is exempt (it
|
||||
// anchors the two-pane view), so it alone survives the miss.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/zz` } })
|
||||
expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
// Erasing back into the parent's own path re-lands on it rather than
|
||||
// filtering the LEFT pane: the level being typed is always the last pane,
|
||||
// never a pane with a deeper level standing to its right. Home is the
|
||||
// display root, so it lands alone.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/zz` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
})
|
||||
|
||||
it('follows the draft into a directory no pane lists, landing the two-pane Miller view', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
expect(columns()).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// Typing past a separator addresses a level nobody shows: the panes walk
|
||||
// to it once the typing rests, landing the ordinary selection-anchored
|
||||
// two-pane view (level | its children) with the tail filtering the right
|
||||
// pane — a typed path moves the Miller view exactly as a crumb jump does.
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything())
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
// Still editing: the panes moved under the draft, the editor stayed.
|
||||
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath').value).toBe(`${DOCS}/h`)
|
||||
// Typing on inside a level the panes already list costs no scan at all:
|
||||
// the prefix filter alone answers the draft, both panes stay.
|
||||
const settled = b.listDirectory.mock.calls.length
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(b.listDirectory.mock.calls).toHaveLength(settled)
|
||||
expect(columns()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the typed level in the last pane, its parent beside it, as the draft walks', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// Two levels down: the typed level on the right, its parent on the left.
|
||||
fireEvent.change(input, { target: { value: `${HARNESS}/` } })
|
||||
await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() })
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
|
||||
// Erasing back to the parent's own path re-lands on it: the level being
|
||||
// typed moves BACK into the last pane instead of staying on the left with
|
||||
// its own child pane still to the right.
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
|
||||
await waitFor(() => { expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() })
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['harness'])
|
||||
expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything())
|
||||
})
|
||||
|
||||
it('holds a stale pane still until its landing, instead of narrowing it first', async () => {
|
||||
// Own three-level tree: the level that goes stale needs two rows for the
|
||||
// narrowing this pins against to be visible at all.
|
||||
const ROOT = '/u'
|
||||
const MID = `${ROOT}/mid`
|
||||
const LEAF = `${MID}/leaf`
|
||||
const chain = [{ name: '/', path: '/', hidden: false }, { name: 'u', path: ROOT, hidden: false }]
|
||||
const tree: Record<string, DirectoryListing> = {
|
||||
[ROOT]: {
|
||||
path: ROOT,
|
||||
home: ROOT,
|
||||
crumbs: chain,
|
||||
entries: [{ name: 'mid', path: MID, hidden: false }, { name: 'other', path: `${ROOT}/other`, hidden: false }],
|
||||
truncated: false,
|
||||
},
|
||||
[MID]: {
|
||||
path: MID,
|
||||
home: ROOT,
|
||||
crumbs: [...chain, { name: 'mid', path: MID, hidden: false }],
|
||||
entries: [{ name: 'leaf', path: LEAF, hidden: false }, { name: 'sibling', path: `${MID}/sibling`, hidden: false }],
|
||||
truncated: false,
|
||||
},
|
||||
[LEAF]: {
|
||||
path: LEAF,
|
||||
home: ROOT,
|
||||
crumbs: [...chain, { name: 'mid', path: MID, hidden: false }, { name: 'leaf', path: LEAF, hidden: false }],
|
||||
entries: [],
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
mount({
|
||||
listDirectory: vi.fn(async (path?: string) => {
|
||||
const asked = path ?? ROOT
|
||||
const found = tree[asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked]
|
||||
if (found === undefined) throw new Error(`cannot list ${asked}`)
|
||||
return found
|
||||
}),
|
||||
})
|
||||
await waitFor(() => { expect(screen.getByText('mid')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${LEAF}/` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling'])
|
||||
// Deleting the separator names the level the LEFT pane lists. That pane
|
||||
// is stale — its landing will move it right — so it must not narrow to
|
||||
// the tail first: one deletion, one movement.
|
||||
fireEvent.change(input, { target: { value: LEAF } })
|
||||
expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling'])
|
||||
await waitFor(() => { expect(within(columns()[0]!).getByText('other')).toBeTruthy() })
|
||||
expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf'])
|
||||
})
|
||||
|
||||
it('keeps the walked-to panes when the editor is cancelled, Open adopting where the walk ended', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
// Cancel closes the editor; it does not rewind the walk. The operator
|
||||
// watched the panes move, so the crumbs, the panes, and Open's target all
|
||||
// stay where the walk ended.
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
expect(screen.getByRole('navigation').textContent).toContain('Documents')
|
||||
const open = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' })
|
||||
expect(open.disabled).toBe(false)
|
||||
fireEvent.click(open)
|
||||
expect(b.onOpen).toHaveBeenCalledWith(DOCS)
|
||||
})
|
||||
|
||||
it('waits both legs out for a walk: one keystroke never flashes a single pane', async () => {
|
||||
let landParent = (): void => {}
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
// The parent leg outlives the submitted-navigation wait bound; a walk
|
||||
// has nothing waiting on it, so it holds the stale view instead of
|
||||
// landing single-pane and upgrading.
|
||||
if (path === HOME) return await new Promise<DirectoryListing>((resolve) => { landParent = () => { resolve(listingFor(HOME)) } })
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(HOME, expect.anything()) })
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
// Well past the submitted-navigation bound: still the pre-walk view.
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
await act(async () => { landParent() })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('walks the panes back up when erased segments leave the listed levels', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
// Erasing back to a directory neither pane lists walks up to it; the
|
||||
// filesystem root is the display root, so it lands the single wide level
|
||||
// with the tail filtering it.
|
||||
fireEvent.change(input, { target: { value: '/ho' } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||
expect(b.listDirectory).toHaveBeenCalledWith('/', expect.anything())
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['home'])
|
||||
})
|
||||
|
||||
it('re-arms the draft-following scan after a keystroke superseded one in flight', async () => {
|
||||
let started = 0
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path !== `${DOCS}/`) return listingFor(path)
|
||||
started += 1
|
||||
// The first scan never settles: the next keystroke aborts it, and only
|
||||
// a re-armed wait can still land the level the draft names.
|
||||
if (started === 1) return await new Promise<DirectoryListing>(() => {})
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(started).toBe(1) })
|
||||
// A further tail keystroke supersedes the in-flight scan; the panes must
|
||||
// still follow, not sit on the stale level until a separator is typed.
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
|
||||
await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
|
||||
})
|
||||
|
||||
it('follows the draft again after an edit releases a failed submission hold', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === HARNESS) throw new Error('target unreadable')
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// Submitting inside the debounce window holds the pending scan back.
|
||||
fireEvent.change(input, { target: { value: HARNESS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('target unreadable') })
|
||||
// Correcting only the final segment leaves the directory part unchanged;
|
||||
// the edit must still release the hold and re-arm the wait.
|
||||
fireEvent.change(input, { target: { value: `${HARNESS}x` } })
|
||||
await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) })
|
||||
await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
|
||||
})
|
||||
|
||||
it('re-parks focus on the editor when a landed scan unmounts the focused row', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// Two levels down, so the walk replaces the LEFT pane the focused row
|
||||
// lives in (a landing that re-lists the same level reuses its rows).
|
||||
fireEvent.change(input, { target: { value: `${HARNESS}/` } })
|
||||
// The keyboard path: focus Tabbed onto a row of the level about to be
|
||||
// replaced. Without a re-park it would fall to body, outside a Modal that
|
||||
// has no focus trap.
|
||||
rowButton(screen.getByRole('listitem')).focus()
|
||||
await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() })
|
||||
expect(document.activeElement).toBe(screen.getByLabelText('browser.editPath'))
|
||||
})
|
||||
|
||||
it('keeps the panes and stays silent when a draft-following scan fails', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${HOME}/nope/x` } })
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledWith(`${HOME}/nope/`, expect.anything()) })
|
||||
// A half-typed directory is unreadable most of the time: the last
|
||||
// readable level keeps rendering and no error interrupts the typing.
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('holds the draft-following scan while a submitted path is in flight', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
// The submitted leg never settles, so the debounce window elapses with
|
||||
// the navigation still owning the view.
|
||||
if (path === HARNESS) return await new Promise<DirectoryListing>(() => {})
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: HARNESS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
// Only the initial home listing and the submitted path — the draft's
|
||||
// directory part was never scanned behind the navigation's back.
|
||||
expect(listDirectory.mock.calls.map(call => call[0])).toEqual([undefined, HARNESS])
|
||||
})
|
||||
|
||||
it('discards draft-following scans that a newer edit superseded', async () => {
|
||||
let landDocs = (): void => {}
|
||||
let failRoot = (): void => {}
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === `${DOCS}/`) return await new Promise<DirectoryListing>((resolve) => { landDocs = () => { resolve(listingFor(DOCS)) } })
|
||||
if (path === '/') {
|
||||
return await new Promise<DirectoryListing>((_, reject) => {
|
||||
failRoot = () => { reject(new Error('root unreadable')) }
|
||||
})
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) })
|
||||
fireEvent.change(input, { target: { value: '/x' } })
|
||||
await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith('/', expect.anything()) })
|
||||
// Back onto the listed level: neither pending scan may still land.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/D` } })
|
||||
await act(async () => { landDocs(); failRoot() })
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => {
|
||||
@@ -813,7 +1117,8 @@ describe('DirectoryBrowser', () => {
|
||||
],
|
||||
truncated: false,
|
||||
}
|
||||
mount({ listDirectory: vi.fn(async () => windowsListing) })
|
||||
const listDirectory = vi.fn(async () => windowsListing)
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
@@ -821,6 +1126,18 @@ describe('DirectoryBrowser', () => {
|
||||
expect(input.value).toBe(ROOT)
|
||||
fireEvent.change(input, { target: { value: `${ROOT}u` } })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Users')
|
||||
// Windows separates on a forward slash too (so does the Host's resolve),
|
||||
// so a path typed that way names its directory; the level the Host
|
||||
// answers with spells it back with a backslash, and once that scan lands
|
||||
// the level answers the typed spelling — the tail filters it.
|
||||
fireEvent.change(input, { target: { value: 'C:/p' } })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Program Files') })
|
||||
// And the same spelling asks for no second scan.
|
||||
const settled = listDirectory.mock.calls.length
|
||||
fireEvent.change(input, { target: { value: 'C:/pr' } })
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(listDirectory.mock.calls).toHaveLength(settled)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Program Files')
|
||||
})
|
||||
|
||||
it('clicking away from the path editor cancels it back to the crumb view', async () => {
|
||||
@@ -977,6 +1294,10 @@ describe('DirectoryBrowser', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: DOCS } })
|
||||
// With no level listed there is no platform separator to read, so the
|
||||
// draft-following wait resolves to nothing and the editor types blind.
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(listDirectory).toHaveBeenCalledTimes(1)
|
||||
listDirectory.mockImplementation(async (path?: string) => listingFor(path))
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
|
||||
|
||||
@@ -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/host/directory-picker-native/README.md
|
||||
README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8
|
||||
README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f
|
||||
README.md: 4dd0c79d080fe097d063cfd2200e30aafc2d2d42
|
||||
README.zh.md: 8ebf2a6e978ad042e522e7e2831a508501d3eca5
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -17,3 +17,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).
|
||||
- **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure. The browse backend remains the fallback at the composition level.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。
|
||||
- **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败。组合层面的回退仍是 browse 后端。
|
||||
|
||||
@@ -19,21 +19,25 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./worker": {
|
||||
"types": "./lib/types/win32-dialog-worker.d.ts",
|
||||
"default": "./lib/worker.cjs"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/worker.cjs",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:^"
|
||||
"@deepseek-ai/dsh-native-command": "workspace:^",
|
||||
"koffi": "^3.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
@@ -50,7 +54,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"tsx": "^4.19.2"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* both sides of the native interaction with one cordis.yml row; no client
|
||||
* code branches on a capability kind.
|
||||
*/
|
||||
import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -20,22 +19,22 @@ export const inject = ['slots', 'workspaces']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the renderless native flow into both
|
||||
* directory-flow holes (declaration-aware deferral — the declaring
|
||||
* ui-workspace entries may activate later, and an HMR collapse re-declares).
|
||||
* directory-flow holes through `slots.inject()` because the ui-workspace
|
||||
* entries may activate later or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
|
||||
ctx.effect(() => {
|
||||
// One occupant, both holes, as a unit: construction or late conflicts
|
||||
// (holes declared after rival providers activated) roll the whole pair
|
||||
// back and fail loud — semantics owned by deferGroupRegistration.
|
||||
const group = deferGroupRegistration(
|
||||
ctx.slots,
|
||||
['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const,
|
||||
NativeDirectoryFlow,
|
||||
name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow),
|
||||
)
|
||||
return () => { group.dispose() }
|
||||
}, 'directory-picker-native: flow registrations')
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
|
||||
* with the `native` capability, opening one native OS chooser on the host
|
||||
* display per pick (macOS `osascript`, Windows STA PowerShell
|
||||
* `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable
|
||||
* when the operator sits at the host's screen; remote deployments compose the
|
||||
* display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback;
|
||||
* Windows opens the modern `IFileOpenDialog` in a spawned child process — a
|
||||
* koffi-driven COM conversation on the child's main thread). Only viable when
|
||||
* the operator sits at the host's screen; remote deployments compose the
|
||||
* browse backend instead.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-native
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Cross-platform native single-directory chooser behind the native backend's capability. */
|
||||
|
||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
||||
import { pickWin32Directory } from './win32-dialog.ts'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type DirectoryPickerRunner = NativeCommandRunner
|
||||
@@ -9,6 +10,8 @@ export type DirectoryPickerRunner = NativeCommandRunner
|
||||
export interface DirectoryPickerInternals {
|
||||
platform?: NodeJS.Platform
|
||||
run?: DirectoryPickerRunner
|
||||
/** Replaces the in-process Win32 dialog (`pickWin32Directory`) for deterministic tests. */
|
||||
pickWin32Dialog?: (signal: AbortSignal) => Promise<string | null>
|
||||
}
|
||||
|
||||
function outputPath(stdout: string): string | null {
|
||||
@@ -64,20 +67,13 @@ export async function pickNativeDirectory(
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
||||
"$dialog.Description = 'Select Workspace Directory'",
|
||||
'$dialog.ShowNewFolderButton = $true',
|
||||
'$result = $dialog.ShowDialog()',
|
||||
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
|
||||
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
||||
' [Console]::WriteLine($dialog.SelectedPath)',
|
||||
'}',
|
||||
].join('; ')
|
||||
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
|
||||
return outputPath(result.stdout)
|
||||
// The koffi-backed IFileOpenDialog child process — the modern picker with
|
||||
// per-monitor-v2 DPI and abort support. koffi is a packaged dependency
|
||||
// whose availability the install guarantees, so there is no fallback
|
||||
// tier: any failure surfaces as-is (the former PowerShell chain was
|
||||
// removed — see the simplification Agent Note).
|
||||
const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory
|
||||
return await pickDialog(signal)
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* koffi-backed Win32 bindings for the folder dialog: the COM vtable calls
|
||||
* behind {@link Win32DialogBindings} plus the cross-thread window closer the
|
||||
* driver uses to service aborts. The module loads on every platform; koffi
|
||||
* itself is imported lazily inside each function, so non-Windows processes
|
||||
* never load it — the same containment as the repo's other `win32.ts`
|
||||
* modules.
|
||||
*
|
||||
* The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and
|
||||
* IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is
|
||||
* frozen Windows ABI since Vista; slots are offsets into the vtable at the
|
||||
* object's first pointer.
|
||||
*/
|
||||
|
||||
import type { Win32DialogBindings, Win32FolderDialog } from './win32-dialog-logic.ts'
|
||||
|
||||
interface KoffiFunction { (...args: unknown[]): unknown }
|
||||
interface KoffiLibrary { func(convention: string, name: string, result: string, args: string[]): KoffiFunction }
|
||||
interface Koffi {
|
||||
load(path: string): KoffiLibrary
|
||||
proto(declaration: string): unknown
|
||||
pointer(type: unknown): unknown
|
||||
call(pointer: unknown, proto: unknown, ...args: unknown[]): unknown
|
||||
decode(value: unknown, offsetOrType: unknown, type?: unknown): unknown
|
||||
register(fn: (...args: unknown[]) => unknown, type: unknown): unknown
|
||||
unregister(callback: unknown): void
|
||||
sizeof(type: string): number
|
||||
view(ref: unknown, len: number): ArrayBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a NUL-terminated UTF-16 string at a native address. koffi's
|
||||
* `_Out_ void **` out-params surface a raw address, and
|
||||
* `koffi.decode(addr, 'str16')` would dereference it as a pointer — crash
|
||||
* on real Windows — so view the memory directly instead.
|
||||
*/
|
||||
function readUtf16(koffi: Koffi, address: unknown): string {
|
||||
const bytes = Buffer.from(koffi.view(address, 32768))
|
||||
let end = 0
|
||||
while (end + 1 < bytes.length && bytes[end] !== 0) end += 2
|
||||
return bytes.toString('utf16le', 0, end)
|
||||
}
|
||||
|
||||
const COINIT_APARTMENTTHREADED = 0x2
|
||||
const CLSCTX_INPROC_SERVER = 0x1
|
||||
const SIGDN_FILESYSPATH = 0x80058000 | 0
|
||||
/**
|
||||
* Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10
|
||||
* 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext`
|
||||
* returns NULL for an unsupported context instead of throwing, so the caller
|
||||
* cascades to the best one the host accepts; DPI stays a cosmetic
|
||||
* best-effort — an unsupported host still gets the modern dialog.
|
||||
*/
|
||||
const DPI_AWARENESS_CONTEXTS = [-4, -3, -2]
|
||||
const WM_CLOSE = 0x10
|
||||
|
||||
/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */
|
||||
const SLOT_RELEASE = 2
|
||||
const SLOT_SHOW = 3
|
||||
const SLOT_SET_OPTIONS = 9
|
||||
const SLOT_SET_TITLE = 17
|
||||
const SLOT_GET_RESULT = 20
|
||||
/** IShellItem vtable slot for `GetDisplayName`. */
|
||||
const SLOT_GET_DISPLAY_NAME = 5
|
||||
|
||||
/**
|
||||
* Encode a canonical GUID string as its 16 little-endian bytes.
|
||||
* @param text - the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` form.
|
||||
* @returns the in-memory GUID bytes CoCreateInstance expects.
|
||||
*/
|
||||
function guidBytes(text: string): Buffer {
|
||||
const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text) as RegExpExecArray
|
||||
const bytes = Buffer.alloc(16)
|
||||
bytes.writeUInt32LE(parseInt(match[1] as string, 16), 0)
|
||||
bytes.writeUInt16LE(parseInt(match[2] as string, 16), 4)
|
||||
bytes.writeUInt16LE(parseInt(match[3] as string, 16), 6)
|
||||
Buffer.from((match[4] as string) + (match[5] as string), 'hex').copy(bytes, 8)
|
||||
return bytes
|
||||
}
|
||||
|
||||
const CLSID_FILE_OPEN_DIALOG = guidBytes('dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7')
|
||||
const IID_IFILE_OPEN_DIALOG = guidBytes('d57c7288-d4ad-4768-be02-9d969532d960')
|
||||
|
||||
/**
|
||||
* Load koffi and expose the dialog bindings for this thread.
|
||||
* @returns the bindings {@link runFolderDialog} sequences against.
|
||||
*/
|
||||
export async function loadWin32DialogBindings(): Promise<Win32DialogBindings> {
|
||||
const koffi = (await import('koffi')).default as unknown as Koffi
|
||||
const ole32 = koffi.load('ole32.dll')
|
||||
const user32 = koffi.load('user32.dll')
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
|
||||
// Vtable slots and out-pointers are pointer-width offsets: 8 on x64/arm64,
|
||||
// 4 on ia32 — koffi reports the running process's width.
|
||||
const pointerSize = koffi.sizeof('void *')
|
||||
const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32'])
|
||||
const coUninitialize = ole32.func('__stdcall', 'CoUninitialize', 'void', [])
|
||||
const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *'])
|
||||
const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *'])
|
||||
const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', [])
|
||||
|
||||
const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)')
|
||||
const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)')
|
||||
const protoSetTitle = koffi.proto('int32 __stdcall DshDialogSetTitle(void *self, str16 title)')
|
||||
const protoGetResult = koffi.proto('int32 __stdcall DshDialogGetResult(void *self, _Out_ void **item)')
|
||||
const protoGetDisplayName = koffi.proto('int32 __stdcall DshItemGetDisplayName(void *self, int32 form, _Out_ void **name)')
|
||||
const protoRelease = koffi.proto('uint32 __stdcall DshComRelease(void *self)')
|
||||
|
||||
/** Bind vtable slot `slot` of COM object `self` to a caller through `proto`. */
|
||||
const method = (self: unknown, slot: number, proto: unknown): (...args: unknown[]) => number => {
|
||||
const vtable = koffi.decode(self, 'void *')
|
||||
const fn = koffi.decode(vtable, slot * pointerSize, 'void *')
|
||||
return (...args: unknown[]) => koffi.call(fn, proto, self, ...args) as number
|
||||
}
|
||||
|
||||
return {
|
||||
setThreadDpiAwareness: () => {
|
||||
let setContext: KoffiFunction
|
||||
try {
|
||||
setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr'])
|
||||
} catch {
|
||||
// Symbol absent (pre-1607 Windows): no per-thread DPI control exists.
|
||||
// Proceed anyway — the cost is a blurry dialog above 100 % scaling on
|
||||
// museum hosts, and the modern picker still beats dropping to the
|
||||
// legacy 5.1 tree over a cosmetic concern.
|
||||
return
|
||||
}
|
||||
for (const context of DPI_AWARENESS_CONTEXTS) {
|
||||
if (setContext(context) !== null) return
|
||||
}
|
||||
// Unreachable in practice (SYSTEM_AWARE is accepted wherever the symbol
|
||||
// exists); if a host ever refuses everything, the dialog still works —
|
||||
// just without a DPI opt-in.
|
||||
},
|
||||
coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number,
|
||||
coUninitialize: () => {
|
||||
coUninitialize()
|
||||
},
|
||||
currentThreadId: () => getCurrentThreadId() as number,
|
||||
createFolderDialog: (): Win32FolderDialog => {
|
||||
const out = Buffer.alloc(pointerSize)
|
||||
const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number
|
||||
if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`)
|
||||
const dialog = koffi.decode(out, 'void *')
|
||||
return {
|
||||
setOptions: options => method(dialog, SLOT_SET_OPTIONS, protoSetOptions)(options),
|
||||
setTitle: title => method(dialog, SLOT_SET_TITLE, protoSetTitle)(title),
|
||||
show: () => method(dialog, SLOT_SHOW, protoShow)(null),
|
||||
resultPath: () => {
|
||||
const itemOut: unknown[] = [null]
|
||||
const gotItem = method(dialog, SLOT_GET_RESULT, protoGetResult)(itemOut)
|
||||
if (gotItem < 0) return { hr: gotItem }
|
||||
const item = itemOut[0]
|
||||
try {
|
||||
const nameOut: unknown[] = [null]
|
||||
const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut)
|
||||
if (gotName < 0) return { hr: gotName }
|
||||
const path = readUtf16(koffi, nameOut[0])
|
||||
coTaskMemFree(nameOut[0])
|
||||
return { hr: gotName, path }
|
||||
} finally {
|
||||
method(item, SLOT_RELEASE, protoRelease)()
|
||||
}
|
||||
},
|
||||
release: () => {
|
||||
method(dialog, SLOT_RELEASE, protoRelease)()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post `WM_CLOSE` to every window of a native thread — the driver's abort
|
||||
* lever against the worker blocked inside `Show`, after which `Show` returns
|
||||
* `HRESULT_CANCELLED` and the worker unwinds normally.
|
||||
* @param threadId - the dialog thread's native id (from the `showing` notice).
|
||||
*/
|
||||
export async function closeThreadWindows(threadId: number): Promise<void> {
|
||||
const koffi = (await import('koffi')).default as unknown as Koffi
|
||||
const user32 = koffi.load('user32.dll')
|
||||
const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr'])
|
||||
const postMessageW = user32.func('__stdcall', 'PostMessageW', 'int', ['void *', 'uint32', 'uintptr', 'intptr'])
|
||||
const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)')
|
||||
const callback = koffi.register((hwnd: unknown) => {
|
||||
postMessageW(hwnd, WM_CLOSE, 0, 0)
|
||||
return 1
|
||||
}, koffi.pointer(protoEnumProc))
|
||||
try {
|
||||
enumThreadWindows(threadId, callback, 0)
|
||||
} finally {
|
||||
koffi.unregister(callback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Real-process half of the Win32 dialog driver: spawn the dialog child
|
||||
* process (source or built plane) and close a dialog thread's windows. The
|
||||
* module itself loads everywhere (the import chain from native-picker.ts is
|
||||
* static); what stays win32-only is koffi, imported dynamically inside the
|
||||
* bindings' functions. The driver's logic is tested against fakes of this
|
||||
* surface instead.
|
||||
*/
|
||||
|
||||
import { spawn, type StdioOptions } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Win32DialogWorkerData } from './win32-dialog-worker.ts'
|
||||
|
||||
/**
|
||||
* Spawn the dialog child process. Built consumers launch the bundled CJS
|
||||
* entry next to this module under plain node; unbuilt (source) consumers
|
||||
* bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is
|
||||
* the child's first window, so Windows activates it without a foreground
|
||||
* call.
|
||||
* @param data - the child payload (dialog title).
|
||||
* @returns the spawned child process.
|
||||
*/
|
||||
export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType<typeof spawn> {
|
||||
const env = { ...process.env, DSH_DIALOG_TITLE: data.title }
|
||||
const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc']
|
||||
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */
|
||||
if (!import.meta.url.endsWith('.ts')) {
|
||||
return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true })
|
||||
}
|
||||
return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true })
|
||||
}
|
||||
|
||||
export { closeThreadWindows } from './win32-dialog-bindings.ts'
|
||||
132
packages/host/directory-picker-native/src/win32-dialog-logic.ts
Normal file
132
packages/host/directory-picker-native/src/win32-dialog-logic.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Pure sequencing of the Win32 `IFileOpenDialog` folder-picker COM
|
||||
* conversation over an injectable bindings seam, so every outcome path
|
||||
* (selection, cancellation, HRESULT failure, cleanup ordering) is testable on
|
||||
* any platform. The koffi-backed bindings live in
|
||||
* `win32-dialog-bindings.ts`, which only a real win32 process ever loads.
|
||||
*/
|
||||
|
||||
/** `HRESULT_FROM_WIN32(ERROR_CANCELLED)`: the user dismissed the dialog. */
|
||||
export const HRESULT_CANCELLED = 0x800704c7 | 0
|
||||
|
||||
/** `FOS_PICKFOLDERS`: the dialog selects directories, not files. */
|
||||
export const FOS_PICKFOLDERS = 0x20
|
||||
/** `FOS_FORCEFILESYSTEM`: only results with a filesystem path can be chosen. */
|
||||
export const FOS_FORCEFILESYSTEM = 0x40
|
||||
/** `FOS_NOCHANGEDIR`: never mutate the process working directory. */
|
||||
export const FOS_NOCHANGEDIR = 0x8
|
||||
|
||||
/** One created folder dialog: the vtable calls the sequencing needs. */
|
||||
export interface Win32FolderDialog {
|
||||
/**
|
||||
* `IFileDialog::SetOptions`.
|
||||
* @param options - the `FOS_*` flag union to apply.
|
||||
* @returns the call's HRESULT.
|
||||
*/
|
||||
setOptions(options: number): number
|
||||
/**
|
||||
* `IFileDialog::SetTitle`.
|
||||
* @param title - the dialog title text.
|
||||
* @returns the call's HRESULT.
|
||||
*/
|
||||
setTitle(title: string): number
|
||||
/**
|
||||
* `IModalWindow::Show` with no owner window; blocks the calling thread
|
||||
* until the user selects or dismisses.
|
||||
* @returns the call's HRESULT (`HRESULT_CANCELLED` on dismissal).
|
||||
*/
|
||||
show(): number
|
||||
/**
|
||||
* `IFileDialog::GetResult` + `IShellItem::GetDisplayName(SIGDN_FILESYSPATH)`,
|
||||
* releasing the shell item and freeing the COM string.
|
||||
* @returns the call chain's HRESULT and, on success, the selected path.
|
||||
*/
|
||||
resultPath(): { hr: number; path?: string }
|
||||
/** Release the dialog's COM reference. */
|
||||
release(): void
|
||||
}
|
||||
|
||||
/** The thread-level native surface the dialog sequencing runs against. */
|
||||
export interface Win32DialogBindings {
|
||||
/**
|
||||
* Opt the calling thread into the best supported DPI awareness
|
||||
* (per-monitor-v2, then per-monitor, then system-aware), checking each
|
||||
* call's result. Best-effort on purpose: a host accepting none of them
|
||||
* (or lacking the API, pre-1607) still shows the modern dialog — possibly
|
||||
* blurry above 100 % scaling — because a cosmetic degradation must not
|
||||
* cost the tier.
|
||||
*/
|
||||
setThreadDpiAwareness(): void
|
||||
/**
|
||||
* `CoInitializeEx(COINIT_APARTMENTTHREADED)` on the calling thread.
|
||||
* @returns the call's HRESULT (`S_FALSE` re-entry is still a success).
|
||||
*/
|
||||
coInitializeSta(): number
|
||||
/**
|
||||
* `CoUninitialize` on the calling thread — COM requires one pairing call
|
||||
* for every successful (including `S_FALSE`) `CoInitializeEx`, even on a
|
||||
* thread that exits right after the conversation.
|
||||
*/
|
||||
coUninitialize(): void
|
||||
/**
|
||||
* `CoCreateInstance(CLSID_FileOpenDialog)`.
|
||||
* @returns the created dialog surface; throws when creation fails.
|
||||
*/
|
||||
createFolderDialog(): Win32FolderDialog
|
||||
/**
|
||||
* `GetCurrentThreadId` — the native id a driver needs to close this
|
||||
* thread's windows from outside.
|
||||
* @returns the calling thread's native id.
|
||||
*/
|
||||
currentThreadId(): number
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw when an HRESULT signals failure.
|
||||
* @param hr - the HRESULT to check.
|
||||
* @param what - the failing call's name for the error message.
|
||||
* @returns the (successful) HRESULT unchanged.
|
||||
*/
|
||||
function check(hr: number, what: string): number {
|
||||
if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`)
|
||||
return hr
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one modal folder-picker conversation on the calling thread: DPI opt-in,
|
||||
* STA init, dialog creation, `Show`, and result extraction, releasing the
|
||||
* dialog on every path.
|
||||
* @param bindings - the native surface (koffi-backed in production, fakes in tests).
|
||||
* @param title - the dialog title text.
|
||||
* @param onShowing - called with the native thread id immediately before the
|
||||
* blocking `Show`, so a driver on another thread can close the dialog.
|
||||
* @returns the selected filesystem path, or null when the user cancels.
|
||||
*/
|
||||
export function runFolderDialog(
|
||||
bindings: Win32DialogBindings,
|
||||
title: string,
|
||||
onShowing: (threadId: number) => void,
|
||||
): string | null {
|
||||
bindings.setThreadDpiAwareness()
|
||||
check(bindings.coInitializeSta(), 'CoInitializeEx')
|
||||
// From here the apartment is initialized (S_OK or S_FALSE) and must be
|
||||
// uninitialized exactly once on every path.
|
||||
try {
|
||||
const dialog = bindings.createFolderDialog()
|
||||
try {
|
||||
check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions')
|
||||
check(dialog.setTitle(title), 'SetTitle')
|
||||
onShowing(bindings.currentThreadId())
|
||||
const shown = dialog.show()
|
||||
if (shown === HRESULT_CANCELLED) return null
|
||||
check(shown, 'Show')
|
||||
const result = dialog.resultPath()
|
||||
check(result.hr, 'GetResult')
|
||||
return result.path as string
|
||||
} finally {
|
||||
dialog.release()
|
||||
}
|
||||
} finally {
|
||||
bindings.coUninitialize()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Child-process entry for the Win32 folder dialog: blocks THIS process
|
||||
* inside the modal `Show` so the host event loop stays live, reporting over
|
||||
* the IPC channel. Spawned as a child process (not a worker thread) so the
|
||||
* dialog is the process's first window and Windows activates it without a
|
||||
* manual foreground call. Protocol: `{kind:'showing',threadId}` right
|
||||
* before the blocking call (the driver's abort lever needs the native
|
||||
* thread id), then exactly one of `{kind:'done',path}` or
|
||||
* `{kind:'error',message}`.
|
||||
*/
|
||||
|
||||
import { loadWin32DialogBindings } from './win32-dialog-bindings.ts'
|
||||
import { runFolderDialog } from './win32-dialog-logic.ts'
|
||||
|
||||
/** The driver-to-child payload: the dialog title (passed via env). */
|
||||
export interface Win32DialogWorkerData { title: string }
|
||||
|
||||
/** One notice or outcome posted back to the driver. */
|
||||
export type Win32DialogWorkerMessage =
|
||||
| { kind: 'showing'; threadId: number }
|
||||
| { kind: 'done'; path: string | null }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
const title = process.env.DSH_DIALOG_TITLE ?? ''
|
||||
if (title === '') throw new Error('win32-dialog-worker: DSH_DIALOG_TITLE is required')
|
||||
if (process.send === undefined) throw new Error('win32-dialog-worker must run as a child process with an IPC channel')
|
||||
// node's internal `send` reads `this.connected`, so bind the receiver.
|
||||
const send = process.send.bind(process)
|
||||
|
||||
const post = (message: Win32DialogWorkerMessage): void => {
|
||||
// Flush before closing the channel; the process exits when the loop drains.
|
||||
/* v8 ignore next 3 -- disconnect needs a live IPC channel the unit lane must not sever (built-worker.e2e.ts owns the real close path). */
|
||||
send(message, () => { if (process.connected) process.disconnect() })
|
||||
}
|
||||
|
||||
// A settled driver (or a dead parent) must not orphan a dialog still on screen.
|
||||
/* v8 ignore next 3 -- the handler exits(0), which would kill the unit lane; built-worker.e2e.ts owns the real disconnect lifecycle. */
|
||||
process.on('disconnect', () => process.exit(0))
|
||||
|
||||
// No top-level await: the built worker ships as CJS, which cannot carry TLA.
|
||||
void (async () => {
|
||||
try {
|
||||
const bindings = await loadWin32DialogBindings()
|
||||
const path = runFolderDialog(bindings, title, (threadId) => {
|
||||
post({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage)
|
||||
})
|
||||
post({ kind: 'done', path } satisfies Win32DialogWorkerMessage)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error)
|
||||
post({ kind: 'error', message } satisfies Win32DialogWorkerMessage)
|
||||
}
|
||||
})()
|
||||
159
packages/host/directory-picker-native/src/win32-dialog.ts
Normal file
159
packages/host/directory-picker-native/src/win32-dialog.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Main-thread driver for the Win32 folder dialog: spawns the dialog child
|
||||
* process (which blocks inside the modal `Show`), maps its message protocol
|
||||
* onto a promise, and services aborts by posting `WM_CLOSE` to the dialog
|
||||
* thread's windows until the child reports back. The real process/window
|
||||
* surface is injectable so every driver path is testable on any platform.
|
||||
*/
|
||||
|
||||
import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts'
|
||||
import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts'
|
||||
|
||||
/** The child-process surface the driver drives (satisfied by `node:child_process`). */
|
||||
export interface Win32DialogWorkerLike {
|
||||
/**
|
||||
* Subscribe to a child-process event.
|
||||
* @param event - `message`, `error`, or `exit`.
|
||||
* @param listener - the event consumer.
|
||||
*/
|
||||
on(event: 'message', listener: (message: Win32DialogWorkerMessage) => void): unknown
|
||||
on(event: 'error', listener: (error: Error) => void): unknown
|
||||
on(event: 'exit', listener: (code: number) => void): unknown
|
||||
/**
|
||||
* Force-stop the child; the abort path's last resort when `WM_CLOSE`
|
||||
* never lands (e.g. the dialog window was never created).
|
||||
* @returns whether a kill signal was delivered.
|
||||
*/
|
||||
kill(): boolean
|
||||
/**
|
||||
* Release the event-loop reference. Called once the pick settles so a
|
||||
* child stuck in the native modal call never blocks process exit.
|
||||
*/
|
||||
unref?(): void
|
||||
}
|
||||
|
||||
/** Injectable process surface for deterministic driver tests. */
|
||||
export interface Win32DialogInternals {
|
||||
/** Replaces the real child spawn (`win32-dialog-host.ts`). */
|
||||
spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike
|
||||
/** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */
|
||||
closeThreadWindows?: (threadId: number) => Promise<void>
|
||||
/** Abort-service cadence override so tests never wait wall-clock time. */
|
||||
closeRetryMs?: number
|
||||
}
|
||||
|
||||
/** The dialog title every host shows. */
|
||||
export const DIALOG_TITLE = 'Select Workspace Directory'
|
||||
|
||||
/** `WM_CLOSE` re-post cadence while an abort waits for the worker to unwind. */
|
||||
const CLOSE_RETRY_MS = 150
|
||||
/** Abort-service attempts before force-terminating the worker. */
|
||||
const CLOSE_MAX_ATTEMPTS = 20
|
||||
|
||||
/** Fail loudly if the closed worker-to-driver union gains an unhandled member. */
|
||||
/* v8 ignore start -- closed-union backstop; unreachable without a TypeScript contract violation */
|
||||
function assertNever(value: never): never {
|
||||
throw new TypeError(`unknown win32 dialog worker message kind: ${String(value)}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/**
|
||||
* Open the modern Win32 folder picker off the event loop.
|
||||
* @param signal - caller lifetime; abort closes the dialog and rejects.
|
||||
* @param internals - worker/window seams for deterministic tests.
|
||||
* @returns the selected path, or null when the user cancels.
|
||||
*/
|
||||
export async function pickWin32Directory(
|
||||
signal: AbortSignal,
|
||||
internals: Win32DialogInternals = {},
|
||||
): Promise<string | null> {
|
||||
if (signal.aborted) throw new Error('native directory picker aborted')
|
||||
const spawnWorker = internals.spawnWorker ?? spawnDialogWorker
|
||||
const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows
|
||||
const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS
|
||||
|
||||
const worker: Win32DialogWorkerLike = spawnWorker({ title: DIALOG_TITLE })
|
||||
let dialogThreadId: number | undefined
|
||||
let closeTimer: NodeJS.Timeout | undefined
|
||||
let settled = false
|
||||
|
||||
return await new Promise<string | null>((resolve, reject) => {
|
||||
const settle = (outcome: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (closeTimer !== undefined) clearInterval(closeTimer)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
worker.unref?.()
|
||||
outcome()
|
||||
}
|
||||
|
||||
const postClose = (): void => {
|
||||
// Before `showing` there is no window to close; the budget below still
|
||||
// runs so a child that never reports cannot dangle the pick. A
|
||||
// rejected close attempt (EnumThreadWindows/PostMessageW refusing) is
|
||||
// discarded: the interval retries it and kill is the backstop.
|
||||
if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined)
|
||||
}
|
||||
|
||||
// Sole caller: the once-registered abort listener, so no re-entry guard.
|
||||
const serviceAbort = (): void => {
|
||||
let attempts = 0
|
||||
// The `showing` notice precedes the blocking `Show`, so the very first
|
||||
// WM_CLOSE can race the window's creation; re-post until the child
|
||||
// reports back, then force-kill as a last resort. The budget is
|
||||
// unconditional — an abort before `showing` (child hung in koffi or
|
||||
// COM init) still ends in kill instead of a dangling promise.
|
||||
closeTimer = setInterval(() => {
|
||||
attempts += 1
|
||||
if (attempts > CLOSE_MAX_ATTEMPTS) {
|
||||
settle(() => {
|
||||
worker.kill()
|
||||
reject(new Error('native directory picker aborted (dialog unresponsive; worker killed)'))
|
||||
})
|
||||
return
|
||||
}
|
||||
postClose()
|
||||
}, closeRetryMs)
|
||||
postClose()
|
||||
}
|
||||
|
||||
const onAbort = (): void => {
|
||||
serviceAbort()
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
worker.on('message', (message: Win32DialogWorkerMessage) => {
|
||||
switch (message.kind) {
|
||||
case 'showing':
|
||||
dialogThreadId = message.threadId
|
||||
// An abort that raced ahead of this notice now has a window to hit.
|
||||
if (signal.aborted) postClose()
|
||||
return
|
||||
case 'done':
|
||||
settle(() => {
|
||||
if (signal.aborted) reject(new Error('native directory picker aborted'))
|
||||
else resolve(message.path)
|
||||
})
|
||||
return
|
||||
case 'error':
|
||||
settle(() => {
|
||||
reject(new Error(`win32 folder dialog failed: ${message.message}`))
|
||||
})
|
||||
return
|
||||
/* v8 ignore next 2 -- closed worker-owned union; a fourth kind becomes a compile error */
|
||||
default:
|
||||
assertNever(message)
|
||||
}
|
||||
})
|
||||
worker.on('error', (error: Error) => {
|
||||
settle(() => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
worker.on('exit', () => {
|
||||
settle(() => {
|
||||
reject(new Error('win32 folder dialog worker exited before reporting a result'))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker
|
||||
* shape): plain `node` runs `lib/worker.cjs` and the bundle reaches its
|
||||
* real koffi requires. POSIX hosts prove the load path end to end through
|
||||
* the deterministic ole32 rejection; win32 skips (a real dialog would
|
||||
* open), where the win32-only smoke in win32-dialog.spec.ts covers the
|
||||
* source plane instead. Skips until a build produces the artifact.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts'
|
||||
|
||||
const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url))
|
||||
|
||||
describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => {
|
||||
it('loads under plain node and reports the native-surface failure', async () => {
|
||||
const message = await new Promise<Win32DialogWorkerMessage>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [builtWorker], {
|
||||
env: { ...process.env, DSH_DIALOG_TITLE: 'Built-artifact guard' },
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
})
|
||||
child.on('message', resolve)
|
||||
child.on('error', reject)
|
||||
child.on('exit', (code) => {
|
||||
reject(new Error(`worker exited (${code}) before reporting`))
|
||||
})
|
||||
})
|
||||
expect(message.kind).toBe('error')
|
||||
expect((message as { kind: 'error'; message: string }).message).toMatch(/ole32|koffi/i)
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -56,7 +56,16 @@ describe('directory-picker-native client half', () => {
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
|
||||
it('fails loudly instead of deduplicating a duplicate package row', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const duplicate = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await expect(duplicate.await()).rejects.toThrow(/already has a registration/)
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
@@ -64,15 +73,14 @@ describe('directory-picker-native client half', () => {
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// This provider activates BEFORE any hole exists: both deferrals wait.
|
||||
// (Duplicate rows of the SAME package converge silently — the deferral
|
||||
// skips a hole its own component already occupies; the conflict needs
|
||||
// a rival provider.)
|
||||
// The rival subscribes first, so synchronous declaration notifications
|
||||
// let it occupy the pair before this provider's waiting injection runs.
|
||||
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
|
||||
yield b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
yield b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
}))
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
// A rival occupies both holes ahead of the pending microtask flush.
|
||||
b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
// The rival keeps both holes; this provider rolled back wholesale and
|
||||
// surfaced the conflict on the fail-loud channel — no partial mix.
|
||||
@@ -97,11 +105,11 @@ describe('directory-picker-native client half', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the first deferral when the second hole is already occupied', async () => {
|
||||
it('rolls back the outer injection when the second hole is already occupied', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
// Foreign occupant in the SECOND registered hole: the pair construction
|
||||
// throws after the first deferral installed its subscription.
|
||||
// throws after the outer injection installed its subscription.
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Native picker tier selection and the execFile adapter: the Win32 dialog
|
||||
* primary (failures surface as-is, no fallback tier), the abort rule, and
|
||||
* the POSIX command tiers (osascript, Zenity → KDialog).
|
||||
*/
|
||||
|
||||
type ExecFileCallback = (
|
||||
error: (Error & { code?: string | number }) | null,
|
||||
stdout: string,
|
||||
@@ -23,6 +29,9 @@ function failure(code: string | number, stderr = ''): Error {
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
/** A Win32 dialog that always fails — the no-fallback case. */
|
||||
const noDialog = async (): Promise<string | null> => { throw new Error('dialog unavailable') }
|
||||
|
||||
describe('native directory picker', () => {
|
||||
it('uses the macOS folder chooser and maps user cancellation to null', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
|
||||
@@ -46,46 +55,79 @@ describe('native directory picker', () => {
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project')
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'")
|
||||
run.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull()
|
||||
run.mockRejectedValueOnce(failure(1, 'Add-Type failed'))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
|
||||
it('uses the Win32 dialog and never spawns a command when it answers', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>()
|
||||
const pickWin32Dialog = vi.fn(async (): Promise<string | null> => 'C:\\work\\selected')
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected')
|
||||
pickWin32Dialog.mockResolvedValueOnce(null)
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBeNull()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces the Win32 dialog failure with no fallback', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>()
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog }))
|
||||
.rejects.toThrow('dialog unavailable')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('wires the real Win32 dialog as the default tier', async () => {
|
||||
// A pre-aborted signal makes the DEFAULT dialog deterministic on every
|
||||
// host: pickWin32Directory throws before spawning any worker or window.
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
const run = vi.fn<DirectoryPickerRunner>()
|
||||
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run }))
|
||||
.rejects.toThrow('native directory picker aborted')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fall back when the caller aborted the dialog', async () => {
|
||||
const abort = new AbortController()
|
||||
abort.abort(new Error('closed'))
|
||||
const run = vi.fn<DirectoryPickerRunner>()
|
||||
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the default command adapter without a shell and preserves command failures', async () => {
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(null, 'C:\\work\\default\r\n', '')
|
||||
callback(null, '/home/test/project\n', '')
|
||||
})
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project')
|
||||
const [command, args, options] = execFileMock.mock.calls[0]!
|
||||
expect(command).toBe('powershell.exe')
|
||||
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
|
||||
expect(command).toBe('zenity')
|
||||
expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory']))
|
||||
expect(options.encoding).toBe('utf8')
|
||||
expect(options.windowsHide).toBe(true)
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||
|
||||
const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
|
||||
// A non-cancellation command failure surfaces as-is with its cause and
|
||||
// captured stdio attached; no tier masks or rewraps it.
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(commandError, 'partial output', 'failure details')
|
||||
callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details')
|
||||
})
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
|
||||
message: 'powershell failed', cause: commandError, code: 7,
|
||||
const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' })
|
||||
.then(() => { throw new Error('expected rejection') }, (error: unknown) => error as Error)
|
||||
expect(surfaced).toMatchObject({
|
||||
message: 'zenity failed', code: 7,
|
||||
stdout: 'partial output', stderr: 'failure details',
|
||||
})
|
||||
expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('uses the current process platform when no platform override is supplied', async () => {
|
||||
// Deterministic on every host: the win32 tier answers from the dialog,
|
||||
// the POSIX tiers from the command runner.
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform')
|
||||
const pickWin32Dialog = async (): Promise<string | null> => 'C:\\default\\platform'
|
||||
const expected = process.platform === 'win32' ? 'C:\\default\\platform' : '/default/platform'
|
||||
await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected)
|
||||
})
|
||||
|
||||
it('maps empty command output to cancellation', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* The koffi-backed bindings against a mocked `koffi` module (the same
|
||||
* technique as dsh-session-persistence-jsonl's win32 suite): a small in-memory
|
||||
* COM world stands in for ole32/user32/kernel32, keeping the vtable dispatch,
|
||||
* result extraction, memory hygiene, and the WM_CLOSE poster covered on every
|
||||
* host. The worker entry is exercised the same way with a mocked process
|
||||
* boundary (env title + `process.send`). Real-COM behavior is pinned by the
|
||||
* win32-only smoke in win32-dialog.spec.ts.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts'
|
||||
|
||||
const E_FAIL = 0x80004005 | 0
|
||||
const WM_CLOSE = 0x10
|
||||
/**
|
||||
* Deliberately NOT 8: the bindings must derive vtable offsets and out-buffer
|
||||
* sizes from koffi.sizeof('void *'), and a hardcoded 8 anywhere fails against
|
||||
* this width (the win32-ia32 bug class).
|
||||
*/
|
||||
const FAKE_POINTER_SIZE = 4
|
||||
|
||||
interface ComWorld {
|
||||
coInitHr: number
|
||||
coCreateHr: number
|
||||
showHr: number
|
||||
getResultHr: number
|
||||
getDisplayNameHr: number
|
||||
hasThreadDpi: boolean
|
||||
/** Contexts `SetThreadDpiAwarenessContext` accepts; others return NULL. */
|
||||
supportedDpiContexts: number[]
|
||||
enumThrows: boolean
|
||||
path: string
|
||||
titles: string[]
|
||||
options: number[]
|
||||
dpiContexts: unknown[]
|
||||
freed: unknown[]
|
||||
released: string[]
|
||||
posted: { hwnd: unknown; message: number }[]
|
||||
registered: number
|
||||
unregistered: number
|
||||
uninitialized: number
|
||||
}
|
||||
|
||||
function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
|
||||
return {
|
||||
coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0,
|
||||
hasThreadDpi: true, supportedDpiContexts: [-4], enumThrows: false,
|
||||
path: 'C:\\选中\\directory',
|
||||
titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [],
|
||||
registered: 0, unregistered: 0, uninitialized: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentinel pointer objects standing in for native addresses. */
|
||||
interface FakePtr { kind: string; [key: string]: unknown }
|
||||
|
||||
function installFakeKoffi(world: ComWorld): void {
|
||||
const dialogPtr: FakePtr = { kind: 'dialog' }
|
||||
const itemPtr: FakePtr = { kind: 'item' }
|
||||
const namePtr: FakePtr = { kind: 'name', text: world.path }
|
||||
const outBuffers = new Map<unknown, FakePtr>()
|
||||
|
||||
const dispatch = (self: FakePtr, slot: number, args: unknown[]): number => {
|
||||
if (self.kind === 'dialog') {
|
||||
switch (slot) {
|
||||
case 9: world.options.push(args[0] as number); return 0
|
||||
case 17: world.titles.push(args[0] as string); return 0
|
||||
case 3: return world.showHr
|
||||
case 20: {
|
||||
if (world.getResultHr < 0) return world.getResultHr
|
||||
;(args[0] as unknown[])[0] = itemPtr
|
||||
return 0
|
||||
}
|
||||
case 2: world.released.push('dialog'); return 0
|
||||
default: throw new Error(`unexpected dialog slot ${slot}`)
|
||||
}
|
||||
}
|
||||
switch (slot) {
|
||||
case 5: {
|
||||
if (world.getDisplayNameHr < 0) return world.getDisplayNameHr
|
||||
;(args[1] as unknown[])[0] = namePtr
|
||||
return 0
|
||||
}
|
||||
case 2: world.released.push('item'); return 0
|
||||
default: throw new Error(`unexpected item slot ${slot}`)
|
||||
}
|
||||
}
|
||||
|
||||
vi.doMock('koffi', () => ({
|
||||
default: {
|
||||
load: (dll: string) => ({
|
||||
func: (_convention: string, name: string, _result: string, _args: string[]) => {
|
||||
switch (name) {
|
||||
case 'CoInitializeEx': return () => world.coInitHr
|
||||
case 'CoUninitialize': return () => { world.uninitialized += 1 }
|
||||
case 'CoCreateInstance': return (...args: unknown[]) => {
|
||||
if (world.coCreateHr < 0) return world.coCreateHr
|
||||
// The out-pointer must be allocated at the fake's pointer width.
|
||||
if ((args[4] as Buffer).length !== FAKE_POINTER_SIZE) {
|
||||
throw new Error(`CoCreateInstance out buffer must be ${FAKE_POINTER_SIZE} bytes`)
|
||||
}
|
||||
outBuffers.set(args[4], dialogPtr)
|
||||
return 0
|
||||
}
|
||||
case 'CoTaskMemFree': return (ptr: unknown) => { world.freed.push(ptr) }
|
||||
case 'GetCurrentThreadId': return () => 31337
|
||||
case 'SetThreadDpiAwarenessContext': {
|
||||
if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`)
|
||||
return (context: unknown) => {
|
||||
world.dpiContexts.push(context)
|
||||
return world.supportedDpiContexts.includes(context as number) ? { kind: 'previous-context' } : null
|
||||
}
|
||||
}
|
||||
case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => {
|
||||
if (world.enumThrows) throw new Error('EnumThreadWindows refused')
|
||||
callback.fn({ kind: 'hwnd', n: 1 }, lparam)
|
||||
callback.fn({ kind: 'hwnd', n: 2 }, lparam)
|
||||
return 1
|
||||
}
|
||||
case 'PostMessageW': return (hwnd: unknown, message: number) => { world.posted.push({ hwnd, message }); return 1 }
|
||||
default: throw new Error(`unexpected native import ${dll}/${name}`)
|
||||
}
|
||||
},
|
||||
}),
|
||||
proto: (declaration: string) => ({ declaration }),
|
||||
pointer: (type: unknown) => type,
|
||||
sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE },
|
||||
view: (value: unknown, len: number): ArrayBuffer => {
|
||||
const bytes = Buffer.alloc(len)
|
||||
bytes.write((value as FakePtr).text as string, 'utf16le')
|
||||
return bytes.buffer
|
||||
},
|
||||
register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } },
|
||||
unregister: () => { world.unregistered += 1 },
|
||||
decode: (value: unknown, offsetOrType: unknown): unknown => {
|
||||
if (offsetOrType === 'str16') return (value as FakePtr).text
|
||||
if (typeof offsetOrType === 'number') {
|
||||
// Vtable slot read: offsets must be multiples of the fake width.
|
||||
if (offsetOrType % FAKE_POINTER_SIZE !== 0) throw new Error(`vtable offset ${offsetOrType} is not pointer-aligned`)
|
||||
const owner = (value as { owner: FakePtr }).owner
|
||||
return { call: (args: unknown[]) => dispatch(owner, offsetOrType / FAKE_POINTER_SIZE, args) }
|
||||
}
|
||||
// decode(x, 'void *'): out-buffer read or vtable read.
|
||||
if (outBuffers.has(value)) return outBuffers.get(value)
|
||||
return { owner: value as FakePtr }
|
||||
},
|
||||
call: (fn: { call: (args: unknown[]) => number }, _proto: unknown, _self: unknown, ...args: unknown[]) => fn.call(args),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function loadBindingsModule(): Promise<typeof import('../src/win32-dialog-bindings.ts')> {
|
||||
return await import('../src/win32-dialog-bindings.ts')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('koffi')
|
||||
vi.doUnmock('node:worker_threads')
|
||||
vi.doUnmock('../src/win32-dialog-bindings.ts')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('loadWin32DialogBindings over the fake COM world', () => {
|
||||
it('drives the full selection conversation with memory hygiene', async () => {
|
||||
const world = comWorld()
|
||||
installFakeKoffi(world)
|
||||
const { loadWin32DialogBindings } = await loadBindingsModule()
|
||||
const bindings = await loadWin32DialogBindings()
|
||||
const showing = vi.fn()
|
||||
|
||||
expect(runFolderDialog(bindings, '选择工作区目录', showing)).toBe('C:\\选中\\directory')
|
||||
expect(world.dpiContexts).toEqual([-4])
|
||||
expect(world.titles).toEqual(['选择工作区目录'])
|
||||
expect(world.options).toHaveLength(1)
|
||||
expect(showing).toHaveBeenCalledWith(31337)
|
||||
expect(world.freed).toHaveLength(1)
|
||||
expect(world.released).toEqual(['item', 'dialog'])
|
||||
expect(world.uninitialized).toBe(1)
|
||||
})
|
||||
|
||||
it('maps dismissal and the S_FALSE CoInitializeEx', async () => {
|
||||
const world = comWorld({ showHr: HRESULT_CANCELLED, coInitHr: 1 })
|
||||
installFakeKoffi(world)
|
||||
const { loadWin32DialogBindings } = await loadBindingsModule()
|
||||
const bindings = await loadWin32DialogBindings()
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
|
||||
expect(world.released).toEqual(['dialog'])
|
||||
expect(world.uninitialized).toBe(1)
|
||||
})
|
||||
|
||||
it('cascades DPI contexts to the first the host accepts', async () => {
|
||||
const world = comWorld({ supportedDpiContexts: [-3] })
|
||||
installFakeKoffi(world)
|
||||
const bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
|
||||
expect(world.dpiContexts).toEqual([-4, -3])
|
||||
})
|
||||
|
||||
it('keeps the tier when no DPI context is accepted or the symbol is absent', async () => {
|
||||
// DPI is a cosmetic best-effort: the modern dialog still opens.
|
||||
const rejecting = comWorld({ supportedDpiContexts: [] })
|
||||
installFakeKoffi(rejecting)
|
||||
let bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
|
||||
expect(rejecting.dpiContexts).toEqual([-4, -3, -2])
|
||||
|
||||
vi.doUnmock('koffi')
|
||||
vi.resetModules()
|
||||
const preThreadDpi = comWorld({ hasThreadDpi: false })
|
||||
installFakeKoffi(preThreadDpi)
|
||||
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
|
||||
expect(preThreadDpi.dpiContexts).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces creation and extraction failures as HRESULT errors', async () => {
|
||||
const creationWorld = comWorld({ coCreateHr: E_FAIL })
|
||||
installFakeKoffi(creationWorld)
|
||||
let bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||
expect(() => bindings.createFolderDialog()).toThrow('CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80004005')
|
||||
|
||||
vi.doUnmock('koffi')
|
||||
vi.resetModules()
|
||||
const resultWorld = comWorld({ getResultHr: E_FAIL })
|
||||
installFakeKoffi(resultWorld)
|
||||
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed')
|
||||
expect(resultWorld.released).toEqual(['dialog'])
|
||||
|
||||
vi.doUnmock('koffi')
|
||||
vi.resetModules()
|
||||
const nameWorld = comWorld({ getDisplayNameHr: E_FAIL })
|
||||
installFakeKoffi(nameWorld)
|
||||
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed')
|
||||
// The shell item is released even when its display name cannot be read.
|
||||
expect(nameWorld.released).toEqual(['item', 'dialog'])
|
||||
expect(nameWorld.freed).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeThreadWindows over the fake COM world', () => {
|
||||
it('posts WM_CLOSE to every window of the thread and unregisters the callback', async () => {
|
||||
const world = comWorld()
|
||||
installFakeKoffi(world)
|
||||
const { closeThreadWindows } = await loadBindingsModule()
|
||||
await closeThreadWindows(777)
|
||||
expect(world.posted).toEqual([
|
||||
{ hwnd: { kind: 'hwnd', n: 1 }, message: WM_CLOSE },
|
||||
{ hwnd: { kind: 'hwnd', n: 2 }, message: WM_CLOSE },
|
||||
])
|
||||
expect(world.registered).toBe(1)
|
||||
expect(world.unregistered).toBe(1)
|
||||
})
|
||||
|
||||
it('unregisters the callback even when the enumeration itself throws', async () => {
|
||||
const world = comWorld({ enumThrows: true })
|
||||
installFakeKoffi(world)
|
||||
const { closeThreadWindows } = await loadBindingsModule()
|
||||
await expect(closeThreadWindows(777)).rejects.toThrow('EnumThreadWindows refused')
|
||||
expect(world.unregistered).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the worker entry over a mocked process boundary', () => {
|
||||
const originalSend = process.send?.bind(process)
|
||||
const originalTitle = process.env.DSH_DIALOG_TITLE
|
||||
|
||||
const installBoundary = (): { posted: { kind: string; message?: string }[] } => {
|
||||
const posted: { kind: string; message?: string }[] = []
|
||||
process.env.DSH_DIALOG_TITLE = 'Pick'
|
||||
// Never invoke the post callback: it runs the worker's disconnect(), and
|
||||
// this process is IPC-connected under the forks pool — severing vitest's
|
||||
// own channel would kill the test worker. The real close lifecycle
|
||||
// belongs to built-worker.e2e.ts.
|
||||
;(process as { send?: unknown }).send = (message: { kind: string }) => {
|
||||
posted.push(message)
|
||||
return true
|
||||
}
|
||||
return { posted }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (process as { send?: unknown }).send
|
||||
if (originalSend !== undefined) (process as { send?: unknown }).send = originalSend
|
||||
if (originalTitle === undefined) delete process.env.DSH_DIALOG_TITLE
|
||||
else process.env.DSH_DIALOG_TITLE = originalTitle
|
||||
vi.doUnmock('../src/win32-dialog-bindings.ts')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('posts showing then done for a completed conversation', async () => {
|
||||
const { posted } = installBoundary()
|
||||
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
|
||||
loadWin32DialogBindings: async () => ({
|
||||
setThreadDpiAwareness: () => undefined,
|
||||
coInitializeSta: () => 0,
|
||||
coUninitialize: () => undefined,
|
||||
currentThreadId: () => 11,
|
||||
createFolderDialog: () => ({
|
||||
setOptions: () => 0,
|
||||
setTitle: () => 0,
|
||||
show: () => 0,
|
||||
resultPath: () => ({ hr: 0, path: 'C:\\from-worker' }),
|
||||
release: () => undefined,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
await import('../src/win32-dialog-worker.ts')
|
||||
expect(posted).toEqual([
|
||||
{ kind: 'showing', threadId: 11 },
|
||||
{ kind: 'done', path: 'C:\\from-worker' },
|
||||
])
|
||||
})
|
||||
|
||||
it('posts the failure message when the native surface cannot load', async () => {
|
||||
const { posted } = installBoundary()
|
||||
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
|
||||
loadWin32DialogBindings: async () => { throw new Error('no ole32 here') },
|
||||
}))
|
||||
await import('../src/win32-dialog-worker.ts')
|
||||
expect(posted).toHaveLength(1)
|
||||
expect(posted[0]?.kind).toBe('error')
|
||||
expect(posted[0]?.message).toContain('no ole32 here')
|
||||
})
|
||||
|
||||
it('stringifies stackless and non-Error failures', async () => {
|
||||
const stackless = new Error('bare message')
|
||||
delete stackless.stack
|
||||
for (const [thrown, expected] of [[stackless, 'bare message'], ['plain refusal', 'plain refusal']] as const) {
|
||||
vi.resetModules()
|
||||
const { posted } = installBoundary()
|
||||
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
|
||||
loadWin32DialogBindings: async () => { throw thrown },
|
||||
}))
|
||||
await import('../src/win32-dialog-worker.ts')
|
||||
expect(posted[0]?.message).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to run without the dialog title', async () => {
|
||||
delete process.env.DSH_DIALOG_TITLE
|
||||
;(process as { send?: unknown }).send = () => true
|
||||
await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('DSH_DIALOG_TITLE is required')
|
||||
})
|
||||
|
||||
it('refuses to run outside a child process', async () => {
|
||||
process.env.DSH_DIALOG_TITLE = 'Pick'
|
||||
delete (process as { send?: unknown }).send
|
||||
await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a child process')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The COM conversation's sequencing against fake bindings: outcome mapping
|
||||
* (selection / cancellation / HRESULT failures at every step) and the
|
||||
* release-on-every-path guarantee, all platform-independent.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
FOS_FORCEFILESYSTEM, FOS_NOCHANGEDIR, FOS_PICKFOLDERS, HRESULT_CANCELLED,
|
||||
runFolderDialog, type Win32DialogBindings, type Win32FolderDialog,
|
||||
} from '../src/win32-dialog-logic.ts'
|
||||
|
||||
const E_FAIL = 0x80004005 | 0
|
||||
|
||||
interface FakeWorld {
|
||||
bindings: Win32DialogBindings
|
||||
dpi: ReturnType<typeof vi.fn>
|
||||
createDialog: ReturnType<typeof vi.fn>
|
||||
uninitialize: ReturnType<typeof vi.fn>
|
||||
dialog: {
|
||||
setOptions: ReturnType<typeof vi.fn>
|
||||
setTitle: ReturnType<typeof vi.fn>
|
||||
show: ReturnType<typeof vi.fn>
|
||||
resultPath: ReturnType<typeof vi.fn>
|
||||
release: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
function world(overrides: Partial<Win32FolderDialog> = {}, coInit = 0): FakeWorld {
|
||||
const dialog = {
|
||||
setOptions: vi.fn(() => 0),
|
||||
setTitle: vi.fn(() => 0),
|
||||
show: vi.fn(() => 0),
|
||||
resultPath: vi.fn(() => ({ hr: 0, path: 'C:\\picked\\目录' })),
|
||||
release: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
const dpi = vi.fn()
|
||||
const createDialog = vi.fn(() => dialog)
|
||||
const uninitialize = vi.fn()
|
||||
const bindings: Win32DialogBindings = {
|
||||
setThreadDpiAwareness: dpi,
|
||||
coInitializeSta: vi.fn(() => coInit),
|
||||
coUninitialize: uninitialize,
|
||||
createFolderDialog: createDialog,
|
||||
currentThreadId: vi.fn(() => 4242),
|
||||
}
|
||||
return { bindings, dpi, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] }
|
||||
}
|
||||
|
||||
describe('runFolderDialog', () => {
|
||||
it('sequences DPI, STA, options, title, show, result extraction, and apartment teardown', () => {
|
||||
const { bindings, dpi, dialog, uninitialize } = world()
|
||||
const showing = vi.fn()
|
||||
expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录')
|
||||
expect(dpi).toHaveBeenCalledOnce()
|
||||
expect(uninitialize).toHaveBeenCalledOnce()
|
||||
expect(dialog.release.mock.invocationCallOrder[0]).toBeLessThan(uninitialize.mock.invocationCallOrder[0] as number)
|
||||
expect(dialog.setOptions).toHaveBeenCalledWith(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR)
|
||||
expect(dialog.setTitle).toHaveBeenCalledWith('Pick')
|
||||
expect(showing).toHaveBeenCalledWith(4242)
|
||||
expect(showing.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number)
|
||||
expect(dialog.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('maps the cancelled HRESULT to null and still releases the dialog and apartment', () => {
|
||||
const { bindings, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) })
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
|
||||
expect(dialog.resultPath).not.toHaveBeenCalled()
|
||||
expect(dialog.release).toHaveBeenCalledOnce()
|
||||
expect(uninitialize).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('accepts the S_FALSE re-entry HRESULT from CoInitializeEx', () => {
|
||||
const { bindings } = world({}, 1)
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\picked\\目录')
|
||||
})
|
||||
|
||||
it('throws on a failing CoInitializeEx without creating a dialog or uninitializing', () => {
|
||||
const { bindings, createDialog, uninitialize } = world({}, E_FAIL)
|
||||
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('CoInitializeEx failed: HRESULT 0x80004005')
|
||||
expect(createDialog).not.toHaveBeenCalled()
|
||||
// A failed CoInitializeEx must NOT be paired with CoUninitialize.
|
||||
expect(uninitialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['SetOptions', { setOptions: vi.fn(() => E_FAIL) }],
|
||||
['SetTitle', { setTitle: vi.fn(() => E_FAIL) }],
|
||||
['Show', { show: vi.fn(() => E_FAIL) }],
|
||||
['GetResult', { resultPath: vi.fn(() => ({ hr: E_FAIL })) }],
|
||||
] satisfies [string, Partial<Win32FolderDialog>][])('releases the dialog and apartment when %s fails', (what, overrides) => {
|
||||
const { bindings, dialog, uninitialize } = world(overrides)
|
||||
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`)
|
||||
expect(dialog.release).toHaveBeenCalledOnce()
|
||||
expect(uninitialize).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
163
packages/host/directory-picker-native/tests/win32-dialog.spec.ts
Normal file
163
packages/host/directory-picker-native/tests/win32-dialog.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Driver tests: the child-process message protocol mapped onto the promise,
|
||||
* the WM_CLOSE abort service (including the show-race retry and the kill
|
||||
* last resort) against fakes, plus the real spawn plumbing — POSIX hosts
|
||||
* prove the default path rejects cleanly (koffi cannot load ole32 there),
|
||||
* and win32 hosts briefly open and auto-abort a real dialog.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts'
|
||||
import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts'
|
||||
|
||||
class FakeWorker extends EventEmitter implements Win32DialogWorkerLike {
|
||||
kill = vi.fn(() => true)
|
||||
post(message: Win32DialogWorkerMessage): void {
|
||||
this.emit('message', message)
|
||||
}
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
worker: FakeWorker
|
||||
internals: Win32DialogInternals
|
||||
close: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function harness(overrides: Partial<Win32DialogInternals> = {}): Harness {
|
||||
const worker = new FakeWorker()
|
||||
const close = vi.fn(async () => undefined)
|
||||
return {
|
||||
worker,
|
||||
close,
|
||||
internals: {
|
||||
spawnWorker: () => worker,
|
||||
closeThreadWindows: close,
|
||||
closeRetryMs: 1,
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const live = (): AbortSignal => new AbortController().signal
|
||||
|
||||
describe('pickWin32Directory', () => {
|
||||
it('resolves the selected path and the cancellation null', async () => {
|
||||
const first = harness()
|
||||
const picked = pickWin32Directory(live(), first.internals)
|
||||
first.worker.post({ kind: 'showing', threadId: 7 })
|
||||
first.worker.post({ kind: 'done', path: 'C:\\picked' })
|
||||
await expect(picked).resolves.toBe('C:\\picked')
|
||||
expect(first.close).not.toHaveBeenCalled()
|
||||
|
||||
const second = harness()
|
||||
const cancelled = pickWin32Directory(live(), second.internals)
|
||||
second.worker.post({ kind: 'done', path: null })
|
||||
await expect(cancelled).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => {
|
||||
const reported = harness()
|
||||
const failing = pickWin32Directory(live(), reported.internals)
|
||||
reported.worker.post({ kind: 'error', message: 'CoCreateInstance failed' })
|
||||
await expect(failing).rejects.toThrow('win32 folder dialog failed: CoCreateInstance failed')
|
||||
|
||||
const crashed = harness()
|
||||
const crashing = pickWin32Directory(live(), crashed.internals)
|
||||
crashed.worker.emit('error', new Error('worker blew up'))
|
||||
await expect(crashing).rejects.toThrow('worker blew up')
|
||||
|
||||
const silent = harness()
|
||||
const exiting = pickWin32Directory(live(), silent.internals)
|
||||
silent.worker.emit('exit', 0)
|
||||
await expect(exiting).rejects.toThrow('exited before reporting a result')
|
||||
})
|
||||
|
||||
it('settles once: a late exit after the result is inert', async () => {
|
||||
const { worker, internals } = harness()
|
||||
const picked = pickWin32Directory(live(), internals)
|
||||
worker.post({ kind: 'done', path: 'C:\\once' })
|
||||
worker.emit('exit', 0)
|
||||
await expect(picked).resolves.toBe('C:\\once')
|
||||
})
|
||||
|
||||
it('throws immediately on an already-aborted signal without spawning', async () => {
|
||||
const spawnWorker = vi.fn()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(pickWin32Directory(controller.signal, { spawnWorker, closeThreadWindows: async () => undefined }))
|
||||
.rejects.toThrow('native directory picker aborted')
|
||||
expect(spawnWorker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('services an abort by closing the dialog thread windows until the worker reports', async () => {
|
||||
const { worker, internals, close } = harness()
|
||||
const controller = new AbortController()
|
||||
// Attach the expectation BEFORE driving the race: on a fast host the
|
||||
// close budget can exhaust (and reject) between waitFor ticks, and a
|
||||
// rejection with no listener yet would count as unhandled.
|
||||
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted')
|
||||
worker.post({ kind: 'showing', threadId: 99 })
|
||||
controller.abort()
|
||||
await vi.waitFor(() => {
|
||||
expect(close).toHaveBeenCalledWith(99)
|
||||
})
|
||||
worker.post({ kind: 'done', path: null })
|
||||
await picked
|
||||
})
|
||||
|
||||
it('starts the close service on the showing notice when the abort came first', async () => {
|
||||
const closeFailures = vi.fn(async () => { throw new Error('window not there yet') })
|
||||
const { worker, internals } = harness({ closeThreadWindows: closeFailures })
|
||||
const controller = new AbortController()
|
||||
// Attached before the race for the same unhandled-rejection reason above.
|
||||
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted')
|
||||
controller.abort()
|
||||
expect(closeFailures).not.toHaveBeenCalled()
|
||||
worker.post({ kind: 'showing', threadId: 12 })
|
||||
await vi.waitFor(() => {
|
||||
expect(closeFailures.mock.calls.length).toBeGreaterThan(1)
|
||||
})
|
||||
worker.post({ kind: 'done', path: null })
|
||||
await picked
|
||||
})
|
||||
|
||||
it('kills a worker that never reports showing after an abort', async () => {
|
||||
// The budget runs without a thread id (nothing to WM_CLOSE yet), so a
|
||||
// worker hung before `showing` cannot dangle the pick.
|
||||
const { worker, internals, close } = harness()
|
||||
const controller = new AbortController()
|
||||
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker killed')
|
||||
controller.abort()
|
||||
await picked
|
||||
expect(worker.kill).toHaveBeenCalledOnce()
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills an unresponsive worker after the close budget', async () => {
|
||||
const { worker, internals, close } = harness()
|
||||
const controller = new AbortController()
|
||||
const picked = pickWin32Directory(controller.signal, internals)
|
||||
worker.post({ kind: 'showing', threadId: 5 })
|
||||
controller.abort()
|
||||
await expect(picked).rejects.toThrow('dialog unresponsive; worker killed')
|
||||
expect(worker.kill).toHaveBeenCalledOnce()
|
||||
expect(close.mock.calls.length).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
// POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped
|
||||
// worker spawns, loads koffi, fails to load ole32.dll, and reports the error.
|
||||
it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => {
|
||||
await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed')
|
||||
}, 30_000)
|
||||
|
||||
// win32 hosts run the true COM smoke instead: a real dialog opens briefly
|
||||
// and the abort service closes it (the same lever a disconnecting client pulls).
|
||||
it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => {
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => {
|
||||
controller.abort()
|
||||
}, 400)
|
||||
await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted')
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -1,3 +1,20 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
// The Win32 dialog worker builds as its own CJS entry (mirroring
|
||||
// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining
|
||||
// the dialog logic while koffi stays an external native require.
|
||||
export default [
|
||||
...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']),
|
||||
{
|
||||
// The artifact is lib/worker.cjs (the ./worker export the workspace
|
||||
// constraint keys on), bundled from the descriptive source entry.
|
||||
entry: { worker: 'lib/types/win32-dialog-worker.js' },
|
||||
outDir: 'lib',
|
||||
format: ['cjs'] as ['cjs'],
|
||||
platform: 'node' as const,
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md
|
||||
README.md: 3749b238b56578ec68610bc13550760aa084bad6
|
||||
README.zh.md: 488da5129ec211c2a064156c22a9d0abf04d99be
|
||||
README.zh.md: 4f7c20e25a4e64f63c611fc56eeec3f249ad67e3
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。
|
||||
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,也能服务于 OS 对话框无法触及的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端通过声明合并加入自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam,无需通过 wire 公布能力:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一项组合配置会同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。
|
||||
|
||||
浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
浏览原语失败时会抛出带类型的 `DirectoryPickerError`(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带出错对象的 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该 seam 服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有多根词汇**——浏览契约每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。
|
||||
- **契约未定义多根目录词汇**——浏览契约每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -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/host/webserver/README.md
|
||||
README.md: ace8c09e43dd8544a28d300f97b04610be78bc69
|
||||
README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a
|
||||
README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4
|
||||
README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
|
||||
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
|
||||
|
||||
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
|
||||
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.
|
||||
|
||||
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package is a pure HTTP carrier between the browser and the routes other plugins register; nothing here reaches a model request.
|
||||
None, as the package is a Web carrier between the browser and the HTTP/upgrade routes other plugins register; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
|
||||
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
|
||||
|
||||
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的 route。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。
|
||||
|
||||
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包只是浏览器与其他插件所注册路由之间的纯 HTTP 载体,其中没有任何内容会进入模型请求。
|
||||
无。该包只是浏览器与其他插件所注册 HTTP/upgrade route 之间的 Web 载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-webserver",
|
||||
"description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts",
|
||||
"description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a
|
||||
* node:http server plus the `httpServer` service (named-route registry + index
|
||||
* transform taps + static dist fallback). Knows no harness concepts — every
|
||||
* feature surface (API bridge, plugin bundles, SSE) is a route some other
|
||||
* plugin registers. Web (browser) shape only — Electron loads dist over
|
||||
* file:// and carries fetch over an IPC bridge, not this server. This package
|
||||
* @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
|
||||
* server plus the `httpServer` service (HTTP and upgrade route registries,
|
||||
* index transform taps, and static dist fallback). Knows no harness concepts;
|
||||
* feature plugins own every registered protocol. Web shape only — Electron
|
||||
* loads dist over file:// and carries fetch over an IPC bridge. This package
|
||||
* never prints: the URL line belongs to the shell.
|
||||
*/
|
||||
|
||||
@@ -12,6 +11,7 @@ import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { dirname } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
@@ -35,6 +35,14 @@ export interface WebRoute {
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** One exact-path HTTP upgrade registration. */
|
||||
export interface WebUpgradeRoute {
|
||||
/** Absolute pathname, no trailing slash. */
|
||||
path: string
|
||||
/** Owns protocol negotiation and the upgraded socket after dispatch. */
|
||||
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
|
||||
export interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
@@ -61,6 +69,8 @@ export class HttpServerService extends Service {
|
||||
|
||||
private readonly exact = new Map<string, WebRoute>()
|
||||
private readonly prefixes = new Map<string, WebRoute>()
|
||||
private readonly upgrades = new Map<string, WebUpgradeRoute>()
|
||||
private readonly upgradedSockets = new Set<Duplex>()
|
||||
private readonly indexTaps: ((html: string) => string)[] = []
|
||||
private readonly distRoot: string
|
||||
private readonly distIndex: string
|
||||
@@ -98,6 +108,20 @@ export class HttpServerService extends Service {
|
||||
return () => { table.delete(route.path) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (this.upgrades.has(route.path)) {
|
||||
throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
|
||||
}
|
||||
this.upgrades.set(route.path, route)
|
||||
return () => { this.upgrades.delete(route.path) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an index.html transform, applied to every index response in
|
||||
* registration order.
|
||||
@@ -147,6 +171,40 @@ export class HttpServerService extends Service {
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
this.server.on('upgrade', (req, socket, head) => {
|
||||
const onError = (error: Error): void => {
|
||||
this.ctx.logger.warn(error)
|
||||
socket.destroy()
|
||||
}
|
||||
socket.on('error', onError)
|
||||
socket.once('close', () => {
|
||||
socket.off('error', onError)
|
||||
this.upgradedSockets.delete(socket)
|
||||
})
|
||||
let route: WebUpgradeRoute | undefined
|
||||
try {
|
||||
/* v8 ignore next -- node:http always sets url on server requests. */
|
||||
route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
if (route === undefined) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
this.upgradedSockets.add(socket)
|
||||
try {
|
||||
Promise.resolve(route.handler(req, socket, head)).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
|
||||
socket.destroy()
|
||||
})
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
|
||||
socket.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.once('error', reject)
|
||||
@@ -158,12 +216,19 @@ export class HttpServerService extends Service {
|
||||
})
|
||||
})
|
||||
|
||||
// close + closeAllConnections: held-open responses (SSE) never end on
|
||||
// their own; without the force-close, close() would hang teardown.
|
||||
this.ctx.effect(() => () => new Promise<void>((resolve) => {
|
||||
this.server.close(() => { resolve() })
|
||||
// Node does not include upgraded sockets in closeAllConnections(), so the
|
||||
// service tracks and destroys them as part of the same ownership boundary.
|
||||
this.ctx.effect(() => async () => {
|
||||
const serverClosed = new Promise<void>((resolve) => {
|
||||
this.server.close(() => { resolve() })
|
||||
})
|
||||
this.server.closeAllConnections()
|
||||
}), 'httpServer.listen')
|
||||
const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise<void>((resolve) => {
|
||||
socket.once('close', () => { resolve() })
|
||||
socket.destroy()
|
||||
}))
|
||||
await Promise.all([serverClosed, ...upgradedClosed])
|
||||
}, 'httpServer.listen')
|
||||
}
|
||||
|
||||
/** Longest-prefix-wins over the prefix table after an exact-table miss. */
|
||||
|
||||
@@ -15,7 +15,7 @@ export const name = 'host-webserver-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relation: route registrations and their disposers must stay
|
||||
* Owned relation: HTTP and upgrade route registrations and their disposers must stay
|
||||
* symmetric — after the owning fiber of a registered route unloads, the
|
||||
* route table must no longer answer for its path (a stale route would keep
|
||||
* serving a disposed plugin's handler). Checked on every fiber teardown
|
||||
@@ -26,7 +26,10 @@ export const inject = ['invariants']
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/plugin', () => {
|
||||
const server = ctx.get('httpServer') as
|
||||
| { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void }
|
||||
| {
|
||||
register(route: { kind: 'exact'; path: string; handler: () => void }): () => void
|
||||
registerUpgrade(route: { path: string; handler: () => void }): () => void
|
||||
}
|
||||
| undefined
|
||||
if (server === undefined) return // no webserver row in this composition
|
||||
// Register/dispose probe on a reserved path: if dispose leaves the route
|
||||
@@ -37,8 +40,11 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
try {
|
||||
server.register(probe)()
|
||||
server.register(probe)()
|
||||
const upgradeProbe = { path: '/__dsh_invariant_upgrade_probe__', handler: () => {} }
|
||||
server.registerUpgrade(upgradeProbe)()
|
||||
server.registerUpgrade(upgradeProbe)()
|
||||
} catch {
|
||||
fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged')
|
||||
fail('httpServer route disposer left a route registered — route tables and fiber lifecycles diverged')
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { once } from 'node:events'
|
||||
import { connect } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context, FiberState } from 'cordis'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import HttpServer from '../src/index.ts'
|
||||
@@ -72,6 +74,24 @@ async function request(port: number, path: string, init?: RequestInit): Promise<
|
||||
return { status: response.status, body: (await response.text()).slice(0, 80) }
|
||||
}
|
||||
|
||||
/** Open one raw upgrade request and return after the handler writes its response. */
|
||||
async function upgrade(port: number, path: string): Promise<ReturnType<typeof connect>> {
|
||||
const socket = connect(port, '127.0.0.1')
|
||||
await once(socket, 'connect')
|
||||
const response = once(socket, 'data')
|
||||
socket.write([
|
||||
`GET ${path} HTTP/1.1`,
|
||||
`Host: 127.0.0.1:${String(port)}`,
|
||||
'Connection: Upgrade',
|
||||
'Upgrade: dsh-test',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'))
|
||||
const [data] = await response as [Buffer]
|
||||
expect(String(data)).toContain('101 Switching Protocols')
|
||||
return socket
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
// Real-Loader composition resolves workspace packages through tsx at test
|
||||
// time; first resolution after the host/client program split is slow enough
|
||||
@@ -111,6 +131,8 @@ describe('real Loader composition', () => {
|
||||
// Static fallback semantics: real asset served, traversal 403, non-GET/
|
||||
// HEAD without a matching route 405.
|
||||
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' })
|
||||
await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true')
|
||||
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' })
|
||||
expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
|
||||
expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
|
||||
|
||||
@@ -129,8 +151,51 @@ describe('real Loader composition', () => {
|
||||
expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback
|
||||
expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
|
||||
|
||||
// Teardown: fiber dispose closes the socket and severs held connections.
|
||||
// Upgrade routes match exact pathnames, reject duplicate ownership, and
|
||||
// become registrable again after disposal. The accepted socket stays open
|
||||
// so the teardown assertion also covers upgraded-connection ownership.
|
||||
let upgradedServerClosed = false
|
||||
const disposeUpgrade = server.registerUpgrade({
|
||||
path: '/events',
|
||||
handler: (_req, socket) => {
|
||||
socket.once('close', () => { upgradedServerClosed = true })
|
||||
socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-test\r\n\r\n')
|
||||
},
|
||||
})
|
||||
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} }))
|
||||
.toThrow(/duplicate upgrade route/)
|
||||
const upgraded = await upgrade(port, '/events?stream=mux')
|
||||
disposeUpgrade()
|
||||
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} })).not.toThrow()
|
||||
|
||||
// The webserver contains raw-socket errors even before an upgrade handler
|
||||
// has installed its protocol implementation.
|
||||
server.registerUpgrade({
|
||||
path: '/upgrade-error',
|
||||
handler: async (_req, socket) => {
|
||||
await Promise.resolve()
|
||||
socket.destroy(new Error('test upgrade transport failure'))
|
||||
},
|
||||
})
|
||||
const failedUpgrade = connect(port, '127.0.0.1')
|
||||
failedUpgrade.on('error', () => { /* The server-side reset is the fixture outcome. */ })
|
||||
await once(failedUpgrade, 'connect')
|
||||
const failedUpgradeClosed = once(failedUpgrade, 'close')
|
||||
failedUpgrade.write([
|
||||
'GET /upgrade-error HTTP/1.1',
|
||||
`Host: 127.0.0.1:${String(port)}`,
|
||||
'Connection: Upgrade',
|
||||
'Upgrade: dsh-test',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'))
|
||||
await failedUpgradeClosed
|
||||
expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
|
||||
|
||||
// Teardown closes both ordinary and upgraded sockets before it resolves.
|
||||
await loaded.fiber.dispose()
|
||||
expect(upgradedServerClosed).toBe(true)
|
||||
upgraded.destroy()
|
||||
await expect(request(port, '/probe')).rejects.toThrow()
|
||||
})
|
||||
|
||||
@@ -140,25 +205,17 @@ describe('real Loader composition', () => {
|
||||
const firstRoot = root
|
||||
root = undefined // keep the first composition's files until the end
|
||||
|
||||
// loader.await() never rejects (allSettled); the bind failure surfaces as
|
||||
// a FAILED fiber whose error escapes as a late rejection — the shape the
|
||||
// boot's installFailLoud is contracted to catch. Capture it here the same
|
||||
// way, and assert it really is the bind error.
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (err: unknown): void => { rejections.push(err) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
let second: Context | undefined
|
||||
try {
|
||||
second = await loadComposition(takenPort)
|
||||
const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
|
||||
expect(entry?.fiber?.state).toBe(FiberState.FAILED)
|
||||
// The rejection escapes a tick after loader.await() settles; bounded poll.
|
||||
for (let i = 0; i < 100 && rejections.length === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
let failure: unknown
|
||||
try {
|
||||
await loadComposition(takenPort)
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(rejections.map(String).join('\n')).toContain('EADDRINUSE')
|
||||
second = context
|
||||
expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/)
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
await second?.fiber.dispose()
|
||||
context = first
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
|
||||
Reference in New Issue
Block a user