Merge branch 'master' of https://github.com/deepseek-harness/deepseek-harness into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml # docs/architecture.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/persistence-catalog.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/session/README.i18n.yaml # packages/core/session/src/types.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/llm/llm/tests/service.spec.ts # packages/sdk/sdk-client/README.i18n.yaml # packages/sdk/sdk-protocol/README.i18n.yaml # packages/sdk/sdk-protocol/README.md # packages/sdk/sdk-protocol/README.zh.md # packages/subagent/subagent-dsh-sdk/README.i18n.yaml # packages/ui/jsonrpc/README.i18n.yaml # packages/ui/jsonrpc/README.md # packages/ui/jsonrpc/README.zh.md # packages/ui/tui/src/index.ts # python/sdk/README.i18n.yaml # scripts/gen-cordis-catalog.ts
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: d44770f70be16c12f44b78155089e092a3e9bba0
|
||||
README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014
|
||||
README.md: 391adb7009a01d1ec95c8dcb809e8a2065aa0b31
|
||||
README.zh.md: 7fc730ed9ec3a067589b277733eb5bb2c42f8b4e
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
|
||||
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/config/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
@@ -11,5 +11,6 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
|
||||
| `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` 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/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 键 |
|
||||
|---|---|---|
|
||||
@@ -11,5 +11,6 @@ dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承
|
||||
| `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` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。
|
||||
|
||||
@@ -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: bf5d0d762365deae83d004e00b94a0ba805d6a6f
|
||||
README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928
|
||||
README.md: cf8cf0aaa7e20436644b92a996b6f7a817b1dd31
|
||||
README.zh.md: 91b3d1d0577d4a9a7df97d3c785e28bb21e47fd1
|
||||
|
||||
@@ -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/cordis.yml`](../../../apps/cli/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 (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).
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
@@ -10,10 +10,14 @@ 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.
|
||||
|
||||
`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.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
`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`.
|
||||
|
||||
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. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
|
||||
@@ -26,6 +30,8 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
|
||||
|
||||
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; the carrier's request 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 exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and 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 both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. 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.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
|
||||
@@ -41,7 +47,7 @@ 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`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **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.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
- **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)).
|
||||
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[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/cordis.yml`](../../../apps/cli/cordis.yml)。
|
||||
所有客户端形态共用的 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`)
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
|
||||
分层与协议决策记录在 [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)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
|
||||
`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.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
`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`。
|
||||
|
||||
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
|
||||
|
||||
@@ -26,6 +30,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 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 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
|
||||
@@ -41,7 +47,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项(先前预留的 `host.listModels` 已作为 `llm.models` 交付);未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
|
||||
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
|
||||
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
@@ -52,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
|
||||
MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary,
|
||||
SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
@@ -39,6 +39,12 @@ import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
// The settings/credentials seams: brand guards run at this wire boundary; the
|
||||
// service reads stay optional (`ctx.get`) so a composition without either
|
||||
// provider still serves every other domain.
|
||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
|
||||
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
@@ -60,14 +66,18 @@ import { openNativePath } from './native-path-opener.ts'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Surface message event types (the pagination counting unit). */
|
||||
/** Conversation message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
|
||||
* naturally includes the in-progress partial.
|
||||
* Message-boundary pagination: count maxMessages append-origin messages
|
||||
* backwards from the window tail. Replacement copies never entered the
|
||||
* conversation a reader sees — they restate a shadowed range for the model
|
||||
* alone — so they consume no quota; the page stays one contiguous raw range,
|
||||
* which keeps a compaction's log-only provenance on the same page as its
|
||||
* replacement. The cut is the starting seq of the oldest message group (chunks
|
||||
* group via sourceEventSeqs — never cut mid-message). The tail page naturally
|
||||
* includes the in-progress partial.
|
||||
*/
|
||||
function paginate(
|
||||
events: readonly SessionEvent[],
|
||||
@@ -79,7 +89,7 @@ function paginate(
|
||||
let cut = 0
|
||||
for (let i = window.length - 1; i >= 0; i--) {
|
||||
const event = window[i] as SessionEvent
|
||||
if (!MESSAGE_TYPES.has(event.type)) continue
|
||||
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
|
||||
count++
|
||||
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
|
||||
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
|
||||
@@ -97,6 +107,82 @@ function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provider/model catalog over every registered route. Shared by the
|
||||
* session-scoped `session.models` (which passes the session's current target
|
||||
* so an unlisted current model still renders selectable) and the host-scoped
|
||||
* `llm.models` (no current). Per-provider failures ride `failures` without
|
||||
* failing the sound groups; groups that advertise nothing are dropped.
|
||||
*/
|
||||
async function buildModelCatalog(
|
||||
ctx: Context,
|
||||
current?: { provider: string; model: string },
|
||||
): Promise<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }> {
|
||||
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
|
||||
try {
|
||||
const advertised = await ctx.llm.listModels(provider.id)
|
||||
const models = [...advertised]
|
||||
if (
|
||||
current !== undefined
|
||||
&& provider.id === current.provider
|
||||
&& !models.some(model => model.id === current.model)
|
||||
) {
|
||||
models.push({
|
||||
provider: provider.id,
|
||||
id: current.model,
|
||||
name: current.model,
|
||||
})
|
||||
}
|
||||
const entries = await Promise.all(models.map(async (model) => {
|
||||
const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
|
||||
const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
|
||||
? undefined
|
||||
: {
|
||||
efforts: resolved.reasoning.efforts.map(effort => ({
|
||||
id: effort.id,
|
||||
name: effort.name,
|
||||
...effort.description === undefined
|
||||
? {}
|
||||
: { description: effort.description },
|
||||
})),
|
||||
...resolved.reasoning.defaultEffort === undefined
|
||||
? {}
|
||||
: { defaultEffort: resolved.reasoning.defaultEffort },
|
||||
}
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...current !== undefined
|
||||
&& provider.id === current.provider
|
||||
&& model.id === current.model
|
||||
&& !advertised.some(candidate => candidate.id === current.model)
|
||||
? { unlisted: true as const }
|
||||
: {},
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
}
|
||||
}))
|
||||
const group: ModelProviderGroup = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
models: entries,
|
||||
}
|
||||
return { kind: 'group' as const, group }
|
||||
} catch (error: unknown) {
|
||||
const failure: ModelCatalogFailure = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
return { kind: 'failure' as const, failure }
|
||||
}
|
||||
}))
|
||||
return {
|
||||
groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []).filter(group => group.models.length > 0),
|
||||
failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap an error result echoing the request's rpcId. */
|
||||
function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error } }
|
||||
@@ -802,6 +888,109 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
}
|
||||
|
||||
/** Missing-service report shared by the settings domain (skills-domain stance). */
|
||||
function settingsAbsent(): RpcError {
|
||||
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
|
||||
}
|
||||
|
||||
/** Missing-service report shared by the credentials domain. */
|
||||
function credentialsAbsent(): RpcError {
|
||||
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
|
||||
}
|
||||
|
||||
/** Map one redacted seam descriptor to its wire view. */
|
||||
function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
|
||||
return {
|
||||
ns: String(descriptor.ns),
|
||||
schema: descriptor.schema,
|
||||
value: descriptor.value,
|
||||
...descriptor.base === undefined ? {} : { base: descriptor.base },
|
||||
...descriptor.user === undefined ? {} : { user: descriptor.user },
|
||||
applies: descriptor.applies,
|
||||
secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
|
||||
revision: descriptor.revision,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings namespaces this proxy serves: exactly those a registered
|
||||
* configurable provider addresses. The settings seam itself is general —
|
||||
* any plugin may register a namespace for its own configuration — but the
|
||||
* Web configuration plane is scoped to model providers, and that boundary
|
||||
* has to be enforced here rather than assumed from the current plugin set.
|
||||
* Without it, every future `settings.register()` would silently become
|
||||
* remotely readable and writable configuration.
|
||||
*/
|
||||
function exposedNamespaces(): Set<string> {
|
||||
return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs))
|
||||
}
|
||||
|
||||
/** Refuse a namespace outside the model-provider boundary, naming why. */
|
||||
function notExposed(request: RpcRequest<unknown>, ns: string): RpcResponse<SettingsNamespaceView> {
|
||||
return err(request, {
|
||||
code: 'settings-not-exposed',
|
||||
message: `settings namespace "${ns}" is not exposed to configuration clients; only a namespace a registered model provider addresses is`,
|
||||
details: { ns },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one settings write (merge or wholesale replace) and acknowledge with
|
||||
* the namespace's new redacted view. A namespace outside the model-provider
|
||||
* boundary is refused before the seam is touched; every seam refusal —
|
||||
* unknown or invalid namespace, read-only provider, schema validation,
|
||||
* storage — becomes one `settings-rejected` carrying the seam's own message.
|
||||
*/
|
||||
async function settingsWrite(
|
||||
request: RpcRequest<unknown>,
|
||||
ns: string,
|
||||
mode: 'update' | 'replace' | 'mutate',
|
||||
section: object,
|
||||
expectedRevision?: number,
|
||||
): Promise<RpcResponse<SettingsNamespaceView>> {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) return err(request, settingsAbsent())
|
||||
const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => {
|
||||
// A stale writer is its own outcome, not a malformed request: the client
|
||||
// must re-read and re-apply rather than treat the write as invalid.
|
||||
if (error instanceof SettingsConflictError) {
|
||||
return err(request, {
|
||||
code: 'settings-conflict',
|
||||
message: error.message,
|
||||
details: { ns, expected: error.expected, actual: error.actual },
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'settings-rejected',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { ns },
|
||||
})
|
||||
}
|
||||
let branded: SettingsNamespace
|
||||
try {
|
||||
branded = settingsNamespace(ns)
|
||||
} catch (error: unknown) {
|
||||
// A malformed name is a client bug, reported as such; it could never be
|
||||
// in the exposed set either, so naming the real fault costs no ground.
|
||||
return rejected(error)
|
||||
}
|
||||
if (!exposedNamespaces().has(ns)) return notExposed(request, ns)
|
||||
try {
|
||||
if (mode === 'update') await settings.update(branded, section, expectedRevision)
|
||||
else if (mode === 'replace') await settings.replace(branded, section, expectedRevision)
|
||||
else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision)
|
||||
} catch (error: unknown) {
|
||||
return rejected(error)
|
||||
}
|
||||
const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
|
||||
if (descriptor === undefined) {
|
||||
// The write committed but the namespace vanished before this read: only
|
||||
// a concurrent registrant disposal can produce it.
|
||||
return err(request, { code: 'internal', message: `settings namespace "${ns}" was disposed after the ${mode}`, details: {} })
|
||||
}
|
||||
return ok(request, namespaceView(descriptor))
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
// Attached sessions summarize from memory; persisted-but-unattached (cold)
|
||||
@@ -912,70 +1101,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const current = targetFor(found.agent).current
|
||||
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
|
||||
try {
|
||||
const advertised = await ctx.llm.listModels(provider.id)
|
||||
const models = [...advertised]
|
||||
if (
|
||||
provider.id === current.provider
|
||||
&& !models.some(model => model.id === current.model)
|
||||
) {
|
||||
models.push({
|
||||
provider: provider.id,
|
||||
id: current.model,
|
||||
name: current.model,
|
||||
})
|
||||
}
|
||||
const entries = await Promise.all(models.map(async (model) => {
|
||||
const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
|
||||
const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
|
||||
? undefined
|
||||
: {
|
||||
efforts: resolved.reasoning.efforts.map(effort => ({
|
||||
id: effort.id,
|
||||
name: effort.name,
|
||||
...effort.description === undefined
|
||||
? {}
|
||||
: { description: effort.description },
|
||||
})),
|
||||
...resolved.reasoning.defaultEffort === undefined
|
||||
? {}
|
||||
: { defaultEffort: resolved.reasoning.defaultEffort },
|
||||
}
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...provider.id === current.provider
|
||||
&& model.id === current.model
|
||||
&& !advertised.some(candidate => candidate.id === current.model)
|
||||
? { unlisted: true as const }
|
||||
: {},
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
}
|
||||
}))
|
||||
const group: ModelProviderGroup = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
models: entries,
|
||||
}
|
||||
return { kind: 'group' as const, group }
|
||||
} catch (error: unknown) {
|
||||
const failure: ModelCatalogFailure = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
return { kind: 'failure' as const, failure }
|
||||
}
|
||||
}))
|
||||
const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
|
||||
const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
|
||||
return ok(request, {
|
||||
current: { ...current },
|
||||
groups: groups.filter(group => group.models.length > 0),
|
||||
failures,
|
||||
})
|
||||
const { groups, failures } = await buildModelCatalog(ctx, current)
|
||||
return ok(request, { current: { ...current }, groups, failures })
|
||||
},
|
||||
|
||||
async selectModel(request) {
|
||||
@@ -1038,6 +1165,75 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
},
|
||||
|
||||
async fork(request) {
|
||||
const { sessionId, atSeq } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const source = found.agent.session
|
||||
const events = source.events
|
||||
// An in-log anchor belongs to the turn containing it and must never
|
||||
// clip backward to an earlier completed turn. Omitted and past-end
|
||||
// anchors retain the last-completed-turn shortcut.
|
||||
const lastSeq = events.at(-1)?.seq ?? -1
|
||||
const anchoredBoundary = atSeq === undefined
|
||||
? undefined
|
||||
: events.find(e => e.type === 'turn/end' && e.seq >= atSeq)
|
||||
const boundary = anchoredBoundary
|
||||
?? (atSeq === undefined || atSeq > lastSeq
|
||||
? events.findLast(e => e.type === 'turn/end')
|
||||
: undefined)
|
||||
if (boundary === undefined) {
|
||||
return err(request, {
|
||||
code: 'fork-unavailable',
|
||||
message: atSeq !== undefined && atSeq <= lastSeq
|
||||
? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}`
|
||||
: `session "${sessionId}" has no completed turn to fork from`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
// Extend the cut through trailing out-of-band appends (session/title,
|
||||
// injections) up to the next turn/start: they are standalone events, so
|
||||
// the seed stays balanced, and the child inherits a title generated
|
||||
// right after the boundary turn.
|
||||
let cut = boundary.seq + 1
|
||||
while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
|
||||
const childId = `session-${randomUUID()}` as SessionId
|
||||
try {
|
||||
await ctx.agents.create({
|
||||
sessionId: childId,
|
||||
seed: events.slice(0, cut),
|
||||
meta: {
|
||||
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
|
||||
parentSession: source.id,
|
||||
seedLength: cut,
|
||||
},
|
||||
agentOptions,
|
||||
setup: installTarget,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `failed to fork session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
// Keep the child in the source's Workspace so the list nests it under
|
||||
// its parent; the child is already published if the attach fails.
|
||||
const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id))
|
||||
if (workspace !== undefined) {
|
||||
try {
|
||||
await workspace.attachSession(childId)
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'workspace-attach-failed',
|
||||
message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
||||
details: { sessionId: childId, workspaceId: workspace.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
return ok(request, { sessionId: childId })
|
||||
},
|
||||
|
||||
async prompt(request) {
|
||||
const { sessionId, mode, content } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
@@ -1450,6 +1646,105 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
describe(request) {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) return Promise.resolve(err(request, settingsAbsent()))
|
||||
const exposed = exposedNamespaces()
|
||||
return Promise.resolve(ok(request, {
|
||||
writable: settings.writable,
|
||||
namespaces: settings.describe({ redactSecrets: true })
|
||||
.filter(descriptor => exposed.has(String(descriptor.ns)))
|
||||
.map(namespaceView),
|
||||
}))
|
||||
},
|
||||
update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision),
|
||||
replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision),
|
||||
mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision),
|
||||
},
|
||||
|
||||
credentials: {
|
||||
async describe(request) {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials === undefined) return err(request, credentialsAbsent())
|
||||
const entries = await Promise.all(request.payload.refs.map(async (ref) => {
|
||||
const info = await credentials.describe(credentialRef(ref))
|
||||
const view: CredentialView = {
|
||||
configured: info.configured,
|
||||
...info.source === undefined ? {} : { source: info.source },
|
||||
writable: info.writable,
|
||||
}
|
||||
return [ref, view] as const
|
||||
}))
|
||||
return ok(request, { credentials: Object.fromEntries(entries) })
|
||||
},
|
||||
|
||||
async set(request) {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials === undefined) return err(request, credentialsAbsent())
|
||||
const { ref, value } = request.payload
|
||||
try {
|
||||
await credentials.set(credentialRef(ref), value)
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'credential-rejected',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { ref },
|
||||
})
|
||||
}
|
||||
return ok(request, {})
|
||||
},
|
||||
|
||||
async unset(request) {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials === undefined) return err(request, credentialsAbsent())
|
||||
const { ref } = request.payload
|
||||
try {
|
||||
await credentials.unset(credentialRef(ref))
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'credential-rejected',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { ref },
|
||||
})
|
||||
}
|
||||
return ok(request, {})
|
||||
},
|
||||
},
|
||||
|
||||
llm: {
|
||||
providers(request) {
|
||||
const registered = ctx.llm.listProviders()
|
||||
const active = new Set(registered.map(provider => provider.id))
|
||||
const directory = ctx.llm.listConfigurableProviders()
|
||||
const declared = new Set(directory.map(entry => entry.provider))
|
||||
const views = directory.map(entry => ({
|
||||
provider: entry.provider,
|
||||
displayName: entry.displayName,
|
||||
settingsNs: entry.settingsNs,
|
||||
settingsPath: [...entry.settingsPath],
|
||||
active: active.has(entry.provider),
|
||||
}))
|
||||
// Routes registered without a directory declaration still appear —
|
||||
// they exist and serve models — just with no settings address.
|
||||
for (const provider of registered) {
|
||||
if (declared.has(provider.id)) continue
|
||||
views.push({
|
||||
provider: provider.id,
|
||||
displayName: provider.name,
|
||||
settingsNs: '',
|
||||
settingsPath: [],
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
return Promise.resolve(ok(request, { providers: views }))
|
||||
},
|
||||
|
||||
async models(request) {
|
||||
return ok(request, await buildModelCatalog(ctx))
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
mux(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
||||
@@ -1593,6 +1888,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
ctx.on('commands/change', () => {
|
||||
queue.push(frame({ type: 'host/commands-changed' }))
|
||||
}),
|
||||
ctx.on('settings/document-updated', (ns) => {
|
||||
// The RAW-section event, not the resolved one: a field going from
|
||||
// inherited to overridden leaves the resolved value equal, and a
|
||||
// configuration client still has to re-read (its held revision is
|
||||
// stale, and the field's meaning changed).
|
||||
queue.push(frame({ type: 'host/settings-changed', ns: String(ns) }))
|
||||
// A provider's own settings carry its model catalog and endpoint,
|
||||
// so a change there invalidates the model list even when the route
|
||||
// set is untouched — `llm/adapters-updated` alone misses it.
|
||||
if (exposedNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' }))
|
||||
}),
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
|
||||
}),
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
queue.push(frame({ type: 'host/models-changed' }))
|
||||
}),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
|
||||
48
packages/host/apiproxy/src/api/credentials.schema.ts
Normal file
48
packages/host/apiproxy/src/api/credentials.schema.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* credentials domain zod schemas (names derived from map keys:
|
||||
* credentialsDescribeRequestSchema / credentialsDescribeValueSchema / …).
|
||||
* The reference-name pattern mirrors the seam's `credentialRef` guard so an
|
||||
* invalid name fails as `bad-request` before reaching the service.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { CredentialView } from './credentials.ts'
|
||||
|
||||
/** POSIX-portable environment-variable name (the seam's `credentialRef` pattern). */
|
||||
export const credentialRefNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
||||
|
||||
/** CredentialView entry of credentials.describe. */
|
||||
export const credentialViewSchema = z.object({
|
||||
configured: z.boolean(),
|
||||
source: z.string().optional(),
|
||||
writable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<CredentialView>>
|
||||
|
||||
/** credentials.describe request payload. */
|
||||
export const credentialsDescribeRequestSchema = z.object({
|
||||
refs: z.array(credentialRefNameSchema).max(64),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.describe'>>>
|
||||
|
||||
/** credentials.describe response value. */
|
||||
export const credentialsDescribeValueSchema = z.object({
|
||||
credentials: z.record(z.string(), credentialViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'credentials.describe'>>>
|
||||
|
||||
/** credentials.set request payload: the one direction a value crosses this wire. */
|
||||
export const credentialsSetRequestSchema = z.object({
|
||||
ref: credentialRefNameSchema,
|
||||
value: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.set'>>>
|
||||
|
||||
/** credentials.set response value. */
|
||||
export const credentialsSetValueSchema = z.object({}) satisfies z.ZodType<Wire<ResponseValue<'credentials.set'>>>
|
||||
|
||||
/** credentials.unset request payload. */
|
||||
export const credentialsUnsetRequestSchema = z.object({
|
||||
ref: credentialRefNameSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.unset'>>>
|
||||
|
||||
/** credentials.unset response value. */
|
||||
export const credentialsUnsetValueSchema = z.object({}) satisfies z.ZodType<Wire<ResponseValue<'credentials.unset'>>>
|
||||
44
packages/host/apiproxy/src/api/credentials.ts
Normal file
44
packages/host/apiproxy/src/api/credentials.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* credentials domain contract: the web face of the credential-reference seam
|
||||
* (`ctx.credentials`). Reads are structurally value-free — a credential view
|
||||
* carries configured/source/writable and has no slot for the value — and the
|
||||
* value crosses the wire in exactly one direction, inside `credentials.set`.
|
||||
* There is no enumeration method by design: clients learn which references
|
||||
* exist from settings schemas and values (`apiKeyEnv` fields).
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Wire view of one credential reference's state. */
|
||||
export interface CredentialView {
|
||||
/** Whether any layer currently supplies a non-empty value. */
|
||||
configured: boolean
|
||||
/** Winning layer when configured (`env`, `file`, …); provider vocabulary. */
|
||||
source?: string
|
||||
/** Whether `credentials.set`/`credentials.unset` can affect this reference. */
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
/** Credentials-domain unary methods (the map keys credentials.* of RpcMethodMap). */
|
||||
export interface CredentialsApi {
|
||||
/**
|
||||
* Describe the named references (batch): configured state, winning source,
|
||||
* and writability — never values. An invalid reference name is a
|
||||
* `bad-request`; an unknown-but-valid one describes as unconfigured.
|
||||
*/
|
||||
describe(request: RpcRequest<{ refs: string[] }>): Promise<RpcResponse<{ credentials: Record<string, CredentialView> }>>
|
||||
|
||||
/**
|
||||
* Store one credential value in the writable layer. Rejected with
|
||||
* `credential-rejected` while a read-only layer (the live environment)
|
||||
* shadows the reference — the write would otherwise appear to succeed while
|
||||
* resolution keeps returning the shadowing value.
|
||||
*/
|
||||
set(request: RpcRequest<{ ref: string; value: string }>): Promise<RpcResponse<{}>>
|
||||
|
||||
/**
|
||||
* Remove one credential from the writable layer; same shadowing rejection
|
||||
* as `set`. Unsetting an absent reference succeeds (idempotent).
|
||||
*/
|
||||
unset(request: RpcRequest<{ ref: string }>): Promise<RpcResponse<{}>>
|
||||
}
|
||||
@@ -23,6 +23,11 @@ export const askUserQuestionItemSchema = z.object({
|
||||
detail: z.string().optional(),
|
||||
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
|
||||
multiSelect: z.boolean().optional(),
|
||||
// Presentation intent: a tagged union on the wire, so an unknown tag is a
|
||||
// rejected frame rather than a silently generic render.
|
||||
intent: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('plan-review'), approve: z.string() }),
|
||||
]).optional(),
|
||||
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
|
||||
|
||||
/** User-message envelope carried by queue baselines. */
|
||||
@@ -64,5 +69,8 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
|
||||
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
|
||||
z.object({ type: z.literal('host/models-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -105,4 +105,23 @@ export type HostFrame =
|
||||
* background rather than diffing.
|
||||
*/
|
||||
| { type: 'host/commands-changed' }
|
||||
/**
|
||||
* One settings namespace's resolved value changed (`settings/updated`
|
||||
* passthrough) — an RPC write, an external `settings.yaml` edit, or a
|
||||
* provider reload all converge here. Clients refetch `settings.describe`;
|
||||
* values never ride the frame (they would need redaction and can go stale).
|
||||
*/
|
||||
| { type: 'host/settings-changed'; ns: string }
|
||||
/**
|
||||
* One credential reference's state changed (`credentials/updated`
|
||||
* passthrough): a set/unset over this wire or an external `.env` edit.
|
||||
* The ref is an environment-variable NAME — never a value.
|
||||
*/
|
||||
| { type: 'host/credentials-changed'; ref: string }
|
||||
/**
|
||||
* The provider topology changed (`llm/adapters-updated` passthrough):
|
||||
* routes registered or dropped, or the configurable directory moved. Pure
|
||||
* invalidation: clients refetch `llm.providers`/`llm.models`/`session.models`.
|
||||
*/
|
||||
| { type: 'host/models-changed' }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
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 { 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. */
|
||||
@@ -22,6 +25,9 @@ export interface ApiProxy {
|
||||
skills: SkillsApi
|
||||
events: EventsApi
|
||||
goals: GoalsApi
|
||||
settings: SettingsApi
|
||||
credentials: CredentialsApi
|
||||
llm: LlmApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -37,6 +43,9 @@ export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
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 { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
|
||||
36
packages/host/apiproxy/src/api/llm.schema.ts
Normal file
36
packages/host/apiproxy/src/api/llm.schema.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* llm domain zod schemas (names derived from map keys: llmProvidersRequestSchema /
|
||||
* llmProvidersValueSchema / llmModelsRequestSchema / llmModelsValueSchema).
|
||||
*/
|
||||
|
||||
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 { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts'
|
||||
|
||||
/** ConfigurableProviderView row of llm.providers. */
|
||||
export const configurableProviderViewSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
settingsNs: z.string(),
|
||||
settingsPath: z.array(z.string()),
|
||||
active: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ConfigurableProviderView>>
|
||||
|
||||
/** llm.providers request payload. */
|
||||
export const llmProvidersRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'llm.providers'>>>
|
||||
|
||||
/** llm.providers response value. */
|
||||
export const llmProvidersValueSchema = z.object({
|
||||
providers: z.array(configurableProviderViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'llm.providers'>>>
|
||||
|
||||
/** llm.models request payload. */
|
||||
export const llmModelsRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'llm.models'>>>
|
||||
|
||||
/** llm.models response value. */
|
||||
export const llmModelsValueSchema = z.object({
|
||||
groups: z.array(modelProviderGroupSchema),
|
||||
failures: z.array(modelCatalogFailureSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'llm.models'>>>
|
||||
43
packages/host/apiproxy/src/api/llm.ts
Normal file
43
packages/host/apiproxy/src/api/llm.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* llm domain contract: host-scoped provider topology for configuration
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { ModelCatalogFailure, ModelProviderGroup } from './sessions.ts'
|
||||
|
||||
/** Wire view of one configurable provider. */
|
||||
export interface ConfigurableProviderView {
|
||||
/** Provider route key (`deepseek-official`, `openai`, …). */
|
||||
provider: string
|
||||
/** Human-readable name for configuration surfaces. */
|
||||
displayName: string
|
||||
/** Settings namespace whose section configures this provider. */
|
||||
settingsNs: string
|
||||
/** Path from that section's root to the provider's profile object (empty = whole section). */
|
||||
settingsPath: string[]
|
||||
/** Whether the route is currently registered (its models are requestable). */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */
|
||||
export interface LlmApi {
|
||||
/**
|
||||
* List every configurable provider with its live/dormant state, in
|
||||
* directory declaration order. Routes registered outside the directory
|
||||
* (an adapter that never declared configurability) are appended with their
|
||||
* registration identity and no settings address.
|
||||
*/
|
||||
providers(request: RpcRequest<{}>): Promise<RpcResponse<{ providers: ConfigurableProviderView[] }>>
|
||||
|
||||
/**
|
||||
* Host-scoped model catalog over every registered provider route: the
|
||||
* settings surface's models view, needing no session. Per-provider listing
|
||||
* failures ride `failures` without failing the sound groups.
|
||||
*/
|
||||
models(request: RpcRequest<{}>): Promise<RpcResponse<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }>>
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
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 { RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
@@ -24,6 +27,7 @@ export interface RpcMethodMap {
|
||||
'session.models': SessionsApi['models']
|
||||
'session.selectModel': SessionsApi['selectModel']
|
||||
'session.rename': SessionsApi['rename']
|
||||
'session.fork': SessionsApi['fork']
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.updateQueue': SessionsApi['updateQueue']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
@@ -46,6 +50,15 @@ export interface RpcMethodMap {
|
||||
'goal.resume': GoalsApi['resume']
|
||||
'goal.complete': GoalsApi['complete']
|
||||
'goal.clear': GoalsApi['clear']
|
||||
'settings.describe': SettingsApi['describe']
|
||||
'settings.update': SettingsApi['update']
|
||||
'settings.replace': SettingsApi['replace']
|
||||
'settings.mutate': SettingsApi['mutate']
|
||||
'credentials.describe': CredentialsApi['describe']
|
||||
'credentials.set': CredentialsApi['set']
|
||||
'credentials.unset': CredentialsApi['unset']
|
||||
'llm.providers': LlmApi['providers']
|
||||
'llm.models': LlmApi['models']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -50,7 +50,12 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('queue-item-not-found'), 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('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('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
|
||||
@@ -50,7 +50,27 @@ export interface RpcErrorDetailsMap {
|
||||
'command-error': {}
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
'unknown-command': {}
|
||||
/**
|
||||
* A settings write was refused (schema validation, unknown namespace,
|
||||
* read-only provider, or storage failure); the message is the seam's text.
|
||||
*/
|
||||
'settings-rejected': { ns: string }
|
||||
/**
|
||||
* A settings namespace exists in the seam but is outside the configuration
|
||||
* plane's model-provider boundary, so this proxy neither reads nor writes
|
||||
* it; the message names the namespace.
|
||||
*/
|
||||
'settings-not-exposed': { ns: string }
|
||||
/**
|
||||
* A settings write carried an `expectedRevision` the namespace has already
|
||||
* moved past: another writer (tab, editor, or an external file edit) landed
|
||||
* first. The details carry both revisions so a client can re-read and retry.
|
||||
*/
|
||||
'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 }
|
||||
'title-invalid': { sessionId: SessionId }
|
||||
'fork-unavailable': { sessionId: SessionId }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,17 @@ export const sessionRenameValueSchema = z.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
|
||||
|
||||
/** session.fork request payload (atSeq anchors the completed-turn cut). */
|
||||
export const sessionForkRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
atSeq: z.number().int().nonnegative().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.fork'>>>
|
||||
|
||||
/** session.fork response value (the child session id). */
|
||||
export const sessionForkValueSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.fork'>>>
|
||||
|
||||
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
|
||||
export const sessionHistoryRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
|
||||
@@ -186,9 +186,11 @@ export interface SessionsApi {
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to message boundaries: one page =
|
||||
* all raw events owned by a whole number of messages (including their chunk / tool events),
|
||||
* never cut mid-message. The tail page (beforeSeq absent) additionally carries the in-flight
|
||||
* Reads a window of history events; page boundaries align to append-origin message
|
||||
* boundaries: one page = all raw events owned by a whole number of such messages (including
|
||||
* their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
|
||||
* `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail
|
||||
* page (beforeSeq absent) additionally carries the in-flight
|
||||
* partial — chunk events already emitted for the last unfinalized message.
|
||||
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
|
||||
* presenter produced one, evaluated against the registry at pagination time); the client
|
||||
@@ -236,6 +238,21 @@ export interface SessionsApi {
|
||||
* one — carried for future rendering; the state change is the feedback). A usage/state error is an
|
||||
* RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
|
||||
*/
|
||||
/**
|
||||
* Forks a new session from a completed-turn prefix of the source. `atSeq`
|
||||
* anchors the cut: the boundary is the first `turn/end` at or after it
|
||||
* (a message's fork button passes the message seq, so the fork includes
|
||||
* that whole turn); a boundary past the log end, or an omitted `atSeq`,
|
||||
* 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.
|
||||
*/
|
||||
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. */
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
|
||||
72
packages/host/apiproxy/src/api/settings.schema.ts
Normal file
72
packages/host/apiproxy/src/api/settings.schema.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* settings domain zod schemas (names derived from map keys: settingsDescribeRequestSchema /
|
||||
* settingsDescribeValueSchema / settingsUpdate* / settingsReplace*).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
|
||||
/** One redacted secret slot. */
|
||||
export const settingsSecretViewSchema = z.object({
|
||||
path: z.array(z.string()),
|
||||
set: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<SettingsSecretView>>
|
||||
|
||||
/** SettingsNamespaceView row of settings.describe and the write responses. */
|
||||
export const settingsNamespaceViewSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
schema: z.unknown(),
|
||||
value: z.unknown(),
|
||||
base: z.unknown().optional(),
|
||||
user: z.unknown().optional(),
|
||||
applies: z.union([z.literal('live'), z.literal('restart')]),
|
||||
secrets: z.array(settingsSecretViewSchema),
|
||||
revision: z.number(),
|
||||
}) satisfies z.ZodType<Wire<SettingsNamespaceView>>
|
||||
|
||||
/** settings.describe request payload. */
|
||||
export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.describe'>>>
|
||||
|
||||
/** settings.describe response value. */
|
||||
export const settingsDescribeValueSchema = z.object({
|
||||
writable: z.boolean(),
|
||||
namespaces: z.array(settingsNamespaceViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'settings.describe'>>>
|
||||
|
||||
/** settings.update request payload. */
|
||||
export const settingsUpdateRequestSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
patch: z.record(z.string(), z.unknown()),
|
||||
expectedRevision: z.number().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'settings.update'>>>
|
||||
|
||||
/** settings.update response value: the namespace's new redacted view. */
|
||||
export const settingsUpdateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.update'>>>
|
||||
|
||||
/** settings.replace request payload. */
|
||||
export const settingsReplaceRequestSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
section: z.record(z.string(), z.unknown()),
|
||||
expectedRevision: z.number().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'settings.replace'>>>
|
||||
|
||||
/** One path-addressed edit of settings.mutate. */
|
||||
export const settingsPathOpSchema = z.discriminatedUnion('op', [
|
||||
z.object({ op: z.literal('set'), path: z.array(z.string()), value: z.unknown() }),
|
||||
z.object({ op: z.literal('unset'), path: z.array(z.string()) }),
|
||||
]) as unknown as z.ZodType<Wire<SettingsPathOpView>>
|
||||
|
||||
/** settings.mutate request payload. */
|
||||
export const settingsMutateRequestSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
ops: z.array(settingsPathOpSchema),
|
||||
expectedRevision: z.number().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'settings.mutate'>>>
|
||||
|
||||
/** settings.mutate response value: the namespace's new redacted view. */
|
||||
export const settingsMutateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.mutate'>>>
|
||||
|
||||
/** settings.replace response value. */
|
||||
export const settingsReplaceValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.replace'>>>
|
||||
90
packages/host/apiproxy/src/api/settings.ts
Normal file
90
packages/host/apiproxy/src/api/settings.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* settings domain contract: the web face of the user-settings seam
|
||||
* (`ctx.settings`). Every payload that leaves this domain is redacted by the
|
||||
* seam (`describe({ redactSecrets: true })` semantics): `role('secret')`
|
||||
* fields never ride a response in any layer, and the `secrets` slot list is
|
||||
* how a form learns a write-only field exists and whether it is configured.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** One schema-declared secret slot inside a redacted namespace value. */
|
||||
export interface SettingsSecretView {
|
||||
/** Path from the section root to the removed field. */
|
||||
path: string[]
|
||||
/** Whether the slot currently holds a value (the value itself never rides). */
|
||||
set: boolean
|
||||
}
|
||||
|
||||
/** Wire view of one registered settings namespace. */
|
||||
export interface SettingsNamespaceView {
|
||||
/** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */
|
||||
ns: string
|
||||
/** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */
|
||||
schema: unknown
|
||||
/** Redacted resolved value (schema defaults → composition base → user layer). */
|
||||
value: unknown
|
||||
/** Redacted composition base layer, when the registrant declared one. */
|
||||
base?: unknown
|
||||
/** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */
|
||||
user?: unknown
|
||||
/** When the owner applies changes. */
|
||||
applies: 'live' | 'restart'
|
||||
/** Every schema-declared secret slot with its configured state. */
|
||||
secrets: SettingsSecretView[]
|
||||
/**
|
||||
* Monotonic revision of the raw user section this view was read at. Send it
|
||||
* back as `expectedRevision` on a write so a stale editor is refused rather
|
||||
* than silently overwriting a concurrent change.
|
||||
*/
|
||||
revision: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One path-addressed edit carried by `settings.mutate`. `set` writes the
|
||||
* value at the path (creating intermediate objects); `unset` removes it. The
|
||||
* empty path addresses the section root.
|
||||
*/
|
||||
export type SettingsPathOpView =
|
||||
| { op: 'set'; path: string[]; value: unknown }
|
||||
| { op: 'unset'; path: string[] }
|
||||
|
||||
/** Settings-domain unary methods (the map keys settings.* of RpcMethodMap). */
|
||||
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.
|
||||
*/
|
||||
describe(request: RpcRequest<{}>): Promise<RpcResponse<{ writable: boolean; namespaces: SettingsNamespaceView[] }>>
|
||||
|
||||
/**
|
||||
* Merge a patch into one namespace's user layer (validate → persist →
|
||||
* commit). Secret-role fields may be INCLUDED in the patch (write-only
|
||||
* direction); a form that leaves a secret untouched simply omits it and the
|
||||
* merge preserves the stored value. Responds with the namespace's new
|
||||
* redacted view; a schema or storage rejection is `settings-rejected`.
|
||||
*/
|
||||
update(request: RpcRequest<{ ns: string; patch: object; expectedRevision?: number }>): Promise<RpcResponse<SettingsNamespaceView>>
|
||||
|
||||
/**
|
||||
* Replace one namespace's user section wholesale — the removal/reset path a
|
||||
* merge cannot express (`section: {}` resets to composition defaults). Keys
|
||||
* absent from `section` are dropped, secrets included: a client must first
|
||||
* fold the descriptor's `user` layer (and re-supply any secret it wants to
|
||||
* keep) or accept the reset.
|
||||
*/
|
||||
replace(request: RpcRequest<{ ns: string; section: object; expectedRevision?: number }>): Promise<RpcResponse<SettingsNamespaceView>>
|
||||
|
||||
/**
|
||||
* Apply path-addressed edits to one namespace's user section, resolved
|
||||
* against the section as stored — NOT against whatever the caller last
|
||||
* read. This is the removal path for any client holding the redacted
|
||||
* descriptor: it names the field it means, so a secret the wire never
|
||||
* returned cannot be deleted as a side effect. `replace` remains the
|
||||
* deliberate wholesale reset.
|
||||
*/
|
||||
mutate(
|
||||
request: RpcRequest<{ ns: string; ops: SettingsPathOpView[]; expectedRevision?: number }>,
|
||||
): Promise<RpcResponse<SettingsNamespaceView>>
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
sessionCancelValueSchema,
|
||||
sessionCreateValueSchema,
|
||||
sessionForkValueSchema,
|
||||
sessionHistoryValueSchema,
|
||||
sessionListValueSchema,
|
||||
sessionModelsValueSchema,
|
||||
@@ -45,6 +46,13 @@ import {
|
||||
goalCompleteValueSchema,
|
||||
goalClearValueSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
import {
|
||||
settingsDescribeValueSchema, settingsMutateValueSchema, settingsReplaceValueSchema, settingsUpdateValueSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import {
|
||||
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -69,6 +77,7 @@ export interface IApiClient {
|
||||
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
|
||||
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
|
||||
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
|
||||
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
@@ -106,6 +115,21 @@ export interface IApiClient {
|
||||
complete(payload: RequestPayload<'goal.complete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.complete'>>>
|
||||
clear(payload: RequestPayload<'goal.clear'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.clear'>>>
|
||||
}
|
||||
settings: {
|
||||
describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
|
||||
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'>>>
|
||||
}
|
||||
credentials: {
|
||||
describe(payload: RequestPayload<'credentials.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.describe'>>>
|
||||
set(payload: RequestPayload<'credentials.set'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.set'>>>
|
||||
unset(payload: RequestPayload<'credentials.unset'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.unset'>>>
|
||||
}
|
||||
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'>>>
|
||||
}
|
||||
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
|
||||
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -121,6 +145,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.models': sessionModelsValueSchema,
|
||||
'session.selectModel': sessionSelectModelValueSchema,
|
||||
'session.rename': sessionRenameValueSchema,
|
||||
'session.fork': sessionForkValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.updateQueue': sessionUpdateQueueValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
@@ -143,6 +168,15 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'goal.resume': goalResumeValueSchema,
|
||||
'goal.complete': goalCompleteValueSchema,
|
||||
'goal.clear': goalClearValueSchema,
|
||||
'settings.describe': settingsDescribeValueSchema,
|
||||
'settings.update': settingsUpdateValueSchema,
|
||||
'settings.replace': settingsReplaceValueSchema,
|
||||
'settings.mutate': settingsMutateValueSchema,
|
||||
'credentials.describe': credentialsDescribeValueSchema,
|
||||
'credentials.set': credentialsSetValueSchema,
|
||||
'credentials.unset': credentialsUnsetValueSchema,
|
||||
'llm.providers': llmProvidersValueSchema,
|
||||
'llm.models': llmModelsValueSchema,
|
||||
}
|
||||
|
||||
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -334,6 +368,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
models: (payload, signal) => this.callUnary('session.models', payload, signal),
|
||||
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
|
||||
fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
|
||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||
@@ -375,6 +410,24 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
clear: (payload, signal) => this.callUnary('goal.clear', payload, signal),
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: (payload, signal) => this.callUnary('settings.describe', 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),
|
||||
}
|
||||
|
||||
readonly credentials: IApiClient['credentials'] = {
|
||||
describe: (payload, signal) => this.callUnary('credentials.describe', payload, signal),
|
||||
set: (payload, signal) => this.callUnary('credentials.set', payload, signal),
|
||||
unset: (payload, signal) => this.callUnary('credentials.unset', payload, signal),
|
||||
}
|
||||
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: (payload, signal) => this.callUnary('llm.providers', payload, signal),
|
||||
models: (payload, signal) => this.callUnary('llm.models', payload, signal),
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
|
||||
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
|
||||
import {
|
||||
sessionCancelRequestSchema,
|
||||
sessionCreateRequestSchema,
|
||||
sessionForkRequestSchema,
|
||||
sessionHistoryRequestSchema,
|
||||
sessionListRequestSchema,
|
||||
sessionModelsRequestSchema,
|
||||
@@ -47,6 +48,13 @@ import {
|
||||
goalCompleteRequestSchema,
|
||||
goalClearRequestSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
import {
|
||||
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsReplaceRequestSchema, settingsUpdateRequestSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import {
|
||||
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -71,6 +79,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
|
||||
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
|
||||
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
|
||||
'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
|
||||
'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) },
|
||||
@@ -93,6 +102,15 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'goal.resume': { schema: goalResumeRequestSchema, invoke: (api, r) => api.goals.resume(r) },
|
||||
'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.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) },
|
||||
'credentials.describe': { schema: credentialsDescribeRequestSchema, invoke: (api, r) => api.credentials.describe(r) },
|
||||
'credentials.set': { schema: credentialsSetRequestSchema, invoke: (api, r) => api.credentials.set(r) },
|
||||
'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) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
|
||||
@@ -59,6 +59,9 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly goals: ApiProxy['goals']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly settings: ApiProxy['settings']
|
||||
readonly credentials: ApiProxy['credentials']
|
||||
readonly llm: ApiProxy['llm']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
|
||||
@@ -77,6 +80,9 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
this.commands = api.commands
|
||||
this.goals = api.goals
|
||||
this.skills = api.skills
|
||||
this.settings = api.settings
|
||||
this.credentials = api.credentials
|
||||
this.llm = api.llm
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
// the unbound-method lint without changing behavior.
|
||||
|
||||
441
packages/host/apiproxy/tests/api-proxy-config.spec.ts
Normal file
441
packages/host/apiproxy/tests/api-proxy-config.spec.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Settings/credentials/llm RPC domains and their host-stream frames over
|
||||
* createApiProxy: layered redacted describe, write-path rejection mapping,
|
||||
* value-free credential views, the directory/live-route merge, and the three
|
||||
* invalidation frames (settings/credentials/models changed).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Settings, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { Credentials } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
||||
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'
|
||||
|
||||
const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
function expectErr<T>(response: RpcResponse<T>): { code: string; message: string; details: unknown } {
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
return response.result.error
|
||||
}
|
||||
|
||||
/** In-memory settings provider: the seam base class owns all tested behavior. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown>
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown>; readOnly?: boolean }) {
|
||||
super(ctx)
|
||||
this.doc = structuredClone(options?.doc ?? {})
|
||||
this.readOnly = options?.readOnly ?? false
|
||||
}
|
||||
|
||||
private readonly readOnly: boolean
|
||||
|
||||
get writable(): boolean {
|
||||
return !this.readOnly
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc[ns] = structuredClone(section)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory credential provider with an env-shadow double for the rejection path. */
|
||||
class MemoryCredentials extends Credentials {
|
||||
private readonly values = new Map<string, string>()
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Credentials>[0], options?: { shadowed?: string[] }) {
|
||||
super(ctx)
|
||||
this.shadowed = new Set(options?.shadowed ?? [])
|
||||
}
|
||||
|
||||
private readonly shadowed: Set<string>
|
||||
|
||||
resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
||||
if (this.shadowed.has(ref)) return Promise.resolve({ value: 'from-env', source: 'env' })
|
||||
const value = this.values.get(ref)
|
||||
return Promise.resolve(value === undefined ? undefined : { value, source: 'file' })
|
||||
}
|
||||
|
||||
describe(ref: CredentialRef): Promise<CredentialInfo> {
|
||||
if (this.shadowed.has(ref)) return Promise.resolve({ configured: true, source: 'env', writable: false })
|
||||
const configured = this.values.has(ref)
|
||||
return Promise.resolve({ configured, ...configured ? { source: 'file' } : {}, writable: true })
|
||||
}
|
||||
|
||||
set(ref: CredentialRef, value: string): Promise<void> {
|
||||
if (this.shadowed.has(ref)) {
|
||||
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
|
||||
}
|
||||
this.values.set(ref, value)
|
||||
this.ctx.emit('credentials/updated', ref)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
unset(ref: CredentialRef): Promise<void> {
|
||||
if (this.shadowed.has(ref)) {
|
||||
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
|
||||
}
|
||||
this.values.delete(ref)
|
||||
this.ctx.emit('credentials/updated', ref)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** Catalog-serving adapter stub for the llm.models path. */
|
||||
class CatalogAdapter extends LlmAdapter {
|
||||
constructor(private readonly name: string, private readonly models: readonly string[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: this.name }
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.models.map(id => ({ provider, id, name: id })))
|
||||
}
|
||||
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('not exercised')
|
||||
}
|
||||
}
|
||||
|
||||
class BrokenCatalogAdapter extends CatalogAdapter {
|
||||
override listModels(): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.reject(new Error('catalog backend down'))
|
||||
}
|
||||
}
|
||||
|
||||
const NS = settingsNamespace('llm-deepseek')
|
||||
|
||||
const AdapterConfig = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().default('DEEPSEEK_API_KEY'),
|
||||
baseURL: z.string(),
|
||||
})
|
||||
|
||||
async function harness(options?: {
|
||||
settings?: false | { doc?: Record<string, unknown>; readOnly?: boolean }
|
||||
credentials?: false | { shadowed?: string[] }
|
||||
/** Skip the directory registration to exercise a namespace the proxy does not expose. */
|
||||
configurableProviders?: false
|
||||
}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LlmService)
|
||||
if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
|
||||
if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
|
||||
// The proxy serves only namespaces a configurable provider addresses, which
|
||||
// is what the real LLM plugins declare at load; the tests mirror that.
|
||||
if (options?.configurableProviders !== false) {
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
||||
])
|
||||
}
|
||||
// Host-stream opener reads the committed-workspace baseline; the stub
|
||||
// suffices — the real workspace composition is api-proxy-workspace.spec's.
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Drain `count` host frames matching `types`, then abort the stream. */
|
||||
async function collectHost(
|
||||
api: ReturnType<typeof createApiProxy>,
|
||||
types: string[],
|
||||
count: number,
|
||||
run: () => Promise<void>,
|
||||
): Promise<HostFrame[]> {
|
||||
const abort = new AbortController()
|
||||
const frames: HostFrame[] = []
|
||||
const stream = api.events.host(request({}), abort.signal)
|
||||
const consume = (async () => {
|
||||
for await (const frame of stream) {
|
||||
if (!types.includes(frame.payload.type)) continue
|
||||
frames.push(frame.payload)
|
||||
if (frames.length >= count) abort.abort()
|
||||
}
|
||||
})()
|
||||
await run()
|
||||
await consume
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('settings domain', () => {
|
||||
it('reports an actionable error when no settings provider is mounted', async () => {
|
||||
const ctx = await harness({ settings: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.settings.describe(request({})))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('dsh-settings-local')
|
||||
})
|
||||
|
||||
it('describes layered redacted namespaces with their secret slots', async () => {
|
||||
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } } } })
|
||||
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.namespaces).toHaveLength(1)
|
||||
const view = value.namespaces[0]!
|
||||
expect(view.ns).toBe('llm-deepseek')
|
||||
expect(view.applies).toBe('live')
|
||||
expect((view.schema as { refs?: unknown }).refs).toBeDefined()
|
||||
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://user' })
|
||||
expect(view.base).toEqual({ baseURL: 'https://base' })
|
||||
expect(view.user).toEqual({ baseURL: 'https://user' })
|
||||
expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
||||
expect(JSON.stringify(value)).not.toContain('user-secret')
|
||||
})
|
||||
|
||||
it('serves only namespaces a registered model provider addresses', async () => {
|
||||
// The settings seam is general: any plugin may register a namespace for
|
||||
// its own configuration. The Web configuration plane is not — it is the
|
||||
// model-provider surface, and a namespace nothing in the provider
|
||||
// directory addresses must be invisible and unwritable here, so a future
|
||||
// plugin cannot become remotely configurable just by registering.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek'])
|
||||
|
||||
for (const response of [
|
||||
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
|
||||
await api.settings.replace(request({ ns: 'some-other-plugin', section: {} })),
|
||||
]) {
|
||||
const error = expectErr(response)
|
||||
expect(error.code).toBe('settings-not-exposed')
|
||||
expect(error.details).toEqual({ ns: 'some-other-plugin' })
|
||||
}
|
||||
// The write never reached the seam.
|
||||
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
|
||||
})
|
||||
|
||||
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
|
||||
const ctx = await harness({ configurableProviders: false })
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces).toEqual([])
|
||||
expect(expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).code)
|
||||
.toBe('settings-not-exposed')
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => {
|
||||
// Editing `models` changes no route, so llm/adapters-updated never fires
|
||||
// and an open model picker kept serving the old catalog. And storing an
|
||||
// override equal to the resolved value emits nothing on settings/updated,
|
||||
// so another tab never learned the field became overridden.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
|
||||
await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } }))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'llm-deepseek' },
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
// The resolved value never moved: base already said https://base.
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value)
|
||||
.toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' })
|
||||
})
|
||||
|
||||
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const opened = expectOk(await api.settings.describe(request({}))).namespaces[0]!.revision
|
||||
expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://first' }, expectedRevision: opened })))
|
||||
.revision).toBe(opened + 1)
|
||||
const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://second' }, expectedRevision: opened })))
|
||||
expect(error.code).toBe('settings-conflict')
|
||||
expect(error.details).toEqual({ ns: 'llm-deepseek', expected: opened, actual: opened + 1 })
|
||||
// The refused write changed nothing.
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.user).toEqual({ baseURL: 'https://first' })
|
||||
})
|
||||
|
||||
it('updates the user layer, answers with the new redacted view, and broadcasts the frame', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
|
||||
const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } })))
|
||||
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' })
|
||||
expect(view.user).toEqual({ baseURL: 'https://next' })
|
||||
expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
||||
expect(JSON.stringify(view)).not.toContain('sk-new')
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'llm-deepseek' }])
|
||||
})
|
||||
|
||||
it('replace resets the user layer wholesale', async () => {
|
||||
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { baseURL: 'https://user' } } } })
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const view = expectOk(await api.settings.replace(request({ ns: 'llm-deepseek', section: {} })))
|
||||
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY' })
|
||||
expect(view.user).toEqual({})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an invalid namespace name', 'Not A Namespace', {}],
|
||||
['a schema-invalid patch', 'llm-deepseek', { baseURL: 42 }],
|
||||
])('rejects %s as settings-rejected', async (_case, ns, patch) => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.settings.update(request({ ns, patch })))
|
||||
expect(error.code).toBe('settings-rejected')
|
||||
expect(error.details).toEqual({ ns })
|
||||
})
|
||||
|
||||
it('answers an unregistered namespace exactly like an unexposed one', async () => {
|
||||
// Deliberately indistinguishable: separating "does not exist" from
|
||||
// "exists but is not yours to configure" would let a caller enumerate the
|
||||
// registered namespaces one probe at a time.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const unknown = expectErr(await api.settings.update(request({ ns: 'unknown-ns', patch: {} })))
|
||||
const unexposed = expectErr(await api.settings.update(request({ ns: 'some-other-plugin', patch: {} })))
|
||||
expect(unknown.code).toBe('settings-not-exposed')
|
||||
expect(unexposed.code).toBe(unknown.code)
|
||||
expect(unexposed.message.replace('some-other-plugin', 'unknown-ns')).toBe(unknown.message)
|
||||
})
|
||||
|
||||
it('maps a read-only provider refusal onto the same rejection', async () => {
|
||||
const ctx = await harness({ settings: { readOnly: true } })
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.writable).toBe(false)
|
||||
const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: {} })))
|
||||
expect(error.code).toBe('settings-rejected')
|
||||
expect(error.message).toContain('read-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('credentials domain', () => {
|
||||
it('reports an actionable error when no credential provider is mounted', async () => {
|
||||
const ctx = await harness({ credentials: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.credentials.describe(request({ refs: ['A'] })))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('dsh-credentials-local')
|
||||
})
|
||||
|
||||
it('describes value-free views and flips state through set/unset with frames', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
|
||||
expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } })
|
||||
const frames = await collectHost(api, ['host/credentials-changed'], 2, async () => {
|
||||
expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' })))
|
||||
const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
|
||||
expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } })
|
||||
expect(JSON.stringify(after)).not.toContain('sk-secret')
|
||||
expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' })))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
|
||||
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a shadowed write onto credential-rejected for set and unset alike', async () => {
|
||||
const ctx = await harness({ credentials: { shadowed: ['DEEPSEEK_API_KEY'] } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const described = expectOk(await api.credentials.describe(request({ refs: ['DEEPSEEK_API_KEY'] })))
|
||||
expect(described.credentials['DEEPSEEK_API_KEY']).toEqual({ configured: true, source: 'env', writable: false })
|
||||
const setError = expectErr(await api.credentials.set(request({ ref: 'DEEPSEEK_API_KEY', value: 'x' })))
|
||||
expect(setError.code).toBe('credential-rejected')
|
||||
expect(setError.details).toEqual({ ref: 'DEEPSEEK_API_KEY' })
|
||||
const unsetError = expectErr(await api.credentials.unset(request({ ref: 'DEEPSEEK_API_KEY' })))
|
||||
expect(unsetError.code).toBe('credential-rejected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('llm domain', () => {
|
||||
it('merges the configurable directory with live routes and appends undeclared ones', async () => {
|
||||
const ctx = await harness({ configurableProviders: false })
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
])
|
||||
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash']))
|
||||
ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1']))
|
||||
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 },
|
||||
{ provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('serves the host-scoped catalog with per-provider failures contained', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash', 'deepseek-v4-pro']))
|
||||
ctx.llm.registerAdapter(['broken'], new BrokenCatalogAdapter('Broken', []))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const value = expectOk(await api.llm.models(request({})))
|
||||
expect(value.groups).toEqual([{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
||||
{ id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
||||
],
|
||||
}])
|
||||
expect(value.failures).toEqual([{ id: 'broken', name: 'Broken', message: 'catalog backend down' }])
|
||||
})
|
||||
|
||||
it('broadcasts host/models-changed at every topology commit point', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/models-changed'], 2, async () => {
|
||||
const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', []))
|
||||
dispose()
|
||||
return Promise.resolve()
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }])
|
||||
})
|
||||
})
|
||||
163
packages/host/apiproxy/tests/api-proxy-fork.spec.ts
Normal file
163
packages/host/apiproxy/tests/api-proxy-fork.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/** Session-fork boundaries, lineage, and inherited model routing. */
|
||||
|
||||
import { describe, expect, it } 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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
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'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
async function composed(): 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.agents.setFactory({
|
||||
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
|
||||
const session = ctx.sessions.create(options.sessionId, {
|
||||
...options.seed === undefined ? {} : { seed: [...options.seed] },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
const agent = {} as Agent
|
||||
const agentCtx = ownerCtx.extend({ agent })
|
||||
Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx })
|
||||
await options.setup?.(agentCtx)
|
||||
ctx.agents.register(agent)
|
||||
return { agent, dispose: () => Promise.resolve() }
|
||||
},
|
||||
resume: () => Promise.reject(new Error('fork test sources are live')),
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session {
|
||||
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
|
||||
for (let turn = 1; turn <= turns; turn++) {
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
if (openTail) {
|
||||
session.append('turn/start', { turn: turns + 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'open prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
return session
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, {
|
||||
provider: 'default-provider',
|
||||
model: 'default-model',
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
describe('sessions.fork', () => {
|
||||
it('cuts at the anchored completed turn and records lineage and cwd', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-source', 2)
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: 1 }))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) return
|
||||
const child = ctx.sessions.get(response.result.value.sessionId)
|
||||
expect(child?.events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'user/message', 'turn/end', 'session/end-seed',
|
||||
])
|
||||
expect(child?.header.parentSession).toBe(source.id)
|
||||
expect(child?.header.cwd).toBe('/proj')
|
||||
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, true)
|
||||
const proxy = api(ctx)
|
||||
const expectedTypes = [
|
||||
'turn/start', 'user/message', 'turn/end',
|
||||
'turn/start', 'user/message', 'turn/end',
|
||||
'session/end-seed',
|
||||
]
|
||||
const omitted = await proxy.sessions.fork(request({ sessionId: source.id }))
|
||||
expect(omitted.result.ok).toBe(true)
|
||||
if (omitted.result.ok) {
|
||||
expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.map(event => event.type))
|
||||
.toEqual(expectedTypes)
|
||||
}
|
||||
const pastEnd = await proxy.sessions.fork(request({ sessionId: source.id, atSeq: 999 }))
|
||||
expect(pastEnd.result.ok).toBe(true)
|
||||
if (pastEnd.result.ok) {
|
||||
expect(ctx.sessions.get(pastEnd.result.value.sessionId)?.events.map(event => event.type))
|
||||
.toEqual(expectedTypes)
|
||||
}
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an in-log anchor whose turn is still open', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-open', 1, true)
|
||||
const anchor = source.events.at(-1)?.seq ?? 0
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'fork-unavailable', details: { sessionId: source.id } },
|
||||
})
|
||||
if (!response.result.ok) expect(response.result.error.message).toMatch(/has not completed/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('installs the latest logged model target before the child can run', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-routed', 1)
|
||||
source.append('request/header', {
|
||||
header: {
|
||||
config: {
|
||||
provider: 'inherited-provider',
|
||||
model: 'inherited-model',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
},
|
||||
},
|
||||
reason: 'initial',
|
||||
})
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: source.id }))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) return
|
||||
const child = ctx.agents.get(response.result.value.sessionId)
|
||||
if (child === undefined) throw new Error('fork did not publish the child agent')
|
||||
const assembly = await child.ctx.systemPrompt.assemble()
|
||||
expect(assembly.variables).toMatchObject({
|
||||
provider: 'inherited-provider',
|
||||
model: 'inherited-model',
|
||||
})
|
||||
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),
|
||||
)).resolves.toMatchObject({
|
||||
provider: 'inherited-provider',
|
||||
model: 'inherited-model',
|
||||
reasoningEffort: 'high',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -85,9 +85,9 @@ async function harness(logged?: {
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
|
||||
{ provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||
{ provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
|
||||
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
|
||||
{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||
{ provider: 'deepseek-official', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
|
||||
], REASONING))
|
||||
ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
|
||||
ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
|
||||
@@ -120,20 +120,20 @@ 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 () => {
|
||||
const { ctx, sessionId } = await harness({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.current).toEqual({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
expect(catalog.groups).toEqual([{
|
||||
id: 'deepseek',
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
|
||||
@@ -165,43 +165,43 @@ describe('Web session model selection', () => {
|
||||
|
||||
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
expect((await ctx.systemPrompt.assemble()).variables)
|
||||
.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
|
||||
const selected = expectValue(await api.sessions.selectModel(request({
|
||||
sessionId,
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})))
|
||||
expect(selected.selected).toEqual({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables)
|
||||
.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
|
||||
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toMatchObject({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
|
||||
const unsupported = await api.sessions.selectModel(request({
|
||||
sessionId,
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'medium',
|
||||
}))
|
||||
@@ -209,7 +209,7 @@ describe('Web session model selection', () => {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'model-unavailable',
|
||||
message: 'provider "deepseek" model "private-preview" does not support reasoning effort "medium"',
|
||||
message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('Web session model selection', () => {
|
||||
},
|
||||
})
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
|
||||
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { CallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
@@ -35,6 +35,35 @@ function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'pr
|
||||
})
|
||||
}
|
||||
|
||||
/** Append a production-shaped human prompt to the session surface. */
|
||||
function appendUserText(session: Session, text: string): SessionEvent {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Append a production-shaped assistant message to the session surface. */
|
||||
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
|
||||
return session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a plugin-owned log-only event. The host proxy is projection-only, so it
|
||||
* declares no compaction vocabulary; the cast writes the real event shape without
|
||||
* depending on the owning package.
|
||||
*/
|
||||
function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
|
||||
return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
|
||||
}
|
||||
|
||||
async function harness(): Promise<{ ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -207,6 +236,55 @@ describe('mux live view computation', () => {
|
||||
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
|
||||
const { ctx } = await harness()
|
||||
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 })
|
||||
const first = appendUserText(session, 'first prompt')
|
||||
appendAssistantText(session, 'first reply', 1)
|
||||
const third = appendUserText(session, 'second prompt')
|
||||
appendAssistantText(session, 'second reply', 2)
|
||||
const shadowed = [...session.surface.nodes]
|
||||
// A compaction transaction: log-only provenance immediately followed by the
|
||||
// replacement that shadows the range.
|
||||
const summary = appendExtension(session, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
|
||||
shadowedSeqs: shadowed,
|
||||
shadowedTokenCount: 0,
|
||||
provider: 'p',
|
||||
model: 'm',
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
|
||||
sourceEventSeqs: [...shadowed, summary.seq],
|
||||
})
|
||||
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('t-hist-compact'),
|
||||
payload: { sessionId: session.id, maxMessages: 2 },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const page = response.result.value.events.map(entry => entry.event)
|
||||
// Two append-origin messages fill the page even though a replacement copy of
|
||||
// the same event type sits in the window: the copy is model-only.
|
||||
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
|
||||
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
|
||||
expect(page.some(event => event.seq === first.seq)).toBe(false)
|
||||
expect(response.result.value.hasMore).toBe(true)
|
||||
// The range stays contiguous, so the checkpoint's provenance is readable on
|
||||
// the same page as the checkpoint itself.
|
||||
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
|
||||
expect(summaryIndex).toBeGreaterThan(-1)
|
||||
expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
|
||||
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
|
||||
})
|
||||
|
||||
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
@@ -24,6 +24,9 @@ function scriptedApi(overrides: {
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
events?: Partial<ApiProxy['events']>
|
||||
goals?: Partial<ApiProxy['goals']>
|
||||
settings?: Partial<ApiProxy['settings']>
|
||||
credentials?: Partial<ApiProxy['credentials']>
|
||||
llm?: Partial<ApiProxy['llm']>
|
||||
respond?: ApiProxy['respond']
|
||||
} = {}): ApiProxy {
|
||||
async function *empty<F>(): AsyncGenerator<RpcRequest<F>> { /* no frames */ }
|
||||
@@ -36,10 +39,10 @@ function scriptedApi(overrides: {
|
||||
history: r => ok(r, {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
models: r => ok(r, {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
}),
|
||||
@@ -47,6 +50,7 @@ function scriptedApi(overrides: {
|
||||
selected: { provider: r.payload.provider, model: r.payload.model },
|
||||
}),
|
||||
rename: r => ok(r, { title: 'renamed', seq: 0 }),
|
||||
fork: r => ok(r, { sessionId: sid('s-fork') }),
|
||||
prompt: r => ok(r, { accepted: true as const }),
|
||||
updateQueue: r => ok(r, { accepted: true as const }),
|
||||
cancel: r => ok(r, { accepted: true as const }),
|
||||
@@ -82,6 +86,24 @@ function scriptedApi(overrides: {
|
||||
clear: err,
|
||||
...overrides.goals,
|
||||
},
|
||||
settings: {
|
||||
describe: r => ok(r, { writable: true, namespaces: [] }),
|
||||
update: err,
|
||||
replace: err,
|
||||
mutate: err,
|
||||
...overrides.settings,
|
||||
},
|
||||
credentials: {
|
||||
describe: r => ok(r, { credentials: {} }),
|
||||
set: err,
|
||||
unset: err,
|
||||
...overrides.credentials,
|
||||
},
|
||||
llm: {
|
||||
providers: r => ok(r, { providers: [] }),
|
||||
models: r => ok(r, { groups: [], failures: [] }),
|
||||
...overrides.llm,
|
||||
},
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
}
|
||||
@@ -91,6 +113,15 @@ function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient {
|
||||
return new InProcessApiClient(toFetchHandler(api), timeoutMs)
|
||||
}
|
||||
|
||||
/** Wrap one scripted method to record its invocation into `seen` before responding. */
|
||||
function recorderInto(seen: { method: string; payload: unknown }[]) {
|
||||
return <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
|
||||
(r: RpcRequest<P>): Promise<RpcResponse<V>> => {
|
||||
seen.push({ method, payload: r.payload })
|
||||
return respond(r)
|
||||
}
|
||||
}
|
||||
|
||||
describe('unary round trip', () => {
|
||||
it('carries payload out and value back through the full wire form', async () => {
|
||||
let seen: RpcRequest<{ cursor?: string }> | undefined
|
||||
@@ -110,6 +141,21 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('routes session fork with its optional cut anchor through the wire', async () => {
|
||||
let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
fork: (request) => {
|
||||
seen = request
|
||||
return ok(request, { sessionId: sid('s-child') })
|
||||
},
|
||||
},
|
||||
})
|
||||
const response = await client(api).sessions.fork({ sessionId: sid('s-parent'), atSeq: 7 })
|
||||
expect(seen?.payload).toEqual({ sessionId: 's-parent', atSeq: 7 })
|
||||
expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } })
|
||||
})
|
||||
|
||||
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
@@ -446,11 +492,7 @@ describe('goals unary surface', () => {
|
||||
|
||||
it('round-trips every goal method with its own payload and value shape', async () => {
|
||||
const seen: { method: string; payload: unknown }[] = []
|
||||
const record = <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
|
||||
(r: RpcRequest<P>): Promise<RpcResponse<V>> => {
|
||||
seen.push({ method, payload: r.payload })
|
||||
return respond(r)
|
||||
}
|
||||
const record = recorderInto(seen)
|
||||
const api = scriptedApi({
|
||||
goals: {
|
||||
create: record('goal.create', r => ok(r, ack)),
|
||||
@@ -564,3 +606,84 @@ describe('envelope tap', () => {
|
||||
expect(batches).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('config unary surface', () => {
|
||||
it('round-trips every settings/credentials/llm method with its own payload and value shape', async () => {
|
||||
const seen: { method: string; payload: unknown }[] = []
|
||||
const record = recorderInto(seen)
|
||||
const view = {
|
||||
ns: 'llm-deepseek',
|
||||
schema: { uid: 1, refs: { 1: { type: 'object' } } },
|
||||
value: { baseURL: 'https://next' },
|
||||
user: { baseURL: 'https://next' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: true }],
|
||||
revision: 0,
|
||||
}
|
||||
const providerRow = {
|
||||
provider: 'openai',
|
||||
displayName: 'openai',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'openai'],
|
||||
active: false,
|
||||
}
|
||||
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] })),
|
||||
update: record('settings.update', r => ok(r, view)),
|
||||
replace: record('settings.replace', r => ok(r, view)),
|
||||
mutate: record('settings.mutate', r => ok(r, view)),
|
||||
},
|
||||
credentials: {
|
||||
describe: record('credentials.describe', r => ok(r, { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } })),
|
||||
set: record('credentials.set', r => ok(r, {})),
|
||||
unset: record('credentials.unset', r => ok(r, {})),
|
||||
},
|
||||
llm: {
|
||||
providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
|
||||
models: record('llm.models', r => ok(r, { groups: [group], failures: [] })),
|
||||
},
|
||||
})
|
||||
const c = client(api)
|
||||
|
||||
const described = await c.settings.describe({})
|
||||
expect(described.result).toEqual({ ok: true, value: { writable: true, namespaces: [view] } })
|
||||
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: {} })
|
||||
expect(replaced.result).toEqual({ ok: true, value: view })
|
||||
const mutated = await c.settings.mutate({
|
||||
ns: 'llm-deepseek',
|
||||
ops: [{ op: 'unset', path: ['baseURL'] }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
expect(mutated.result).toEqual({ ok: true, value: view })
|
||||
const creds = await c.credentials.describe({ refs: ['OPENAI_API_KEY'] })
|
||||
expect(creds.result).toEqual({ ok: true, value: { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } } })
|
||||
expect((await c.credentials.set({ ref: 'OPENAI_API_KEY', value: 'sk-x' })).result).toEqual({ ok: true, value: {} })
|
||||
expect((await c.credentials.unset({ ref: 'OPENAI_API_KEY' })).result).toEqual({ ok: true, value: {} })
|
||||
const providers = await c.llm.providers({})
|
||||
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: [] } })
|
||||
|
||||
expect(seen.map(call => call.method)).toEqual([
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.providers', 'llm.models',
|
||||
])
|
||||
expect(seen[1]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
|
||||
expect(seen[3]?.payload)
|
||||
.toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
|
||||
expect(seen[5]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
|
||||
})
|
||||
|
||||
it('rejects an invalid credential reference name at the carrier boundary', async () => {
|
||||
const api = scriptedApi()
|
||||
const response = await client(api).credentials.set({ ref: 'not a var', value: 'x' })
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('bad-request')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
},
|
||||
@@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async rename(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
|
||||
},
|
||||
async fork(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-fork' as never } } }
|
||||
},
|
||||
async prompt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
@@ -167,6 +170,39 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
async describe(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, namespaces: [] } } }
|
||||
},
|
||||
async update(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
|
||||
},
|
||||
async replace(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
|
||||
},
|
||||
async mutate(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
async describe(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { credentials: {} } } }
|
||||
},
|
||||
async set(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: {} } }
|
||||
},
|
||||
async unset(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: {} } }
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
async providers(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { providers: [] } } }
|
||||
},
|
||||
async models(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } }
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: (_request, signal) => stream(muxFrames, signal),
|
||||
host: (_request, signal) => stream(hostFrames, signal),
|
||||
@@ -216,7 +252,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
const selected = await c.sessions.selectModel({
|
||||
sessionId: 's' as never,
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
@@ -224,7 +260,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
ok: true,
|
||||
value: {
|
||||
selected: {
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: 'max',
|
||||
},
|
||||
|
||||
@@ -160,13 +160,13 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionHistoryValueSchema.parse({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}).hasMore).toBe(false)
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [{
|
||||
id: 'deepseek-v4-flash',
|
||||
@@ -186,12 +186,12 @@ describe('sessions domain schemas', () => {
|
||||
}).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash')
|
||||
expect(sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
}).reasoningEffort).toBe('max')
|
||||
expect(sessionSelectModelValueSchema.parse({
|
||||
selected: { provider: 'deepseek', model: 'deepseek-v4-pro', reasoningEffort: 'max' },
|
||||
selected: { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' },
|
||||
}).selected.reasoningEffort).toBe('max')
|
||||
expect(() => sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
@@ -200,14 +200,14 @@ describe('sessions domain schemas', () => {
|
||||
})).toThrow()
|
||||
expect(() => sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'm',
|
||||
reasoningEffort: '',
|
||||
})).toThrow()
|
||||
expect(() => sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek', model: 'm' },
|
||||
current: { provider: 'deepseek-official', model: 'm' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'm', name: 'M', reasoning: { efforts: [] } }],
|
||||
}],
|
||||
@@ -399,6 +399,17 @@ describe('events frame schemas', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
|
||||
})
|
||||
|
||||
it('carries a question presentation intent through, and rejects an unknown one', () => {
|
||||
const intent = { kind: 'plan-review', approve: 'Approve' }
|
||||
expect(askUserQuestionItemSchema.parse({
|
||||
id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }], intent,
|
||||
}).intent).toEqual(intent)
|
||||
// An unrecognised tag is a rejected frame, not a silently generic render.
|
||||
for (const invalid of [{ kind: 'plan-review' }, { kind: 'poll', approve: 'Approve' }, { approve: 'Approve' }]) {
|
||||
expect(() => askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?', intent: invalid })).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: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow()
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
6
packages/host/directory-picker-auto/README.i18n.yaml
Normal file
6
packages/host/directory-picker-auto/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/directory-picker-auto/README.md
|
||||
README.md: 10d1784590b79fdfef3cf6683d389182cd8437b6
|
||||
README.zh.md: 86ec9f2c3a87557e86038ce7d3f89887c5bb3546
|
||||
21
packages/host/directory-picker-auto/README.md
Normal file
21
packages/host/directory-picker-auto/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-host-directory-picker-auto
|
||||
|
||||
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).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the chooser only composes the GUI host's directory selection; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments.
|
||||
- **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot.
|
||||
- **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once.
|
||||
21
packages/host/directory-picker-auto/README.zh.md
Normal file
21
packages/host/directory-picker-auto/README.zh.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-host-directory-picker-auto
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[目录选择 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 流程)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该选择器仅组合 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。
|
||||
- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。
|
||||
- **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。
|
||||
47
packages/host/directory-picker-auto/package.json
Normal file
47
packages/host/directory-picker-auto/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-directory-picker-auto",
|
||||
"description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
71
packages/host/directory-picker-auto/src/index.ts
Normal file
71
packages/host/directory-picker-auto/src/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Adaptive chooser of the directory-picker seam: resolves the host's
|
||||
* situation once at boot (bind host, SSH launch, display session, Linux
|
||||
* chooser binary) and mounts the matching dual-face backend — `-native` or
|
||||
* `-browse` — as a real Loader entry in the in-memory root tree. Because the
|
||||
* backend arrives as an ordinary entry, its browser half is discovered
|
||||
* exactly as a config-row's would be, so the seam's one-row-swaps-both-faces
|
||||
* invariant holds for the resolved choice; pinning an interaction remains
|
||||
* composing that backend row directly instead of this one.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
// Empty type imports carry the `loader` and `httpServer` Context merges for the reads below.
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import { canExecute, hasLinuxChooserBinary } from './probe.ts'
|
||||
import type { DirectoryPickerBackendKind } from './resolve.ts'
|
||||
import { resolveDirectoryPickerBackend } from './resolve.ts'
|
||||
|
||||
export { canExecute, hasLinuxChooserBinary } from './probe.ts'
|
||||
export type { DirectoryPickerBackendKind, DirectoryPickerEnv, DirectoryPickerHostFacts } from './resolve.ts'
|
||||
export { resolveDirectoryPickerBackend } from './resolve.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'directory-picker-auto'
|
||||
/** Required services: the effective bind host (`httpServer`) and the entry tree the backend mounts into (`loader`). */
|
||||
export const inject = ['httpServer', 'loader']
|
||||
|
||||
/**
|
||||
* Backend package per resolved kind — fixed composition vocabulary, not a
|
||||
* tunable. Exported because the reference is a runtime string the static
|
||||
* config gate cannot see in a yml row: `verify-cordis-config` requires every
|
||||
* app composing this chooser to declare both values as dependencies.
|
||||
*/
|
||||
export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
|
||||
native: '@deepseek-ai/dsh-host-directory-picker-native',
|
||||
browse: '@deepseek-ai/dsh-host-directory-picker-browse',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the backend from one boot-time sample and mount it as a Loader
|
||||
* entry; the effect's disposer removes the entry and joins the backend
|
||||
* fiber's teardown, so unloading this plugin returns only after both faces
|
||||
* of the mounted backend (and their dependents) quiesced.
|
||||
* @param ctx - cordis context carrying the injected `httpServer` and `loader`.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<void> {
|
||||
const backend = resolveDirectoryPickerBackend({
|
||||
bindHost: ctx.httpServer.host,
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute),
|
||||
})
|
||||
await ctx.effect(async () => {
|
||||
// Root-tree create: the Loader root is in-memory (write() is a no-op), so
|
||||
// the mounted row can never be persisted back into a config file.
|
||||
const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] })
|
||||
return async () => {
|
||||
// Tree teardown (group.stop) can have removed the entry already;
|
||||
// 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()
|
||||
}
|
||||
}, 'directory-picker-auto: backend entry')
|
||||
}
|
||||
25
packages/host/directory-picker-auto/src/invariant.ts
Normal file
25
packages/host/directory-picker-auto/src/invariant.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Package-owned invariant companion for the adaptive directory-picker chooser.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-auto'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'host-directory-picker-auto-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: the sole effect is one boot-time Loader-entry mount owned by the plugin fiber; the store is authoritative. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register the adaptive directory-picker invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
44
packages/host/directory-picker-auto/src/probe.ts
Normal file
44
packages/host/directory-picker-auto/src/probe.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* PATH probe for the native backend's Linux chooser binaries: one boot-time
|
||||
* sampled fact for the resolver, so an attended Linux host without
|
||||
* zenity/kdialog keeps the working `browse` interaction instead of a backend
|
||||
* whose every pick fails.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto/probe
|
||||
*/
|
||||
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
import { delimiter, join } from 'node:path'
|
||||
|
||||
/** The chooser binaries the native backend can drive on Linux (zenity, KDialog fallback). */
|
||||
const LINUX_CHOOSER_BINARIES = ['zenity', 'kdialog'] as const
|
||||
|
||||
/**
|
||||
* Whether the current process may execute the candidate path.
|
||||
* @param candidate - absolute or PATH-joined file path.
|
||||
* @returns true only for an existing executable file.
|
||||
*/
|
||||
export function canExecute(candidate: string): boolean {
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK)
|
||||
} catch {
|
||||
// Absent or non-executable candidate — the only signals accessSync(X_OK) emits.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a PATH value for one of the native backend's Linux chooser binaries.
|
||||
* @param pathValue - the `PATH` environment value (absent or empty scans nothing).
|
||||
* @param isExecutable - executability predicate ({@link canExecute} in production; injected for deterministic tests).
|
||||
* @returns whether any PATH directory holds an executable chooser binary.
|
||||
*/
|
||||
export function hasLinuxChooserBinary(pathValue: string | undefined, isExecutable: (candidate: string) => boolean): boolean {
|
||||
for (const dir of (pathValue ?? '').split(delimiter)) {
|
||||
if (dir === '') continue
|
||||
for (const name of LINUX_CHOOSER_BINARIES) {
|
||||
if (isExecutable(join(dir, name))) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
53
packages/host/directory-picker-auto/src/resolve.ts
Normal file
53
packages/host/directory-picker-auto/src/resolve.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Boot-time backend resolution for the adaptive directory-picker composition:
|
||||
* one pure decision from sampled host facts to a concrete backend kind. The
|
||||
* caller samples exactly once per boot, so the mounted capability stays
|
||||
* stable for the service lifetime as the seam requires.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto/resolve
|
||||
*/
|
||||
|
||||
import type { Config as HttpServerConfig } from '@deepseek-ai/dsh-host-webserver'
|
||||
|
||||
/** Concrete interaction backend the resolver chooses between. */
|
||||
export type DirectoryPickerBackendKind = 'native' | 'browse'
|
||||
|
||||
/** Environment keys the resolution reads (a `process.env` subset). */
|
||||
export type DirectoryPickerEnv = Readonly<
|
||||
Partial<Record<'SSH_CONNECTION' | 'SSH_TTY' | 'DISPLAY' | 'WAYLAND_DISPLAY', string>>
|
||||
>
|
||||
|
||||
/** Host facts the backend choice is a pure function of, sampled once at boot. */
|
||||
export interface DirectoryPickerHostFacts {
|
||||
/** Effective webserver bind host (the schema's closed loopback/all-interfaces union). */
|
||||
bindHost: HttpServerConfig['host']
|
||||
/** Host process platform. */
|
||||
platform: NodeJS.Platform
|
||||
/** Environment sample; SSH marks a remote operator, DISPLAY/WAYLAND_DISPLAY a Linux display. */
|
||||
env: DirectoryPickerEnv
|
||||
/** Whether a Linux chooser binary the native backend can drive (zenity/kdialog) is on PATH; consulted only when `platform` is linux. */
|
||||
linuxChooser: boolean
|
||||
}
|
||||
|
||||
/** An env value counts only when set and non-blank (an empty export is "unset" by shell convention). */
|
||||
const present = (value: string | undefined): boolean => value !== undefined && value !== ''
|
||||
|
||||
/**
|
||||
* Resolve which backend serves this boot. `native` requires every signal that
|
||||
* the operator can see the host display and the native backend can serve it:
|
||||
* a loopback-only bind (an all-interfaces bind admits remote browsers no OS
|
||||
* chooser can reach), no SSH launch (under SSH port-forwarding the chooser
|
||||
* would open on the unattended server), and a servable display session —
|
||||
* assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a
|
||||
* chooser binary on linux, and never true elsewhere (the native backend
|
||||
* drives exactly darwin/win32/linux). Anything ambiguous resolves to
|
||||
* `browse`, which works everywhere.
|
||||
* @param facts - the sampled host facts.
|
||||
* @returns the backend kind to mount.
|
||||
*/
|
||||
export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts): DirectoryPickerBackendKind {
|
||||
if (facts.bindHost !== '127.0.0.1') return 'browse'
|
||||
if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return 'browse'
|
||||
if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native'
|
||||
if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse'
|
||||
return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? 'native' : 'browse'
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* REAL-composition coverage: a test-only cordis.yml booted through the
|
||||
* vendored Loader mounts the webserver row plus the adaptive chooser, and the
|
||||
* assertions observe the durable outcome — which backend entry the chooser
|
||||
* mounted into the Loader store, the capability the seam then serves, and
|
||||
* that disposing the chooser removes the mounted entry again (HMR safety),
|
||||
* joining the backend's own teardown before the disposer settles.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import HttpServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||
import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native'
|
||||
import * as DirectoryPickerAuto from '../src/index.ts'
|
||||
|
||||
const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
|
||||
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
|
||||
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||
|
||||
let root: string | undefined
|
||||
let fakeBin: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
for (const dir of [root, fakeBin]) {
|
||||
// maxRetries absorbs teardown stragglers (e.g. an unawaited fiber's late
|
||||
// file handle) that can otherwise race the recursive scan into ENOTEMPTY.
|
||||
if (dir !== undefined) await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 })
|
||||
}
|
||||
root = undefined
|
||||
fakeBin = undefined
|
||||
})
|
||||
|
||||
/** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
|
||||
async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
|
||||
const dist = join(root, 'dist')
|
||||
mkdirSync(dist)
|
||||
const distIndex = join(dist, 'index.html')
|
||||
await writeFile(distIndex, '<head></head><body>shell</body>')
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-host-webserver'",
|
||||
' config:',
|
||||
` host: '${bindHost}'`,
|
||||
' port: 0',
|
||||
` distIndex: '${distIndex}'`,
|
||||
`- name: '${AUTO}'`,
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-host-webserver', HttpServer],
|
||||
[AUTO, DirectoryPickerAuto],
|
||||
[NATIVE, NativeDirectoryPicker],
|
||||
[BROWSE, BrowseDirectoryPicker],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
return { ctx: context, configPath }
|
||||
}
|
||||
|
||||
/** Entry names currently present in the loader store (root tree plus subtrees). */
|
||||
function entryNames(ctx: Context): string[] {
|
||||
return [...ctx.loader.entries()].map(entry => entry.options.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Force every signal of an attended host on any platform: no SSH launch, a
|
||||
* display, and a PATH holding one executable chooser binary so the real
|
||||
* probe resolves identically on hosts with and without zenity/kdialog.
|
||||
*/
|
||||
function stubAttendedHost(): void {
|
||||
fakeBin = mkdtempSync(join(tmpdir(), 'dsh-picker-bin-'))
|
||||
const zenity = join(fakeBin, 'zenity')
|
||||
writeFileSync(zenity, '#!/bin/sh\n')
|
||||
chmodSync(zenity, 0o755)
|
||||
vi.stubEnv('PATH', fakeBin)
|
||||
vi.stubEnv('SSH_CONNECTION', '')
|
||||
vi.stubEnv('SSH_TTY', '')
|
||||
vi.stubEnv('DISPLAY', ':0')
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
// The 60s budget covers this file's static imports (webserver plus both
|
||||
// backend node halves through tsx), which dominate on cold caches; the
|
||||
// Loader itself resolves nothing here — `loader.internal` is a module map.
|
||||
it('mounts the native backend for an attended loopback host and unmounts it on disposal', { timeout: 60_000 }, async () => {
|
||||
stubAttendedHost()
|
||||
const { ctx, configPath } = await loadComposition('127.0.0.1')
|
||||
|
||||
const unloaded = [...ctx.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
expect(entryNames(ctx)).toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(BROWSE)
|
||||
const picker = ctx.get('directoryPicker') as DirectoryPicker
|
||||
expect(picker.capability().kind).toBe('native')
|
||||
// The mounted row lives in the Loader's in-memory root tree only — the
|
||||
// booted config file must never gain the resolved backend row.
|
||||
expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
|
||||
|
||||
// HMR safety: disposing the chooser's fiber removes the entry it created,
|
||||
// and the disposer joins the backend's teardown — the service is gone the
|
||||
// moment dispose() settles, with no further loader await.
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
await autoEntry.fiber!.dispose()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(ctx.get('directoryPicker')).toBeUndefined()
|
||||
// Self-disposing an include-tree entry persists `disabled: true` (loader
|
||||
// behavior, not the chooser's); await that debounced write so it cannot
|
||||
// race the temp-dir removal, and pin that the persisted row is the
|
||||
// chooser itself — the resolved backend still never reaches the file.
|
||||
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
|
||||
expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
|
||||
})
|
||||
|
||||
it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => {
|
||||
stubAttendedHost()
|
||||
vi.stubEnv('SSH_CONNECTION', '10.0.0.2 55 10.0.0.9 22')
|
||||
const { ctx } = await loadComposition('127.0.0.1')
|
||||
|
||||
expect(entryNames(ctx)).toContain(BROWSE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
const picker = ctx.get('directoryPicker') as DirectoryPicker
|
||||
expect(picker.capability().kind).toBe('browse')
|
||||
})
|
||||
|
||||
it('mounts the browse backend for an all-interfaces bind even on an attended host', { timeout: 60_000 }, async () => {
|
||||
stubAttendedHost()
|
||||
const { ctx } = await loadComposition('0.0.0.0')
|
||||
|
||||
expect(entryNames(ctx)).toContain(BROWSE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
})
|
||||
|
||||
it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => {
|
||||
stubAttendedHost()
|
||||
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)
|
||||
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)
|
||||
// Same self-dispose persistence as above: let the write land before teardown.
|
||||
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
|
||||
})
|
||||
})
|
||||
91
packages/host/directory-picker-auto/tests/resolve.spec.ts
Normal file
91
packages/host/directory-picker-auto/tests/resolve.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { canExecute, hasLinuxChooserBinary } from '../src/probe.ts'
|
||||
import { resolveDirectoryPickerBackend } from '../src/resolve.ts'
|
||||
import type { DirectoryPickerHostFacts } from '../src/resolve.ts'
|
||||
|
||||
/** Baseline facts that resolve to `native`; each case overrides one signal (darwin never consults `linuxChooser`). */
|
||||
const attended: DirectoryPickerHostFacts = {
|
||||
bindHost: '127.0.0.1',
|
||||
platform: 'darwin',
|
||||
env: {},
|
||||
linuxChooser: false,
|
||||
}
|
||||
|
||||
describe('resolveDirectoryPickerBackend', () => {
|
||||
it('resolves native for a loopback bind on a display platform', () => {
|
||||
expect(resolveDirectoryPickerBackend(attended)).toBe('native')
|
||||
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'win32' })).toBe('native')
|
||||
})
|
||||
|
||||
it('resolves browse for an all-interfaces bind regardless of other signals', () => {
|
||||
expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '0.0.0.0' })).toBe('browse')
|
||||
})
|
||||
|
||||
it('resolves browse under an SSH launch (either env marker)', () => {
|
||||
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '10.0.0.2 55 10.0.0.9 22' } })).toBe('browse')
|
||||
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_TTY: '/dev/pts/3' } })).toBe('browse')
|
||||
})
|
||||
|
||||
it('requires a display session and a chooser binary on linux', () => {
|
||||
const linux: DirectoryPickerHostFacts = { ...attended, platform: 'linux', linuxChooser: true }
|
||||
expect(resolveDirectoryPickerBackend(linux)).toBe('browse')
|
||||
expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' } })).toBe('native')
|
||||
expect(resolveDirectoryPickerBackend({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('native')
|
||||
expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' }, linuxChooser: false })).toBe('browse')
|
||||
})
|
||||
|
||||
it('resolves browse on platforms the native backend cannot serve, display or not', () => {
|
||||
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'freebsd', env: { DISPLAY: ':0' }, linuxChooser: true })).toBe('browse')
|
||||
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'openbsd', env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('browse')
|
||||
})
|
||||
|
||||
it('treats blank env exports as unset', () => {
|
||||
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '', SSH_TTY: '' } })).toBe('native')
|
||||
expect(resolveDirectoryPickerBackend({
|
||||
...attended, platform: 'linux', linuxChooser: true, env: { DISPLAY: '', WAYLAND_DISPLAY: '' },
|
||||
})).toBe('browse')
|
||||
})
|
||||
})
|
||||
|
||||
let probeRoot: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (probeRoot !== undefined) rmSync(probeRoot, { recursive: true, force: true })
|
||||
probeRoot = undefined
|
||||
})
|
||||
|
||||
describe('hasLinuxChooserBinary', () => {
|
||||
it('finds a chooser binary in any PATH segment, skipping empty segments', () => {
|
||||
const seen: string[] = []
|
||||
const path = ['', '/opt/none', '/usr/local/bin'].join(delimiter)
|
||||
const found = hasLinuxChooserBinary(path, (candidate) => {
|
||||
seen.push(candidate)
|
||||
return candidate === join('/usr/local/bin', 'kdialog')
|
||||
})
|
||||
expect(found).toBe(true)
|
||||
expect(seen).toEqual([
|
||||
join('/opt/none', 'zenity'), join('/opt/none', 'kdialog'),
|
||||
join('/usr/local/bin', 'zenity'), join('/usr/local/bin', 'kdialog'),
|
||||
])
|
||||
})
|
||||
|
||||
it('reports absence when no segment holds a chooser binary', () => {
|
||||
expect(hasLinuxChooserBinary(['/a', '/b'].join(delimiter), () => false)).toBe(false)
|
||||
expect(hasLinuxChooserBinary('', () => true)).toBe(false)
|
||||
expect(hasLinuxChooserBinary(undefined, () => true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canExecute', () => {
|
||||
it('accepts an executable file and rejects an absent one', () => {
|
||||
probeRoot = mkdtempSync(join(tmpdir(), 'dsh-picker-probe-'))
|
||||
const binary = join(probeRoot, 'zenity')
|
||||
writeFileSync(binary, '#!/bin/sh\n')
|
||||
chmodSync(binary, 0o755)
|
||||
expect(canExecute(binary)).toBe(true)
|
||||
expect(canExecute(join(probeRoot, 'kdialog'))).toBe(false)
|
||||
})
|
||||
})
|
||||
27
packages/host/directory-picker-auto/tsconfig.json
Normal file
27
packages/host/directory-picker-auto/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: 318380405214d5f25ad77e348c4e134a8981ffb3
|
||||
README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754
|
||||
README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
|
||||
README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f
|
||||
|
||||
@@ -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, breadcrumb with a click-to-edit path zone, 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 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).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `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 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**: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)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
|
||||
* headless here — mask, card, Escape only — and this module owns the figma
|
||||
* frame: 600×420 card (viewport-clamped), header (title + crumbs, l3 separator),
|
||||
* frame: 680×500 card (viewport-clamped; upsized from the figma 600×420),
|
||||
* header (title + crumbs, l3 separator),
|
||||
* the one-or-two-column Miller content, and the bordered footer. */
|
||||
|
||||
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
|
||||
@@ -8,19 +9,32 @@
|
||||
* columns scroll, so shrinking the height keeps Open/Cancel reachable
|
||||
* instead of clipping them below a fixed overlay. */
|
||||
.dialog.dialog {
|
||||
width: min(600px, 100%);
|
||||
height: min(420px, calc(100dvh - 32px));
|
||||
width: min(680px, 100%);
|
||||
height: min(500px, calc(100dvh - 32px));
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
/* The Modal card is an l2 surface and the columns below scroll on it:
|
||||
* rebind the scrollbar indirection to the elevation pair here, on the
|
||||
* surface, so it inherits down to whichever descendant scrolls (the
|
||||
* rebinding contract in ui-theme styles/scrollbar.css). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
|
||||
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
|
||||
* observers; display:contents keeps header/content/footer as direct flex
|
||||
* children of the Modal card. */
|
||||
.editorScope {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 22px 14px 12px 24px;
|
||||
padding: 16px 14px 8px 24px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
@@ -54,8 +68,12 @@
|
||||
align-items: stretch;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
gap: 20px;
|
||||
/* 12px of row gap on each side of the divider; the left side reads wider
|
||||
* by the column's trailing 8px scrollbar clearance, which is deliberate —
|
||||
* the thumb needs that room, the right pane's rows do not. */
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.crumbTrail {
|
||||
@@ -126,28 +144,36 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with
|
||||
* the hairline divider centered between them; each column scrolls alone. */
|
||||
/* Miller content: symmetric 16px vertical padding so the divider clears the
|
||||
* header and footer rules evenly; each column scrolls alone (column widths
|
||||
* live at .column). */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 16px 24px 0;
|
||||
/* Anchors the floating loading pill (.loadingFloat). */
|
||||
position: relative;
|
||||
/* Right inset is slimmer than the left: the trailing column's own 8px
|
||||
* scrollbar clearance makes up the optical difference. */
|
||||
padding: 16px 16px 16px 24px;
|
||||
}
|
||||
|
||||
/* Columns split the row evenly around the divider (a solo column takes the
|
||||
* whole row); 256px is the floor below which the row scrolls (scrollbar
|
||||
* hidden, the effect pins the child pane into view) instead of squeezing
|
||||
* the panes. */
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 256px;
|
||||
flex: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.columnWide {
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
min-width: 256px;
|
||||
overflow-y: auto;
|
||||
/* The themed scrollbar occupies the column's edge (styled scrollbars are
|
||||
* classic, gutter-taking ones); the extra clearance keeps the row pills
|
||||
* clear of the thumb. */
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
@@ -216,6 +242,10 @@
|
||||
.status,
|
||||
.error {
|
||||
padding: 4px;
|
||||
/* The loading pill occupies the opposite corner while a stale status stays
|
||||
* visible. Reserve its widest localized footprint so wrapped text cannot
|
||||
* run underneath it on a narrow card. */
|
||||
padding-right: 120px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -228,8 +258,25 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed
|
||||
* card leaves the figma 28px below the 36px buttons. */
|
||||
/* The slow-scan indicator floats over the content's bottom-RIGHT corner on
|
||||
* the card background instead of occupying a row: a scan must never shift
|
||||
* the columns' height, and the stale view keeps rendering beneath it (it
|
||||
* only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right,
|
||||
* not left: the truncated/error status rows flow at the bottom LEFT and
|
||||
* stay on screen through a scan, with their reserved right padding keeping
|
||||
* both legible even on a narrow card. After .status in the cascade — the
|
||||
* element carries both classes and this padding must win the
|
||||
* same-specificity race. */
|
||||
.loadingFloat {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
/* Footer: l3 separator on top, symmetric padding so the row sits vertically
|
||||
* centered in the bar; New-folder and the show-hidden toggle pin left. */
|
||||
.footerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -238,10 +285,42 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 12px 24px 28px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* Show-hidden toggle: a subtle fixed-label text button left of the gap;
|
||||
* the pressed state seats a check glyph after the label (Menu's selected
|
||||
* vocabulary; trailing so the label never shifts) instead of flipping the
|
||||
* wording. */
|
||||
.showHiddenToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.showHiddenToggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.showHiddenToggle:disabled {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.showHiddenToggleActive {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.footerGap {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
/**
|
||||
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
|
||||
* 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls
|
||||
* 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls
|
||||
* sideways, the columns scroll down) whose header carries the title, the selection-path
|
||||
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
|
||||
* full-width level until a row is selected, then two 256px columns (level |
|
||||
* selected folder's children) around a hairline divider. Selecting in the
|
||||
* full-width level until a row is selected, then two columns splitting the
|
||||
* row evenly (256px floor; level | selected folder's children) around a
|
||||
* hairline divider. Navigations land selection-anchored and quiet: the
|
||||
* previous view keeps rendering while a crumb jump or a submitted path is
|
||||
* scanned, then target and parent legs land as one two-pane frame (a slow
|
||||
* parent leg falls back to landing the target alone and upgrading in
|
||||
* place), so stepping back keeps two panes away from the display root and
|
||||
* navigation never flashes an intermediate frame. Selecting in the
|
||||
* right column shifts the view one level deeper. "New folder" opens a nested
|
||||
* create dialog targeting the selected folder (or the level itself) and
|
||||
* selects the created folder. Open adopts the selected folder, falling back
|
||||
* to the listed level. Pure consumer of the injected browse calls — the
|
||||
* owning flow decides what "Open" means and owns the workspace-creation
|
||||
* error surface. Hidden entries are host-flagged and filtered here (a
|
||||
* show-hidden toggle is deferred work, client-side only).
|
||||
* 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).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
|
||||
Button, IconCheckOutline16, IconChevronRightOutline14, 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'
|
||||
@@ -47,6 +57,24 @@ function failureText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a scan may stay visually silent before the floating "Loading…"
|
||||
* pill appears. The stale view keeps rendering while a scan is in flight, so
|
||||
* a listing that settles inside this window swaps the panes with no
|
||||
* intermediate frame at all; only a genuinely slow host (a network mount, a
|
||||
* cold disk) surfaces the indicator.
|
||||
*/
|
||||
const SLOW_SCAN_DELAY_MS = 300
|
||||
|
||||
/**
|
||||
* How long a navigation landing waits for its parent leg before committing
|
||||
* the target alone. Inside the window both legs land as ONE two-pane frame —
|
||||
* no single-pane flash between them; past it the target commits single-pane
|
||||
* at once (an Enter-submitted navigation is never held hostage by a stalled
|
||||
* parent) and the late parent leg upgrades the landing in place.
|
||||
*/
|
||||
const PARENT_LEG_WAIT_MS = 200
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -59,17 +87,59 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
|
||||
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
|
||||
}
|
||||
|
||||
/**
|
||||
* The listing's platform separator, inferred from the home path the host
|
||||
* stamped — never from typed text or entry paths, where a backslash is a
|
||||
* legal POSIX name character. Still a heuristic at the last step: a POSIX
|
||||
* home directory whose own name contains a backslash would misread.
|
||||
* TODO: replace with a host-stamped `separator` field on the wire
|
||||
* DirectoryListing so the platform fact travels verbatim (the trade-off is
|
||||
* recorded in the directory-picker capability seam Agent Note).
|
||||
*/
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
/** One column of folder rows (the Miller view renders one or two of these). */
|
||||
function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
|
||||
function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: {
|
||||
entries: readonly DirectoryEntry[]
|
||||
selectedPath: string | null
|
||||
busy: boolean
|
||||
onPick: (entry: DirectoryEntry) => void
|
||||
wide: boolean
|
||||
showHidden: boolean
|
||||
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
|
||||
})
|
||||
return (
|
||||
<div className={clsx(css.column, wide && css.columnWide)} role="list">
|
||||
{entries.filter(entry => !entry.hidden).map((entry) => {
|
||||
<div className={css.column} role="list">
|
||||
{visible.map((entry) => {
|
||||
const selected = entry.path === selectedPath
|
||||
return (
|
||||
// The wrapper carries the list semantics; the row keeps its NATIVE
|
||||
@@ -80,6 +150,15 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
|
||||
aria-current={selected || undefined}
|
||||
className={clsx(css.row, selected && css.rowSelected)}
|
||||
disabled={busy}
|
||||
// While the path editor is open, keep focus in it: a focus
|
||||
// steal on mousedown would blur the editor and (in engines
|
||||
// where the blur lands before our guards) drop this click.
|
||||
// Outside editing, rows keep native focus behavior.
|
||||
onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined}
|
||||
// Editing-time focus parking happens after commit (the
|
||||
// DirectoryBrowser refocus effect): a right-pane pick replaces
|
||||
// this very column, so focusing the clicked node here would
|
||||
// still fall to body.
|
||||
onClick={() => { onPick(entry) }}
|
||||
>
|
||||
{selected
|
||||
@@ -107,9 +186,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const [selected, setSelected] = useState<DirectoryEntry | null>(null)
|
||||
const [child, setChild] = useState<DirectoryListing | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
// Derived from `loading` and `scanWindow` by the slow-scan effect below:
|
||||
// true only once the current listing call has been in flight for
|
||||
// SLOW_SCAN_DELAY_MS, so fast listings never render the indicator at all.
|
||||
const [slowScan, setSlowScan] = useState(false)
|
||||
// Every listing call owns a fresh silence window. `loading` may stay true
|
||||
// across a superseding row pick or across a navigation's target and parent
|
||||
// legs, so its boolean edge cannot identify the start of each scan.
|
||||
const [scanWindow, setScanWindow] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
|
||||
const [pathDraft, setPathDraft] = useState<string | null>(null)
|
||||
// Show-hidden toggle state (pure client-side filter, reset on each open).
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
// Create-folder state: null = closed; a string = the nested dialog's draft.
|
||||
const [folderDraft, setFolderDraft] = useState<string | null>(null)
|
||||
const [creatingFolder, setCreatingFolder] = useState(false)
|
||||
@@ -148,36 +237,128 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
return ++requestSeq.current
|
||||
}, [])
|
||||
|
||||
/** Hide any prior indicator and start a fresh silence window for one listing call. */
|
||||
const restartSlowScanWindow = useCallback((): void => {
|
||||
setSlowScan(false)
|
||||
setScanWindow(value => value + 1)
|
||||
}, [])
|
||||
|
||||
/** Launch one listing under a fresh controller so a later supersession can abort it. */
|
||||
const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise<DirectoryListing> } => {
|
||||
const seq = supersede()
|
||||
const controller = new AbortController()
|
||||
scanController.current = controller
|
||||
restartSlowScanWindow()
|
||||
return { seq, scan: listDirectory(path, controller.signal) }
|
||||
}, [supersede, listDirectory])
|
||||
}, [supersede, restartSlowScanWindow, listDirectory])
|
||||
|
||||
/** Replace the whole view with one freshly listed level (no selection). */
|
||||
/**
|
||||
* Launch a follow-up listing under the CURRENT supersession seq: a newer
|
||||
* intent aborts it like the leg it continues, and it supersedes nothing.
|
||||
*/
|
||||
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
|
||||
const controller = new AbortController()
|
||||
scanController.current = controller
|
||||
restartSlowScanWindow()
|
||||
return listDirectory(path, controller.signal)
|
||||
}, [restartSlowScanWindow, listDirectory])
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly navigated 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.
|
||||
*/
|
||||
const navigate = useCallback((path?: string) => {
|
||||
const { seq, scan } = launchListing(path)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
scan.then((next) => {
|
||||
scan.then((target) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setParent(next)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
// 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
|
||||
const landSingle = (): void => {
|
||||
if (landed || seq !== requestSeq.current) return
|
||||
landed = true
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
}
|
||||
// Arity is label-independent: only the collapsed chain's depth decides.
|
||||
if (displayCrumbs(target, '').length < 2) { landSingle(); return }
|
||||
const parentCrumb = target.crumbs.at(-2)
|
||||
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
|
||||
if (parentCrumb === undefined) { landSingle(); return }
|
||||
continueScan(parentCrumb.path).then((parentLevel) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
// Windows resolves a typed path preserving its case; anchor on the
|
||||
// parent level's actual entry so selection comparisons hold.
|
||||
const sep = separatorOf(parentLevel)
|
||||
const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value)
|
||||
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
|
||||
if (match === undefined) { landSingle(); return }
|
||||
landed = true
|
||||
setParent(parentLevel)
|
||||
setSelected(match)
|
||||
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)
|
||||
}, () => {
|
||||
// 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)
|
||||
}, (reason: unknown) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
setError(failureText(reason))
|
||||
})
|
||||
}, [launchListing])
|
||||
}, [launchListing, continueScan])
|
||||
|
||||
/** Select a row of the listed level and preview its children on the right. */
|
||||
// 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
|
||||
// input. Pointer-out cancels never set (or clear) these — yanking focus
|
||||
// back from wherever the user clicked would be worse than the fall.
|
||||
const refocusPick = useRef(false)
|
||||
const refocusEditZone = useRef(false)
|
||||
const pathInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const editZoneRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
/**
|
||||
* Select a row of the listed level and preview its children on the right.
|
||||
* Deliberately NOT one-frame like navigate(): a pick's first duty is the
|
||||
* immediate selected state on the clicked row, and the pane split IS that
|
||||
* feedback (aria-current pill, crumbs following the selection) — holding
|
||||
* it back for the child listing would make clicks feel dropped. The quiet
|
||||
* rule governs whole-view replacement, where nothing acknowledges the
|
||||
* click but the swap itself.
|
||||
*/
|
||||
const select = useCallback((entry: DirectoryEntry) => {
|
||||
const { seq, scan } = launchListing(entry.path)
|
||||
// A pick while the path editor is open adopts the (filtered) row and
|
||||
// closes the editor — the draft served its purpose. Focus re-parks on
|
||||
// the selection after commit (see the refocus effect below).
|
||||
if (pathDraft !== null) refocusPick.current = true
|
||||
setPathDraft(null)
|
||||
setSelected(entry)
|
||||
setChild(null)
|
||||
setLoading(true)
|
||||
@@ -193,8 +374,31 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// An unreadable selection cannot be the committing target while the
|
||||
// breadcrumb still names the level: fall back to the single pane.
|
||||
setSelected(null)
|
||||
// Clearing the selection can unmount the very row the pick parked
|
||||
// focus on (a dot-revealed hidden row re-hides); the refocus effect
|
||||
// re-parks on the edit zone only if focus actually fell to body.
|
||||
refocusEditZone.current = true
|
||||
})
|
||||
}, [launchListing])
|
||||
}, [launchListing, pathDraft])
|
||||
|
||||
/** 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
|
||||
// late success must not jump to the cancelled path, so the pending
|
||||
// request is superseded and the view leaves the loading state.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
setError(null)
|
||||
// Editing may have superseded the selection's preview request; a
|
||||
// selection with no preview would render a half-empty two-pane view, so
|
||||
// cancel falls back to the single-pane level.
|
||||
if (child === null) setSelected(null)
|
||||
// With no level listed yet (the editor superseded the initial home
|
||||
// listing), plain cancellation would leave a permanently blank picker:
|
||||
// restart the home listing.
|
||||
if (parent === null) navigate()
|
||||
}, [supersede, child, parent, navigate])
|
||||
|
||||
/** A right-column pick advances the view one level: child becomes the level. */
|
||||
const advance = useCallback((entry: DirectoryEntry) => {
|
||||
@@ -213,14 +417,24 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setCreatingFolder(false)
|
||||
setShowHidden(false)
|
||||
navigate()
|
||||
return
|
||||
}
|
||||
supersede()
|
||||
// Closing mid-scan leaves nothing to load: without this edge the
|
||||
// slow-scan effect keeps arming while hidden and the reopened dialog
|
||||
// would show the indicator on its first frame instead of waiting out a
|
||||
// fresh silence window (reopen's navigate() produces no loading edge).
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
setPathDraft(null)
|
||||
setFolderDraft(null)
|
||||
setCreateError(null)
|
||||
// A close mid-flight (failed Enter, then Cancel) may leave refocus
|
||||
// flags armed; retire them so a later render cannot consume them.
|
||||
refocusPick.current = false
|
||||
refocusEditZone.current = false
|
||||
}, [open, navigate, supersede])
|
||||
|
||||
/** The folder a create or Open acts on: the selection, else the listed level. */
|
||||
@@ -249,6 +463,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// create target becomes the listed level and the new folder its selection.
|
||||
const { seq, scan } = launchListing(targetPath)
|
||||
setLoading(true)
|
||||
// Symmetric with navigate/select: a launched scan clears the stale
|
||||
// failure text (and keeps the floating indicator's corner the only
|
||||
// occupant of the content's right edge while it shows).
|
||||
setError(null)
|
||||
scan.then((level) => {
|
||||
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
|
||||
if (seq !== requestSeq.current) return
|
||||
@@ -268,6 +486,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
})
|
||||
}
|
||||
|
||||
// The slow-scan gate for the loading indicator: each listing call restarts
|
||||
// the timer even when a superseding scan or a navigation's parent leg keeps
|
||||
// `loading` continuously true. A settle inside its own window means the swap
|
||||
// happened with nothing shown.
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setSlowScan(false)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [loading, scanWindow])
|
||||
|
||||
// After the hooks: a closed dialog renders nothing and evaluates no copy.
|
||||
const crumbSource = child ?? parent
|
||||
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
|
||||
@@ -285,6 +516,37 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const row = millerRowRef.current
|
||||
if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth
|
||||
}, [childPath])
|
||||
// Every editor exit that would drop focus to body re-parks it after
|
||||
// commit, so keyboard traversal stays inside the dialog (the Modal has no
|
||||
// focus trap): a pick lands on the selection's row — aria-current in the
|
||||
// freshly rendered left pane, which survives even a right-pane advance
|
||||
// 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 (pathDraft !== null) return
|
||||
if (refocusPick.current) {
|
||||
refocusPick.current = false
|
||||
refocusEditZone.current = false
|
||||
const rowHost = millerRowRef.current
|
||||
/* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */
|
||||
if (rowHost === null) return
|
||||
const row = rowHost.querySelector<HTMLButtonElement>('button[aria-current="true"]')
|
||||
/* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */
|
||||
if (row === null) return
|
||||
row.focus()
|
||||
return
|
||||
}
|
||||
if (refocusEditZone.current) {
|
||||
refocusEditZone.current = false
|
||||
// Re-park only when the close actually dropped focus to body; focus
|
||||
// the user parked elsewhere (a surviving row) stays theirs.
|
||||
if (document.activeElement !== document.body) return
|
||||
const zone = editZoneRef.current
|
||||
/* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */
|
||||
if (zone === null) return
|
||||
zone.focus()
|
||||
}
|
||||
})
|
||||
|
||||
if (!open) return null
|
||||
const twoPane = selected !== null
|
||||
@@ -310,149 +572,220 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
className={clsx(css.dialog)}
|
||||
headless
|
||||
>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{t('browser.title')}</h2>
|
||||
<div className={css.crumbBar}>
|
||||
{pathDraft === null
|
||||
? (
|
||||
<>
|
||||
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={crumb.path} className={css.crumbSeat}>
|
||||
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumb}
|
||||
disabled={parentInert}
|
||||
onClick={() => { navigate(crumb.path) }}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumbEditZone}
|
||||
aria-label={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.
|
||||
disabled={parentInert}
|
||||
onClick={() => {
|
||||
{/* Path-edit cancellation is observed at the card scope, not the
|
||||
* input: once Tab parks focus on a filtered row the input is off the
|
||||
* event path, yet Escape must still collapse the editor (not the
|
||||
* dialog) and a further focus move out of the card must still
|
||||
* cancel. display:contents keeps header/content/footer as direct
|
||||
* flex children of the Modal card. */}
|
||||
<div
|
||||
className={css.editorScope}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape' || pathDraft === null) return
|
||||
// stopPropagation keeps the card-scope Escape from the Modal's
|
||||
// document listener — the same containment the input previously
|
||||
// provided for itself.
|
||||
event.stopPropagation()
|
||||
// Escape while the input holds focus is about to unmount it; with
|
||||
// focus already parked on a row, that row survives the cancel and
|
||||
// keeps focus naturally. Assignment (not a conditional set) also
|
||||
// retires a stale flag a failed or still-upgrading Enter left.
|
||||
refocusEditZone.current = document.activeElement === pathInputRef.current
|
||||
cancelPathEdit()
|
||||
}}
|
||||
// Focus leaving THIS dialog card while editing cancels like Escape.
|
||||
// Guarded non-cancel paths: window/tab focus loss (document no
|
||||
// longer focused); a focus move that stays inside the card (Tab
|
||||
// onto the filtered rows or the footer toggle); and pointer paths,
|
||||
// where rows and the toggle suppress focus steal on mousedown while
|
||||
// editing so their click lands first. Enter keeps focus in the
|
||||
// input while its navigation is in flight, so a submitted path is
|
||||
// never withdrawn here. Anchored to this card via closest, not any
|
||||
// [role="dialog"], so focus escaping into a sibling overlay cancels.
|
||||
onBlur={(event) => {
|
||||
if (pathDraft === null) return
|
||||
if (!document.hasFocus()) return
|
||||
const card = event.currentTarget.closest('[role="dialog"]')
|
||||
/* v8 ignore next -- narrowing guard: this scope always renders inside the Modal card. */
|
||||
if (card === null) return
|
||||
if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) return
|
||||
// The user moved focus out of the card themselves: cancel without
|
||||
// re-parking (a lingering Enter-failure flag must not yank focus
|
||||
// back either).
|
||||
refocusEditZone.current = false
|
||||
cancelPathEdit()
|
||||
}}
|
||||
>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{t('browser.title')}</h2>
|
||||
<div className={css.crumbBar}>
|
||||
{pathDraft === null
|
||||
? (
|
||||
<>
|
||||
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={crumb.path} className={css.crumbSeat}>
|
||||
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumb}
|
||||
disabled={parentInert}
|
||||
onClick={() => { navigate(crumb.path) }}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumbEditZone}
|
||||
aria-label={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.
|
||||
disabled={parentInert}
|
||||
ref={editZoneRef}
|
||||
onClick={() => {
|
||||
// Opening the editor supersedes any pending listing: a
|
||||
// settlement landing before the first keystroke would
|
||||
// otherwise close the editor via navigate's draft reset.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
setPathDraft(selected?.path ?? parent?.path ?? '')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
className={css.pathInput}
|
||||
value={pathDraft}
|
||||
aria-label={t('browser.editPath')}
|
||||
autoFocus
|
||||
disabled={parentInert}
|
||||
onChange={(event) => {
|
||||
supersede()
|
||||
setLoading(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
|
||||
// is the recovery path for a failed home listing).
|
||||
if (parent === null) {
|
||||
setPathDraft('')
|
||||
return
|
||||
}
|
||||
const base = selected?.path ?? parent.path
|
||||
const sep = separatorOf(parent)
|
||||
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
className={css.pathInput}
|
||||
value={pathDraft}
|
||||
aria-label={t('browser.editPath')}
|
||||
autoFocus
|
||||
ref={pathInputRef}
|
||||
disabled={parentInert}
|
||||
onChange={(event) => {
|
||||
// Editing the draft supersedes any in-flight navigation:
|
||||
// its completion must neither clear the newer text nor
|
||||
// repopulate the view with the older path.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
setPathDraft(event.target.value)
|
||||
}}
|
||||
{...compositionGuard}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !composingRef.current) {
|
||||
event.preventDefault()
|
||||
// Trim only detects a blank draft; the Host gets the
|
||||
// original text — a real directory name may end in
|
||||
// whitespace, and trimming would list its sibling.
|
||||
if (pathDraft.trim() !== '') navigate(pathDraft)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
// Cancel also withdraws a navigation the editor already
|
||||
// launched: its late success must not jump to the
|
||||
// cancelled path, so the pending request is superseded
|
||||
// and the view leaves the loading state.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
setError(null)
|
||||
// Editing may have superseded the selection's preview
|
||||
// request; a selection with no preview would render a
|
||||
// half-empty two-pane view, so cancel falls back to the
|
||||
// single-pane level.
|
||||
if (child === null) setSelected(null)
|
||||
// With no level listed yet (the editor superseded the
|
||||
// initial home listing), plain cancellation would leave a
|
||||
// permanently blank picker: restart the home listing.
|
||||
if (parent === null) navigate()
|
||||
}
|
||||
}}
|
||||
setPathDraft(event.target.value)
|
||||
}}
|
||||
{...compositionGuard}
|
||||
// Escape and focus-leave cancellation live on the card-scope
|
||||
// wrapper above (they must work after focus Tabs onto the
|
||||
// rows); this handler owns only submission.
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !composingRef.current) {
|
||||
event.preventDefault()
|
||||
// Trim only detects a blank draft; the Host gets the
|
||||
// original text — a real directory name may end in
|
||||
// whitespace, and trimming would list its sibling.
|
||||
if (pathDraft.trim() !== '') {
|
||||
// Success will unmount the still-focused input; park
|
||||
// focus on the returning crumb edit zone (a failure
|
||||
// keeps the editor, so the flag waits until close).
|
||||
refocusEditZone.current = true
|
||||
navigate(pathDraft)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.content}>
|
||||
<div className={css.millerRow} ref={millerRowRef}>
|
||||
{parent !== null && (
|
||||
<LevelColumn
|
||||
entries={parent.entries}
|
||||
selectedPath={selected?.path ?? null}
|
||||
busy={parentInert}
|
||||
onPick={select}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(parent, pathDraft)}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.content}>
|
||||
<div className={css.millerRow} ref={millerRowRef}>
|
||||
{parent !== null && (
|
||||
<LevelColumn
|
||||
entries={parent.entries}
|
||||
selectedPath={selected?.path ?? null}
|
||||
busy={parentInert}
|
||||
onPick={select}
|
||||
wide={!twoPane}
|
||||
/>
|
||||
)}
|
||||
{twoPane && <span className={css.divider} />}
|
||||
{twoPane && child !== null && (
|
||||
<LevelColumn
|
||||
entries={child.entries}
|
||||
selectedPath={null}
|
||||
busy={parentInert}
|
||||
onPick={advance}
|
||||
wide={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
||||
{/* The backend bounds a level at its complete-result limit; say so
|
||||
{twoPane && <span className={css.divider} />}
|
||||
{twoPane && child !== null && (
|
||||
<LevelColumn
|
||||
entries={child.entries}
|
||||
selectedPath={null}
|
||||
busy={parentInert}
|
||||
onPick={advance}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(child, pathDraft)}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading && slowScan
|
||||
&& <div className={clsx(css.status, css.loadingFloat)} role="status">{t('browser.loading')}</div>}
|
||||
{/* The backend bounds a level at its complete-result limit; say so
|
||||
* whenever a visible pane was cut instead of letting the tail of a
|
||||
* huge directory go silently missing. */}
|
||||
{(parent?.truncated === true || child?.truncated === true) && !loading
|
||||
* huge directory go silently missing. The note describes the panes
|
||||
* on screen, so an in-flight scan leaves it alone — hiding it while
|
||||
* the stale view still shows the cut level would shift the columns
|
||||
* on every navigation away from it. */}
|
||||
{(parent?.truncated === true || child?.truncated === true)
|
||||
&& <div className={css.status} role="status">{t('browser.truncated')}</div>}
|
||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||
</div>
|
||||
<div className={css.footerBar}>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<IconPlusOutline16 size={14} />}
|
||||
disabled={parent === null || loading || parentInert || draftPending}
|
||||
onClick={() => {
|
||||
setFolderDraft('')
|
||||
setCreateError(null)
|
||||
}}
|
||||
>
|
||||
{t('browser.newFolder')}
|
||||
</Button>
|
||||
<span className={css.footerGap} />
|
||||
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={clsx(css.footerAction)}
|
||||
disabled={targetPath === null || loading || parentInert || draftPending}
|
||||
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
|
||||
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
|
||||
>
|
||||
{t('browser.open')}
|
||||
</Button>
|
||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||
</div>
|
||||
<div className={css.footerBar}>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<IconPlusOutline16 size={14} />}
|
||||
disabled={parent === null || loading || parentInert || draftPending}
|
||||
onClick={() => {
|
||||
setFolderDraft('')
|
||||
setCreateError(null)
|
||||
}}
|
||||
>
|
||||
{t('browser.newFolder')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)}
|
||||
aria-pressed={showHidden}
|
||||
disabled={parentInert}
|
||||
// The toggle composes with the path editor (dot-led prefixes and
|
||||
// this filter interleave): while editing, don't steal focus, so
|
||||
// toggling never blur-cancels a draft mid-thought. Outside editing
|
||||
// it keeps native focus behavior.
|
||||
onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
|
||||
onClick={() => { setShowHidden(prev => !prev) }}
|
||||
>
|
||||
{t('browser.showHidden')}
|
||||
{/* Trailing check (Menu's selected vocabulary): the label never
|
||||
* shifts when the pressed state toggles. */}
|
||||
{showHidden && <IconCheckOutline16 size={14} />}
|
||||
</button>
|
||||
<span className={css.footerGap} />
|
||||
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={clsx(css.footerAction)}
|
||||
disabled={targetPath === null || loading || parentInert || draftPending}
|
||||
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
|
||||
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
|
||||
>
|
||||
{t('browser.open')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
|
||||
<Modal
|
||||
|
||||
@@ -46,6 +46,7 @@ export function apply(ctx: ClientContext): void {
|
||||
'browser.editPath': '编辑路径',
|
||||
'browser.loading': '加载中…',
|
||||
'browser.truncated': '文件夹过多,仅显示开头部分。',
|
||||
'browser.showHidden': '显示隐藏文件',
|
||||
}],
|
||||
['en', {
|
||||
'browser.title': 'Select Workspace Directory',
|
||||
@@ -60,6 +61,7 @@ export function apply(ctx: ClientContext): void {
|
||||
'browser.editPath': 'Edit path',
|
||||
'browser.loading': 'Loading…',
|
||||
'browser.truncated': 'Too many folders to list; only the beginning is shown.',
|
||||
'browser.showHidden': 'Show hidden files',
|
||||
}],
|
||||
]
|
||||
try {
|
||||
|
||||
@@ -162,6 +162,7 @@ describe('directory-picker-browse client half', () => {
|
||||
// zh is the shipped default locale.
|
||||
expect(injected.t('browser.title')).toBe('选择工作区目录')
|
||||
expect(injected.t('browser.newFolder')).toBe('新建文件夹')
|
||||
expect(injected.t('browser.showHidden')).toBe('显示隐藏文件')
|
||||
})
|
||||
|
||||
it('drives the injected browse calls through the hole entry', async () => {
|
||||
|
||||
@@ -29,6 +29,25 @@ function listingFor(path?: string): DirectoryListing {
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
'/': {
|
||||
path: '/',
|
||||
home: HOME,
|
||||
crumbs: [{ name: '/', path: '/', hidden: false }],
|
||||
entries: [{ name: 'home', path: '/home', hidden: false }],
|
||||
truncated: false,
|
||||
},
|
||||
[`${HOME}/.config`]: {
|
||||
path: `${HOME}/.config`,
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'home', path: '/home', hidden: false },
|
||||
{ name: 'u', path: HOME, hidden: false },
|
||||
{ name: '.config', path: `${HOME}/.config`, hidden: true },
|
||||
],
|
||||
entries: [],
|
||||
truncated: false,
|
||||
},
|
||||
[DOCS]: {
|
||||
path: DOCS,
|
||||
home: HOME,
|
||||
@@ -92,6 +111,12 @@ function rowButton(item: HTMLElement): HTMLButtonElement {
|
||||
}
|
||||
|
||||
describe('DirectoryBrowser', () => {
|
||||
it('renders nothing and launches no listing while initially closed', () => {
|
||||
const b = mount({ open: false })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(b.listDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
@@ -103,6 +128,28 @@ describe('DirectoryBrowser', () => {
|
||||
expect(screen.queryByRole('button', { name: '/' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows hidden entries when the toggle is on and hides them again on close', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
expect(screen.queryByText('.config')).toBeNull()
|
||||
// The fixed-label toggle reports its state through aria-pressed. Its
|
||||
// mousedown never steals focus (so it composes with the path editor).
|
||||
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false')
|
||||
fireEvent.mouseDown(toggle)
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(screen.getByText('.config')).toBeTruthy()
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(screen.queryByText('.config')).toBeNull()
|
||||
// Close resets the toggle.
|
||||
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
||||
b.view.rerender(<DirectoryBrowser {...b.props} open />)
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
expect(screen.queryByText('.config')).toBeNull()
|
||||
})
|
||||
|
||||
it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
@@ -153,7 +200,7 @@ describe('DirectoryBrowser', () => {
|
||||
expect(signals[2]?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('jumps back through a crumb into a fresh single-column level', async () => {
|
||||
it('a crumb jump to the display root (home) lands the single wide level', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
@@ -164,6 +211,393 @@ describe('DirectoryBrowser', () => {
|
||||
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
|
||||
})
|
||||
|
||||
it('a crumb jump away from the root lands two-pane with the target selected', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(rowButton(within(columns()[1]!).getByRole('listitem')))
|
||||
await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() })
|
||||
// Jumping to the Documents crumb is a step BACK one pane, not a
|
||||
// collapse: Documents stays selected in the home level, its children
|
||||
// stay on the right.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
|
||||
await waitFor(() => {
|
||||
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||
})
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a navigation to the filesystem root keeps the single wide level', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: '/' } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// A one-crumb chain has no parent level to show on the left.
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('home') })
|
||||
expect(columns()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const signals: (AbortSignal | undefined)[] = []
|
||||
const settlers: ((value: DirectoryListing) => void)[] = []
|
||||
// Only the FIRST explicit HOME request (the parent leg) hangs; the
|
||||
// later home crumb jump lists normally.
|
||||
let homeCalls = 0
|
||||
const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
if (path === HOME && ++homeCalls === 1) {
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
|
||||
}
|
||||
return Promise.resolve(listingFor(path))
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The target settled but the parent leg hangs: inside the wait bound
|
||||
// nothing commits yet.
|
||||
await act(async () => {})
|
||||
expect(settlers).toHaveLength(1)
|
||||
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
|
||||
// The wait bound expires: the target commits alone — editor closed,
|
||||
// single-pane DOCS level.
|
||||
await act(async () => { vi.advanceTimersByTime(200) })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(columns()).toHaveLength(1)
|
||||
// A newer jump aborts the pending parent leg ON THE WIRE, not merely
|
||||
// dropping its settlement.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
expect(signals[2]?.aborted).toBe(true)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
// Its late resolution changes nothing either.
|
||||
await act(async () => { settlers[0]!(listingFor(HOME)) })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Listing fake whose explicit-path scans stay pending until the test
|
||||
* settles them by path; the absent-path form (the initial home listing)
|
||||
* resolves normally so mounting is a one-flush setup.
|
||||
*/
|
||||
function manualLister() {
|
||||
const settlers = new Map<string, (value: DirectoryListing) => void>()
|
||||
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
|
||||
if (path === undefined) return Promise.resolve(listingFor(path))
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
|
||||
})
|
||||
return { settlers, listDirectory }
|
||||
}
|
||||
|
||||
it('lands a navigation as ONE two-pane frame: the stale view holds until both legs arrive', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { settlers, listDirectory } = manualLister()
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The target settles while the parent leg is still in flight: nothing
|
||||
// commits yet — the editor stays open over the stale home level, and no
|
||||
// single-pane DOCS frame ever renders.
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
|
||||
expect(screen.queryByText('harness')).toBeNull()
|
||||
// The parent leg settles inside the wait bound: one commit straight to
|
||||
// the two-pane landing, editor closed.
|
||||
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// The wait-bound timer firing after the landing is a no-op.
|
||||
await act(async () => { vi.advanceTimersByTime(200) })
|
||||
expect(columns()).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a stalled parent leg lands the target alone at the wait bound, then upgrades in place', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { settlers, listDirectory } = manualLister()
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The target can consume most of the outer scan's silence window.
|
||||
await act(async () => { vi.advanceTimersByTime(250) })
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
// Its parent leg gets a fresh silence window. Crossing the original
|
||||
// scan's 300ms deadline therefore cannot flash the indicator during the
|
||||
// bounded landing wait.
|
||||
await act(async () => { vi.advanceTimersByTime(199) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
// The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone.
|
||||
await act(async () => { vi.advanceTimersByTime(1) })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// The late parent leg still upgrades the landing in place, exactly as
|
||||
// if it had made the bound. (Reopening the editor meanwhile would
|
||||
// supersede the upgrade — the editor-open handler withdraws pending
|
||||
// listings — so a late upgrade can never close a resumed draft.)
|
||||
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('Escape inside the landing window withdraws the submitted navigation', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { settlers, listDirectory } = manualLister()
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: DOCS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
// Nothing has committed yet; Escape supersedes the landing entirely.
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
await act(async () => { vi.advanceTimersByTime(200) })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.queryByText('harness')).toBeNull()
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// The home level is truncated so its note is on screen when the slow
|
||||
// scan starts: dropping the note's old !loading guard means it must
|
||||
// keep rendering through the scan, coexisting with the indicator.
|
||||
const settlers = new Map<string, (value: DirectoryListing) => void>()
|
||||
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
|
||||
if (path === undefined) return Promise.resolve({ ...listingFor(path), truncated: true })
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.getByText('browser.truncated')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// In flight but still inside the silence window: no indicator, and the
|
||||
// stale level's truncated note stays put (no layout churn on launch).
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.getByText('browser.truncated')).toBeTruthy()
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
// Past it: the indicator floats while the stale level — truncated note
|
||||
// included — keeps rendering beneath it.
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
expect(screen.getByText('browser.truncated')).toBeTruthy()
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
// Landing (both legs) retires the indicator with the scan, and the
|
||||
// fresh listings' own truncated state replaces the stale note.
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.queryByText('browser.truncated')).toBeNull()
|
||||
expect(columns()).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('restarts the silence window when a row pick supersedes a pending scan', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const pending: ((value: DirectoryListing) => void)[] = []
|
||||
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
|
||||
if (path === undefined) return Promise.resolve(listingFor(path))
|
||||
return new Promise<DirectoryListing>((resolve) => { pending.push(resolve) })
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
const documents = rowButton(screen.getByRole('listitem'))
|
||||
fireEvent.click(documents)
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
// The same row remains actionable while its preview is pending. A second
|
||||
// pick starts a new listing without a false `loading` edge.
|
||||
fireEvent.click(documents)
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(299) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
await act(async () => { pending.at(-1)!(listingFor(DOCS)) })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// Every home listing hangs: the initial open's scan is the one the
|
||||
// close interrupts, and the reopen's scan proves the fresh window.
|
||||
const settlers: ((value: DirectoryListing) => void)[] = []
|
||||
const listDirectory = vi.fn((_path?: string, _signal?: AbortSignal) =>
|
||||
new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) }))
|
||||
const { view, props } = mount({ listDirectory })
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
// Close while the scan is in flight, then reopen: the first frame must
|
||||
// wait out a fresh silence window, not inherit the armed indicator.
|
||||
view.rerender(<DirectoryBrowser {...props} open={false} />)
|
||||
view.rerender(<DirectoryBrowser {...props} open />)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
// The reopened scan settles normally.
|
||||
await act(async () => { settlers.at(-1)!(listingFor(undefined)) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the single-pane landing when the truncated parent level lacks the target', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
// The parent leg names HOME explicitly; serve it a truncated window
|
||||
// that misses Documents (the initial open uses the absent-path form).
|
||||
if (path === HOME) return { ...listingFor(HOME), entries: [], truncated: true }
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
// The upgrade would orphan the selection (no source row): it stays off.
|
||||
await act(async () => {})
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.queryByText('browser.truncated')).toBeNull()
|
||||
})
|
||||
|
||||
it('anchors the upgrade on the parent level actual entry under Windows case folding', async () => {
|
||||
const ROOT = 'C:\\'
|
||||
const TYPED = 'c:\\users'
|
||||
const winRoot: DirectoryListing = {
|
||||
path: ROOT,
|
||||
home: ROOT,
|
||||
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
|
||||
entries: [{ name: 'Users', path: 'C:\\Users', hidden: false }],
|
||||
truncated: false,
|
||||
}
|
||||
const winUsers: DirectoryListing = {
|
||||
path: TYPED,
|
||||
home: ROOT,
|
||||
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }],
|
||||
entries: [],
|
||||
truncated: false,
|
||||
}
|
||||
mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: TYPED } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The typed case differs from the real entry; the upgrade selects the
|
||||
// parent level's ACTUAL entry so aria-current and exemptions hold.
|
||||
await waitFor(() => {
|
||||
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||
})
|
||||
expect(within(columns()[0]!).getByText('Users')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === `${HOME}/.config`) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/.co` } })
|
||||
const row = rowButton(screen.getByRole('listitem'))
|
||||
fireEvent.mouseDown(row)
|
||||
fireEvent.click(row)
|
||||
// The failed selection re-hides the picked row; focus fell to body and
|
||||
// re-parks on the crumb edit zone.
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByText('.config')).toBeNull()
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
})
|
||||
|
||||
it('leaves focus on a surviving row when its pick fails', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === DOCS) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/do` } })
|
||||
const row = rowButton(screen.getByRole('listitem'))
|
||||
row.focus()
|
||||
fireEvent.mouseDown(row)
|
||||
fireEvent.click(row)
|
||||
// Documents survives the cleared selection (it is not hidden): the
|
||||
// user's focus on it is not yanked to the edit zone.
|
||||
await screen.findByRole('alert')
|
||||
expect(document.activeElement).toBe(row)
|
||||
})
|
||||
|
||||
it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
// The initial open lists home through the absent-path form; only the
|
||||
// parent leg names HOME explicitly.
|
||||
if (path === HOME) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'parent gone', details: { path } })
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The target listed fine; the failed parent leg neither blocks the
|
||||
// landing nor surfaces an error for a level nobody asked to see.
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
@@ -186,18 +620,221 @@ describe('DirectoryBrowser', () => {
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
expect(input.value).toBe(HOME)
|
||||
// The editor seeds with a trailing separator so typing continues into
|
||||
// child names.
|
||||
expect(input.value).toBe(`${HOME}/`)
|
||||
fireEvent.change(input, { target: { value: DOCS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
expect(columns()).toHaveLength(1)
|
||||
// Away from the root a navigation lands two-pane: the target selected
|
||||
// in its parent level, its own children on the right.
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
// The submitted navigation unmounted the focused input; focus parks on
|
||||
// the crumb edit zone that replaced it.
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const again = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(again, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(again, { key: 'Enter' })
|
||||
expect(b.listDirectory).toHaveBeenCalledTimes(2)
|
||||
// Initial home + the DOCS target leg + its parent leg; the blank draft
|
||||
// added none.
|
||||
expect(b.listDirectory).toHaveBeenCalledTimes(3)
|
||||
fireEvent.keyDown(again, { key: 'Escape' })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// Escape with focus in the input parks focus on the returning edit zone.
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
})
|
||||
|
||||
it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// The seeded empty segment leaves the level as-is: hidden stays hidden.
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
// Case-insensitive prefix narrows the rows.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/do` } })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
// 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).
|
||||
fireEvent.change(input, { target: { value: `${HOME}/zzz` } })
|
||||
expect(screen.queryByRole('listitem')).toBeNull()
|
||||
// A draft naming some other directory (or none) leaves the level whole.
|
||||
fireEvent.change(input, { target: { value: 'no-separator' } })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
})
|
||||
|
||||
it('filters the child pane in two-pane mode and follows the draft back up a level', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// The seed comes from the selection, so the draft tail addresses the
|
||||
// RIGHT pane (the selection's children).
|
||||
expect(input.value).toBe(`${DOCS}/`)
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
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()
|
||||
})
|
||||
|
||||
it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => {
|
||||
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}/do` } })
|
||||
// A blur while the document itself lost focus (window switch, dev-tools
|
||||
// focus) must not discard the draft: value and filter both survive.
|
||||
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false)
|
||||
fireEvent.focusOut(input)
|
||||
hasFocus.mockRestore()
|
||||
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
// A keyboard focus move that stays inside the dialog (Tab onto the
|
||||
// filtered row) keeps the draft too — the results stay reachable.
|
||||
fireEvent.focusOut(input, { relatedTarget: rowButton(screen.getByRole('listitem')) })
|
||||
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
|
||||
// Toggling show-hidden mid-edit suppresses focus steal: the draft and
|
||||
// its filter survive the toggle in both directions.
|
||||
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
|
||||
fireEvent.mouseDown(toggle)
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
// Focus landing outside the dialog cancels like Escape — even when the
|
||||
// departure happens from a row the user had Tabbed onto, not the input
|
||||
// (the observer lives on the card scope, not the input).
|
||||
fireEvent.focusOut(rowButton(screen.getByRole('listitem')), { relatedTarget: document.body })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// Outside editing the card-scope observer is inert.
|
||||
fireEvent.focusOut(screen.getByRole('button', { name: 'browser.showHidden' }))
|
||||
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Escape with focus on a filtered row collapses the editor, not the dialog', 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}/do` } })
|
||||
// Tab parked focus on the result row; Escape must still mean "leave
|
||||
// path editing", not "close the whole dialog".
|
||||
const row = rowButton(screen.getByRole('listitem'))
|
||||
row.focus()
|
||||
fireEvent.keyDown(row, { key: 'Escape' })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(b.onClose).not.toHaveBeenCalled()
|
||||
// Focus was already on a surviving row, so nothing re-parks it.
|
||||
expect(document.activeElement).toBe(row)
|
||||
// With no draft left, Escape falls through to the Modal and closes.
|
||||
fireEvent.keyDown(row, { key: 'Escape' })
|
||||
expect(b.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a picked dot-revealed hidden row stays visible as the selection', async () => {
|
||||
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}/.co` } })
|
||||
const row = rowButton(screen.getByRole('listitem'))
|
||||
expect(row.textContent).toBe('.config')
|
||||
fireEvent.mouseDown(row)
|
||||
fireEvent.click(row)
|
||||
// The pick cleared the draft (and with it the dot-reveal), but the
|
||||
// selection is exempt from the hidden filter: the anchor row survives.
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(within(columns()[0]!).getByText('.config')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('picking a filtered row adopts it and closes the path editor', async () => {
|
||||
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}/do` } })
|
||||
// The row suppresses focus steal on mousedown (no blur-cancel unmounts
|
||||
// the filtered rows mid-gesture), then the click both selects the row
|
||||
// and closes the editor.
|
||||
const row = rowButton(screen.getByRole('listitem'))
|
||||
fireEvent.mouseDown(row)
|
||||
fireEvent.click(row)
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// Focus parks on the picked row (the editor's input just unmounted and
|
||||
// the Modal has no focus trap to catch a fall to body).
|
||||
expect(document.activeElement).toBe(row)
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a right-pane pick while editing parks focus on the advanced selection', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
// The advance replaces BOTH panes (the picked button's own column
|
||||
// unmounts), so focus is re-parked on the selection's aria-current row
|
||||
// in the freshly rendered left pane rather than the clicked node.
|
||||
const row = rowButton(within(columns()[1]!).getByRole('listitem'))
|
||||
fireEvent.mouseDown(row)
|
||||
fireEvent.click(row)
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') })
|
||||
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
|
||||
})
|
||||
|
||||
it('seeds and filters with backslashes on a Windows-rooted listing', async () => {
|
||||
const ROOT = 'C:\\'
|
||||
const windowsListing: DirectoryListing = {
|
||||
path: ROOT,
|
||||
home: ROOT,
|
||||
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
|
||||
entries: [
|
||||
{ name: 'Program Files', path: `${ROOT}Program Files`, hidden: false },
|
||||
{ name: 'Users', path: `${ROOT}Users`, hidden: false },
|
||||
],
|
||||
truncated: false,
|
||||
}
|
||||
mount({ listDirectory: vi.fn(async () => windowsListing) })
|
||||
await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// The root already ends in its separator: no doubled backslash.
|
||||
expect(input.value).toBe(ROOT)
|
||||
fireEvent.change(input, { target: { value: `${ROOT}u` } })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Users')
|
||||
})
|
||||
|
||||
it('clicking away from the path editor cancels it back to the crumb view', async () => {
|
||||
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: '/somewhere/else' } })
|
||||
// Focus moving anywhere outside the editor abandons the draft like Escape.
|
||||
fireEvent.focusOut(input)
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// The crumb view is back and the abandoned draft was never navigated to.
|
||||
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
})
|
||||
|
||||
it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => {
|
||||
@@ -713,11 +1350,13 @@ describe('DirectoryBrowser', () => {
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' }))
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
// The newer jump lands two-pane: Documents selected at home, children right.
|
||||
await waitFor(() => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() })
|
||||
resolveSlow(listingFor(undefined))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
// The stale home listing did not replace the newer Documents level.
|
||||
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
||||
// The stale home listing did not replace the newer Documents landing.
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('names the create target by its path when the level reports no crumbs', async () => {
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 8ef8889c875f5b1d07c015ddef819591041c8d7f
|
||||
README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d
|
||||
README.md: 3749b238b56578ec68610bc13550760aa084bad6
|
||||
README.zh.md: 488da5129ec211c2a064156c22a9d0abf04d99be
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together.
|
||||
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself.
|
||||
|
||||
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[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 流程。
|
||||
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)。
|
||||
|
||||
|
||||
@@ -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: e715e4452ddb808f36e6b097eee0fda7b8d0bfb0
|
||||
README.zh.md: 05e7e10d7815c8f26bb90597b38b7c6b83a86dbc
|
||||
README.md: ace8c09e43dd8544a28d300f97b04610be78bc69
|
||||
README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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, and `port` reads the listening port (the OS-assigned value when `port` is 0). 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[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 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
|
||||
朴素的 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。注册顺序不承载任何面向请求的语义。
|
||||
|
||||
该包不了解任何 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。
|
||||
|
||||
|
||||
@@ -78,6 +78,11 @@ export class HttpServerService extends Service {
|
||||
return this.listenedPort
|
||||
}
|
||||
|
||||
/** The configured bind host (the loopback or all-interfaces literal). */
|
||||
get host(): Config['host'] {
|
||||
return this.config.host
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a named route. Duplicate (kind, path) throws — route patterns are
|
||||
* a composition-level contract, so a collision is a misconfiguration.
|
||||
|
||||
Reference in New Issue
Block a user