Merge branch 'master' of https://github.com/deepseek-harness/deepseek-harness into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml # docs/architecture.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/persistence-catalog.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/session/README.i18n.yaml # packages/core/session/src/types.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/llm/llm/tests/service.spec.ts # packages/sdk/sdk-client/README.i18n.yaml # packages/sdk/sdk-protocol/README.i18n.yaml # packages/sdk/sdk-protocol/README.md # packages/sdk/sdk-protocol/README.zh.md # packages/subagent/subagent-dsh-sdk/README.i18n.yaml # packages/ui/jsonrpc/README.i18n.yaml # packages/ui/jsonrpc/README.md # packages/ui/jsonrpc/README.zh.md # packages/ui/tui/src/index.ts # python/sdk/README.i18n.yaml # scripts/gen-cordis-catalog.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: bf5d0d762365deae83d004e00b94a0ba805d6a6f
|
||||
README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928
|
||||
README.md: cf8cf0aaa7e20436644b92a996b6f7a817b1dd31
|
||||
README.zh.md: 91b3d1d0577d4a9a7df97d3c785e28bb21e47fd1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml).
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml).
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
@@ -10,10 +10,14 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
|
||||
|
||||
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
|
||||
|
||||
`session.history` pages on append-origin message boundaries: `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
|
||||
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
|
||||
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
|
||||
@@ -26,6 +30,8 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
|
||||
@@ -41,7 +47,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations (the former `host.listModels` reservation shipped as `llm.models`); an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
|
||||
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。
|
||||
|
||||
## 契约层(`/api`)
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
|
||||
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
|
||||
|
||||
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。
|
||||
|
||||
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
|
||||
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
|
||||
|
||||
@@ -26,6 +30,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由某个已暴露提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
|
||||
@@ -41,7 +47,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项(先前预留的 `host.listModels` 已作为 `llm.models` 交付);未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
|
||||
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
|
||||
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
@@ -52,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
|
||||
MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary,
|
||||
SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
@@ -39,6 +39,12 @@ import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
// The settings/credentials seams: brand guards run at this wire boundary; the
|
||||
// service reads stay optional (`ctx.get`) so a composition without either
|
||||
// provider still serves every other domain.
|
||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
|
||||
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
@@ -60,14 +66,18 @@ import { openNativePath } from './native-path-opener.ts'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Surface message event types (the pagination counting unit). */
|
||||
/** Conversation message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
|
||||
* naturally includes the in-progress partial.
|
||||
* Message-boundary pagination: count maxMessages append-origin messages
|
||||
* backwards from the window tail. Replacement copies never entered the
|
||||
* conversation a reader sees — they restate a shadowed range for the model
|
||||
* alone — so they consume no quota; the page stays one contiguous raw range,
|
||||
* which keeps a compaction's log-only provenance on the same page as its
|
||||
* replacement. The cut is the starting seq of the oldest message group (chunks
|
||||
* group via sourceEventSeqs — never cut mid-message). The tail page naturally
|
||||
* includes the in-progress partial.
|
||||
*/
|
||||
function paginate(
|
||||
events: readonly SessionEvent[],
|
||||
@@ -79,7 +89,7 @@ function paginate(
|
||||
let cut = 0
|
||||
for (let i = window.length - 1; i >= 0; i--) {
|
||||
const event = window[i] as SessionEvent
|
||||
if (!MESSAGE_TYPES.has(event.type)) continue
|
||||
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
|
||||
count++
|
||||
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
|
||||
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
|
||||
@@ -97,6 +107,82 @@ function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provider/model catalog over every registered route. Shared by the
|
||||
* session-scoped `session.models` (which passes the session's current target
|
||||
* so an unlisted current model still renders selectable) and the host-scoped
|
||||
* `llm.models` (no current). Per-provider failures ride `failures` without
|
||||
* failing the sound groups; groups that advertise nothing are dropped.
|
||||
*/
|
||||
async function buildModelCatalog(
|
||||
ctx: Context,
|
||||
current?: { provider: string; model: string },
|
||||
): Promise<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }> {
|
||||
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
|
||||
try {
|
||||
const advertised = await ctx.llm.listModels(provider.id)
|
||||
const models = [...advertised]
|
||||
if (
|
||||
current !== undefined
|
||||
&& provider.id === current.provider
|
||||
&& !models.some(model => model.id === current.model)
|
||||
) {
|
||||
models.push({
|
||||
provider: provider.id,
|
||||
id: current.model,
|
||||
name: current.model,
|
||||
})
|
||||
}
|
||||
const entries = await Promise.all(models.map(async (model) => {
|
||||
const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
|
||||
const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
|
||||
? undefined
|
||||
: {
|
||||
efforts: resolved.reasoning.efforts.map(effort => ({
|
||||
id: effort.id,
|
||||
name: effort.name,
|
||||
...effort.description === undefined
|
||||
? {}
|
||||
: { description: effort.description },
|
||||
})),
|
||||
...resolved.reasoning.defaultEffort === undefined
|
||||
? {}
|
||||
: { defaultEffort: resolved.reasoning.defaultEffort },
|
||||
}
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...current !== undefined
|
||||
&& provider.id === current.provider
|
||||
&& model.id === current.model
|
||||
&& !advertised.some(candidate => candidate.id === current.model)
|
||||
? { unlisted: true as const }
|
||||
: {},
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
}
|
||||
}))
|
||||
const group: ModelProviderGroup = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
models: entries,
|
||||
}
|
||||
return { kind: 'group' as const, group }
|
||||
} catch (error: unknown) {
|
||||
const failure: ModelCatalogFailure = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
return { kind: 'failure' as const, failure }
|
||||
}
|
||||
}))
|
||||
return {
|
||||
groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []).filter(group => group.models.length > 0),
|
||||
failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap an error result echoing the request's rpcId. */
|
||||
function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error } }
|
||||
@@ -802,6 +888,109 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
}
|
||||
|
||||
/** Missing-service report shared by the settings domain (skills-domain stance). */
|
||||
function settingsAbsent(): RpcError {
|
||||
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
|
||||
}
|
||||
|
||||
/** Missing-service report shared by the credentials domain. */
|
||||
function credentialsAbsent(): RpcError {
|
||||
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
|
||||
}
|
||||
|
||||
/** Map one redacted seam descriptor to its wire view. */
|
||||
function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
|
||||
return {
|
||||
ns: String(descriptor.ns),
|
||||
schema: descriptor.schema,
|
||||
value: descriptor.value,
|
||||
...descriptor.base === undefined ? {} : { base: descriptor.base },
|
||||
...descriptor.user === undefined ? {} : { user: descriptor.user },
|
||||
applies: descriptor.applies,
|
||||
secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
|
||||
revision: descriptor.revision,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings namespaces this proxy serves: exactly those a registered
|
||||
* configurable provider addresses. The settings seam itself is general —
|
||||
* any plugin may register a namespace for its own configuration — but the
|
||||
* Web configuration plane is scoped to model providers, and that boundary
|
||||
* has to be enforced here rather than assumed from the current plugin set.
|
||||
* Without it, every future `settings.register()` would silently become
|
||||
* remotely readable and writable configuration.
|
||||
*/
|
||||
function exposedNamespaces(): Set<string> {
|
||||
return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs))
|
||||
}
|
||||
|
||||
/** Refuse a namespace outside the model-provider boundary, naming why. */
|
||||
function notExposed(request: RpcRequest<unknown>, ns: string): RpcResponse<SettingsNamespaceView> {
|
||||
return err(request, {
|
||||
code: 'settings-not-exposed',
|
||||
message: `settings namespace "${ns}" is not exposed to configuration clients; only a namespace a registered model provider addresses is`,
|
||||
details: { ns },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one settings write (merge or wholesale replace) and acknowledge with
|
||||
* the namespace's new redacted view. A namespace outside the model-provider
|
||||
* boundary is refused before the seam is touched; every seam refusal —
|
||||
* unknown or invalid namespace, read-only provider, schema validation,
|
||||
* storage — becomes one `settings-rejected` carrying the seam's own message.
|
||||
*/
|
||||
async function settingsWrite(
|
||||
request: RpcRequest<unknown>,
|
||||
ns: string,
|
||||
mode: 'update' | 'replace' | 'mutate',
|
||||
section: object,
|
||||
expectedRevision?: number,
|
||||
): Promise<RpcResponse<SettingsNamespaceView>> {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) return err(request, settingsAbsent())
|
||||
const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => {
|
||||
// A stale writer is its own outcome, not a malformed request: the client
|
||||
// must re-read and re-apply rather than treat the write as invalid.
|
||||
if (error instanceof SettingsConflictError) {
|
||||
return err(request, {
|
||||
code: 'settings-conflict',
|
||||
message: error.message,
|
||||
details: { ns, expected: error.expected, actual: error.actual },
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'settings-rejected',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { ns },
|
||||
})
|
||||
}
|
||||
let branded: SettingsNamespace
|
||||
try {
|
||||
branded = settingsNamespace(ns)
|
||||
} catch (error: unknown) {
|
||||
// A malformed name is a client bug, reported as such; it could never be
|
||||
// in the exposed set either, so naming the real fault costs no ground.
|
||||
return rejected(error)
|
||||
}
|
||||
if (!exposedNamespaces().has(ns)) return notExposed(request, ns)
|
||||
try {
|
||||
if (mode === 'update') await settings.update(branded, section, expectedRevision)
|
||||
else if (mode === 'replace') await settings.replace(branded, section, expectedRevision)
|
||||
else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision)
|
||||
} catch (error: unknown) {
|
||||
return rejected(error)
|
||||
}
|
||||
const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
|
||||
if (descriptor === undefined) {
|
||||
// The write committed but the namespace vanished before this read: only
|
||||
// a concurrent registrant disposal can produce it.
|
||||
return err(request, { code: 'internal', message: `settings namespace "${ns}" was disposed after the ${mode}`, details: {} })
|
||||
}
|
||||
return ok(request, namespaceView(descriptor))
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
// Attached sessions summarize from memory; persisted-but-unattached (cold)
|
||||
@@ -912,70 +1101,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const current = targetFor(found.agent).current
|
||||
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
|
||||
try {
|
||||
const advertised = await ctx.llm.listModels(provider.id)
|
||||
const models = [...advertised]
|
||||
if (
|
||||
provider.id === current.provider
|
||||
&& !models.some(model => model.id === current.model)
|
||||
) {
|
||||
models.push({
|
||||
provider: provider.id,
|
||||
id: current.model,
|
||||
name: current.model,
|
||||
})
|
||||
}
|
||||
const entries = await Promise.all(models.map(async (model) => {
|
||||
const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
|
||||
const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
|
||||
? undefined
|
||||
: {
|
||||
efforts: resolved.reasoning.efforts.map(effort => ({
|
||||
id: effort.id,
|
||||
name: effort.name,
|
||||
...effort.description === undefined
|
||||
? {}
|
||||
: { description: effort.description },
|
||||
})),
|
||||
...resolved.reasoning.defaultEffort === undefined
|
||||
? {}
|
||||
: { defaultEffort: resolved.reasoning.defaultEffort },
|
||||
}
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...provider.id === current.provider
|
||||
&& model.id === current.model
|
||||
&& !advertised.some(candidate => candidate.id === current.model)
|
||||
? { unlisted: true as const }
|
||||
: {},
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
}
|
||||
}))
|
||||
const group: ModelProviderGroup = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
models: entries,
|
||||
}
|
||||
return { kind: 'group' as const, group }
|
||||
} catch (error: unknown) {
|
||||
const failure: ModelCatalogFailure = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
return { kind: 'failure' as const, failure }
|
||||
}
|
||||
}))
|
||||
const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
|
||||
const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
|
||||
return ok(request, {
|
||||
current: { ...current },
|
||||
groups: groups.filter(group => group.models.length > 0),
|
||||
failures,
|
||||
})
|
||||
const { groups, failures } = await buildModelCatalog(ctx, current)
|
||||
return ok(request, { current: { ...current }, groups, failures })
|
||||
},
|
||||
|
||||
async selectModel(request) {
|
||||
@@ -1038,6 +1165,75 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
},
|
||||
|
||||
async fork(request) {
|
||||
const { sessionId, atSeq } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const source = found.agent.session
|
||||
const events = source.events
|
||||
// An in-log anchor belongs to the turn containing it and must never
|
||||
// clip backward to an earlier completed turn. Omitted and past-end
|
||||
// anchors retain the last-completed-turn shortcut.
|
||||
const lastSeq = events.at(-1)?.seq ?? -1
|
||||
const anchoredBoundary = atSeq === undefined
|
||||
? undefined
|
||||
: events.find(e => e.type === 'turn/end' && e.seq >= atSeq)
|
||||
const boundary = anchoredBoundary
|
||||
?? (atSeq === undefined || atSeq > lastSeq
|
||||
? events.findLast(e => e.type === 'turn/end')
|
||||
: undefined)
|
||||
if (boundary === undefined) {
|
||||
return err(request, {
|
||||
code: 'fork-unavailable',
|
||||
message: atSeq !== undefined && atSeq <= lastSeq
|
||||
? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}`
|
||||
: `session "${sessionId}" has no completed turn to fork from`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
// Extend the cut through trailing out-of-band appends (session/title,
|
||||
// injections) up to the next turn/start: they are standalone events, so
|
||||
// the seed stays balanced, and the child inherits a title generated
|
||||
// right after the boundary turn.
|
||||
let cut = boundary.seq + 1
|
||||
while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
|
||||
const childId = `session-${randomUUID()}` as SessionId
|
||||
try {
|
||||
await ctx.agents.create({
|
||||
sessionId: childId,
|
||||
seed: events.slice(0, cut),
|
||||
meta: {
|
||||
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
|
||||
parentSession: source.id,
|
||||
seedLength: cut,
|
||||
},
|
||||
agentOptions,
|
||||
setup: installTarget,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `failed to fork session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
// Keep the child in the source's Workspace so the list nests it under
|
||||
// its parent; the child is already published if the attach fails.
|
||||
const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id))
|
||||
if (workspace !== undefined) {
|
||||
try {
|
||||
await workspace.attachSession(childId)
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'workspace-attach-failed',
|
||||
message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
||||
details: { sessionId: childId, workspaceId: workspace.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
return ok(request, { sessionId: childId })
|
||||
},
|
||||
|
||||
async prompt(request) {
|
||||
const { sessionId, mode, content } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
@@ -1450,6 +1646,105 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
describe(request) {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) return Promise.resolve(err(request, settingsAbsent()))
|
||||
const exposed = exposedNamespaces()
|
||||
return Promise.resolve(ok(request, {
|
||||
writable: settings.writable,
|
||||
namespaces: settings.describe({ redactSecrets: true })
|
||||
.filter(descriptor => exposed.has(String(descriptor.ns)))
|
||||
.map(namespaceView),
|
||||
}))
|
||||
},
|
||||
update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision),
|
||||
replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision),
|
||||
mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision),
|
||||
},
|
||||
|
||||
credentials: {
|
||||
async describe(request) {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials === undefined) return err(request, credentialsAbsent())
|
||||
const entries = await Promise.all(request.payload.refs.map(async (ref) => {
|
||||
const info = await credentials.describe(credentialRef(ref))
|
||||
const view: CredentialView = {
|
||||
configured: info.configured,
|
||||
...info.source === undefined ? {} : { source: info.source },
|
||||
writable: info.writable,
|
||||
}
|
||||
return [ref, view] as const
|
||||
}))
|
||||
return ok(request, { credentials: Object.fromEntries(entries) })
|
||||
},
|
||||
|
||||
async set(request) {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials === undefined) return err(request, credentialsAbsent())
|
||||
const { ref, value } = request.payload
|
||||
try {
|
||||
await credentials.set(credentialRef(ref), value)
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'credential-rejected',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { ref },
|
||||
})
|
||||
}
|
||||
return ok(request, {})
|
||||
},
|
||||
|
||||
async unset(request) {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials === undefined) return err(request, credentialsAbsent())
|
||||
const { ref } = request.payload
|
||||
try {
|
||||
await credentials.unset(credentialRef(ref))
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'credential-rejected',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { ref },
|
||||
})
|
||||
}
|
||||
return ok(request, {})
|
||||
},
|
||||
},
|
||||
|
||||
llm: {
|
||||
providers(request) {
|
||||
const registered = ctx.llm.listProviders()
|
||||
const active = new Set(registered.map(provider => provider.id))
|
||||
const directory = ctx.llm.listConfigurableProviders()
|
||||
const declared = new Set(directory.map(entry => entry.provider))
|
||||
const views = directory.map(entry => ({
|
||||
provider: entry.provider,
|
||||
displayName: entry.displayName,
|
||||
settingsNs: entry.settingsNs,
|
||||
settingsPath: [...entry.settingsPath],
|
||||
active: active.has(entry.provider),
|
||||
}))
|
||||
// Routes registered without a directory declaration still appear —
|
||||
// they exist and serve models — just with no settings address.
|
||||
for (const provider of registered) {
|
||||
if (declared.has(provider.id)) continue
|
||||
views.push({
|
||||
provider: provider.id,
|
||||
displayName: provider.name,
|
||||
settingsNs: '',
|
||||
settingsPath: [],
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
return Promise.resolve(ok(request, { providers: views }))
|
||||
},
|
||||
|
||||
async models(request) {
|
||||
return ok(request, await buildModelCatalog(ctx))
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
mux(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
||||
@@ -1593,6 +1888,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
ctx.on('commands/change', () => {
|
||||
queue.push(frame({ type: 'host/commands-changed' }))
|
||||
}),
|
||||
ctx.on('settings/document-updated', (ns) => {
|
||||
// The RAW-section event, not the resolved one: a field going from
|
||||
// inherited to overridden leaves the resolved value equal, and a
|
||||
// configuration client still has to re-read (its held revision is
|
||||
// stale, and the field's meaning changed).
|
||||
queue.push(frame({ type: 'host/settings-changed', ns: String(ns) }))
|
||||
// A provider's own settings carry its model catalog and endpoint,
|
||||
// so a change there invalidates the model list even when the route
|
||||
// set is untouched — `llm/adapters-updated` alone misses it.
|
||||
if (exposedNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' }))
|
||||
}),
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
|
||||
}),
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
queue.push(frame({ type: 'host/models-changed' }))
|
||||
}),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
|
||||
48
packages/host/apiproxy/src/api/credentials.schema.ts
Normal file
48
packages/host/apiproxy/src/api/credentials.schema.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* credentials domain zod schemas (names derived from map keys:
|
||||
* credentialsDescribeRequestSchema / credentialsDescribeValueSchema / …).
|
||||
* The reference-name pattern mirrors the seam's `credentialRef` guard so an
|
||||
* invalid name fails as `bad-request` before reaching the service.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { CredentialView } from './credentials.ts'
|
||||
|
||||
/** POSIX-portable environment-variable name (the seam's `credentialRef` pattern). */
|
||||
export const credentialRefNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
||||
|
||||
/** CredentialView entry of credentials.describe. */
|
||||
export const credentialViewSchema = z.object({
|
||||
configured: z.boolean(),
|
||||
source: z.string().optional(),
|
||||
writable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<CredentialView>>
|
||||
|
||||
/** credentials.describe request payload. */
|
||||
export const credentialsDescribeRequestSchema = z.object({
|
||||
refs: z.array(credentialRefNameSchema).max(64),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.describe'>>>
|
||||
|
||||
/** credentials.describe response value. */
|
||||
export const credentialsDescribeValueSchema = z.object({
|
||||
credentials: z.record(z.string(), credentialViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'credentials.describe'>>>
|
||||
|
||||
/** credentials.set request payload: the one direction a value crosses this wire. */
|
||||
export const credentialsSetRequestSchema = z.object({
|
||||
ref: credentialRefNameSchema,
|
||||
value: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.set'>>>
|
||||
|
||||
/** credentials.set response value. */
|
||||
export const credentialsSetValueSchema = z.object({}) satisfies z.ZodType<Wire<ResponseValue<'credentials.set'>>>
|
||||
|
||||
/** credentials.unset request payload. */
|
||||
export const credentialsUnsetRequestSchema = z.object({
|
||||
ref: credentialRefNameSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.unset'>>>
|
||||
|
||||
/** credentials.unset response value. */
|
||||
export const credentialsUnsetValueSchema = z.object({}) satisfies z.ZodType<Wire<ResponseValue<'credentials.unset'>>>
|
||||
44
packages/host/apiproxy/src/api/credentials.ts
Normal file
44
packages/host/apiproxy/src/api/credentials.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* credentials domain contract: the web face of the credential-reference seam
|
||||
* (`ctx.credentials`). Reads are structurally value-free — a credential view
|
||||
* carries configured/source/writable and has no slot for the value — and the
|
||||
* value crosses the wire in exactly one direction, inside `credentials.set`.
|
||||
* There is no enumeration method by design: clients learn which references
|
||||
* exist from settings schemas and values (`apiKeyEnv` fields).
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Wire view of one credential reference's state. */
|
||||
export interface CredentialView {
|
||||
/** Whether any layer currently supplies a non-empty value. */
|
||||
configured: boolean
|
||||
/** Winning layer when configured (`env`, `file`, …); provider vocabulary. */
|
||||
source?: string
|
||||
/** Whether `credentials.set`/`credentials.unset` can affect this reference. */
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
/** Credentials-domain unary methods (the map keys credentials.* of RpcMethodMap). */
|
||||
export interface CredentialsApi {
|
||||
/**
|
||||
* Describe the named references (batch): configured state, winning source,
|
||||
* and writability — never values. An invalid reference name is a
|
||||
* `bad-request`; an unknown-but-valid one describes as unconfigured.
|
||||
*/
|
||||
describe(request: RpcRequest<{ refs: string[] }>): Promise<RpcResponse<{ credentials: Record<string, CredentialView> }>>
|
||||
|
||||
/**
|
||||
* Store one credential value in the writable layer. Rejected with
|
||||
* `credential-rejected` while a read-only layer (the live environment)
|
||||
* shadows the reference — the write would otherwise appear to succeed while
|
||||
* resolution keeps returning the shadowing value.
|
||||
*/
|
||||
set(request: RpcRequest<{ ref: string; value: string }>): Promise<RpcResponse<{}>>
|
||||
|
||||
/**
|
||||
* Remove one credential from the writable layer; same shadowing rejection
|
||||
* as `set`. Unsetting an absent reference succeeds (idempotent).
|
||||
*/
|
||||
unset(request: RpcRequest<{ ref: string }>): Promise<RpcResponse<{}>>
|
||||
}
|
||||
@@ -23,6 +23,11 @@ export const askUserQuestionItemSchema = z.object({
|
||||
detail: z.string().optional(),
|
||||
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
|
||||
multiSelect: z.boolean().optional(),
|
||||
// Presentation intent: a tagged union on the wire, so an unknown tag is a
|
||||
// rejected frame rather than a silently generic render.
|
||||
intent: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('plan-review'), approve: z.string() }),
|
||||
]).optional(),
|
||||
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
|
||||
|
||||
/** User-message envelope carried by queue baselines. */
|
||||
@@ -64,5 +69,8 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
|
||||
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
|
||||
z.object({ type: z.literal('host/models-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -105,4 +105,23 @@ export type HostFrame =
|
||||
* background rather than diffing.
|
||||
*/
|
||||
| { type: 'host/commands-changed' }
|
||||
/**
|
||||
* One settings namespace's resolved value changed (`settings/updated`
|
||||
* passthrough) — an RPC write, an external `settings.yaml` edit, or a
|
||||
* provider reload all converge here. Clients refetch `settings.describe`;
|
||||
* values never ride the frame (they would need redaction and can go stale).
|
||||
*/
|
||||
| { type: 'host/settings-changed'; ns: string }
|
||||
/**
|
||||
* One credential reference's state changed (`credentials/updated`
|
||||
* passthrough): a set/unset over this wire or an external `.env` edit.
|
||||
* The ref is an environment-variable NAME — never a value.
|
||||
*/
|
||||
| { type: 'host/credentials-changed'; ref: string }
|
||||
/**
|
||||
* The provider topology changed (`llm/adapters-updated` passthrough):
|
||||
* routes registered or dropped, or the configurable directory moved. Pure
|
||||
* invalidation: clients refetch `llm.providers`/`llm.models`/`session.models`.
|
||||
*/
|
||||
| { type: 'host/models-changed' }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { CredentialsApi } from './credentials.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
|
||||
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
|
||||
@@ -22,6 +25,9 @@ export interface ApiProxy {
|
||||
skills: SkillsApi
|
||||
events: EventsApi
|
||||
goals: GoalsApi
|
||||
settings: SettingsApi
|
||||
credentials: CredentialsApi
|
||||
llm: LlmApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -37,6 +43,9 @@ export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
export type { CredentialsApi, CredentialView } from './credentials.ts'
|
||||
export type { ConfigurableProviderView, LlmApi } from './llm.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
|
||||
36
packages/host/apiproxy/src/api/llm.schema.ts
Normal file
36
packages/host/apiproxy/src/api/llm.schema.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* llm domain zod schemas (names derived from map keys: llmProvidersRequestSchema /
|
||||
* llmProvidersValueSchema / llmModelsRequestSchema / llmModelsValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { ConfigurableProviderView } from './llm.ts'
|
||||
import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts'
|
||||
|
||||
/** ConfigurableProviderView row of llm.providers. */
|
||||
export const configurableProviderViewSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
settingsNs: z.string(),
|
||||
settingsPath: z.array(z.string()),
|
||||
active: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ConfigurableProviderView>>
|
||||
|
||||
/** llm.providers request payload. */
|
||||
export const llmProvidersRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'llm.providers'>>>
|
||||
|
||||
/** llm.providers response value. */
|
||||
export const llmProvidersValueSchema = z.object({
|
||||
providers: z.array(configurableProviderViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'llm.providers'>>>
|
||||
|
||||
/** llm.models request payload. */
|
||||
export const llmModelsRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'llm.models'>>>
|
||||
|
||||
/** llm.models response value. */
|
||||
export const llmModelsValueSchema = z.object({
|
||||
groups: z.array(modelProviderGroupSchema),
|
||||
failures: z.array(modelCatalogFailureSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'llm.models'>>>
|
||||
43
packages/host/apiproxy/src/api/llm.ts
Normal file
43
packages/host/apiproxy/src/api/llm.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* llm domain contract: host-scoped provider topology for configuration
|
||||
* surfaces. `llm.providers` merges the configurable-provider directory
|
||||
* (which providers CAN be configured, and where their settings live) with the
|
||||
* live route registry; `llm.models` is the session-independent model catalog
|
||||
* (`session.models` minus the per-session current/unlisted logic). Both
|
||||
* invalidate on the `host/models-changed` frame.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { ModelCatalogFailure, ModelProviderGroup } from './sessions.ts'
|
||||
|
||||
/** Wire view of one configurable provider. */
|
||||
export interface ConfigurableProviderView {
|
||||
/** Provider route key (`deepseek-official`, `openai`, …). */
|
||||
provider: string
|
||||
/** Human-readable name for configuration surfaces. */
|
||||
displayName: string
|
||||
/** Settings namespace whose section configures this provider. */
|
||||
settingsNs: string
|
||||
/** Path from that section's root to the provider's profile object (empty = whole section). */
|
||||
settingsPath: string[]
|
||||
/** Whether the route is currently registered (its models are requestable). */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */
|
||||
export interface LlmApi {
|
||||
/**
|
||||
* List every configurable provider with its live/dormant state, in
|
||||
* directory declaration order. Routes registered outside the directory
|
||||
* (an adapter that never declared configurability) are appended with their
|
||||
* registration identity and no settings address.
|
||||
*/
|
||||
providers(request: RpcRequest<{}>): Promise<RpcResponse<{ providers: ConfigurableProviderView[] }>>
|
||||
|
||||
/**
|
||||
* Host-scoped model catalog over every registered provider route: the
|
||||
* settings surface's models view, needing no session. Per-provider listing
|
||||
* failures ride `failures` without failing the sound groups.
|
||||
*/
|
||||
models(request: RpcRequest<{}>): Promise<RpcResponse<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }>>
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { CredentialsApi } from './credentials.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
@@ -24,6 +27,7 @@ export interface RpcMethodMap {
|
||||
'session.models': SessionsApi['models']
|
||||
'session.selectModel': SessionsApi['selectModel']
|
||||
'session.rename': SessionsApi['rename']
|
||||
'session.fork': SessionsApi['fork']
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.updateQueue': SessionsApi['updateQueue']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
@@ -46,6 +50,15 @@ export interface RpcMethodMap {
|
||||
'goal.resume': GoalsApi['resume']
|
||||
'goal.complete': GoalsApi['complete']
|
||||
'goal.clear': GoalsApi['clear']
|
||||
'settings.describe': SettingsApi['describe']
|
||||
'settings.update': SettingsApi['update']
|
||||
'settings.replace': SettingsApi['replace']
|
||||
'settings.mutate': SettingsApi['mutate']
|
||||
'credentials.describe': CredentialsApi['describe']
|
||||
'credentials.set': CredentialsApi['set']
|
||||
'credentials.unset': CredentialsApi['unset']
|
||||
'llm.providers': LlmApi['providers']
|
||||
'llm.models': LlmApi['models']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -50,7 +50,12 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
|
||||
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
|
||||
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
|
||||
@@ -50,7 +50,27 @@ export interface RpcErrorDetailsMap {
|
||||
'command-error': {}
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
'unknown-command': {}
|
||||
/**
|
||||
* A settings write was refused (schema validation, unknown namespace,
|
||||
* read-only provider, or storage failure); the message is the seam's text.
|
||||
*/
|
||||
'settings-rejected': { ns: string }
|
||||
/**
|
||||
* A settings namespace exists in the seam but is outside the configuration
|
||||
* plane's model-provider boundary, so this proxy neither reads nor writes
|
||||
* it; the message names the namespace.
|
||||
*/
|
||||
'settings-not-exposed': { ns: string }
|
||||
/**
|
||||
* A settings write carried an `expectedRevision` the namespace has already
|
||||
* moved past: another writer (tab, editor, or an external file edit) landed
|
||||
* first. The details carry both revisions so a client can re-read and retry.
|
||||
*/
|
||||
'settings-conflict': { ns: string; expected: number; actual: number }
|
||||
/** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */
|
||||
'credential-rejected': { ref: string }
|
||||
'title-invalid': { sessionId: SessionId }
|
||||
'fork-unavailable': { sessionId: SessionId }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,17 @@ export const sessionRenameValueSchema = z.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
|
||||
|
||||
/** session.fork request payload (atSeq anchors the completed-turn cut). */
|
||||
export const sessionForkRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
atSeq: z.number().int().nonnegative().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.fork'>>>
|
||||
|
||||
/** session.fork response value (the child session id). */
|
||||
export const sessionForkValueSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.fork'>>>
|
||||
|
||||
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
|
||||
export const sessionHistoryRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
|
||||
@@ -186,9 +186,11 @@ export interface SessionsApi {
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to message boundaries: one page =
|
||||
* all raw events owned by a whole number of messages (including their chunk / tool events),
|
||||
* never cut mid-message. The tail page (beforeSeq absent) additionally carries the in-flight
|
||||
* Reads a window of history events; page boundaries align to append-origin message
|
||||
* boundaries: one page = all raw events owned by a whole number of such messages (including
|
||||
* their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
|
||||
* `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail
|
||||
* page (beforeSeq absent) additionally carries the in-flight
|
||||
* partial — chunk events already emitted for the last unfinalized message.
|
||||
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
|
||||
* presenter produced one, evaluated against the registry at pagination time); the client
|
||||
@@ -236,6 +238,21 @@ export interface SessionsApi {
|
||||
* one — carried for future rendering; the state change is the feedback). A usage/state error is an
|
||||
* RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
|
||||
*/
|
||||
/**
|
||||
* Forks a new session from a completed-turn prefix of the source. `atSeq`
|
||||
* anchors the cut: the boundary is the first `turn/end` at or after it
|
||||
* (a message's fork button passes the message seq, so the fork includes
|
||||
* that whole turn); a boundary past the log end, or an omitted `atSeq`,
|
||||
* falls back to the source's last completed turn. An in-log anchor whose
|
||||
* turn is still open fails with `fork-unavailable` instead of clipping to
|
||||
* an earlier turn. The child inherits the source cwd, latest logged model
|
||||
* target, workspace attachment, and `parentSessionId` lineage; the seed
|
||||
* prefix carries the source title.
|
||||
*/
|
||||
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
|
||||
72
packages/host/apiproxy/src/api/settings.schema.ts
Normal file
72
packages/host/apiproxy/src/api/settings.schema.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* settings domain zod schemas (names derived from map keys: settingsDescribeRequestSchema /
|
||||
* settingsDescribeValueSchema / settingsUpdate* / settingsReplace*).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
|
||||
/** One redacted secret slot. */
|
||||
export const settingsSecretViewSchema = z.object({
|
||||
path: z.array(z.string()),
|
||||
set: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<SettingsSecretView>>
|
||||
|
||||
/** SettingsNamespaceView row of settings.describe and the write responses. */
|
||||
export const settingsNamespaceViewSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
schema: z.unknown(),
|
||||
value: z.unknown(),
|
||||
base: z.unknown().optional(),
|
||||
user: z.unknown().optional(),
|
||||
applies: z.union([z.literal('live'), z.literal('restart')]),
|
||||
secrets: z.array(settingsSecretViewSchema),
|
||||
revision: z.number(),
|
||||
}) satisfies z.ZodType<Wire<SettingsNamespaceView>>
|
||||
|
||||
/** settings.describe request payload. */
|
||||
export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.describe'>>>
|
||||
|
||||
/** settings.describe response value. */
|
||||
export const settingsDescribeValueSchema = z.object({
|
||||
writable: z.boolean(),
|
||||
namespaces: z.array(settingsNamespaceViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'settings.describe'>>>
|
||||
|
||||
/** settings.update request payload. */
|
||||
export const settingsUpdateRequestSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
patch: z.record(z.string(), z.unknown()),
|
||||
expectedRevision: z.number().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'settings.update'>>>
|
||||
|
||||
/** settings.update response value: the namespace's new redacted view. */
|
||||
export const settingsUpdateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.update'>>>
|
||||
|
||||
/** settings.replace request payload. */
|
||||
export const settingsReplaceRequestSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
section: z.record(z.string(), z.unknown()),
|
||||
expectedRevision: z.number().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'settings.replace'>>>
|
||||
|
||||
/** One path-addressed edit of settings.mutate. */
|
||||
export const settingsPathOpSchema = z.discriminatedUnion('op', [
|
||||
z.object({ op: z.literal('set'), path: z.array(z.string()), value: z.unknown() }),
|
||||
z.object({ op: z.literal('unset'), path: z.array(z.string()) }),
|
||||
]) as unknown as z.ZodType<Wire<SettingsPathOpView>>
|
||||
|
||||
/** settings.mutate request payload. */
|
||||
export const settingsMutateRequestSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
ops: z.array(settingsPathOpSchema),
|
||||
expectedRevision: z.number().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'settings.mutate'>>>
|
||||
|
||||
/** settings.mutate response value: the namespace's new redacted view. */
|
||||
export const settingsMutateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.mutate'>>>
|
||||
|
||||
/** settings.replace response value. */
|
||||
export const settingsReplaceValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.replace'>>>
|
||||
90
packages/host/apiproxy/src/api/settings.ts
Normal file
90
packages/host/apiproxy/src/api/settings.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* settings domain contract: the web face of the user-settings seam
|
||||
* (`ctx.settings`). Every payload that leaves this domain is redacted by the
|
||||
* seam (`describe({ redactSecrets: true })` semantics): `role('secret')`
|
||||
* fields never ride a response in any layer, and the `secrets` slot list is
|
||||
* how a form learns a write-only field exists and whether it is configured.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** One schema-declared secret slot inside a redacted namespace value. */
|
||||
export interface SettingsSecretView {
|
||||
/** Path from the section root to the removed field. */
|
||||
path: string[]
|
||||
/** Whether the slot currently holds a value (the value itself never rides). */
|
||||
set: boolean
|
||||
}
|
||||
|
||||
/** Wire view of one registered settings namespace. */
|
||||
export interface SettingsNamespaceView {
|
||||
/** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */
|
||||
ns: string
|
||||
/** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */
|
||||
schema: unknown
|
||||
/** Redacted resolved value (schema defaults → composition base → user layer). */
|
||||
value: unknown
|
||||
/** Redacted composition base layer, when the registrant declared one. */
|
||||
base?: unknown
|
||||
/** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */
|
||||
user?: unknown
|
||||
/** When the owner applies changes. */
|
||||
applies: 'live' | 'restart'
|
||||
/** Every schema-declared secret slot with its configured state. */
|
||||
secrets: SettingsSecretView[]
|
||||
/**
|
||||
* Monotonic revision of the raw user section this view was read at. Send it
|
||||
* back as `expectedRevision` on a write so a stale editor is refused rather
|
||||
* than silently overwriting a concurrent change.
|
||||
*/
|
||||
revision: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One path-addressed edit carried by `settings.mutate`. `set` writes the
|
||||
* value at the path (creating intermediate objects); `unset` removes it. The
|
||||
* empty path addresses the section root.
|
||||
*/
|
||||
export type SettingsPathOpView =
|
||||
| { op: 'set'; path: string[]; value: unknown }
|
||||
| { op: 'unset'; path: string[] }
|
||||
|
||||
/** Settings-domain unary methods (the map keys settings.* of RpcMethodMap). */
|
||||
export interface SettingsApi {
|
||||
/**
|
||||
* Describe every registered namespace: redacted layered values plus the
|
||||
* serialized schema a client renders its form from. `writable: false`
|
||||
* (read-only provider) tells the client to disable every write control.
|
||||
*/
|
||||
describe(request: RpcRequest<{}>): Promise<RpcResponse<{ writable: boolean; namespaces: SettingsNamespaceView[] }>>
|
||||
|
||||
/**
|
||||
* Merge a patch into one namespace's user layer (validate → persist →
|
||||
* commit). Secret-role fields may be INCLUDED in the patch (write-only
|
||||
* direction); a form that leaves a secret untouched simply omits it and the
|
||||
* merge preserves the stored value. Responds with the namespace's new
|
||||
* redacted view; a schema or storage rejection is `settings-rejected`.
|
||||
*/
|
||||
update(request: RpcRequest<{ ns: string; patch: object; expectedRevision?: number }>): Promise<RpcResponse<SettingsNamespaceView>>
|
||||
|
||||
/**
|
||||
* Replace one namespace's user section wholesale — the removal/reset path a
|
||||
* merge cannot express (`section: {}` resets to composition defaults). Keys
|
||||
* absent from `section` are dropped, secrets included: a client must first
|
||||
* fold the descriptor's `user` layer (and re-supply any secret it wants to
|
||||
* keep) or accept the reset.
|
||||
*/
|
||||
replace(request: RpcRequest<{ ns: string; section: object; expectedRevision?: number }>): Promise<RpcResponse<SettingsNamespaceView>>
|
||||
|
||||
/**
|
||||
* Apply path-addressed edits to one namespace's user section, resolved
|
||||
* against the section as stored — NOT against whatever the caller last
|
||||
* read. This is the removal path for any client holding the redacted
|
||||
* descriptor: it names the field it means, so a secret the wire never
|
||||
* returned cannot be deleted as a side effect. `replace` remains the
|
||||
* deliberate wholesale reset.
|
||||
*/
|
||||
mutate(
|
||||
request: RpcRequest<{ ns: string; ops: SettingsPathOpView[]; expectedRevision?: number }>,
|
||||
): Promise<RpcResponse<SettingsNamespaceView>>
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
sessionCancelValueSchema,
|
||||
sessionCreateValueSchema,
|
||||
sessionForkValueSchema,
|
||||
sessionHistoryValueSchema,
|
||||
sessionListValueSchema,
|
||||
sessionModelsValueSchema,
|
||||
@@ -45,6 +46,13 @@ import {
|
||||
goalCompleteValueSchema,
|
||||
goalClearValueSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
import {
|
||||
settingsDescribeValueSchema, settingsMutateValueSchema, settingsReplaceValueSchema, settingsUpdateValueSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import {
|
||||
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -69,6 +77,7 @@ export interface IApiClient {
|
||||
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
|
||||
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
|
||||
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
|
||||
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
@@ -106,6 +115,21 @@ export interface IApiClient {
|
||||
complete(payload: RequestPayload<'goal.complete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.complete'>>>
|
||||
clear(payload: RequestPayload<'goal.clear'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.clear'>>>
|
||||
}
|
||||
settings: {
|
||||
describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
|
||||
update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.update'>>>
|
||||
replace(payload: RequestPayload<'settings.replace'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.replace'>>>
|
||||
mutate(payload: RequestPayload<'settings.mutate'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.mutate'>>>
|
||||
}
|
||||
credentials: {
|
||||
describe(payload: RequestPayload<'credentials.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.describe'>>>
|
||||
set(payload: RequestPayload<'credentials.set'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.set'>>>
|
||||
unset(payload: RequestPayload<'credentials.unset'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.unset'>>>
|
||||
}
|
||||
llm: {
|
||||
providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.providers'>>>
|
||||
models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.models'>>>
|
||||
}
|
||||
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
|
||||
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -121,6 +145,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.models': sessionModelsValueSchema,
|
||||
'session.selectModel': sessionSelectModelValueSchema,
|
||||
'session.rename': sessionRenameValueSchema,
|
||||
'session.fork': sessionForkValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.updateQueue': sessionUpdateQueueValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
@@ -143,6 +168,15 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'goal.resume': goalResumeValueSchema,
|
||||
'goal.complete': goalCompleteValueSchema,
|
||||
'goal.clear': goalClearValueSchema,
|
||||
'settings.describe': settingsDescribeValueSchema,
|
||||
'settings.update': settingsUpdateValueSchema,
|
||||
'settings.replace': settingsReplaceValueSchema,
|
||||
'settings.mutate': settingsMutateValueSchema,
|
||||
'credentials.describe': credentialsDescribeValueSchema,
|
||||
'credentials.set': credentialsSetValueSchema,
|
||||
'credentials.unset': credentialsUnsetValueSchema,
|
||||
'llm.providers': llmProvidersValueSchema,
|
||||
'llm.models': llmModelsValueSchema,
|
||||
}
|
||||
|
||||
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -334,6 +368,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
models: (payload, signal) => this.callUnary('session.models', payload, signal),
|
||||
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
|
||||
fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
|
||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||
@@ -375,6 +410,24 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
clear: (payload, signal) => this.callUnary('goal.clear', payload, signal),
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: (payload, signal) => this.callUnary('settings.describe', payload, signal),
|
||||
update: (payload, signal) => this.callUnary('settings.update', payload, signal),
|
||||
replace: (payload, signal) => this.callUnary('settings.replace', payload, signal),
|
||||
mutate: (payload, signal) => this.callUnary('settings.mutate', payload, signal),
|
||||
}
|
||||
|
||||
readonly credentials: IApiClient['credentials'] = {
|
||||
describe: (payload, signal) => this.callUnary('credentials.describe', payload, signal),
|
||||
set: (payload, signal) => this.callUnary('credentials.set', payload, signal),
|
||||
unset: (payload, signal) => this.callUnary('credentials.unset', payload, signal),
|
||||
}
|
||||
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: (payload, signal) => this.callUnary('llm.providers', payload, signal),
|
||||
models: (payload, signal) => this.callUnary('llm.models', payload, signal),
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
|
||||
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
|
||||
import {
|
||||
sessionCancelRequestSchema,
|
||||
sessionCreateRequestSchema,
|
||||
sessionForkRequestSchema,
|
||||
sessionHistoryRequestSchema,
|
||||
sessionListRequestSchema,
|
||||
sessionModelsRequestSchema,
|
||||
@@ -47,6 +48,13 @@ import {
|
||||
goalCompleteRequestSchema,
|
||||
goalClearRequestSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
import {
|
||||
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsReplaceRequestSchema, settingsUpdateRequestSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import {
|
||||
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -71,6 +79,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
|
||||
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
|
||||
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
|
||||
'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
|
||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||
@@ -93,6 +102,15 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'goal.resume': { schema: goalResumeRequestSchema, invoke: (api, r) => api.goals.resume(r) },
|
||||
'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) },
|
||||
'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) },
|
||||
'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) },
|
||||
'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) },
|
||||
'settings.replace': { schema: settingsReplaceRequestSchema, invoke: (api, r) => api.settings.replace(r) },
|
||||
'settings.mutate': { schema: settingsMutateRequestSchema, invoke: (api, r) => api.settings.mutate(r) },
|
||||
'credentials.describe': { schema: credentialsDescribeRequestSchema, invoke: (api, r) => api.credentials.describe(r) },
|
||||
'credentials.set': { schema: credentialsSetRequestSchema, invoke: (api, r) => api.credentials.set(r) },
|
||||
'credentials.unset': { schema: credentialsUnsetRequestSchema, invoke: (api, r) => api.credentials.unset(r) },
|
||||
'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) },
|
||||
'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
|
||||
@@ -59,6 +59,9 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly goals: ApiProxy['goals']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly settings: ApiProxy['settings']
|
||||
readonly credentials: ApiProxy['credentials']
|
||||
readonly llm: ApiProxy['llm']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
|
||||
@@ -77,6 +80,9 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
this.commands = api.commands
|
||||
this.goals = api.goals
|
||||
this.skills = api.skills
|
||||
this.settings = api.settings
|
||||
this.credentials = api.credentials
|
||||
this.llm = api.llm
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
// the unbound-method lint without changing behavior.
|
||||
|
||||
441
packages/host/apiproxy/tests/api-proxy-config.spec.ts
Normal file
441
packages/host/apiproxy/tests/api-proxy-config.spec.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Settings/credentials/llm RPC domains and their host-stream frames over
|
||||
* createApiProxy: layered redacted describe, write-path rejection mapping,
|
||||
* value-free credential views, the directory/live-route merge, and the three
|
||||
* invalidation frames (settings/credentials/models changed).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Settings, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { Credentials } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
function expectErr<T>(response: RpcResponse<T>): { code: string; message: string; details: unknown } {
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
return response.result.error
|
||||
}
|
||||
|
||||
/** In-memory settings provider: the seam base class owns all tested behavior. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown>
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown>; readOnly?: boolean }) {
|
||||
super(ctx)
|
||||
this.doc = structuredClone(options?.doc ?? {})
|
||||
this.readOnly = options?.readOnly ?? false
|
||||
}
|
||||
|
||||
private readonly readOnly: boolean
|
||||
|
||||
get writable(): boolean {
|
||||
return !this.readOnly
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc[ns] = structuredClone(section)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory credential provider with an env-shadow double for the rejection path. */
|
||||
class MemoryCredentials extends Credentials {
|
||||
private readonly values = new Map<string, string>()
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Credentials>[0], options?: { shadowed?: string[] }) {
|
||||
super(ctx)
|
||||
this.shadowed = new Set(options?.shadowed ?? [])
|
||||
}
|
||||
|
||||
private readonly shadowed: Set<string>
|
||||
|
||||
resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
||||
if (this.shadowed.has(ref)) return Promise.resolve({ value: 'from-env', source: 'env' })
|
||||
const value = this.values.get(ref)
|
||||
return Promise.resolve(value === undefined ? undefined : { value, source: 'file' })
|
||||
}
|
||||
|
||||
describe(ref: CredentialRef): Promise<CredentialInfo> {
|
||||
if (this.shadowed.has(ref)) return Promise.resolve({ configured: true, source: 'env', writable: false })
|
||||
const configured = this.values.has(ref)
|
||||
return Promise.resolve({ configured, ...configured ? { source: 'file' } : {}, writable: true })
|
||||
}
|
||||
|
||||
set(ref: CredentialRef, value: string): Promise<void> {
|
||||
if (this.shadowed.has(ref)) {
|
||||
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
|
||||
}
|
||||
this.values.set(ref, value)
|
||||
this.ctx.emit('credentials/updated', ref)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
unset(ref: CredentialRef): Promise<void> {
|
||||
if (this.shadowed.has(ref)) {
|
||||
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
|
||||
}
|
||||
this.values.delete(ref)
|
||||
this.ctx.emit('credentials/updated', ref)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** Catalog-serving adapter stub for the llm.models path. */
|
||||
class CatalogAdapter extends LlmAdapter {
|
||||
constructor(private readonly name: string, private readonly models: readonly string[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: this.name }
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.models.map(id => ({ provider, id, name: id })))
|
||||
}
|
||||
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('not exercised')
|
||||
}
|
||||
}
|
||||
|
||||
class BrokenCatalogAdapter extends CatalogAdapter {
|
||||
override listModels(): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.reject(new Error('catalog backend down'))
|
||||
}
|
||||
}
|
||||
|
||||
const NS = settingsNamespace('llm-deepseek')
|
||||
|
||||
const AdapterConfig = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().default('DEEPSEEK_API_KEY'),
|
||||
baseURL: z.string(),
|
||||
})
|
||||
|
||||
async function harness(options?: {
|
||||
settings?: false | { doc?: Record<string, unknown>; readOnly?: boolean }
|
||||
credentials?: false | { shadowed?: string[] }
|
||||
/** Skip the directory registration to exercise a namespace the proxy does not expose. */
|
||||
configurableProviders?: false
|
||||
}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LlmService)
|
||||
if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
|
||||
if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
|
||||
// The proxy serves only namespaces a configurable provider addresses, which
|
||||
// is what the real LLM plugins declare at load; the tests mirror that.
|
||||
if (options?.configurableProviders !== false) {
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
||||
])
|
||||
}
|
||||
// Host-stream opener reads the committed-workspace baseline; the stub
|
||||
// suffices — the real workspace composition is api-proxy-workspace.spec's.
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Drain `count` host frames matching `types`, then abort the stream. */
|
||||
async function collectHost(
|
||||
api: ReturnType<typeof createApiProxy>,
|
||||
types: string[],
|
||||
count: number,
|
||||
run: () => Promise<void>,
|
||||
): Promise<HostFrame[]> {
|
||||
const abort = new AbortController()
|
||||
const frames: HostFrame[] = []
|
||||
const stream = api.events.host(request({}), abort.signal)
|
||||
const consume = (async () => {
|
||||
for await (const frame of stream) {
|
||||
if (!types.includes(frame.payload.type)) continue
|
||||
frames.push(frame.payload)
|
||||
if (frames.length >= count) abort.abort()
|
||||
}
|
||||
})()
|
||||
await run()
|
||||
await consume
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('settings domain', () => {
|
||||
it('reports an actionable error when no settings provider is mounted', async () => {
|
||||
const ctx = await harness({ settings: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.settings.describe(request({})))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('dsh-settings-local')
|
||||
})
|
||||
|
||||
it('describes layered redacted namespaces with their secret slots', async () => {
|
||||
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } } } })
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.writable).toBe(true)
|
||||
expect(value.namespaces).toHaveLength(1)
|
||||
const view = value.namespaces[0]!
|
||||
expect(view.ns).toBe('llm-deepseek')
|
||||
expect(view.applies).toBe('live')
|
||||
expect((view.schema as { refs?: unknown }).refs).toBeDefined()
|
||||
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://user' })
|
||||
expect(view.base).toEqual({ baseURL: 'https://base' })
|
||||
expect(view.user).toEqual({ baseURL: 'https://user' })
|
||||
expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
||||
expect(JSON.stringify(value)).not.toContain('user-secret')
|
||||
})
|
||||
|
||||
it('serves only namespaces a registered model provider addresses', async () => {
|
||||
// The settings seam is general: any plugin may register a namespace for
|
||||
// its own configuration. The Web configuration plane is not — it is the
|
||||
// model-provider surface, and a namespace nothing in the provider
|
||||
// directory addresses must be invisible and unwritable here, so a future
|
||||
// plugin cannot become remotely configurable just by registering.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek'])
|
||||
|
||||
for (const response of [
|
||||
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
|
||||
await api.settings.replace(request({ ns: 'some-other-plugin', section: {} })),
|
||||
]) {
|
||||
const error = expectErr(response)
|
||||
expect(error.code).toBe('settings-not-exposed')
|
||||
expect(error.details).toEqual({ ns: 'some-other-plugin' })
|
||||
}
|
||||
// The write never reached the seam.
|
||||
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
|
||||
})
|
||||
|
||||
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
|
||||
const ctx = await harness({ configurableProviders: false })
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces).toEqual([])
|
||||
expect(expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).code)
|
||||
.toBe('settings-not-exposed')
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => {
|
||||
// Editing `models` changes no route, so llm/adapters-updated never fires
|
||||
// and an open model picker kept serving the old catalog. And storing an
|
||||
// override equal to the resolved value emits nothing on settings/updated,
|
||||
// so another tab never learned the field became overridden.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
|
||||
await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } }))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'llm-deepseek' },
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
// The resolved value never moved: base already said https://base.
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value)
|
||||
.toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' })
|
||||
})
|
||||
|
||||
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const opened = expectOk(await api.settings.describe(request({}))).namespaces[0]!.revision
|
||||
expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://first' }, expectedRevision: opened })))
|
||||
.revision).toBe(opened + 1)
|
||||
const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://second' }, expectedRevision: opened })))
|
||||
expect(error.code).toBe('settings-conflict')
|
||||
expect(error.details).toEqual({ ns: 'llm-deepseek', expected: opened, actual: opened + 1 })
|
||||
// The refused write changed nothing.
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.user).toEqual({ baseURL: 'https://first' })
|
||||
})
|
||||
|
||||
it('updates the user layer, answers with the new redacted view, and broadcasts the frame', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
|
||||
const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } })))
|
||||
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' })
|
||||
expect(view.user).toEqual({ baseURL: 'https://next' })
|
||||
expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
||||
expect(JSON.stringify(view)).not.toContain('sk-new')
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'llm-deepseek' }])
|
||||
})
|
||||
|
||||
it('replace resets the user layer wholesale', async () => {
|
||||
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { baseURL: 'https://user' } } } })
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const view = expectOk(await api.settings.replace(request({ ns: 'llm-deepseek', section: {} })))
|
||||
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY' })
|
||||
expect(view.user).toEqual({})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an invalid namespace name', 'Not A Namespace', {}],
|
||||
['a schema-invalid patch', 'llm-deepseek', { baseURL: 42 }],
|
||||
])('rejects %s as settings-rejected', async (_case, ns, patch) => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.settings.update(request({ ns, patch })))
|
||||
expect(error.code).toBe('settings-rejected')
|
||||
expect(error.details).toEqual({ ns })
|
||||
})
|
||||
|
||||
it('answers an unregistered namespace exactly like an unexposed one', async () => {
|
||||
// Deliberately indistinguishable: separating "does not exist" from
|
||||
// "exists but is not yours to configure" would let a caller enumerate the
|
||||
// registered namespaces one probe at a time.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const unknown = expectErr(await api.settings.update(request({ ns: 'unknown-ns', patch: {} })))
|
||||
const unexposed = expectErr(await api.settings.update(request({ ns: 'some-other-plugin', patch: {} })))
|
||||
expect(unknown.code).toBe('settings-not-exposed')
|
||||
expect(unexposed.code).toBe(unknown.code)
|
||||
expect(unexposed.message.replace('some-other-plugin', 'unknown-ns')).toBe(unknown.message)
|
||||
})
|
||||
|
||||
it('maps a read-only provider refusal onto the same rejection', async () => {
|
||||
const ctx = await harness({ settings: { readOnly: true } })
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.writable).toBe(false)
|
||||
const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: {} })))
|
||||
expect(error.code).toBe('settings-rejected')
|
||||
expect(error.message).toContain('read-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('credentials domain', () => {
|
||||
it('reports an actionable error when no credential provider is mounted', async () => {
|
||||
const ctx = await harness({ credentials: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.credentials.describe(request({ refs: ['A'] })))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('dsh-credentials-local')
|
||||
})
|
||||
|
||||
it('describes value-free views and flips state through set/unset with frames', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
|
||||
expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } })
|
||||
const frames = await collectHost(api, ['host/credentials-changed'], 2, async () => {
|
||||
expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' })))
|
||||
const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
|
||||
expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } })
|
||||
expect(JSON.stringify(after)).not.toContain('sk-secret')
|
||||
expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' })))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
|
||||
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a shadowed write onto credential-rejected for set and unset alike', async () => {
|
||||
const ctx = await harness({ credentials: { shadowed: ['DEEPSEEK_API_KEY'] } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const described = expectOk(await api.credentials.describe(request({ refs: ['DEEPSEEK_API_KEY'] })))
|
||||
expect(described.credentials['DEEPSEEK_API_KEY']).toEqual({ configured: true, source: 'env', writable: false })
|
||||
const setError = expectErr(await api.credentials.set(request({ ref: 'DEEPSEEK_API_KEY', value: 'x' })))
|
||||
expect(setError.code).toBe('credential-rejected')
|
||||
expect(setError.details).toEqual({ ref: 'DEEPSEEK_API_KEY' })
|
||||
const unsetError = expectErr(await api.credentials.unset(request({ ref: 'DEEPSEEK_API_KEY' })))
|
||||
expect(unsetError.code).toBe('credential-rejected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('llm domain', () => {
|
||||
it('merges the configurable directory with live routes and appends undeclared ones', async () => {
|
||||
const ctx = await harness({ configurableProviders: false })
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
])
|
||||
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash']))
|
||||
ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1']))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const value = expectOk(await api.llm.providers(request({})))
|
||||
expect(value.providers).toEqual([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false },
|
||||
{ provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('serves the host-scoped catalog with per-provider failures contained', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash', 'deepseek-v4-pro']))
|
||||
ctx.llm.registerAdapter(['broken'], new BrokenCatalogAdapter('Broken', []))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const value = expectOk(await api.llm.models(request({})))
|
||||
expect(value.groups).toEqual([{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
||||
{ id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
||||
],
|
||||
}])
|
||||
expect(value.failures).toEqual([{ id: 'broken', name: 'Broken', message: 'catalog backend down' }])
|
||||
})
|
||||
|
||||
it('broadcasts host/models-changed at every topology commit point', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/models-changed'], 2, async () => {
|
||||
const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', []))
|
||||
dispose()
|
||||
return Promise.resolve()
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }])
|
||||
})
|
||||
})
|
||||
163
packages/host/apiproxy/tests/api-proxy-fork.spec.ts
Normal file
163
packages/host/apiproxy/tests/api-proxy-fork.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/** Session-fork boundaries, lineage, and inherited model routing. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
async function composed(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
ctx.agents.setFactory({
|
||||
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
|
||||
const session = ctx.sessions.create(options.sessionId, {
|
||||
...options.seed === undefined ? {} : { seed: [...options.seed] },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
const agent = {} as Agent
|
||||
const agentCtx = ownerCtx.extend({ agent })
|
||||
Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx })
|
||||
await options.setup?.(agentCtx)
|
||||
ctx.agents.register(agent)
|
||||
return { agent, dispose: () => Promise.resolve() }
|
||||
},
|
||||
resume: () => Promise.reject(new Error('fork test sources are live')),
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session {
|
||||
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
|
||||
for (let turn = 1; turn <= turns; turn++) {
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
if (openTail) {
|
||||
session.append('turn/start', { turn: turns + 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'open prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
return session
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, {
|
||||
provider: 'default-provider',
|
||||
model: 'default-model',
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
describe('sessions.fork', () => {
|
||||
it('cuts at the anchored completed turn and records lineage and cwd', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-source', 2)
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: 1 }))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) return
|
||||
const child = ctx.sessions.get(response.result.value.sessionId)
|
||||
expect(child?.events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'user/message', 'turn/end', 'session/end-seed',
|
||||
])
|
||||
expect(child?.header.parentSession).toBe(source.id)
|
||||
expect(child?.header.cwd).toBe('/proj')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses the last completed turn only for omitted and past-end anchors', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-tail', 2, true)
|
||||
const proxy = api(ctx)
|
||||
const expectedTypes = [
|
||||
'turn/start', 'user/message', 'turn/end',
|
||||
'turn/start', 'user/message', 'turn/end',
|
||||
'session/end-seed',
|
||||
]
|
||||
const omitted = await proxy.sessions.fork(request({ sessionId: source.id }))
|
||||
expect(omitted.result.ok).toBe(true)
|
||||
if (omitted.result.ok) {
|
||||
expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.map(event => event.type))
|
||||
.toEqual(expectedTypes)
|
||||
}
|
||||
const pastEnd = await proxy.sessions.fork(request({ sessionId: source.id, atSeq: 999 }))
|
||||
expect(pastEnd.result.ok).toBe(true)
|
||||
if (pastEnd.result.ok) {
|
||||
expect(ctx.sessions.get(pastEnd.result.value.sessionId)?.events.map(event => event.type))
|
||||
.toEqual(expectedTypes)
|
||||
}
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an in-log anchor whose turn is still open', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-open', 1, true)
|
||||
const anchor = source.events.at(-1)?.seq ?? 0
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'fork-unavailable', details: { sessionId: source.id } },
|
||||
})
|
||||
if (!response.result.ok) expect(response.result.error.message).toMatch(/has not completed/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('installs the latest logged model target before the child can run', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-routed', 1)
|
||||
source.append('request/header', {
|
||||
header: {
|
||||
config: {
|
||||
provider: 'inherited-provider',
|
||||
model: 'inherited-model',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
},
|
||||
},
|
||||
reason: 'initial',
|
||||
})
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: source.id }))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) return
|
||||
const child = ctx.agents.get(response.result.value.sessionId)
|
||||
if (child === undefined) throw new Error('fork did not publish the child agent')
|
||||
const assembly = await child.ctx.systemPrompt.assemble()
|
||||
expect(assembly.variables).toMatchObject({
|
||||
provider: 'inherited-provider',
|
||||
model: 'inherited-model',
|
||||
})
|
||||
const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' }
|
||||
await expect(agentEvents(child.ctx, child).waterfall(
|
||||
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback),
|
||||
)).resolves.toMatchObject({
|
||||
provider: 'inherited-provider',
|
||||
model: 'inherited-model',
|
||||
reasoningEffort: 'high',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -85,9 +85,9 @@ async function harness(logged?: {
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
|
||||
{ provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||
{ provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
|
||||
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
|
||||
{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||
{ provider: 'deepseek-official', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
|
||||
], REASONING))
|
||||
ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
|
||||
ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
|
||||
@@ -120,20 +120,20 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
|
||||
describe('Web session model selection', () => {
|
||||
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
|
||||
const { ctx, sessionId } = await harness({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.current).toEqual({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
expect(catalog.groups).toEqual([{
|
||||
id: 'deepseek',
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
|
||||
@@ -165,43 +165,43 @@ describe('Web session model selection', () => {
|
||||
|
||||
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
expect((await ctx.systemPrompt.assemble()).variables)
|
||||
.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
|
||||
const selected = expectValue(await api.sessions.selectModel(request({
|
||||
sessionId,
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})))
|
||||
expect(selected.selected).toEqual({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables)
|
||||
.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
|
||||
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toMatchObject({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
|
||||
const unsupported = await api.sessions.selectModel(request({
|
||||
sessionId,
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'medium',
|
||||
}))
|
||||
@@ -209,7 +209,7 @@ describe('Web session model selection', () => {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'model-unavailable',
|
||||
message: 'provider "deepseek" model "private-preview" does not support reasoning effort "medium"',
|
||||
message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('Web session model selection', () => {
|
||||
},
|
||||
})
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
|
||||
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { CallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
@@ -35,6 +35,35 @@ function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'pr
|
||||
})
|
||||
}
|
||||
|
||||
/** Append a production-shaped human prompt to the session surface. */
|
||||
function appendUserText(session: Session, text: string): SessionEvent {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Append a production-shaped assistant message to the session surface. */
|
||||
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
|
||||
return session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a plugin-owned log-only event. The host proxy is projection-only, so it
|
||||
* declares no compaction vocabulary; the cast writes the real event shape without
|
||||
* depending on the owning package.
|
||||
*/
|
||||
function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
|
||||
return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
|
||||
}
|
||||
|
||||
async function harness(): Promise<{ ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -207,6 +236,55 @@ describe('mux live view computation', () => {
|
||||
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const first = appendUserText(session, 'first prompt')
|
||||
appendAssistantText(session, 'first reply', 1)
|
||||
const third = appendUserText(session, 'second prompt')
|
||||
appendAssistantText(session, 'second reply', 2)
|
||||
const shadowed = [...session.surface.nodes]
|
||||
// A compaction transaction: log-only provenance immediately followed by the
|
||||
// replacement that shadows the range.
|
||||
const summary = appendExtension(session, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
|
||||
shadowedSeqs: shadowed,
|
||||
shadowedTokenCount: 0,
|
||||
provider: 'p',
|
||||
model: 'm',
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
|
||||
sourceEventSeqs: [...shadowed, summary.seq],
|
||||
})
|
||||
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('t-hist-compact'),
|
||||
payload: { sessionId: session.id, maxMessages: 2 },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const page = response.result.value.events.map(entry => entry.event)
|
||||
// Two append-origin messages fill the page even though a replacement copy of
|
||||
// the same event type sits in the window: the copy is model-only.
|
||||
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
|
||||
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
|
||||
expect(page.some(event => event.seq === first.seq)).toBe(false)
|
||||
expect(response.result.value.hasMore).toBe(true)
|
||||
// The range stays contiguous, so the checkpoint's provenance is readable on
|
||||
// the same page as the checkpoint itself.
|
||||
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
|
||||
expect(summaryIndex).toBeGreaterThan(-1)
|
||||
expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
|
||||
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
|
||||
})
|
||||
|
||||
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
@@ -24,6 +24,9 @@ function scriptedApi(overrides: {
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
events?: Partial<ApiProxy['events']>
|
||||
goals?: Partial<ApiProxy['goals']>
|
||||
settings?: Partial<ApiProxy['settings']>
|
||||
credentials?: Partial<ApiProxy['credentials']>
|
||||
llm?: Partial<ApiProxy['llm']>
|
||||
respond?: ApiProxy['respond']
|
||||
} = {}): ApiProxy {
|
||||
async function *empty<F>(): AsyncGenerator<RpcRequest<F>> { /* no frames */ }
|
||||
@@ -36,10 +39,10 @@ function scriptedApi(overrides: {
|
||||
history: r => ok(r, {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
models: r => ok(r, {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
}),
|
||||
@@ -47,6 +50,7 @@ function scriptedApi(overrides: {
|
||||
selected: { provider: r.payload.provider, model: r.payload.model },
|
||||
}),
|
||||
rename: r => ok(r, { title: 'renamed', seq: 0 }),
|
||||
fork: r => ok(r, { sessionId: sid('s-fork') }),
|
||||
prompt: r => ok(r, { accepted: true as const }),
|
||||
updateQueue: r => ok(r, { accepted: true as const }),
|
||||
cancel: r => ok(r, { accepted: true as const }),
|
||||
@@ -82,6 +86,24 @@ function scriptedApi(overrides: {
|
||||
clear: err,
|
||||
...overrides.goals,
|
||||
},
|
||||
settings: {
|
||||
describe: r => ok(r, { writable: true, namespaces: [] }),
|
||||
update: err,
|
||||
replace: err,
|
||||
mutate: err,
|
||||
...overrides.settings,
|
||||
},
|
||||
credentials: {
|
||||
describe: r => ok(r, { credentials: {} }),
|
||||
set: err,
|
||||
unset: err,
|
||||
...overrides.credentials,
|
||||
},
|
||||
llm: {
|
||||
providers: r => ok(r, { providers: [] }),
|
||||
models: r => ok(r, { groups: [], failures: [] }),
|
||||
...overrides.llm,
|
||||
},
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
}
|
||||
@@ -91,6 +113,15 @@ function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient {
|
||||
return new InProcessApiClient(toFetchHandler(api), timeoutMs)
|
||||
}
|
||||
|
||||
/** Wrap one scripted method to record its invocation into `seen` before responding. */
|
||||
function recorderInto(seen: { method: string; payload: unknown }[]) {
|
||||
return <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
|
||||
(r: RpcRequest<P>): Promise<RpcResponse<V>> => {
|
||||
seen.push({ method, payload: r.payload })
|
||||
return respond(r)
|
||||
}
|
||||
}
|
||||
|
||||
describe('unary round trip', () => {
|
||||
it('carries payload out and value back through the full wire form', async () => {
|
||||
let seen: RpcRequest<{ cursor?: string }> | undefined
|
||||
@@ -110,6 +141,21 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('routes session fork with its optional cut anchor through the wire', async () => {
|
||||
let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
fork: (request) => {
|
||||
seen = request
|
||||
return ok(request, { sessionId: sid('s-child') })
|
||||
},
|
||||
},
|
||||
})
|
||||
const response = await client(api).sessions.fork({ sessionId: sid('s-parent'), atSeq: 7 })
|
||||
expect(seen?.payload).toEqual({ sessionId: 's-parent', atSeq: 7 })
|
||||
expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } })
|
||||
})
|
||||
|
||||
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
@@ -446,11 +492,7 @@ describe('goals unary surface', () => {
|
||||
|
||||
it('round-trips every goal method with its own payload and value shape', async () => {
|
||||
const seen: { method: string; payload: unknown }[] = []
|
||||
const record = <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
|
||||
(r: RpcRequest<P>): Promise<RpcResponse<V>> => {
|
||||
seen.push({ method, payload: r.payload })
|
||||
return respond(r)
|
||||
}
|
||||
const record = recorderInto(seen)
|
||||
const api = scriptedApi({
|
||||
goals: {
|
||||
create: record('goal.create', r => ok(r, ack)),
|
||||
@@ -564,3 +606,84 @@ describe('envelope tap', () => {
|
||||
expect(batches).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('config unary surface', () => {
|
||||
it('round-trips every settings/credentials/llm method with its own payload and value shape', async () => {
|
||||
const seen: { method: string; payload: unknown }[] = []
|
||||
const record = recorderInto(seen)
|
||||
const view = {
|
||||
ns: 'llm-deepseek',
|
||||
schema: { uid: 1, refs: { 1: { type: 'object' } } },
|
||||
value: { baseURL: 'https://next' },
|
||||
user: { baseURL: 'https://next' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: true }],
|
||||
revision: 0,
|
||||
}
|
||||
const providerRow = {
|
||||
provider: 'openai',
|
||||
displayName: 'openai',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'openai'],
|
||||
active: false,
|
||||
}
|
||||
const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
|
||||
const api = scriptedApi({
|
||||
settings: {
|
||||
describe: record('settings.describe', r => ok(r, { writable: true, namespaces: [view] })),
|
||||
update: record('settings.update', r => ok(r, view)),
|
||||
replace: record('settings.replace', r => ok(r, view)),
|
||||
mutate: record('settings.mutate', r => ok(r, view)),
|
||||
},
|
||||
credentials: {
|
||||
describe: record('credentials.describe', r => ok(r, { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } })),
|
||||
set: record('credentials.set', r => ok(r, {})),
|
||||
unset: record('credentials.unset', r => ok(r, {})),
|
||||
},
|
||||
llm: {
|
||||
providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
|
||||
models: record('llm.models', r => ok(r, { groups: [group], failures: [] })),
|
||||
},
|
||||
})
|
||||
const c = client(api)
|
||||
|
||||
const described = await c.settings.describe({})
|
||||
expect(described.result).toEqual({ ok: true, value: { writable: true, namespaces: [view] } })
|
||||
const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
|
||||
expect(updated.result).toEqual({ ok: true, value: view })
|
||||
const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} })
|
||||
expect(replaced.result).toEqual({ ok: true, value: view })
|
||||
const mutated = await c.settings.mutate({
|
||||
ns: 'llm-deepseek',
|
||||
ops: [{ op: 'unset', path: ['baseURL'] }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
expect(mutated.result).toEqual({ ok: true, value: view })
|
||||
const creds = await c.credentials.describe({ refs: ['OPENAI_API_KEY'] })
|
||||
expect(creds.result).toEqual({ ok: true, value: { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } } })
|
||||
expect((await c.credentials.set({ ref: 'OPENAI_API_KEY', value: 'sk-x' })).result).toEqual({ ok: true, value: {} })
|
||||
expect((await c.credentials.unset({ ref: 'OPENAI_API_KEY' })).result).toEqual({ ok: true, value: {} })
|
||||
const providers = await c.llm.providers({})
|
||||
expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
|
||||
const models = await c.llm.models({})
|
||||
expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } })
|
||||
|
||||
expect(seen.map(call => call.method)).toEqual([
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.providers', 'llm.models',
|
||||
])
|
||||
expect(seen[1]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
|
||||
expect(seen[3]?.payload)
|
||||
.toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
|
||||
expect(seen[5]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
|
||||
})
|
||||
|
||||
it('rejects an invalid credential reference name at the carrier boundary', async () => {
|
||||
const api = scriptedApi()
|
||||
const response = await client(api).credentials.set({ ref: 'not a var', value: 'x' })
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('bad-request')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
},
|
||||
@@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async rename(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
|
||||
},
|
||||
async fork(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-fork' as never } } }
|
||||
},
|
||||
async prompt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
@@ -167,6 +170,39 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
async describe(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, namespaces: [] } } }
|
||||
},
|
||||
async update(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
|
||||
},
|
||||
async replace(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
|
||||
},
|
||||
async mutate(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
async describe(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { credentials: {} } } }
|
||||
},
|
||||
async set(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: {} } }
|
||||
},
|
||||
async unset(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: {} } }
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
async providers(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { providers: [] } } }
|
||||
},
|
||||
async models(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } }
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: (_request, signal) => stream(muxFrames, signal),
|
||||
host: (_request, signal) => stream(hostFrames, signal),
|
||||
@@ -216,7 +252,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
const selected = await c.sessions.selectModel({
|
||||
sessionId: 's' as never,
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
@@ -224,7 +260,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
ok: true,
|
||||
value: {
|
||||
selected: {
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: 'max',
|
||||
},
|
||||
|
||||
@@ -160,13 +160,13 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionHistoryValueSchema.parse({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}).hasMore).toBe(false)
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [{
|
||||
id: 'deepseek-v4-flash',
|
||||
@@ -186,12 +186,12 @@ describe('sessions domain schemas', () => {
|
||||
}).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash')
|
||||
expect(sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
}).reasoningEffort).toBe('max')
|
||||
expect(sessionSelectModelValueSchema.parse({
|
||||
selected: { provider: 'deepseek', model: 'deepseek-v4-pro', reasoningEffort: 'max' },
|
||||
selected: { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' },
|
||||
}).selected.reasoningEffort).toBe('max')
|
||||
expect(() => sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
@@ -200,14 +200,14 @@ describe('sessions domain schemas', () => {
|
||||
})).toThrow()
|
||||
expect(() => sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'm',
|
||||
reasoningEffort: '',
|
||||
})).toThrow()
|
||||
expect(() => sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek', model: 'm' },
|
||||
current: { provider: 'deepseek-official', model: 'm' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'm', name: 'M', reasoning: { efforts: [] } }],
|
||||
}],
|
||||
@@ -399,6 +399,17 @@ describe('events frame schemas', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
|
||||
})
|
||||
|
||||
it('carries a question presentation intent through, and rejects an unknown one', () => {
|
||||
const intent = { kind: 'plan-review', approve: 'Approve' }
|
||||
expect(askUserQuestionItemSchema.parse({
|
||||
id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }], intent,
|
||||
}).intent).toEqual(intent)
|
||||
// An unrecognised tag is a rejected frame, not a silently generic render.
|
||||
for (const invalid of [{ kind: 'plan-review' }, { kind: 'poll', approve: 'Approve' }, { approve: 'Approve' }]) {
|
||||
expect(() => askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?', intent: invalid })).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a queue snapshot with malformed items', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow()
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user