Merge remote-tracking branch 'origin/master' into worktree/fix-multi-select-custom-answer

# Conflicts:
#	apps/web/tests/snapshots/question-composer/answered.expected.md
#	apps/web/tests/snapshots/question-composer/session.jsonl
#	docs/core-data-structures/user-interaction.i18n.yaml
#	packages/client/ui-question/README.i18n.yaml
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/user-interaction/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-03 16:09:17 +08:00
2065 changed files with 168375 additions and 14240 deletions

View File

@@ -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: 00588ecaa8127b1f7c43c97479d2352ee1ce5c3e
README.zh.md: 0add0cb8196f5e81d58998bbe36fb3c5413340ee
README.md: ed04a3431e4c9114b3115119c1e69d378cec77ae
README.zh.md: e50a7f49cb8753f5b26dd27cb1d48a9cc18e9fd7

View File

@@ -12,21 +12,35 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`.
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message`, `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 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 an Agent-bound ordinary-session operation 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` reads its source from attached state or persistence inspection without acquiring an Agent, then 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 ordinary child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace, or the nearest workspace-owning ancestor when the source is a subagent. 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.
Generic Agent-bound session, command, and goal operations serve ordinary sessions only. They return `agent-busy` for a session-backed subagent instead of resuming or driving it; explicit-id `session.create` adoption and the attached-only queue controls enforce the same ownership boundary. Subagent conversation reads and continuation use the dedicated `subagent.*` domain, which retains catalog-mode and direct-parent authorization.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
Pending inbox input is a live control-plane contract, not session history. The gateway mirrors `InboxItem` occurrences from `agent/inbox/*` with their `queued` or `steering` placement and broadcasts authoritative `session/queue` snapshots on every change and reconnect. A steering occurrence remains in this projection until the corresponding durable `steering/message` has been published, preserving the Host's linear event order during the handoff. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and strict steer transfers its complete message into the current next-step window. A closed window returns `steer-unavailable` without changing the row. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached ordinary-session Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. The `session.list` summaries and `host/session-added` frames also carry the optional durable `origin: 'subagent'` classification so navigation can suppress duplicate child rows immediately and after reconnect; that bit is never continuation authority.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot without discarding the learned provider page size. Limit probes and stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier request signal cancels persistence listing, cold-summary collection, and every search call, including a limit or stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an `internal` business error so clients can retain metadata-only matches.
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
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 `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. `command.*` addresses an ordinary session's Agent and resumes a cold ordinary session when needed, while `skill.list` resolves the project root from the session header without touching the Agent registry. `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `subagent.*` domain addresses direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the complete durable one-shot and continuable catalog from `ctx.subagents.listChildren`, including each healthy row's origin-classified `hasChildren` hint, replaces corpus activity with the exact child Agent driver's running state, and includes an exact-live-parent hint; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` accepts only continuable addresses, requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
## Carrier layer (`/client` + root)
@@ -43,7 +57,8 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
- **Reserved seams stay out of `RpcMethodMap`** — `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.
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
- **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).
- **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. `session.history` is inspection-only, but an Agent-bound ordinary-session operation resumes a cold session and writes the pickup boundary. 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).

View File

@@ -12,21 +12,35 @@
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent智能体然后按追加来源的消息边界分页。`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`
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到某项绑定到 Agent 的普通会话操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方模型推理reasoning目标以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`
`session.fork` 会从已附加状态或持久化检查中读取源会话而不获取 Agent再将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`不会向较早位置裁剪。发布后的普通子会话会先继承源会话的种子历史、cwd、日志中最新的提供方模型推理reasoning目标及谱系再加入源 Workspace若源会话是 subagent则改为附加到最近拥有 Workspace 的祖先。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering中途引导不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent绝不恢复冷会话因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()`
绑定到 Agent 的通用会话、命令与目标操作只服务普通会话。对于由会话支撑的 subagent它们会返回 `agent-busy`,而不是恢复或驱动它;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件也会执行同一所有权边界。subagent 对话读取与继续执行使用专用的 `subagent.*` 领域,该领域保留目录 mode 与直接 parent 授权
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法
待处理的 inbox 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*``InboxItem` 入队项及其 `queued``steering` placement并在每次变更和重连时广播权威的 `session/queue` 快照。steering 入队项会一直保留在该投影中,直到对应的持久 `steering/message` 已发布,从而在交接期间保持 Host 的线性事件顺序。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,严格 steering 会把其完整消息转移到当前 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不改变该行。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的普通会话 Agent绝不恢复冷会话因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。`session.list` 摘要与 `host/session-added` 帧还会携带可选的持久化分类 `origin: 'subagent'`,使导航在实时创建与重连后都能隐藏重复的 child 行;该标记绝不是继续执行的权威依据。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering中途引导匹配项并持续消费该结果流直到获得至多 20 个可见会话snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始,但不会丢弃探测所得的提供方页面大小。上限探测与陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`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` 而不是做差分。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。`command.*` 寻址普通会话的 Agent,并在需要时恢复冷态普通会话;`skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision``settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable``credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list``ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,并把语料活动状态替换为确切 child Agent driver 的运行状态,同时提供确切 parent 是否存活的提示;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 只接受可继续地址,要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。
## 载体层(`/client` + 根路径)
@@ -43,7 +57,8 @@ 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()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`session.history` 只执行检查,但绑定到 Agent 的普通会话操作会恢复冷会话并写入这条拾起边界。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Noteagent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。

View File

@@ -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:^",
@@ -51,8 +52,11 @@
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
/**
* commands domain contract: the web catalog/dispatch face of the host command
* registry (`ctx.commands`). Both methods address one session's agent via
* `sessionId` — every served session has an Agent (Session+Agent are born
* together), so there is no agent-less surface on this wire.
* registry (`ctx.commands`). Both methods address an ordinary session's Agent
* via `sessionId`, resuming it when cold. Session-backed subagents reject with
* `agent-busy` and retain their dedicated continuation owner.
*/
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
@@ -27,7 +27,8 @@ export interface CommandDescriptor {
export interface CommandsApi {
/**
* Lists the addressed agent's effective command catalog (name-sorted,
* globals plus its scoped shadows).
* globals plus its scoped shadows). Session-backed subagents reject with
* `agent-busy`.
*/
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
@@ -42,6 +43,7 @@ export interface CommandsApi {
* pairing id, letting the issuing client correlate this acknowledgment
* with that flow node. The signal rides beside the request, never on the
* wire: the fetch carrier's request signal cancels the running handler.
* Session-backed subagents reject with `agent-busy` before dispatch.
*/
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>

View 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'>>>

View 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<{}>>
}

View File

@@ -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>>
/** Unified message envelope carried by transient queue frames. */
@@ -49,6 +54,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
sessionId: sessionIdSchema,
items: z.array(z.object({
id: inboxItemIdSchema,
placement: z.union([z.literal('queued'), z.literal('steering')]),
message: messageSchema,
})),
}),
@@ -60,12 +66,23 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
/** HostFrame union (payload slot of a host-stream ServerRequest). */
export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
z.object({
type: z.literal('host/session-added'),
sessionId: sessionIdSchema,
blank: z.boolean(),
parentSessionId: sessionIdSchema.optional(),
origin: z.literal('subagent').optional(),
cwd: z.string().optional(),
}),
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
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/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
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>

View File

@@ -32,10 +32,12 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/** One pending queued occurrence in an authoritative queue snapshot. */
/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
export interface QueuedInboxItem {
/** Agent-owned occurrence identity used by queue mutations. */
/** Agent-owned occurrence identity; queue mutations address only `queued` items. */
id: InboxItemId
/** Agent-resolved FIFO placement; clients render queued and steering items on different surfaces. */
placement: 'queued' | 'steering'
/** Complete pending message; it is not durable until the Agent claims it. */
message: Message
}
@@ -71,11 +73,12 @@ export type MuxFrame =
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
/**
* Complete transient queue state after every enqueue, mutation, claim, or
* Complete transient inbox state after every enqueue, mutation, claim, or
* discard. Pending work is not model-visible and therefore has no durable
* session event; the whole snapshot makes edit, deletion, cancel, and
* reconnect converge through one authoritative signal. Pending steering is
* outside this Web queue projection.
* reconnect converge through one authoritative signal. `session/queue`
* covers both resolved placements: queued items render
* in QueueDock, while pending steering renders at the conversation tail.
*/
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
/**
@@ -90,9 +93,9 @@ export type MuxFrame =
| { type: 'stream/error'; error: RpcError }
/**
* Host stream frames. session-added carries the lineage anchor, the project
* cwd, and the blank bit (the list-summary fields a client cannot wait for a
* refresh to learn); the frame fires at session/created, so blank is
* Host stream frames. session-added carries the lineage anchor, product
* origin, project cwd, and blank bit (the list-summary fields a client cannot
* wait for a refresh to learn); the frame fires at session/created, so blank is
* constantly true — clients flip it on the session's first
* `host/session-status(running:true)` (a blank session never runs), and a
* reconnecting client takes `session.list`'s summary.blank as authoritative.
@@ -101,19 +104,48 @@ export type MuxFrame =
* workspace mutation (create/attach/order change — the client upserts, while
* `workspace.list` provides the reconnect baseline); workspace-removed is the
* committed registration-deletion increment and never implies directory or
* session-log deletion.
* session-log deletion; archived-sessions-changed pushes the full registry
* archive set after every durable change (same full-snapshot posture as
* workspace-changed — `workspace.list` re-baselines it on reconnect).
*/
export type HostFrame =
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
| {
type: 'host/session-added'
sessionId: SessionId
blank: boolean
parentSessionId?: SessionId
origin?: 'subagent'
cwd?: string
}
| { type: 'host/session-removed'; sessionId: SessionId }
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
| { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] }
/**
* The command registry changed (`commands/change` passthrough). Pure
* invalidation signal, no payload: clients refetch `command.list` in the
* 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 }

View File

@@ -22,7 +22,11 @@ export interface GoalRef {
readonly revision: number
}
/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */
/**
* Goal-domain unary methods. Every mutation resolves an ordinary session's
* Agent and applies one CAS-guarded verb; session-backed subagents reject with
* `agent-busy`.
*/
export interface GoalsApi {
/** Create and arm a goal. */
create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>):

View File

@@ -9,19 +9,27 @@ import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { SkillsApi } from './skills.ts'
import type { SubagentsApi } from './subagents.ts'
import type { EventsApi } from './events.ts'
import type { GoalsApi } from './goals.ts'
import type { SettingsApi } from './settings.ts'
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. */
export interface ApiProxy {
sessions: SessionsApi
subagents: SubagentsApi
host: HostApi
workspace: WorkspaceApi
commands: CommandsApi
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>
}
@@ -29,14 +37,21 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, 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'
@@ -58,5 +73,11 @@ export { RpcId, transportError } from './rpc.ts'
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
// ---- Fixed session-search product bounds ----
export {
SESSION_SEARCH_RESULT_LIMIT,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
} from './session-search.ts'
// ---- Method registry and derived generics ----
export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'

View 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'>>>

View 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[] }>>
}

View File

@@ -10,6 +10,10 @@ 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 { SubagentsApi } from './subagents.ts'
import type { RpcResponse } from './rpc.ts'
/**
@@ -19,14 +23,19 @@ import type { RpcResponse } from './rpc.ts'
*/
export interface RpcMethodMap {
'session.list': SessionsApi['list']
'session.search': SessionsApi['search']
'session.create': SessionsApi['create']
'session.history': SessionsApi['history']
'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']
'subagent.list': SubagentsApi['list']
'subagent.history': SubagentsApi['history']
'subagent.prompt': SubagentsApi['prompt']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']
@@ -37,6 +46,7 @@ export interface RpcMethodMap {
'workspace.rename': WorkspaceApi['rename']
'workspace.delete': WorkspaceApi['delete']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
'workspace.archiveSession': WorkspaceApi['archiveSession']
'command.list': CommandsApi['list']
'command.execute': CommandsApi['execute']
'skill.list': SkillsApi['list']
@@ -46,6 +56,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). */

View File

@@ -48,9 +48,25 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({
parentSessionId: z.string(),
childSessionId: z.string(),
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
}) }),
z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -46,11 +46,42 @@ export interface RpcErrorDetailsMap {
'directory-picker-unavailable': { capability: string }
'agent-busy': { reason: string }
'queue-item-not-found': { itemId: InboxItemId }
'steer-unavailable': { itemId: InboxItemId }
/** A known slash command reported a usage/state error; the message is the command's own text. */
'command-error': {}
/** A leading-/ prompt named no registered command; the message names the token. */
'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 }
'subagent-parent-unavailable': { parentSessionId: SessionId }
'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
'subagent-catalog-diagnostic': {
parentSessionId: SessionId
childSessionId: SessionId
reason: 'corrupt' | 'unsupported' | 'unavailable'
}
'subagent-not-resumable': { childSessionId: SessionId }
'subagent-unauthorized': { childSessionId: SessionId }
'subagent-delivery-unavailable': { childSessionId: SessionId }
'internal': {}
}

View File

@@ -0,0 +1,22 @@
/** Maximum number of sessions returned by one sidebar search. */
export const SESSION_SEARCH_RESULT_LIMIT = 20
/** Maximum snippet length in Unicode code points. */
export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
/**
* Return the longest prefix containing at most `maximum` Unicode code points.
* @param value - text to bound.
* @param maximum - non-negative code-point limit.
* @returns `value` unchanged when it fits, otherwise a code-point-safe prefix.
*/
export function truncateUnicodeCodePoints(value: string, maximum: number): string {
let count = 0
let end = 0
for (const codePoint of value) {
if (count === maximum) return value.slice(0, end)
count++
end += codePoint.length
}
return value
}

View File

@@ -12,10 +12,15 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
import {
SESSION_SEARCH_RESULT_LIMIT,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
truncateUnicodeCodePoints,
} from './session-search.ts'
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
@@ -48,6 +53,7 @@ export const sessionSummarySchema = z.object({
running: z.boolean(),
blank: z.boolean(),
parentSessionId: sessionIdSchema.optional(),
origin: z.literal('subagent').optional(),
cwd: z.string().optional(),
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
}) as unknown as z.ZodType<Wire<SessionSummary>>
@@ -62,6 +68,33 @@ export const sessionListValueSchema: z.ZodType<Wire<ResponseValue<'session.list'
items: z.array(sessionSummarySchema),
})
/** Fixed wire bound for one interactive sidebar query. */
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
/** session.search request payload. */
export const sessionSearchRequestSchema = z.object({
query: z.string().trim().min(1).max(SESSION_SEARCH_QUERY_MAX_CHARS)
.refine(query => !query.includes('\0'), { message: 'search query must not contain NUL' }),
}) satisfies z.ZodType<Wire<RequestPayload<'session.search'>>>
/** One session.search result. */
export const sessionSearchItemSchema = z.object({
sessionId: sessionIdSchema,
snippet: z.string().refine(
snippet => truncateUnicodeCodePoints(
snippet,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
) === snippet,
{ message: `search snippet must contain at most ${SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS} Unicode code points` },
),
}) satisfies z.ZodType<Wire<SessionSearchItem>>
/** session.search response value. */
export const sessionSearchValueSchema = z.object({
items: z.array(sessionSearchItemSchema).max(SESSION_SEARCH_RESULT_LIMIT),
hasMore: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.search'>>>
/** session.create request payload (at most one of workspaceId / cwd). */
export const sessionCreateRequestSchema = z.object({
workspaceId: workspaceIdSchema.optional(),
@@ -89,6 +122,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,
@@ -151,10 +195,10 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
]) as unknown as z.ZodType<ToolEventView>
/** One session.history item: the session event plus its optional host-computed tool view. */
export const historyEntrySchema = z.object({
export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({
event: sessionEventSchema,
view: toolEventViewSchema.optional(),
}) satisfies z.ZodType<Wire<HistoryEntry>>
}) as unknown as z.ZodType<Wire<HistoryEntry>>
/**
* Projection baseline passthrough: `values` stays a wide record — each value
@@ -168,11 +212,11 @@ export const sessionProjectionsBlockSchema = z.object({
}) as unknown as z.ZodType<SessionProjectionsBlock>
/** session.history response value (projections rides the tail page only). */
export const sessionHistoryValueSchema = z.object({
export const sessionHistoryValueSchema: z.ZodType<Wire<ResponseValue<'session.history'>>> = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
projections: sessionProjectionsBlockSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
})
/** session.models request payload. */
export const sessionModelsRequestSchema = z.object({
@@ -225,6 +269,7 @@ export const sessionUpdateQueueRequestSchema = z.object({
action: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }),
z.object({ kind: z.literal('remove') }),
z.object({ kind: z.literal('steer') }),
]),
}) as unknown as z.ZodType<RequestPayload<'session.updateQueue'>>

View File

@@ -129,6 +129,7 @@ export interface SessionModels {
export type QueueAction =
| { kind: 'edit'; content: ContentBlock[] }
| { kind: 'remove' }
| { kind: 'steer' }
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
@@ -153,6 +154,8 @@ export interface SessionSummary {
blank: boolean
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
parentSessionId?: SessionId
/** Coarse durable origin used by navigation surfaces; never proves resumability. */
origin?: 'subagent'
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
cwd?: string
/**
@@ -169,11 +172,28 @@ export interface SessionSummary {
projections?: SessionProjectionsBlock
}
/** One session-content search result; display metadata stays owned by `session.list`. */
export interface SessionSearchItem {
sessionId: SessionId
/** Plain-text excerpt around the strongest matching visible message. */
snippet: string
}
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
export interface SessionsApi {
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
/**
* Searches the current user/assistant/steering message surface across
* sessions visible to `list`. Results contain at most 20 sessions and carry
* no continuation cursor; `hasMore` asks the client to refine the query.
*/
search(
request: RpcRequest<{ query: string }>,
signal: AbortSignal,
): Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>>
/**
* Creates a real session and its idle agent. At most one of `workspaceId` /
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
@@ -186,9 +206,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
@@ -198,17 +220,22 @@ export interface SessionsApi {
* the client needs a fresh baseline already pulls the tail page, and
* loadOlder (the only beforeSeq path) is the only path that never needs one.
* A deployment without the registry serves histories without the block.
* Reading history uses an attached Session or persistence inspection and
* never resumes or publishes an Agent.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
/**
* Reads a fresh advisory model directory for an ordinary session. Provider
* lookups run independently; subagents reject with `agent-busy`.
*/
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
/**
* Selects the complete target for this session. Exact model metadata
* validates an optional reasoning effort, while catalog membership remains
* advisory.
* advisory. Session-backed subagents reject with `agent-busy`.
*/
selectModel(request: RpcRequest<{
sessionId: SessionId
@@ -224,6 +251,7 @@ export interface SessionsApi {
* normalized accepted title and the title event's seq return so the caller
* can settle its projection cell without waiting for the push frame. A
* title that normalizes to empty fails with `title-invalid`.
* Session-backed subagents reject with `agent-busy`.
*/
rename(request: RpcRequest<{ sessionId: SessionId; title: string }>):
Promise<RpcResponse<{ title: string; seq: number }>>
@@ -236,16 +264,39 @@ 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 and `parentSessionId` lineage; the seed prefix carries the source
* title. Reading the source uses attached state or persistence inspection
* without acquiring an Agent. Workspace attachment follows the source
* directly, or the nearest workspace-owning ancestor when the source is a
* subagent.
*/
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/**
* Edits or removes one pending queued occurrence.
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
* Session-backed subagents reject with `agent-busy`.
*/
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
Promise<RpcResponse<{ accepted: true }>>
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
/**
* Stops an ordinary session's active turn, preserving pending inbox work
* that resumes in FIFO order after cancellation settles. Session-backed
* subagents reject with `agent-busy`.
*/
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
}

View 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'>>>

View 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>>
}

View File

@@ -0,0 +1,77 @@
/** Zod schemas for the browser-safe subagent domain. */
import { z } from 'zod'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import {
contentBlockSchema, historyEntrySchema, sessionIdSchema, sessionProjectionsBlockSchema,
} from './sessions.schema.ts'
import type { SubagentListEntry } from './subagents.ts'
/** Healthy and diagnostic durable catalog rows. */
export const subagentListEntrySchema = z.union([
z.object({
kind: z.literal('child'),
id: sessionIdSchema,
mode: z.literal('one-shot'),
activity: z.union([z.literal('running'), z.literal('inactive')]),
hasChildren: z.boolean(),
label: z.string().optional(),
}),
z.object({
kind: z.literal('child'),
id: sessionIdSchema,
mode: z.literal('continuable'),
activity: z.union([z.literal('running'), z.literal('inactive')]),
hasChildren: z.boolean(),
label: z.string(),
}),
z.object({
kind: z.literal('diagnostic'),
id: sessionIdSchema,
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
}),
]) satisfies z.ZodType<Wire<SubagentListEntry>>
/** subagent.list request payload. */
export const subagentListRequestSchema = z.object({
parentSessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.list'>>>
/** subagent.list response value. */
export const subagentListValueSchema = z.object({
entries: z.array(subagentListEntrySchema),
parentAvailable: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.list'>>>
/** subagent.history request payload. */
export const subagentHistoryRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.union([z.literal('one-shot'), z.literal('continuable')]),
beforeSeq: z.number().int().nonnegative().optional(),
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.history'>>>
/** subagent.history response value. */
export const subagentHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
projections: sessionProjectionsBlockSchema.optional(),
}) as unknown as z.ZodType<Wire<ResponseValue<'subagent.history'>>>
/** subagent.prompt request payload. */
export const subagentPromptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.literal('continuable'),
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
/** subagent.prompt response value. */
export const subagentPromptValueSchema = z.object({
messageId: messageIdSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.prompt'>>>

View File

@@ -0,0 +1,96 @@
/**
* Browser-safe subagent domain contract. Persisted transcript reads never
* activate an Agent, while continuable prompts route through the exact live
* direct parent into the child's Agent inbox.
*/
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcRequest, RpcResponse } from './rpc.ts'
import type { HistoryEntry, SessionProjectionsBlock } from './sessions.ts'
/** Complete durable direct-child catalog row. */
export type SubagentListEntry =
| {
kind: 'child'
id: SessionId
/** Whether the child Agent driver is running at the Host sampling boundary. */
activity: 'running' | 'inactive'
/** Whether a direct descendant has durable `origin: 'subagent'`. */
hasChildren: boolean
} & (
| {
mode: 'one-shot'
label?: string
}
| {
mode: 'continuable'
label: string
}
)
| {
kind: 'diagnostic'
id: SessionId
reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/** Inbox identity returned once the continuation accepts one human message. */
export interface SubagentPromptReceipt {
messageId: MessageId
}
/** Durable parent/child address that selects subagent transport in the client. */
export type SubagentAddress =
& {
parentSessionId: SessionId
childSessionId: SessionId
}
& (
| { mode: 'one-shot' }
| { mode: 'continuable' }
)
/** Complete direct-child catalog plus the delivery-time parent availability hint. */
export interface SubagentCatalog {
entries: SubagentListEntry[]
parentAvailable: boolean
}
/** Subagent-domain unary methods. */
export interface SubagentsApi {
/**
* Lists direct session-backed children without loading either side. Parent
* availability is a hint; continuable prompt performs the authoritative
* check.
*/
list(
request: RpcRequest<{ parentSessionId: SessionId }>,
signal?: AbortSignal,
): Promise<RpcResponse<SubagentCatalog>>
/**
* Reads one healthy catalog child's persisted raw log with ordinary
* message-aligned pagination and render intents, without Agent activation.
*/
history(
request: RpcRequest<SubagentAddress & { beforeSeq?: number; maxMessages?: number }>,
signal?: AbortSignal,
): Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: SessionProjectionsBlock
}>>
/**
* Delivers human content to a continuable child through the exact live
* parent's continuation owner. Success identifies the message accepted by
* the child's FIFO inbox; later execution is independent of this request.
*/
prompt(
request: RpcRequest<
Extract<SubagentAddress, { mode: 'continuable' }> & { content: ContentBlock[] }
>,
signal: AbortSignal,
): Promise<RpcResponse<SubagentPromptReceipt>>
}

View File

@@ -28,6 +28,7 @@ export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType<Wire<
/** workspace.list response value. */
export const workspaceListValueSchema = z.object({
items: z.array(workspaceViewSchema),
archivedSessionIds: z.array(sessionIdSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
@@ -80,3 +81,13 @@ export const workspaceInsertSessionBeforeRequestSchema = z.object({
export const workspaceInsertSessionBeforeValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>
/** workspace.archiveSession request payload. */
export const workspaceArchiveSessionRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.archiveSession'>>>
/** workspace.archiveSession response value: the full updated archive set. */
export const workspaceArchiveSessionValueSchema = z.object({
archivedSessionIds: z.array(sessionIdSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.archiveSession'>>>

View File

@@ -22,7 +22,7 @@ export interface WorkspaceView {
workspaceId: WorkspaceId
/** Canonical directory path (host-side realpath canon). */
path: string
/** Unique display title (defaults to the path basename at create). */
/** Display title (defaults to the path basename at create). */
title: string
/**
* Sessions accounted under this workspace, in manually owned order
@@ -37,8 +37,13 @@ export interface WorkspaceView {
/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */
export interface WorkspaceApi {
/** Lists all workspaces in the registry's durable display order. */
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[] }>>
/**
* Lists all workspaces in the registry's durable display order, plus the
* registry-global archive set (the reconnect baseline of
* `host/archived-sessions-changed`). Archived sessions stay in their
* workspace's `sessionIds` account; grouping surfaces hide them.
*/
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[]; archivedSessionIds: SessionId[] }>>
/**
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
@@ -48,8 +53,8 @@ export interface WorkspaceApi {
* root before registering. Either spelling resolving to a directory already
* owned by a workspace returns that workspace (`created: false`) for the
* existing-folder spelling. Create-by-name rejects an existing title with
* `workspace-name-conflict`; a new path whose basename duplicates another
* Workspace title is rejected by the registry with the same code.
* `workspace-name-conflict`; path adoption allows distinct canonical paths
* whose basenames produce the same display title.
* A new name-created workspace uses `name` as both directory name and title;
* a path-created workspace uses the registry's basename title default.
*/
@@ -86,4 +91,15 @@ export interface WorkspaceApi {
sessionId: SessionId
beforeSessionId?: SessionId
}>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
/**
* Adds one session to the registry-global archive set: the session
* disappears from every grouping surface but keeps its session log and its
* workspace accounting slot (a future unarchive restores its position).
* Idempotent for an already archived id. A session neither live nor in
* session persistence fails with `session-not-found`. Returns the full
* updated set (same snapshot the changed frame carries).
*/
archiveSession(request: RpcRequest<{ sessionId: SessionId }>):
Promise<RpcResponse<{ archivedSessionIds: SessionId[] }>>
}

View File

@@ -20,15 +20,18 @@ import {
import {
sessionCancelValueSchema,
sessionCreateValueSchema,
sessionForkValueSchema,
sessionHistoryValueSchema,
sessionListValueSchema,
sessionModelsValueSchema,
sessionPromptValueSchema,
sessionRenameValueSchema,
sessionSearchValueSchema,
sessionSelectModelValueSchema,
sessionUpdateQueueValueSchema,
} from '../api/sessions.schema.ts'
import {
workspaceArchiveSessionValueSchema,
workspaceCreateValueSchema,
workspaceDeleteValueSchema,
workspaceInsertSessionBeforeValueSchema,
@@ -45,14 +48,27 @@ 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'
import {
subagentHistoryValueSchema,
subagentListValueSchema,
subagentPromptValueSchema,
} from '../api/subagents.schema.ts'
/**
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
* methods take the business payload directly — the carrier mints the rpcId and wraps the
* envelope. Business code needing the call's rpcId reads it from the RpcResponse echo.
* Unary methods and respond accept an optional external AbortSignal as the last parameter
* (merged with the instance timeout via AbortSignal.any; same "signal rides beside the
* request, never on the wire" discipline as the stream signatures).
* Unary methods and respond accept an optional external AbortSignal as the last parameter.
* Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
* carry only that external signal. In both cases the signal rides beside the request, never
* on the wire, like the stream signatures.
* Stream methods accept an optional onOpen callback: it fires once the SSE transport is
* readable (response headers received, before any frame) — the "stream established" signal
* connection controllers need for the readiness handshake. Generators are lazy, so the
@@ -64,15 +80,22 @@ import {
export interface IApiClient {
sessions: {
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.search'>>>
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
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'>>>
}
subagents: {
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
@@ -86,6 +109,7 @@ export interface IApiClient {
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>>
}
commands: {
list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.list'>>>
@@ -106,6 +130,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>
}
@@ -116,14 +155,19 @@ export interface IApiClient {
*/
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
'session.list': sessionListValueSchema,
'session.search': sessionSearchValueSchema,
'session.create': sessionCreateValueSchema,
'session.history': sessionHistoryValueSchema,
'session.models': sessionModelsValueSchema,
'session.selectModel': sessionSelectModelValueSchema,
'session.rename': sessionRenameValueSchema,
'session.fork': sessionForkValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
'session.cancel': sessionCancelValueSchema,
'subagent.list': subagentListValueSchema,
'subagent.history': subagentHistoryValueSchema,
'subagent.prompt': subagentPromptValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
@@ -134,6 +178,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'workspace.rename': workspaceRenameValueSchema,
'workspace.delete': workspaceDeleteValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
'workspace.archiveSession': workspaceArchiveSessionValueSchema,
'command.list': commandListValueSchema,
'command.execute': commandExecuteValueSchema,
'skill.list': skillListValueSchema,
@@ -143,11 +188,23 @@ 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). */
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
const DEFAULT_TIMEOUT_MS = 30_000
/** Whether a unary call uses the transport health deadline or only caller/connection cancellation. */
type UnaryTimeoutPolicy = 'default' | 'caller-signal-only'
/** URL base for in-process handler injection (fake authority, opencode precedent). */
const INTERNAL_BASE = 'http://dsh.internal'
@@ -165,7 +222,7 @@ export abstract class AbstractApiClient implements IApiClient {
private flushScheduled = false
private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
/** @param timeoutMs - unary timeout; streams never time out (long-lived by nature). */
/** @param timeoutMs - timeout for bounded unary calls; user-paced calls and streams do not use it. */
constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
/** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
@@ -220,15 +277,15 @@ export abstract class AbstractApiClient implements IApiClient {
/**
* Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
* timeout merged with the caller's optional external signal, non-2xx → transport throw.
* optional default timeout merged with the caller's external signal, non-2xx → transport throw.
*/
private async postJson(
path: string,
body: ClientRequest | ClientResponse,
signal: AbortSignal | undefined,
useDefaultTimeout = true,
timeoutPolicy: UnaryTimeoutPolicy = 'default',
): Promise<Response> {
const requestSignal = useDefaultTimeout
const requestSignal = timeoutPolicy === 'default'
? signal === undefined
? AbortSignal.timeout(this.timeoutMs)
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
@@ -252,11 +309,11 @@ export abstract class AbstractApiClient implements IApiClient {
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
useDefaultTimeout = true,
timeoutPolicy: UnaryTimeoutPolicy = 'default',
): Promise<RpcResponse<ResponseValue<K>>> {
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
this.onEnvelope(message)
const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout)
const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy)
const full = serverResponseSchema.parse(await response.json())
this.onEnvelope(full)
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
@@ -329,21 +386,31 @@ export abstract class AbstractApiClient implements IApiClient {
readonly sessions: IApiClient['sessions'] = {
list: (payload, signal) => this.callUnary('session.list', payload, signal),
search: (payload, signal) => this.callUnary('session.search', payload, signal),
create: (payload, signal) => this.callUnary('session.create', payload, signal),
history: (payload, signal) => this.callUnary('session.history', payload, signal),
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),
}
readonly subagents: IApiClient['subagents'] = {
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
}
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
// A native system dialog is user-paced and may legitimately stay open
// longer than the normal unary deadline. Caller/connection aborts remain.
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
pickDirectory: (payload, signal) => this.callUnary(
'host.pickDirectory', payload, signal, 'caller-signal-only',
),
listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal),
createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal),
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
@@ -355,11 +422,16 @@ export abstract class AbstractApiClient implements IApiClient {
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal),
}
readonly commands: IApiClient['commands'] = {
list: (payload, signal) => this.callUnary('command.list', payload, signal),
execute: (payload, signal) => this.callUnary('command.execute', payload, signal),
// Command handlers are user-driven operations and may legitimately exceed
// the transport health deadline. Caller/connection aborts remain.
execute: (payload, signal) => this.callUnary(
'command.execute', payload, signal, 'caller-signal-only',
),
}
readonly skills: IApiClient['skills'] = {
@@ -375,6 +447,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),

View File

@@ -17,11 +17,13 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
import {
sessionCancelRequestSchema,
sessionCreateRequestSchema,
sessionForkRequestSchema,
sessionHistoryRequestSchema,
sessionListRequestSchema,
sessionModelsRequestSchema,
sessionPromptRequestSchema,
sessionRenameRequestSchema,
sessionSearchRequestSchema,
sessionSelectModelRequestSchema,
sessionUpdateQueueRequestSchema,
} from '../api/sessions.schema.ts'
@@ -31,6 +33,7 @@ import {
hostPickDirectoryRequestSchema,
} from '../api/host.schema.ts'
import {
workspaceArchiveSessionRequestSchema,
workspaceCreateRequestSchema,
workspaceDeleteRequestSchema,
workspaceInsertSessionBeforeRequestSchema,
@@ -47,6 +50,18 @@ 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'
import {
subagentHistoryRequestSchema,
subagentListRequestSchema,
subagentPromptRequestSchema,
} from '../api/subagents.schema.ts'
/**
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
@@ -55,7 +70,8 @@ import {
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
* documented on Wire); the dispatch point carries the one Wire→exact cast.
* Every invoke receives the carrier Request's signal; methods whose contract
* declares a signal parameter (command.execute) forward it, the rest ignore it.
* declares a signal parameter (session.search and command.execute) forward it,
* the rest ignore it.
*/
type UnaryRoutes = {
[K in keyof RpcMethodMap]: {
@@ -66,14 +82,19 @@ type UnaryRoutes = {
const UNARY_ROUTES: UnaryRoutes = {
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
'session.search': { schema: sessionSearchRequestSchema, invoke: (api, r, signal) => api.sessions.search(r, signal) },
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
'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) },
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
@@ -84,6 +105,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) },
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
@@ -93,6 +115,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). */

View File

@@ -45,7 +45,10 @@ export interface Config {
* project directory and the fallback parent for name-created Workspaces.
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = ['agents', 'directoryPicker', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
static inject = [
'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'tools', 'userInteraction', 'workspace',
]
static Config: z<Config> = z.object({
provider: z.string().required(),
@@ -54,11 +57,15 @@ export class ApiProxyService extends Service implements ApiProxy {
})
readonly sessions: ApiProxy['sessions']
readonly subagents: ApiProxy['subagents']
readonly workspace: ApiProxy['workspace']
readonly host: ApiProxy['host']
readonly commands: ApiProxy['commands']
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']
@@ -72,11 +79,15 @@ export class ApiProxyService extends Service implements ApiProxy {
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
})
this.sessions = api.sessions
this.subagents = api.subagents
this.workspace = api.workspace
this.host = api.host
this.commands = api.commands
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.

View File

@@ -1,21 +1,19 @@
/**
* Cold-session and degenerate-composition paths of the host ApiProxy:
* sessions.list merging persisted-but-unattached summaries (mtime source,
* createdAt fallbacks, lineage projection), the resume error split when
* the composition has no persistence gate and no agent factory, and the
* agent-busy mapping of a synchronous prompt rejection.
* metadata-only listing, Agent-free history reads, subagent ownership
* isolation, and prompt failure mapping.
*/
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
@@ -42,7 +40,7 @@ describe('sessions.list cold merge', () => {
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
const metas = [
header('session-a', 1000),
header('session-b', 2000, { parentSession: sid('session-parent') }),
header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('session-c', 1500),
]
// Structural fake of the persistence face list() consumes: list + locate.
@@ -74,6 +72,7 @@ describe('sessions.list cold merge', () => {
expect(a?.parentSessionId).toBeUndefined()
expect(b?.updatedAt).toBe(2000)
expect(b?.parentSessionId).toBe('session-parent')
expect(b?.origin).toBe('subagent')
expect(c?.updatedAt).toBe(1500)
})
})
@@ -114,8 +113,160 @@ describe('attached updatedAt excludes end-seed', () => {
})
})
describe('subagent ownership fence', () => {
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('session-child')
const meta = header('session-child', 1000, {
parentSession: sid('session-parent'),
seedLength: 0,
})
const events = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } },
surfaceOp: 'append',
},
{
type: 'subagent/descriptor',
seq: 2,
time: 3,
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
},
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
const inspect = vi.fn(() => Promise.resolve({ meta, events }))
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect,
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const history = await api.sessions.history(request({ sessionId }))
expect(history.result.ok).toBe(true)
if (history.result.ok) {
expect(history.result.value.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type))
}
expect(ctx.agents.get(sessionId)).toBeUndefined()
const prompt = await api.sessions.prompt(request({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'follow up' }],
}))
expect(prompt.result.ok).toBe(false)
if (!prompt.result.ok) {
expect(prompt.result.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
}
const create = await api.sessions.create(request({ sessionId, cwd: '/proj' }))
expect(create.result.ok).toBe(false)
if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
expect(resume).not.toHaveBeenCalled()
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(inspect).toHaveBeenCalledTimes(3)
})
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
ctx.agents.register(parent)
const originSession = ctx.sessions.create(sid('session-origin-child'), {
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
})
const cancel = vi.fn()
const updateInbox = vi.fn(() => 'applied' as const)
const originChild = {
id: originSession.id,
session: originSession,
status: 'idle',
ctx,
cancel,
updateInbox,
} as unknown as Agent
ctx.agents.register(originChild)
const startingSession = ctx.sessions.create(sid('session-starting-child'), {
meta: { cwd: '/proj', parentSession: parent.id },
})
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
ctx.agents.enter(startingChild, parent)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
expect(stopped.result.ok).toBe(false)
if (!stopped.result.ok) expect(stopped.result.error.code).toBe('agent-busy')
expect(cancel).not.toHaveBeenCalled()
const queued = await api.sessions.updateQueue(request({
sessionId: originChild.id,
itemId: InboxItemId('queued-item'),
action: { kind: 'remove' },
}))
expect(queued.result.ok).toBe(false)
if (!queued.result.ok) expect(queued.result.error.code).toBe('agent-busy')
expect(updateInbox).not.toHaveBeenCalled()
const models = await api.sessions.models(request({ sessionId: startingChild.id }))
expect(models.result.ok).toBe(false)
if (!models.result.ok) expect(models.result.error.code).toBe('agent-busy')
const create = await api.sessions.create(request({ sessionId: originChild.id, cwd: '/proj' }))
expect(create.result.ok).toBe(false)
if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
const history = await api.sessions.history(request({ sessionId: originChild.id }))
expect(history.result.ok).toBe(true)
expect(ctx.agents.get(originChild.id)).toBe(originChild)
})
it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const session = ctx.sessions.create(sid('session-ordinary-fork'), {
seed: [{
type: 'subagent/descriptor',
seq: 0,
time: 1,
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' },
}],
meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 },
})
const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
ctx.agents.register(agent)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.prompt(request({
sessionId: agent.id,
mode: 'queue',
content: [{ type: 'text', text: 'ordinary work' }],
}))
expect(response.result.ok).toBe(true)
expect(followup).toHaveBeenCalledOnce()
})
})
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
it('list skips the cold merge and history reports missing persistence as internal', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
@@ -126,15 +277,32 @@ describe('degenerate composition (no persistence, no factory)', () => {
expect(listed.result.ok).toBe(true)
if (listed.result.ok) expect(listed.result.value.items).toEqual([])
// No persistence → the servable gate passes silently; the factory-less
// registry then rejects resume, which is NOT a SessionNotFound.
// No persistence means cold history cannot inspect a transcript.
const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) {
expect(response.result.error.code).toBe('internal')
expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
expect(response.result.error.message).toMatch(/history unavailable for session "session-ghost"/)
}
})
it('maps a persistence catalog miss to session-not-found without inspection', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const inspect = vi.fn()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([]),
inspect,
} as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
expect(inspect).not.toHaveBeenCalled()
})
})
describe('sessions.prompt synchronous rejection', () => {
@@ -170,4 +338,43 @@ describe('sessions.prompt synchronous rejection', () => {
}
}
})
it('classifies a raced cold-resume ID collision as agent-busy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
locate: () => undefined,
} as never)
// The raced winner: a live parent-owned subagent publishes the identity
// while the generic cold resume is in flight, so the resume collides.
const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
ctx.agents.register(parent)
const childSession = ctx.sessions.create(sessionId, {
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
})
const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
// The parent's `enter()` wins the identity between the pre-resume
// re-check and publication; the generic resume then collides.
ctx.agents.register(child)
throw new Error('session id already published')
})
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false)
if (!models.result.ok) {
expect(models.result.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
}
})
})

View File

@@ -99,6 +99,17 @@ describe('command.list', () => {
expect(error.code).toBe('internal')
expect(error.message).toContain('command registry')
})
it('does not route a live subagent through the generic command domain', async () => {
const ctx = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj', origin: 'subagent' } })
const agent = { id: session.id, session, status: 'idle', ctx } as Agent
ctx.agents.register(agent)
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.commands.list(request({ sessionId: agent.id })))
expect(error).toMatchObject({ code: 'agent-busy' })
})
})
describe('command.execute', () => {
@@ -142,7 +153,7 @@ describe('command.execute', () => {
const api = createApiProxy(ctx, DEFAULTS)
const missing = expectErr(await api.commands.execute(
request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal))
expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
expect(missing.code).toBe('internal') // Cold Agent-bound access fails loud when persistence is absent.
const bare = await harness({ commands: false })
const bareApi = createApiProxy(bare, DEFAULTS)
@@ -280,13 +291,14 @@ function inboxItem(id: string, message: UserMessage, placement: InboxPlacement):
}
describe('session.updateQueue', () => {
it('routes an addressable action and reports a lost claim race', async () => {
it('routes addressable actions and reports strict steer races', async () => {
const ctx = await harness()
const agent = stubAgent(ctx)
const seen: unknown[] = []
agent.updateInbox = (id, action) => {
seen.push({ id, action })
return id === InboxItemId('present') ? 'applied' : 'not-found'
if (id === InboxItemId('present')) return 'applied'
return id === InboxItemId('closed') ? 'steer-unavailable' : 'not-found'
}
const api = createApiProxy(ctx, DEFAULTS)
@@ -308,9 +320,22 @@ describe('session.updateQueue', () => {
},
})
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
const closed = await api.sessions.updateQueue({
rpcId: RpcId('q-closed'),
payload: {
sessionId: agent.id,
itemId: InboxItemId('closed'),
action: { kind: 'steer' },
},
})
expect(expectErr(closed)).toMatchObject({
code: 'steer-unavailable',
details: { itemId: 'closed' },
})
expect(seen).toEqual([
{ id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } },
{ id: 'claimed', action: { kind: 'remove' } },
{ id: 'closed', action: { kind: 'steer' } },
])
})
@@ -354,7 +379,7 @@ describe('session/queue frames', () => {
const liveFrames = (await collected).filter(frame => frame.type === 'session/queue')
expect(liveFrames.map(frame => frame.items)).toEqual([
[{ id: edited.id, message: edited.message }],
[{ id: edited.id, placement: edited.placement, message: edited.message }],
])
const replay = new AbortController()
const replayFrames = await collect<MuxFrame>(
@@ -362,14 +387,41 @@ describe('session/queue frames', () => {
expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames)
})
it('expires unmatched mutations after the synchronous re-entry window', async () => {
const ctx = await harness()
const agent = stubAgent(ctx)
const api = createApiProxy(ctx, DEFAULTS)
const original = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'original'), 'queued')
const staleEdit = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'stale edit'), 'queued')
const staleTerminal = inboxItem('i-stale-terminal', inboxMessage('m-stale-terminal', 'keep me'), 'queued')
ctx.emit('agent/inbox/update', agent, staleEdit)
ctx.emit('agent/inbox/discard', agent, [staleTerminal])
await Promise.resolve()
ctx.emit('agent/inbox/enqueue', agent, original)
ctx.emit('agent/inbox/enqueue', agent, staleTerminal)
const replay = new AbortController()
const frames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-expired-unseen'), payload: {} }, replay.signal), 2, replay)
expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([{
type: 'session/queue',
sessionId: agent.id,
items: [
{ id: original.id, placement: original.placement, message: original.message },
{ id: staleTerminal.id, placement: staleTerminal.placement, message: staleTerminal.message },
],
}])
})
it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const live = new AbortController()
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
// subscribed baseline + one queued snapshot; pending steering stays off this wire.
const liveCollected = collect<MuxFrame>(liveStream, 2, live)
// subscribed baseline + one snapshot per accepted inbox occurrence.
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued')
const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering')
@@ -381,7 +433,15 @@ describe('session/queue frames', () => {
{
type: 'session/queue',
sessionId: agent.id,
items: [{ id: queued.id, message: queued.message }],
items: [{ id: queued.id, placement: 'queued', message: queued.message }],
},
{
type: 'session/queue',
sessionId: agent.id,
items: [
{ id: queued.id, placement: 'queued', message: queued.message },
{ id: steering.id, placement: 'steering', message: steering.message },
],
},
])
@@ -389,7 +449,88 @@ describe('session/queue frames', () => {
const replay = new AbortController()
const replayFrames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay)
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]])
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]])
})
it('publishes the durable steering event before retiring its transient row', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const abort = new AbortController()
const collected = collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-steering-order'), payload: {} }, abort.signal), 5, abort)
const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering')
agent.session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
ctx.emit('agent/inbox/enqueue', agent, steering)
ctx.emit('agent/inbox/dequeue', agent, steering)
agent.session.append('steering/message', {
turn: 1,
message: steering.message,
}, { surfaceOp: 'append' })
const frames = await collected
expect(frames.map(frame => frame.type)).toEqual([
'session/subscribed',
'session/event',
'session/queue',
'session/event',
'session/queue',
])
expect(frames[2]).toMatchObject({
type: 'session/queue',
items: [{ id: steering.id, placement: 'steering' }],
})
expect(frames[3]).toMatchObject({
type: 'session/event',
event: { type: 'steering/message', data: { message: { id: steering.message.id } } },
})
expect(frames[4]).toMatchObject({ type: 'session/queue', items: [] })
})
it('retains claimed steering in re-entrant snapshots until its durable event', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering')
const queued = inboxItem('i-reentrant', inboxMessage('m-reentrant', 'later'), 'queued')
ctx.on('agent/inbox/dequeue', (subject, item) => {
if (subject === agent && item.id === steering.id) ctx.emit('agent/inbox/enqueue', agent, queued)
})
const abort = new AbortController()
const collected = collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-steering-reentrant-order'), payload: {} }, abort.signal), 6, abort)
agent.session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
ctx.emit('agent/inbox/enqueue', agent, steering)
ctx.emit('agent/inbox/dequeue', agent, steering)
agent.session.append('steering/message', {
turn: 1,
message: steering.message,
}, { surfaceOp: 'append' })
const frames = await collected
expect(frames[3]).toMatchObject({
type: 'session/queue',
items: [
{ id: steering.id, placement: 'steering' },
{ id: queued.id, placement: 'queued' },
],
})
expect(frames[4]).toMatchObject({
type: 'session/event',
event: { type: 'steering/message', data: { message: { id: steering.message.id } } },
})
expect(frames[5]).toMatchObject({
type: 'session/queue',
items: [{ id: queued.id, placement: 'queued' }],
})
})
it('publishes edits in place in the authoritative order', async () => {
@@ -409,10 +550,16 @@ describe('session/queue frames', () => {
const frames = (await collected).filter(frame => frame.type === 'session/queue')
expect(frames.map(frame => frame.items)).toEqual([
[{ id: first.id, message: first.message }],
[{ id: first.id, message: first.message }, { id: second.id, message: second.message }],
[{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }],
[{ id: first.id, message: first.message }],
[{ id: first.id, placement: first.placement, message: first.message }],
[
{ id: first.id, placement: first.placement, message: first.message },
{ id: second.id, placement: second.placement, message: second.message },
],
[
{ id: first.id, placement: first.placement, message: first.message },
{ id: edited.id, placement: edited.placement, message: edited.message },
],
[{ id: first.id, placement: first.placement, message: first.message }],
])
})

View File

@@ -0,0 +1,480 @@
/**
* 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)
// Model-provider namespaces plus the explicit Web preference and product
// onboarding allowlists are the proxy's complete settings surface.
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 model-provider and explicitly allowlisted Web namespaces only', async () => {
// The settings seam is general: any plugin may register a namespace for
// its own configuration. The Web configuration plane remains opt-in, so a
// future internal plugin cannot become remotely configurable just by
// registering; permission and the product onboarding namespace are the
// non-model namespaces intentionally admitted by this surface.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
ctx.settings.register(settingsNamespace('permission'), z.object({
defaultPreset: z.union(['read-only', 'workspace-write']).required(),
}), {
base: { defaultPreset: 'read-only' },
})
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.settings.describe(request({})))
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission'])
const permission = expectOk(await api.settings.mutate(request({
ns: 'permission',
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
})))
expect(permission.value).toEqual({ defaultPreset: 'workspace-write' })
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('serves the product onboarding namespace without invalidating the model catalog', async () => {
const ctx = await harness()
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.toEqual(['ui-onboarding'])
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
expectOk(await api.settings.mutate(request({
ns: 'ui-onboarding',
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
})))
})
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
})
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('broadcasts a permission change without invalidating the model catalog', async () => {
const ctx = await harness()
const permission = ctx.settings.register(settingsNamespace('permission'), z.object({
defaultPreset: z.union(['read-only', 'workspace-write']).required(),
}), {
base: { defaultPreset: 'read-only' },
})
const api = createApiProxy(ctx, DEFAULTS)
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => {
await permission.update({ defaultPreset: 'workspace-write' })
})
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
})
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' }])
})
})

View File

@@ -0,0 +1,288 @@
/** Session-fork boundaries, lineage, and inherited model routing. */
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
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(workspaces: readonly Workspace[] = []): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('workspace', { list: () => workspaces } 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
}
/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
type Tail = 'none' | 'open' | 'aborted'
function liveAgent(
ctx: Context,
id: string,
turns: number,
tail: Tail = 'none',
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
if (tail !== 'none') {
session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
if (tail === 'aborted') session.append('turn/end', { turn: turns + 1, reason: { kind: 'aborted' } })
}
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('attaches a subagent fork to its nearest workspace-owning ancestor', async () => {
const accounted: SessionId[] = []
const attachSession = vi.fn<(sessionId: SessionId) => Promise<void>>()
.mockResolvedValue(undefined)
const workspace = {
sessionIds: accounted,
attachSession,
} as unknown as Workspace
const ctx = await composed([workspace])
const owner = liveAgent(ctx, 'session-owner', 1)
accounted.push(owner.id)
const child = liveAgent(ctx, 'session-child', 1, 'none', {
parentSession: owner.id,
origin: 'subagent',
})
const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', {
parentSession: child.id,
origin: 'subagent',
})
ctx.provide('sessionQuery', {
traceSession: vi.fn(() => Promise.resolve({
target: { header: grandchild.header, live: true, persisted: false },
ancestors: [
{ header: child.header, live: true, persisted: false },
{ header: owner.header, live: true, persisted: false },
],
descendants: [],
complete: true,
root: { header: owner.header, live: true, persisted: false },
})),
} as never)
const response = await api(ctx).sessions.fork(request({ sessionId: grandchild.id }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
expect(attachSession).toHaveBeenCalledWith(response.result.value.sessionId)
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
parentSession: grandchild.id,
cwd: '/proj',
})
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
await ctx.fiber.dispose()
})
it('forks a persisted subagent without resuming its Agent', async () => {
const ctx = await composed()
const sourceId = sid('session-cold-subagent')
const parentId = sid('session-cold-parent')
const header: SessionHeader = {
version: 0,
id: sourceId,
createdAt: 1,
cwd: '/proj',
parentSession: parentId,
origin: 'subagent',
}
const events = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
} as never)
ctx.provide('sessionQuery', {
traceSession: () => Promise.resolve({
target: { header, live: false, persisted: true },
ancestors: [],
descendants: [],
complete: true,
root: { header, live: false, persisted: true },
}),
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const response = await api(ctx).sessions.fork(request({ sessionId: sourceId }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
expect(resume).not.toHaveBeenCalled()
expect(ctx.agents.get(sourceId)).toBeUndefined()
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
parentSession: sourceId,
cwd: '/proj',
})
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
await ctx.fiber.dispose()
})
it('uses the last completed turn only for omitted and past-end anchors', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-tail', 2, 'open')
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('cuts through an aborted turn: stopped is closed, not open', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-aborted', 1, 'aborted')
// What a stopped message's fork button anchors on: the frozen node sits
// one event before its turn/end, floored client-side to that event's seq.
const anchor = (source.events.at(-1)?.seq ?? 0) - 1
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
expect(ctx.sessions.get(response.result.value.sessionId)?.events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
])
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, 'open')
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()
})
})

View File

@@ -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()
})
})

View File

@@ -1,20 +1,16 @@
/**
* Projection carrier paths of the host ApiProxy: the history tail page's
* projections block reads the registry's watermark snapshot (asOfSeq = last
* event seq, one consistent cut); loadOlder pages never carry the block; a
* composition without the registry serves histories without it; a disposed
* registration's key leaves subsequent responses; and every unit change is
* pushed to mux consumers as a session/projection frame minted here.
* Projection carrier paths of the host ApiProxy: history tail pages snapshot
* attached state or fold one cold inspected prefix, loadOlder omits the block,
* and live unit changes push session/projection frames.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { z } from 'zod'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -53,9 +49,6 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return { ctx, session }
}
@@ -87,6 +80,40 @@ describe('session.history projections block', () => {
expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
})
it('folds a cold inspected prefix without publishing an Agent', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
ctx.sessionProjections.register(lastUserUnit())
const sessionId = SessionId('session-cold-history')
const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/tmp' }
const events = [{
type: 'user/message',
seq: 0,
time: 2,
data: createUserMessage({
content: [{ type: 'text', text: 'persisted' }],
source: { kind: 'user' },
}),
surfaceOp: 'append',
}] as SessionEvent[]
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
const response = await api(ctx).sessions.history(request({ sessionId }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.projections).toEqual({
asOfSeq: 0,
values: { 'test/last-user': { text: 'persisted' } },
})
expect(ctx.agents.get(sessionId)).toBeUndefined()
})
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())

View File

@@ -0,0 +1,880 @@
/**
* Host session.search projection: list-equivalent visibility, fixed message
* filters and result bound, cancellation mapping, and unavailable/failure
* behavior.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { stat } from 'node:fs/promises'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import {
SessionQueryError,
type SessionSearchHit,
type SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, stat: vi.fn(actual.stat) }
})
const sid = (value: string): SessionId => value as SessionId
const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
function request(query: string): RpcRequest<{ query: string }> {
return { rpcId: RpcId(`search-${query}`), payload: { query } }
}
function header(id: string, cwd: string | null = '/project'): SessionHeader {
return {
version: 0,
id: sid(id),
createdAt: 100,
...(cwd === null ? {} : { cwd }),
}
}
function hit(id: string, index = 0): SessionSearchHit {
const session = header(id)
return {
header: session,
live: true,
persisted: false,
bestMatch: {
sessionId: session.id,
seq: index,
type: 'user/message',
time: 200 + index,
surface: 'current',
snippet: `match ${index}`,
},
}
}
async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
return ctx
}
describe('session.search', () => {
it('searches only list-visible ids and current conversation-message events', async () => {
const ctx = await baseContext()
const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') })
live.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'live text' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const cold = header('cold', '/cold')
const legacy = header('legacy', null)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([cold, legacy]),
locate: () => undefined,
} as never)
const searchSessions = vi.fn((
_request: SessionSearchRequest,
_exec?: { signal?: AbortSignal },
) => Promise.resolve({
items: [
{
header: legacy,
live: false,
persisted: true,
bestMatch: {
sessionId: legacy.id,
seq: 3,
type: 'user/message' as const,
time: 190,
surface: 'current' as const,
snippet: 'must remain hidden',
},
},
{
header: cold,
live: false,
persisted: true,
bestMatch: {
sessionId: cold.id,
seq: 4,
type: 'assistant/message' as const,
time: 200,
surface: 'current' as const,
snippet: 'the matching answer',
},
},
],
}))
ctx.provide('sessionQuery', { searchSessions } as never)
const api = createApiProxy(ctx, defaults)
const signal = new AbortController().signal
const response = await api.sessions.search(request('matching answer'), signal)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'cold', snippet: 'the matching answer' }],
hasMore: false,
},
})
expect(searchSessions).toHaveBeenCalledOnce()
const [query, exec] = searchSessions.mock.calls[0] as unknown as [
SessionSearchRequest,
{ signal: AbortSignal },
]
expect(query).toEqual({
query: 'matching answer',
eventFilters: [
{
kind: 'type',
values: ['user/message', 'assistant/message', 'steering/message'],
},
{ kind: 'surface', values: ['current'] },
],
limit: 20,
})
expect(exec.signal).toBe(signal)
})
it('returns an empty page without invoking the index when no session is visible', async () => {
const ctx = await baseContext()
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const api = createApiProxy(ctx, defaults)
const response = await api.sessions.search(
request('anything'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
expect(searchSessions).not.toHaveBeenCalled()
})
it('rejects snippets whose provider provenance violates the Host filters', async () => {
const ctx = await baseContext()
const visible = hit('visible')
ctx.sessions.create(visible.header.id, { meta: visible.header })
const withBestMatch = (
index: number,
bestMatch: Partial<SessionSearchHit['bestMatch']>,
): SessionSearchHit => {
const base = hit('visible', index)
return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } }
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({
items: [
withBestMatch(0, { sessionId: sid('hidden') }),
withBestMatch(1, { surface: 'shadowed' }),
withBestMatch(2, { type: 'tool/result' }),
withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }),
],
}),
} as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('match'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'visible', snippet: 'allowed snippet' }],
hasMore: false,
},
})
})
it('pages the globally ranked stream until the 20-item Host boundary is known', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({
items: [hit('hidden-ranked-first'), ...items.slice(0, 19)],
nextCursor: 'page-2',
})
.mockResolvedValueOnce({ items: items.slice(19) })
ctx.provide('sessionQuery', {
searchSessions,
} as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('match'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: true,
value: { hasMore: true },
})
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.items).toHaveLength(20)
expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19')
expect(searchSessions).toHaveBeenCalledTimes(2)
expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' })
})
it('learns a provider maxLimit of 10 and collects the 20-item result plus lookahead', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const invalidLimit = new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
const limit = providerRequest.limit
if (limit === undefined) throw new Error('Host search must request an explicit provider limit')
if (limit > 10) return Promise.reject(invalidLimit)
const offset = providerRequest.cursor === undefined
? 0
: Number.parseInt(providerRequest.cursor.slice('offset-'.length), 10)
const end = Math.min(items.length, offset + limit)
return Promise.resolve({
items: items.slice(offset, end),
...end < items.length ? { nextCursor: `offset-${end}` } : {},
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('adaptive-page-limit'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: true,
value: { hasMore: true },
})
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.items.map(item => item.sessionId))
.toEqual(items.slice(0, 20).map(item => item.header.id))
expect(searchSessions.mock.calls.map(([providerRequest]) => ({
limit: providerRequest.limit,
cursor: providerRequest.cursor,
}))).toEqual([
{ limit: 20, cursor: undefined },
{ limit: 10, cursor: undefined },
{ limit: 10, cursor: 'offset-10' },
{ limit: 10, cursor: 'offset-20' },
])
})
it('counts a page-limit probe inside the 100-call budget', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const invalidLimit = new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
if (searchSessions.mock.calls.length === 1) {
expect(providerRequest).toMatchObject({ limit: 20 })
return Promise.reject(invalidLimit)
}
expect(providerRequest.limit).toBe(10)
return Promise.resolve({
items: [],
nextCursor: `page-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('endless-pages'),
new AbortController().signal,
)
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error).toMatchObject({ code: 'internal' })
expect(response.result.error.message).toContain('100-call work budget')
expect(searchSessions).toHaveBeenCalledTimes(100)
})
it('restarts a stale continuation with its learned limit and original visibility snapshot', async () => {
const ctx = await baseContext()
const oldOnly = hit('old-only', 0)
const shared = hit('shared', 1)
const freshFirst = hit('fresh-first', 2)
const freshLast = hit('fresh-last', 3)
for (const item of [oldOnly, shared, freshFirst, freshLast]) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const late = hit('late-visible', 4)
const stale = new SessionQueryError(
'provider generation changed',
'SESSION_QUERY_STALE_CURSOR',
)
const invalidLimit = new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
switch (searchSessions.mock.calls.length) {
case 1:
expect(providerRequest).toMatchObject({ limit: 20 })
expect(providerRequest).not.toHaveProperty('cursor')
return Promise.reject(invalidLimit)
case 2:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest).not.toHaveProperty('cursor')
return Promise.resolve({
items: [oldOnly, shared],
nextCursor: 'old-cursor',
})
case 3:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest.cursor).toBe('old-cursor')
ctx.sessions.create(late.header.id, { meta: late.header })
return Promise.reject(stale)
case 4:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest).not.toHaveProperty('cursor')
return Promise.resolve({
items: [freshFirst, shared],
nextCursor: 'old-cursor',
})
case 5:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest.cursor).toBe('old-cursor')
return Promise.resolve({ items: [freshLast, late] })
default:
return Promise.reject(new Error('unexpected provider call'))
}
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('stale-restart'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [
{ sessionId: 'fresh-first', snippet: 'match 2' },
{ sessionId: 'shared', snippet: 'match 1' },
{ sessionId: 'fresh-last', snippet: 'match 3' },
],
hasMore: false,
},
})
expect(searchSessions).toHaveBeenCalledTimes(5)
})
it('counts continuous stale restarts against the 100-call budget', async () => {
const ctx = await baseContext()
const partial = hit('partial')
ctx.sessions.create(partial.header.id, { meta: partial.header })
const stale = new SessionQueryError(
'provider generation changed',
'SESSION_QUERY_STALE_CURSOR',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
if (searchSessions.mock.calls.length > 100) {
return Promise.reject(new Error('provider was called after the shared budget'))
}
if (providerRequest.cursor !== undefined) return Promise.reject(stale)
return Promise.resolve({
items: [partial],
nextCursor: `cursor-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('stale-churn'),
new AbortController().signal,
)
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('internal')
expect(response.result.error.message).toContain('100-call work budget')
expect(response.result).not.toHaveProperty('value')
expect(searchSessions).toHaveBeenCalledTimes(100)
})
it('gives abort priority over a coincident stale continuation failure', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const controller = new AbortController()
const stale = new SessionQueryError(
'provider generation changed',
'SESSION_QUERY_STALE_CURSOR',
)
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'stale-cursor' })
.mockImplementationOnce(() => {
controller.abort()
return Promise.reject(stale)
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('abort-stale'),
controller.signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('does not retry a stale first-page failure', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn(() => Promise.reject(new SessionQueryError(
'provider generation changed before paging',
'SESSION_QUERY_STALE_CURSOR',
)))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('first-page-stale'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(response.result).not.toHaveProperty('value')
expect(searchSessions).toHaveBeenCalledOnce()
})
it('does not adapt an invalid-limit continuation failure', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
.mockRejectedValueOnce(new SessionQueryError(
'continuation limit is invalid',
'SESSION_QUERY_INVALID_LIMIT',
))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('continuation-invalid-limit'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(searchSessions).toHaveBeenCalledTimes(2)
expect(searchSessions.mock.calls.map(([providerRequest]) => (
providerRequest as SessionSearchRequest
).limit))
.toEqual([20, 20])
})
it('stops page-limit adaptation at one item', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => Promise.reject(
new SessionQueryError(
`provider rejects ${providerRequest.limit}`,
'SESSION_QUERY_INVALID_LIMIT',
),
))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('minimum-page-limit'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit))
.toEqual([20, 10, 5, 2, 1])
})
it('gives abort priority over a coincident invalid first-page limit', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const controller = new AbortController()
const searchSessions = vi.fn(() => {
controller.abort()
return Promise.reject(new SessionQueryError(
'provider rejects 20',
'SESSION_QUERY_INVALID_LIMIT',
))
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('abort-invalid-limit'),
controller.signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).toHaveBeenCalledOnce()
})
it('rejects an oversized provider page', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('oversized-page'),
new AbortController().signal,
)
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error).toMatchObject({ code: 'internal' })
expect(response.result.error.message).toContain('returned 21 items; maximum is 20')
})
it('uses the learned provider limit for the overproduction guard', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const oversized = Array.from({ length: 11 }, (_, index) => hit(`oversized-${index}`))
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
if (providerRequest.limit === 20) {
return Promise.reject(new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
))
}
return Promise.resolve({ items: oversized })
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('adapted-oversized-page'),
new AbortController().signal,
)
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error).toMatchObject({ code: 'internal' })
expect(response.result.error.message).toContain('returned 11 items; maximum is 10')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => {
const ctx = await baseContext()
const visible = hit('visible')
ctx.sessions.create(visible.header.id, { meta: visible.header })
const expected = `${'x'.repeat(239)}😀`
const overlong = {
...visible,
bestMatch: {
...visible.bestMatch,
snippet: `${expected}${'y'.repeat(10_000)}`,
},
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({ items: [overlong] }),
} as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('bounded-snippet'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'visible', snippet: expected }],
hasMore: false,
},
})
})
it('fails closed when the provider repeats a continuation cursor', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('repeated-cursor'),
new AbortController().signal,
)
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error).toMatchObject({ code: 'internal' })
expect(response.result.error.message).toContain('repeated a continuation cursor')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('validates a repeated cursor before accepting the authorized lookahead', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('repeated-lookahead-cursor'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(response.result).not.toHaveProperty('value')
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.message).toContain('repeated a continuation cursor')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('does not count duplicate session ids toward the result or lookahead boundary', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' })
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' })
.mockResolvedValueOnce({ items: items.slice(20) })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('duplicate-pages'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: true,
value: { hasMore: true },
})
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.items.map(item => item.sessionId)).toEqual(
items.slice(0, 20).map(item => item.header.id),
)
expect(searchSessions).toHaveBeenCalledTimes(3)
})
it('cancels on a continuation page and passes the carrier signal to both calls', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const controller = new AbortController()
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
.mockImplementationOnce(() => {
controller.abort()
return Promise.resolve({ items: [] })
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('cancel-continuation'),
controller.signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).toHaveBeenCalledTimes(2)
for (const call of searchSessions.mock.calls) {
expect(call[1]).toEqual({ signal: controller.signal })
}
})
it('keeps visibility sets above SQLite variable limits out of provider bindings', async () => {
const ctx = await baseContext()
const cold = Array.from(
{ length: 32_751 },
(_, index) => header(`cold-${index}`, `/cold-${index}`),
)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(cold),
locate: () => undefined,
} as never)
const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({
items: [hit('cold-32750')],
}))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('large corpus'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'cold-32750', snippet: 'match 0' }],
hasMore: false,
},
})
expect(searchSessions).toHaveBeenCalledOnce()
expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters')
})
it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
const ctx = await baseContext()
const controller = new AbortController()
const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
const list = vi.fn((signal?: AbortSignal) => {
expect(signal).toBe(controller.signal)
return Promise.resolve(cold)
})
let locateCalls = 0
ctx.provide('sessionPersistence', {
list,
locate: () => {
locateCalls++
controller.abort()
return undefined
},
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('cancel-during-visibility'),
controller.signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(list).toHaveBeenCalledOnce()
expect(locateCalls).toBe(1)
expect(searchSessions).not.toHaveBeenCalled()
})
it('awaits every started cold-summary stat before returning cancellation', async () => {
const ctx = await baseContext()
const controller = new AbortController()
const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
const statGates = cold.map(() => Promise.withResolvers<{ mtimeMs: number }>())
const statMock = vi.mocked(stat)
statMock.mockClear()
for (const gate of statGates) {
statMock.mockImplementationOnce((() => gate.promise) as never)
}
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(cold),
locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }),
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
let settled = false
const responsePromise = createApiProxy(ctx, defaults).sessions.search(
request('cancel-during-cold-stats'),
controller.signal,
).finally(() => {
settled = true
})
await vi.waitFor(() => {
expect(statMock).toHaveBeenCalledTimes(16)
})
controller.abort()
statGates[0]!.resolve({ mtimeMs: 101 })
await new Promise<void>(resolve => setImmediate(resolve))
expect(settled).toBe(false)
for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 })
const response = await responsePromise
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).not.toHaveBeenCalled()
})
it('maps missing composition, query cancellation, and provider failure', async () => {
const missingCtx = await baseContext()
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
const missingApi = createApiProxy(missingCtx, defaults)
const preAborted = new AbortController()
preAborted.abort()
const cancelledBeforeLookup = await missingApi.sessions.search(
request('cancel-before-lookup'),
preAborted.signal,
)
expect(cancelledBeforeLookup.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
const missing = await missingApi.sessions.search(
request('needle'),
new AbortController().signal,
)
expect(missing.result.ok).toBe(false)
if (missing.result.ok) throw new Error('unreachable')
expect(missing.result.error.code).toBe('internal')
expect(missing.result.error.message).toContain('does not mount')
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED')
const searchSessions = vi.fn()
.mockRejectedValueOnce(aborted)
.mockRejectedValueOnce(new Error('database unavailable'))
ctx.provide('sessionQuery', { searchSessions } as never)
const api = createApiProxy(ctx, defaults)
const cancelled = await api.sessions.search(
request('first'),
new AbortController().signal,
)
expect(cancelled.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
const failed = await api.sessions.search(
request('second'),
new AbortController().signal,
)
expect(failed.result.ok).toBe(false)
if (failed.result.ok) throw new Error('unreachable')
expect(failed.result.error.code).toBe('internal')
expect(failed.result.error.message).toContain('database unavailable')
})
})

View File

@@ -0,0 +1,226 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import { RpcId } from '../src/api/rpc.ts'
import type { RpcRequest } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
const sid = (value: string): SessionId => value as SessionId
const PARENT = sid('parent')
const CHILD = sid('child')
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId('subagent-rpc'), payload }
}
function bench(options: {
parentLive?: boolean
childStatus?: 'idle' | 'running'
entries?: object[]
followupError?: Error
listError?: Error
readError?: Error
historyParent?: SessionId
} = {}) {
const parent = { id: PARENT }
const child = options.childStatus === undefined
? undefined
: { id: CHILD, status: options.childStatus }
const getAgent = vi.fn((id: SessionId) => {
if (options.parentLive !== false && id === PARENT) return parent
if (id === CHILD) return child
return undefined
})
const listChildren = vi.fn(() => options.listError === undefined
? Promise.resolve(options.entries ?? [
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: false,
},
])
: Promise.reject(options.listError))
const followup = vi.fn((
_parent: unknown,
_childId: SessionId,
_content: unknown,
_delivery: { source: { kind: string; rpcId: RpcId }; signal: AbortSignal },
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const readSession = vi.fn(() => options.readError === undefined
? Promise.resolve({
session: {
version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader,
events: [
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
] as unknown as SessionEvent[],
})
: Promise.reject(options.readError))
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('sessionQuery', { readSession })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, readSession, followup, parent }
}
describe('subagent gateway', () => {
it('lists the complete catalog and reports exact live-parent availability', async () => {
const { api, listChildren } = bench({ parentLive: false, entries: [
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: true,
},
{
kind: 'child', id: sid('one-shot'), mode: 'one-shot',
activity: 'inactive', hasChildren: false,
},
{ kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' },
] })
const response = await api.subagents.list(request({ parentSessionId: PARENT }))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toMatchObject({
ok: true,
value: {
parentAvailable: false,
entries: [
{ kind: 'child', mode: 'continuable' },
{ kind: 'child', mode: 'one-shot' },
{ kind: 'diagnostic' },
],
},
})
expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
})
it('derives catalog activity from the live child Agent rather than Session residency', async () => {
const residentIdle = bench({ childStatus: 'idle', entries: [{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] })
expect((await residentIdle.api.subagents.list(request({ parentSessionId: PARENT }))).result)
.toMatchObject({ ok: true, value: { entries: [{ activity: 'inactive' }] } })
const running = bench({ childStatus: 'running' })
expect((await running.api.subagents.list(request({ parentSessionId: PARENT }))).result)
.toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
})
it('reads a healthy direct child without looking up or activating any Agent', async () => {
const { api, getAgent, readSession } = bench()
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
}))
expect(response.result).toMatchObject({
ok: true,
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
})
expect(readSession).toHaveBeenCalledWith(CHILD)
expect(getAgent).not.toHaveBeenCalled()
})
it('reads one-shot history and rejects an address with the wrong mode', async () => {
const oneShot = {
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
activity: 'inactive', hasChildren: false,
}
const { api, readSession } = bench({ entries: [oneShot] })
expect((await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
}))).result).toMatchObject({ ok: true })
expect((await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } })
expect(readSession).toHaveBeenCalledTimes(1)
})
it('rejects a diagnostic address before reading history', async () => {
const { api, readSession } = bench({ entries: [
{ kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
] })
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))
expect(response.result).toMatchObject({
ok: false,
error: {
code: 'subagent-catalog-diagnostic',
details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
},
})
expect(readSession).not.toHaveBeenCalled()
})
it('routes human content through the exact live parent with rpc attribution', async () => {
const { api, parent, followup } = bench()
const content = [{ type: 'text' as const, text: '继续' }]
const signal = new AbortController().signal
const response = await api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content,
}), signal)
expect(response.result).toMatchObject({
ok: true, value: { messageId: 'message-1' },
})
expect(followup).toHaveBeenCalledWith(
parent,
CHILD,
content,
{ source: { kind: 'user', rpcId: RpcId('subagent-rpc') }, signal },
)
})
it('fails before delivery when the parent is absent and maps continuation failures', async () => {
const absent = bench({ parentLive: false })
expect((await absent.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'subagent-parent-unavailable' },
})
expect(absent.listChildren).not.toHaveBeenCalled()
const failed = bench({ followupError: new SubagentError('draining', 'DRAINING') })
expect((await failed.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'subagent-delivery-unavailable' },
})
})
it('maps history disappearance and hides unexpected backend details', async () => {
const disappeared = bench({
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
})
expect((await disappeared.api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({
ok: false,
error: {
code: 'subagent-not-found',
message: 'subagent disappeared during history read',
details: { parentSessionId: PARENT, childSessionId: CHILD },
},
})
const catalog = bench({ listError: new Error('secret descriptor') })
expect((await catalog.api.subagents.list(request({
parentSessionId: PARENT,
}))).result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'subagent catalog read failed' },
})
const prompt = bench({ followupError: new Error('secret provider') })
expect((await prompt.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
})

View File

@@ -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, trigger: { kind: 'message', source: { kind: 'user' } } })
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' })

View File

@@ -48,10 +48,11 @@ function stubAgent(session: Session): Agent {
acceptsNextStep: false,
ctx: new Context(),
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -292,18 +293,25 @@ describe('workspace.create', () => {
}
})
it('rejects different paths that derive the same Workspace title', async () => {
it('adopts different paths that derive the same Workspace title', async () => {
const { api, workspaceRoot } = await harness()
const first = join(workspaceRoot, 'one', 'project')
const second = join(workspaceRoot, 'two', 'project')
mkdirSync(first, { recursive: true })
mkdirSync(second, { recursive: true })
expectOk(await api.workspace.create(request({ path: first })))
const conflict = await api.workspace.create(request({ path: second }))
expect(conflict.result).toMatchObject({
ok: false,
error: { code: 'workspace-name-conflict', details: { name: 'project' } },
const firstResult = expectOk(await api.workspace.create(request({ path: first })))
const secondResult = expectOk(await api.workspace.create(request({ path: second })))
expect(firstResult).toMatchObject({
created: true,
workspace: { path: first, title: 'project' },
})
expect(secondResult).toMatchObject({
created: true,
workspace: { path: second, title: 'project' },
})
expect(secondResult.workspace.workspaceId).not.toBe(firstResult.workspace.workspaceId)
expect(expectOk(await api.workspace.list(request({}))).items.map(workspace => workspace.path))
.toEqual([second, first])
})
})
@@ -356,6 +364,36 @@ describe('session creation and Workspace membership', () => {
})
describe('Host Workspace increments', () => {
it('projects subagent origin in attached summaries and creation increments', async () => {
const { api, ctx } = await harness()
const abort = new AbortController()
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const pending = nextHostFrame(stream)
const childId = SessionId('session-subagent-child')
ctx.sessions.create(childId, {
meta: {
cwd: '/tmp',
parentSession: SessionId('session-parent'),
origin: 'subagent',
},
})
expect(await pending).toMatchObject({
payload: {
type: 'host/session-added',
sessionId: childId,
parentSessionId: 'session-parent',
origin: 'subagent',
},
})
expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual(
expect.objectContaining({ sessionId: childId, origin: 'subagent' }),
)
abort.abort()
})
it('streams committed Workspace and Session increments after empty baselines', async () => {
const { api } = await harness()
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
@@ -441,4 +479,44 @@ describe('Host Workspace increments', () => {
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
abort.abort()
})
it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
const { api } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
const sessionId = SessionId('session-to-archive')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])
const abort = new AbortController()
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const changed = nextHostFrame(stream)
expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
.toEqual([sessionId])
expect(await changed).toMatchObject({
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] },
})
// Accounting and the session itself are untouched; list re-baselines the set.
const listed = expectOk(await api.workspace.list(request({})))
expect(listed.archivedSessionIds).toEqual([sessionId])
expect(listed.items[0]?.sessionIds).toEqual([sessionId])
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
// The idempotent repeat emits no second frame: the next observed frame is
// the workspace-changed of a later attach, not another archive snapshot.
const after = nextHostFrame(stream)
expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
.toEqual([sessionId])
const otherSession = SessionId('session-after-archive')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession })))
expect((await after).payload.type).not.toBe('host/archived-sessions-changed')
const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') }))
expect(missing.result).toMatchObject({
ok: false,
error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } },
})
abort.abort()
})
})

View File

@@ -19,11 +19,15 @@ function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>>
/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
function scriptedApi(overrides: {
sessions?: Partial<ApiProxy['sessions']>
subagents?: Partial<ApiProxy['subagents']>
host?: Partial<ApiProxy['host']>
commands?: Partial<ApiProxy['commands']>
skills?: Partial<ApiProxy['skills']>
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 */ }
@@ -32,14 +36,15 @@ function scriptedApi(overrides: {
return {
sessions: {
list: r => ok(r, { items: [] }),
search: r => ok(r, { items: [], hasMore: false }),
create: r => ok(r, { sessionId: sid('s-new') }),
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,11 +52,18 @@ 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 }),
...overrides.sessions,
},
subagents: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
...overrides.subagents,
},
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
pickDirectory: r => ok(r, { path: null }),
@@ -61,11 +73,12 @@ function scriptedApi(overrides: {
...overrides.host,
},
workspace: {
list: r => ok(r, { items: [] }),
list: r => ok(r, { items: [], archivedSessionIds: [] }),
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
delete: r => ok(r, { deleted: true as const }),
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }),
},
commands: {
list: r => ok(r, { commands: [] }),
@@ -82,6 +95,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 +122,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 +150,59 @@ describe('unary round trip', () => {
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
})
it('round-trips a trimmed session search query and its bounded result metadata', async () => {
let seen: RpcRequest<{ query: string }> | undefined
const api = scriptedApi({
sessions: {
search: (request) => {
seen = request
return ok(request, {
items: [{ sessionId: sid('s1'), snippet: 'matching message text' }],
hasMore: true,
})
},
},
})
const response = await client(api).sessions.search({ query: ' message text ' })
expect(seen?.payload).toEqual({ query: 'message text' })
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 's1', snippet: 'matching message text' }],
hasMore: true,
},
})
})
it('rejects an overlong session-search snippet at the client value boundary', async () => {
const api = scriptedApi({
sessions: {
search: request => ok(request, {
items: [{ sessionId: sid('s1'), snippet: '😀'.repeat(241) }],
hasMore: false,
}),
},
})
await expect(client(api).sessions.search({ query: 'message' }))
.rejects.toThrow(/240 Unicode code points/)
})
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)
@@ -275,10 +368,12 @@ describe('workspace domain round trip', () => {
it('routes both workspace methods through their handler rows and value schemas', async () => {
const c = client(scriptedApi())
const list = await c.workspace.list({})
expect(list.result).toEqual({ ok: true, value: { items: [] } })
expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } })
const created = await c.workspace.create({ path: '/t' })
expect(created.result.ok).toBe(true)
if (created.result.ok) expect(created.result.value.created).toBe(true)
const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never })
expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } })
})
it('rejects a create payload violating the exactly-one refine at the handler', async () => {
@@ -446,11 +541,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 +655,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')
})
})

View File

@@ -22,6 +22,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
if (overrides.crashOn === 'session.list') throw new Error('impl crashed')
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
},
async search(request, signal) {
if (request.payload.query === 'hang') {
if (!signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
return {
rpcId: request.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
}
}
return {
rpcId: request.rpcId,
result: {
ok: true,
value: { items: [{ sessionId: 's1' as never, snippet: 'fixture match' }], hasMore: false },
},
}
},
async create(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
},
@@ -43,7 +63,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 +90,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 } } }
},
@@ -80,6 +103,31 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
subagents: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } }
},
async history(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }
},
async prompt(request, signal) {
if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) {
if (!signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
return {
rpcId: request.rpcId,
result: { ok: false, error: { code: 'cancelled' as const, message: 'aborted', details: {} } },
}
}
return {
rpcId: request.rpcId,
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
},
host: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
@@ -99,7 +147,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
workspace: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
return { rpcId: request.rpcId, result: { ok: true, value: { items: [], archivedSessionIds: [] } } }
},
async create(request) {
return {
@@ -122,6 +170,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
}
},
async archiveSession(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } }
},
},
commands: {
async list(request) {
@@ -167,6 +218,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),
@@ -212,11 +296,15 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
it('covers create/prompt/updateQueue/cancel/describe passthrough', async () => {
const c = client()
expect((await c.sessions.search({ query: 'fixture' })).result).toEqual({
ok: true,
value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false },
})
expect((await c.sessions.create({})).result.ok).toBe(true)
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 +312,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
ok: true,
value: {
selected: {
provider: 'deepseek',
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'max',
},
@@ -289,6 +377,85 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
})
it('lets command.execute finish after the 30-second default unary deadline', async () => {
vi.useFakeTimers()
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => {
const controller = new AbortController()
setTimeout(() => {
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
}, milliseconds)
return controller.signal
})
try {
const api = fakeApi()
api.commands.execute = async (request) => {
await new Promise(resolve => setTimeout(resolve, 30_001))
return {
rpcId: request.rpcId,
result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } },
}
}
const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' })
const assertion = expect(execution).resolves.toMatchObject({
result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } },
})
await Promise.all([
vi.advanceTimersByTimeAsync(30_001),
assertion,
])
expect(timeoutSpy).not.toHaveBeenCalled()
} finally {
timeoutSpy.mockRestore()
vi.useRealTimers()
}
})
it('round-trips the subagent domain through the wire form', async () => {
const c = client()
expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result)
.toEqual({ ok: true, value: { entries: [], parentAvailable: false } })
expect((await c.subagents.history({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'one-shot',
})).result).toEqual({ ok: true, value: { events: [], hasMore: false } })
expect((await c.subagents.prompt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
})
it('keeps caller and connection aborts on command.execute', async () => {
const api = fakeApi()
const started = Promise.withResolvers<AbortSignal>()
api.commands.execute = async (request, signal) => {
started.resolve(signal)
if (!signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
return {
rpcId: request.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
}
}
const controller = new AbortController()
const execution = client(api).commands.execute(
{ sessionId: 's' as never, line: '/hang' },
controller.signal,
)
const handlerSignal = await started.promise
controller.abort(new Error('connection closed'))
await expect(execution).rejects.toThrow('connection closed')
expect(handlerSignal.aborted).toBe(true)
})
it('propagates the carrier Request signal into command.execute', async () => {
const handler = toFetchHandler(fakeApi())
const controller = new AbortController()
@@ -303,6 +470,57 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into session.search', async () => {
const handler = toFetchHandler(fakeApi())
const controller = new AbortController()
const body = JSON.stringify({
type: 'client-request',
rpcId: 'r-search-sig',
method: 'session.search',
payload: { query: 'hang' },
})
const pending = handler.fetch(new Request(
'http://x/api/session.search',
{ method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
))
controller.abort()
const response = await pending
const parsed = await response.json() as {
rpcId: string
result: { error?: { code: string } }
}
expect(parsed.rpcId).toBe('r-search-sig')
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into subagent.prompt', async () => {
const handler = toFetchHandler(fakeApi())
const controller = new AbortController()
const body = JSON.stringify({
type: 'client-request',
rpcId: 'r-subagent-sig',
method: 'subagent.prompt',
payload: {
parentSessionId: 'parent',
childSessionId: 'child',
mode: 'continuable',
content: [{ type: 'text', text: 'hang' }],
},
})
const pending = handler.fetch(new Request(
'http://x/api/subagent.prompt',
{ method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
))
controller.abort()
const response = await pending
const parsed = await response.json() as {
rpcId: string
result: { error?: { code: string } }
}
expect(parsed.rpcId).toBe('r-subagent-sig')
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into host.pickDirectory', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request, signal) => {

View File

@@ -10,7 +10,8 @@ import {
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
sessionSearchRequestSchema, sessionSearchValueSchema, sessionSelectModelRequestSchema,
sessionSelectModelValueSchema, sessionSummarySchema,
sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema,
} from '../src/api/sessions.schema.ts'
import {
@@ -19,6 +20,7 @@ import {
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
} from '../src/api/host.schema.ts'
import {
workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema,
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
@@ -34,6 +36,11 @@ import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../s
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
import { goalEditRequestSchema } from '../src/api/goals.schema.ts'
import {
subagentHistoryRequestSchema, subagentHistoryValueSchema, subagentListEntrySchema,
subagentListRequestSchema, subagentListValueSchema, subagentPromptRequestSchema,
subagentPromptValueSchema,
} from '../src/api/subagents.schema.ts'
describe('RpcId', () => {
it('brands a raw string at zero runtime cost', () => {
@@ -70,9 +77,16 @@ describe('rpcErrorSchema', () => {
}).code).toBe('model-unavailable')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found')
expect(rpcErrorSchema.parse({ code: 'steer-unavailable', message: 'm', details: { itemId: 'i' } }).code).toBe('steer-unavailable')
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
expect(rpcErrorSchema.parse({ code: 'subagent-parent-unavailable', message: 'm', details: { parentSessionId: 'p' } }).code).toBe('subagent-parent-unavailable')
expect(rpcErrorSchema.parse({ code: 'subagent-not-found', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c' } }).code).toBe('subagent-not-found')
expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic')
expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable')
expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized')
expect(rpcErrorSchema.parse({ code: 'subagent-delivery-unavailable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-delivery-unavailable')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
@@ -128,7 +142,13 @@ describe('sessions domain schemas', () => {
expect(sessionIdSchema.parse('s1')).toBe('s1')
expect(() => sessionIdSchema.parse('')).toThrow()
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true })
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
expect(sessionSummarySchema.parse({
sessionId: 's1', updatedAt: 1, running: true, blank: false,
parentSessionId: 'p', origin: 'subagent', cwd: '/x',
})).toMatchObject({ origin: 'subagent', cwd: '/x' })
expect(() => sessionSummarySchema.parse({
sessionId: 's1', updatedAt: 1, running: false, blank: false, origin: 'fork',
})).toThrow()
// blank is mandatory: a summary without it fails the parse.
expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow()
const event = sessionEventSchema.parse({
@@ -150,6 +170,36 @@ describe('sessions domain schemas', () => {
expect(sessionListRequestSchema.parse({})).toEqual({})
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
expect(sessionSearchRequestSchema.parse({ query: ' exact phrase ' })).toEqual({ query: 'exact phrase' })
expect(() => sessionSearchRequestSchema.parse({ query: ' ' })).toThrow()
expect(() => sessionSearchRequestSchema.parse({ query: 'bad\0query' })).toThrow(/NUL/)
expect(() => sessionSearchRequestSchema.parse({ query: 'x'.repeat(501) })).toThrow()
expect(sessionSearchValueSchema.parse({
items: [{ sessionId: 's1', snippet: 'matching text' }],
hasMore: true,
})).toEqual({
items: [{ sessionId: 's1', snippet: 'matching text' }],
hasMore: true,
})
expect(sessionSearchValueSchema.parse({
items: [{ sessionId: 's1', snippet: '😀'.repeat(240) }],
hasMore: false,
}).items[0]?.snippet).toBe('😀'.repeat(240))
expect(() => sessionSearchValueSchema.parse({
items: [{ sessionId: 's1', snippet: '😀'.repeat(241) }],
hasMore: false,
})).toThrow(/240 Unicode code points/)
expect(() => sessionSearchValueSchema.parse({
items: [{ sessionId: '', snippet: 'matching text' }],
hasMore: false,
})).toThrow()
expect(() => sessionSearchValueSchema.parse({
items: Array.from(
{ length: 21 },
(_, index) => ({ sessionId: `s${index}`, snippet: 'matching text' }),
),
hasMore: true,
})).toThrow()
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
@@ -160,13 +210,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 +236,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 +250,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: [] } }],
}],
@@ -231,6 +281,9 @@ describe('sessions domain schemas', () => {
expect(sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: 'i1', action: { kind: 'remove' },
}).action.kind).toBe('remove')
expect(sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: 'i1', action: { kind: 'steer' },
}).action.kind).toBe('steer')
expect(() => sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: 'i1', action: { kind: 'promote' },
})).toThrow()
@@ -240,6 +293,45 @@ describe('sessions domain schemas', () => {
})
})
describe('subagent domain schemas', () => {
it('validates the direct catalog and addressed history pair', () => {
const child = {
kind: 'child', id: 'c', mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: true,
}
const oneShot = {
kind: 'child', id: 'o', mode: 'one-shot', activity: 'inactive', hasChildren: false,
}
const diagnostic = { kind: 'diagnostic', id: 'bad', reason: 'unsupported' }
expect(subagentListEntrySchema.parse(child)).toEqual(child)
expect(subagentListEntrySchema.parse(oneShot)).toEqual(oneShot)
expect(subagentListEntrySchema.parse(diagnostic)).toEqual(diagnostic)
expect(() => subagentListEntrySchema.parse({
kind: 'child', id: 'missing', mode: 'one-shot', activity: 'inactive',
})).toThrow()
expect(subagentListRequestSchema.parse({ parentSessionId: 'p' })).toEqual({ parentSessionId: 'p' })
expect(subagentListValueSchema.parse({
entries: [child, oneShot, diagnostic], parentAvailable: true,
}).entries).toHaveLength(3)
expect(subagentHistoryRequestSchema.parse({
parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', beforeSeq: 4, maxMessages: 2,
}).beforeSeq).toBe(4)
expect(() => subagentHistoryRequestSchema.parse({
parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', maxMessages: 0,
})).toThrow()
expect(subagentHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
})
it('validates continuable prompt content and the accepted inbox identity', () => {
expect(subagentPromptRequestSchema.parse({
parentSessionId: 'p', childSessionId: 'c', mode: 'continuable',
content: [{ type: 'text', text: '继续' }],
}).childSessionId).toBe('c')
expect(subagentPromptValueSchema.parse({ messageId: 'm1' }).messageId).toBe('m1')
expect(() => subagentPromptValueSchema.parse({ route: 'started', taskId: 't2' })).toThrow()
})
})
describe('host domain schemas', () => {
it('validates describe request/value', () => {
expect(hostDescribeRequestSchema.parse({})).toEqual({})
@@ -281,7 +373,16 @@ describe('workspace domain schemas', () => {
expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1'])
expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow()
expect(workspaceListRequestSchema.parse({})).toEqual({})
expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1)
expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1)
expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow()
})
it('archiveSession request/value carry the id and the full updated set', () => {
expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow()
expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds)
.toEqual(['s1', 's2'])
expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
})
it('create requires exactly one of path/name (both refine arms)', () => {
@@ -380,7 +481,7 @@ describe('events frame schemas', () => {
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
{ type: 'session/queue', sessionId: 's', items: [
{ id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } },
{ id: 'i1', placement: 'steering', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } },
] },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
@@ -399,6 +500,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: '', message: {} }] })).toThrow()
@@ -407,7 +519,7 @@ describe('events frame schemas', () => {
it('accepts every host frame branch', () => {
const frames = [
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' },
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p', origin: 'subagent' },
{ type: 'host/session-added', sessionId: 's', blank: true },
{ type: 'host/session-removed', sessionId: 's' },
{ type: 'host/session-status', sessionId: 's', running: true },
@@ -421,6 +533,9 @@ describe('events frame schemas', () => {
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
expect(() => hostFrameSchema.parse({
type: 'host/session-added', sessionId: 's', blank: true, origin: 'fork',
})).toThrow()
})
})

View File

@@ -11,6 +11,12 @@
{
"path": "../../goal/goal"
},
{
"path": "../../settings/settings"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../../vendor/cordis"
},
@@ -41,9 +47,18 @@
{
"path": "../../session-projection/session-projection-cache"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../subagent/subagent"
},
{
"path": "../../skill/skill"
},