Merge origin/master into goal-ui: adopt the rewritten client core and apiproxy carrier
Conflict rulings follow the projection-reattach plan: - host/runtime package (deleted on master): take master; the PR's boot composition moves to the cordis.yml roster and its goals handlers will be re-landed in dsh-host-apiproxy; the session.prompt slash interception and its spec are dropped entirely (superseded by command.execute + command/run logging). - client core (rewritten on master): take master; the PR's Session goal fields/methods, ConversationSnapshot.goal, goalActions injection, and the hard-mounted GoalBar are all superseded by the 'goal' session projection (useProjection) and will return as the ui-goal plugin. - wire contract: union of master's workspace/command/skill domains and the PR's goal domain, minus goal.get (the read side is the projection block + session/projection frames; six mutation RPCs stay). - GoalBar component and spec leave ui-conversation (they re-land in the new ui-goal package); IconSparkle16 stays in ui-conversation chat. - The web-slash-command-dispatch note documents the dropped interception and is removed; the goal-bar note will be rewritten for the projection model. - pnpm-lock.yaml taken from master (reinstall recomputes).
This commit is contained in:
6
packages/host/apiproxy/README.i18n.yaml
Normal file
6
packages/host/apiproxy/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: c20887b73b9b9deb278db30d34d84df07257d664
|
||||
README.zh.md: 18a2f97477e5f57127371429b0dd59ba01f04341
|
||||
@@ -1,6 +1,8 @@
|
||||
# @deepseek-ai/dsh-host-apiproxy
|
||||
|
||||
The ApiProxy front layer every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser) and the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side). Host assembly lives in `dsh-host-runtime`.
|
||||
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).
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
@@ -8,6 +10,20 @@ 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`'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 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`.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests.
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `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.
|
||||
|
||||
## 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.
|
||||
@@ -22,6 +38,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 `dsh-host-runtime` and is still a stub there.
|
||||
- **`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.
|
||||
- **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** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser.
|
||||
|
||||
44
packages/host/apiproxy/README.zh.md
Normal file
44
packages/host/apiproxy/README.zh.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# @deepseek-ai/dsh-host-apiproxy
|
||||
|
||||
[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`)
|
||||
|
||||
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。
|
||||
|
||||
分层与协议决策记录在 [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` 的尾页(不带 `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.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包定义客户端与宿主间的协议契约和载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **Linux 原生选择器依赖桌面工具**:Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-apiproxy",
|
||||
"description": "ApiProxy front layer: the TS contract (api/) and the fetch carrier pair (fetch/); host assembly lives in dsh-host-runtime",
|
||||
"description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -40,12 +40,19 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"schemastery": "^3.18.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -53,6 +60,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
|
||||
1303
packages/host/apiproxy/src/api-proxy.ts
Normal file
1303
packages/host/apiproxy/src/api-proxy.ts
Normal file
File diff suppressed because it is too large
Load Diff
44
packages/host/apiproxy/src/api/commands.schema.ts
Normal file
44
packages/host/apiproxy/src/api/commands.schema.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* commands domain zod schemas (names derived from map keys: commandListRequestSchema /
|
||||
* commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { CommandDescriptor } from './commands.ts'
|
||||
|
||||
/** CommandDescriptor row of command.list. */
|
||||
export const commandDescriptorSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
input: z.object({ hint: z.string() }).optional(),
|
||||
}) satisfies z.ZodType<Wire<CommandDescriptor>>
|
||||
|
||||
/** command.list request payload. */
|
||||
export const commandListRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.list'>>>
|
||||
|
||||
/** command.list response value. */
|
||||
export const commandListValueSchema = z.object({
|
||||
commands: z.array(commandDescriptorSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'command.list'>>>
|
||||
|
||||
/** command.execute request payload. */
|
||||
export const commandExecuteRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
line: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
|
||||
|
||||
/** CommandId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const commandIdSchema = z.string().min(1) as unknown as z.ZodType<CommandId>
|
||||
|
||||
/** command.execute response value: pure admission — outcomes ride the logged
|
||||
* lifecycle events; commandId (present exactly when matched) correlates with them. */
|
||||
export const commandExecuteValueSchema = z.object({
|
||||
matched: z.boolean(),
|
||||
commandId: commandIdSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>
|
||||
48
packages/host/apiproxy/src/api/commands.ts
Normal file
48
packages/host/apiproxy/src/api/commands.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* commands domain contract: the web catalog/dispatch face of the host command
|
||||
* registry (`ctx.commands`). Both methods address one session's agent via
|
||||
* `sessionId` — every served session has an Agent (Session+Agent are born
|
||||
* together), so there is no agent-less surface on this wire.
|
||||
*/
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
* Handler-free command view served to clients. Wire mirror of the host
|
||||
* registry descriptor (which stays host-side with its cordis dependencies);
|
||||
* no source field — the host descriptor has none.
|
||||
*/
|
||||
export interface CommandDescriptor {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
readonly name: string
|
||||
/** Human-readable summary used in discovery UI. */
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: { readonly hint: string }
|
||||
}
|
||||
|
||||
/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */
|
||||
export interface CommandsApi {
|
||||
/**
|
||||
* Lists the addressed agent's effective command catalog (name-sorted,
|
||||
* globals plus its scoped shadows).
|
||||
*/
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
|
||||
|
||||
/**
|
||||
* Parses and executes one slash-command line against the addressed agent
|
||||
* without sending it to the model — pure admission semantics. matched=false
|
||||
* when syntax or name does not resolve (the client falls back to its
|
||||
* default sink). The handler's outcome does NOT ride the response: the host
|
||||
* executor durably logs the lifecycle (`command/run`/`command/done`), which
|
||||
* broadcasts on the mux stream and renders as a persistent flow node.
|
||||
* `commandId` is present exactly when matched — the minted lifecycle
|
||||
* pairing id, letting the issuing client correlate this acknowledgment
|
||||
* with that flow node. The signal rides beside the request, never on the
|
||||
* wire: the fetch carrier's request signal cancels the running handler.
|
||||
*/
|
||||
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
|
||||
}
|
||||
@@ -10,33 +10,53 @@ import type { HostFrame, MuxFrame } from './events.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import { sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
export const askUserQuestionItemSchema = z.object({
|
||||
id: z.string(),
|
||||
question: z.string(),
|
||||
header: z.string().optional(),
|
||||
detail: z.string().optional(),
|
||||
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
|
||||
multiSelect: z.boolean().optional(),
|
||||
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
|
||||
|
||||
/** Unified message envelope carried by transient queue frames. */
|
||||
const messageSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
role: z.union([z.literal('system'), z.literal('user'), z.literal('assistant')]),
|
||||
content: z.array(contentBlockSchema),
|
||||
source: z.looseObject({ kind: z.string() }),
|
||||
})
|
||||
|
||||
/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
|
||||
export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
|
||||
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
|
||||
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
|
||||
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
|
||||
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema) }),
|
||||
// Non-empty by wire contract: the user-interaction service rejects empty
|
||||
// batches at ask() (EMPTY_QUESTIONS), so an empty frame is host breakage
|
||||
// and must fail loud here, not reach the composer.
|
||||
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
|
||||
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
|
||||
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }),
|
||||
// value stays wide: it already passed its unit's own schema on the host,
|
||||
// and deep-validating here would import every domain's schema into the carrier.
|
||||
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<MuxFrame>
|
||||
|
||||
/** HostFrame union (payload slot of a host-stream ServerRequest). */
|
||||
export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional() }),
|
||||
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
|
||||
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
|
||||
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
|
||||
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
|
||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
|
||||
// Client-side consumers take the render-intent vocabulary from the contract;
|
||||
// dsh-tools remains its owner.
|
||||
@@ -33,8 +35,9 @@ export type ToolEventView =
|
||||
export interface EventsApi {
|
||||
/**
|
||||
* All-session aggregated mux stream. On open, emits a subscribed control frame for every
|
||||
* attached session and replays each session's still-pending approval/question requested
|
||||
* frames (rpcId reused verbatim — the refresh-recovery baseline).
|
||||
* attached session, then replays each session's still-pending approval/question requested
|
||||
* frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the
|
||||
* generic projection pair (history-tail projections block + session/projection frames).
|
||||
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
|
||||
* stream + refetch history.
|
||||
*/
|
||||
@@ -58,12 +61,55 @@ export type MuxFrame =
|
||||
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
|
||||
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
|
||||
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
|
||||
/**
|
||||
* A message entered the addressed agent's inbox. A queued message is not
|
||||
* model-visible, so there is no session event to carry it; this transient
|
||||
* frame is the only wire signal. On stream open the
|
||||
* host replays the current queue snapshot for every attached session (same
|
||||
* refresh-recovery baseline as pending questions); queue clearing on cancel
|
||||
* has no dedicated frame — clients fold it from the status flip.
|
||||
* `steering` is the host's acceptance-time queue classification and remains
|
||||
* authoritative in reconnect snapshots. `message.source` carries the prompt's rpcId
|
||||
* when the message came over this wire (the client's provisional-echo
|
||||
* reconciliation key).
|
||||
*/
|
||||
| { type: 'session/queued'; sessionId: SessionId; message: Message; steering: boolean }
|
||||
/**
|
||||
* One projection unit's finished value changed (session-projection RFC).
|
||||
* Live push state, never logged — replay recomputes on the host (the
|
||||
* tool-view posture). `value` is the unit's schema-validated view output;
|
||||
* `seq` is the unit's watermark at emission. Clients keep one generic
|
||||
* per-session value store under higher-seq-wins, seeded by the history
|
||||
* tail page's projections block.
|
||||
*/
|
||||
| { type: 'session/projection'; sessionId: SessionId; key: string; value: unknown; seq: number }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
/** Host stream frames. session-added carries the lineage anchor; agent-error is the only outlet for live failures with no turn position. */
|
||||
/**
|
||||
* Host stream frames. session-added carries the lineage anchor, the project
|
||||
* cwd, and the blank bit (the list-summary fields a client cannot wait for a
|
||||
* refresh to learn); the frame fires at session/created, so blank is
|
||||
* constantly true — clients flip it on the session's first
|
||||
* `host/session-status(running:true)` (a blank session never runs), and a
|
||||
* reconnecting client takes `session.list`'s summary.blank as authoritative.
|
||||
* agent-error is the only outlet for live failures with no turn position;
|
||||
* workspace-changed pushes the full new snapshot after every durable
|
||||
* workspace mutation (create/attach/order change — the client upserts, while
|
||||
* `workspace.list` provides the reconnect baseline); workspace-removed is the
|
||||
* committed registration-deletion increment and never implies directory or
|
||||
* session-log deletion.
|
||||
*/
|
||||
export type HostFrame =
|
||||
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId }
|
||||
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
|
||||
| { type: 'host/session-removed'; sessionId: SessionId }
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
|
||||
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
|
||||
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
|
||||
/**
|
||||
* The command registry changed (`commands/change` passthrough). Pure
|
||||
* invalidation signal, no payload: clients refetch `command.list` in the
|
||||
* background rather than diffing.
|
||||
*/
|
||||
| { type: 'host/commands-changed' }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
@@ -32,16 +32,6 @@ export const goalViewSchema = z.object({
|
||||
activation: z.union([z.literal('armed'), z.literal('disarmed')]),
|
||||
}) as unknown as z.ZodType<Wire<GoalView>>
|
||||
|
||||
/** goal.get request payload. */
|
||||
export const goalGetRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.get'>>>
|
||||
|
||||
/** goal.get response value. */
|
||||
export const goalGetValueSchema = z.object({
|
||||
goal: goalViewSchema.nullable(),
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.get'>>>
|
||||
|
||||
/** goal.create request payload. */
|
||||
export const goalCreateRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
|
||||
@@ -58,11 +58,8 @@ export interface EditGoalRequest {
|
||||
readonly maxGoalRounds?: number
|
||||
}
|
||||
|
||||
/** Goal-domain unary methods. */
|
||||
/** Goal-domain unary methods (mutations only: the read side is the 'goal' session projection). */
|
||||
export interface GoalsApi {
|
||||
/** Read the current goal for one session. Returns null when no goal is current. */
|
||||
get(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ goal: GoalView | null }>>
|
||||
|
||||
/** Create and arm a goal. */
|
||||
create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>):
|
||||
Promise<RpcResponse<{ goal: GoalView }>>
|
||||
|
||||
@@ -17,3 +17,21 @@ export const hostDescribeValueSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
attachedSessions: z.number().int().nonnegative(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
/** host.pickDirectory request payload (empty object literal). */
|
||||
export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.pickDirectory'>>>
|
||||
|
||||
/** host.pickDirectory response value; null means the user cancelled. */
|
||||
export const hostPickDirectoryValueSchema = z.object({
|
||||
path: z.string().nullable(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>
|
||||
|
||||
/** host.openPath request payload. */
|
||||
export const hostOpenPathRequestSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'host.openPath'>>>
|
||||
|
||||
/** host.openPath response value. */
|
||||
export const hostOpenPathValueSchema = z.object({
|
||||
opened: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.openPath'>>>
|
||||
|
||||
@@ -22,4 +22,20 @@ export interface HostApi {
|
||||
model?: string
|
||||
attachedSessions: number
|
||||
}>>
|
||||
|
||||
/** Open the operating system's single-directory picker; cancellation returns null. */
|
||||
pickDirectory(
|
||||
request: RpcRequest<{}>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ path: string | null }>>
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application
|
||||
* (Finder / Explorer / xdg-open hand-off). The browser carrier restricts this
|
||||
* privileged method to loopback, same-origin requests.
|
||||
*/
|
||||
openPath(
|
||||
request: RpcRequest<{ path: string }>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>>
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
@@ -14,6 +17,9 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
export interface ApiProxy {
|
||||
sessions: SessionsApi
|
||||
host: HostApi
|
||||
workspace: WorkspaceApi
|
||||
commands: CommandsApi
|
||||
skills: SkillsApi
|
||||
events: EventsApi
|
||||
goals: GoalsApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
@@ -21,8 +27,14 @@ export interface ApiProxy {
|
||||
}
|
||||
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason, CreateGoalRequest, EditGoalRequest } from './goals.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
@@ -42,7 +54,7 @@ export type {
|
||||
} from './rpc.ts'
|
||||
|
||||
// ---- Errors and ids ----
|
||||
export { RpcId } from './rpc.ts'
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
|
||||
// ---- Method registry and derived generics ----
|
||||
|
||||
@@ -6,18 +6,36 @@
|
||||
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Method name → method signature. Signatures are the single source of truth; payload/value types are always derived from here. */
|
||||
/**
|
||||
* Method name → method signature. Signatures are the single source of truth; payload/value
|
||||
* types are always derived from here. A method may declare a trailing AbortSignal after the
|
||||
* request (command.execute): the carrier passes its request signal, never a wire field.
|
||||
*/
|
||||
export interface RpcMethodMap {
|
||||
'session.list': SessionsApi['list']
|
||||
'session.create': SessionsApi['create']
|
||||
'session.history': SessionsApi['history']
|
||||
'session.models': SessionsApi['models']
|
||||
'session.selectModel': SessionsApi['selectModel']
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
'host.describe': HostApi['describe']
|
||||
'goal.get': GoalsApi['get']
|
||||
'host.pickDirectory': HostApi['pickDirectory']
|
||||
'host.openPath': HostApi['openPath']
|
||||
'workspace.list': WorkspaceApi['list']
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
'workspace.rename': WorkspaceApi['rename']
|
||||
'workspace.delete': WorkspaceApi['delete']
|
||||
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
|
||||
@@ -33,7 +33,15 @@ export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
|
||||
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
|
||||
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
|
||||
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
|
||||
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }),
|
||||
z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: 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({}) }),
|
||||
|
||||
@@ -30,7 +30,15 @@ export function RpcId(id: string): RpcId {
|
||||
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
|
||||
export interface RpcErrorDetailsMap {
|
||||
'bad-request': { issues: ZodIssue[] }
|
||||
'cancelled': {}
|
||||
'session-not-found': { sessionId: SessionId }
|
||||
'model-unavailable': { provider: string; model: string }
|
||||
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
|
||||
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
|
||||
'workspace-not-found': { workspaceId: string }
|
||||
'workspace-invalid-path': { path: string }
|
||||
'workspace-name-conflict': { name: string }
|
||||
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
|
||||
'agent-busy': { reason: string }
|
||||
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
||||
'command-error': {}
|
||||
@@ -53,6 +61,20 @@ export type RpcError = {
|
||||
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
|
||||
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
|
||||
|
||||
/**
|
||||
* Fold a transport exception into the RpcResult error branch (unified error
|
||||
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
|
||||
* carrier consumer folds the same way.
|
||||
* @param error - the thrown value from the carrier.
|
||||
* @returns the error branch of an RpcResult.
|
||||
*/
|
||||
export function transportError<T>(error: unknown): RpcResult<T> {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature-layer narrow form, request side (domain-interface view, shared by
|
||||
* both directions): rpcId is explicit in the signature, never mixed into the
|
||||
|
||||
@@ -9,12 +9,24 @@ import { z } from 'zod'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { HistoryEntry, SessionSummary } from './sessions.ts'
|
||||
import type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
|
||||
/**
|
||||
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
|
||||
* than in workspace.schema because session.create references it while
|
||||
* workspace.schema references sessionIdSchema — schema modules must stay a
|
||||
* DAG (both casts used at module top level; a cycle is a load-time TDZ).
|
||||
*/
|
||||
export const workspaceIdSchema = z.string().min(1) as unknown as z.ZodType<WorkspaceId>
|
||||
|
||||
/** SessionEvent passthrough: strict envelope, wide data (the client fold handles unknown types via its documented default). */
|
||||
export const sessionEventSchema = z.object({
|
||||
type: z.string(),
|
||||
@@ -30,6 +42,7 @@ export const sessionSummarySchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
updatedAt: z.number(),
|
||||
running: z.boolean(),
|
||||
blank: z.boolean(),
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<SessionSummary>>
|
||||
@@ -44,10 +57,15 @@ export const sessionListValueSchema = z.object({
|
||||
items: z.array(sessionSummarySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
|
||||
|
||||
/** session.create request payload. */
|
||||
/** session.create request payload (at most one of workspaceId / cwd). */
|
||||
export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
|
||||
sessionId: sessionIdSchema.optional(),
|
||||
}).refine(
|
||||
payload => payload.workspaceId === undefined || payload.cwd === undefined,
|
||||
{ message: 'session.create accepts workspaceId or cwd, not both' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
|
||||
|
||||
/** session.create response value. */
|
||||
export const sessionCreateValueSchema = z.object({
|
||||
@@ -61,6 +79,49 @@ export const sessionHistoryRequestSchema = z.object({
|
||||
maxMessages: z.number().int().positive().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
|
||||
|
||||
/** Complete provider/model target. */
|
||||
export const modelTargetSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
reasoningEffort: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelTarget>>
|
||||
|
||||
/** One adapter-owned reasoning effort. */
|
||||
export const modelReasoningEffortSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelReasoningEffort>>
|
||||
|
||||
/** Exact-model reasoning metadata. */
|
||||
export const modelReasoningSchema = z.object({
|
||||
efforts: z.array(modelReasoningEffortSchema).min(1),
|
||||
defaultEffort: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelReasoning>>
|
||||
|
||||
/** One advisory model entry inside a provider group. */
|
||||
export const modelCatalogModelSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
unlisted: z.literal(true).optional(),
|
||||
reasoning: modelReasoningSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
|
||||
|
||||
/** One successfully loaded provider group. */
|
||||
export const modelProviderGroupSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
models: z.array(modelCatalogModelSchema),
|
||||
}) satisfies z.ZodType<Wire<ModelProviderGroup>>
|
||||
|
||||
/** One provider-local catalog failure. */
|
||||
export const modelCatalogFailureSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
message: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogFailure>>
|
||||
|
||||
/**
|
||||
* ToolEventView passthrough: lock only the `for` discriminant and the presence
|
||||
* of a card-tagged `view` object. The view interior is a host-computed product
|
||||
@@ -78,12 +139,49 @@ export const historyEntrySchema = z.object({
|
||||
view: toolEventViewSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<HistoryEntry>>
|
||||
|
||||
/** session.history response value. */
|
||||
/**
|
||||
* Projection baseline passthrough: `values` stays a wide record — each value
|
||||
* was already parsed by its provider's own schema on the host side, and
|
||||
* deep-validating here would import every domain's schema into the carrier.
|
||||
*/
|
||||
export const sessionProjectionsBlockSchema = z.object({
|
||||
// -1 = empty log (the lastSeq convention of session/subscribed).
|
||||
asOfSeq: z.number().int().min(-1),
|
||||
values: z.record(z.string(), z.unknown()),
|
||||
}) as unknown as z.ZodType<SessionProjectionsBlock>
|
||||
|
||||
/** session.history response value (projections rides the tail page only). */
|
||||
export const sessionHistoryValueSchema = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
projections: sessionProjectionsBlockSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
|
||||
|
||||
/** session.models request payload. */
|
||||
export const sessionModelsRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.models'>>>
|
||||
|
||||
/** session.models response value. */
|
||||
export const sessionModelsValueSchema = z.object({
|
||||
current: modelTargetSchema,
|
||||
groups: z.array(modelProviderGroupSchema),
|
||||
failures: z.array(modelCatalogFailureSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
|
||||
|
||||
/** session.selectModel request payload. */
|
||||
export const sessionSelectModelRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
reasoningEffort: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.selectModel'>>>
|
||||
|
||||
/** session.selectModel response value. */
|
||||
export const sessionSelectModelValueSchema = z.object({
|
||||
selected: modelTargetSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.selectModel'>>>
|
||||
|
||||
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
|
||||
export const contentBlockSchema = z.looseObject({ type: z.string() })
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// The pure-type outlet: api/ is browser-importable, and the package root's
|
||||
// cordis Context merge (via dsh-agent) must not enter client aggregates.
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
@@ -31,6 +35,95 @@ export interface HistoryEntry {
|
||||
view?: ToolEventView
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection baseline riding the history tail page: one synchronous cut
|
||||
* over every registered projection unit, read from the registry's watermark
|
||||
* cache. `asOfSeq` is the seq of the last committed event every value
|
||||
* reflects — the window tail event seq (`-1` for an empty log, mirroring
|
||||
* `session/subscribed.lastSeq`), directly comparable with
|
||||
* `session/projection` frame seqs under the client's higher-seq-wins rule. A
|
||||
* key absent from `values` means the capability is absent (its domain plugin
|
||||
* is unmounted).
|
||||
*/
|
||||
export interface SessionProjectionsBlock {
|
||||
/** Seq of the last event the values reflect; -1 for an empty log. */
|
||||
asOfSeq: number
|
||||
/** Whole current value per registered projection key. */
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/** Complete model target selected for one session. */
|
||||
export interface ModelTarget {
|
||||
/** Registered provider route. */
|
||||
provider: string
|
||||
/** Provider-owned model id. */
|
||||
model: string
|
||||
/** Adapter-owned reasoning effort; absence preserves adapter/provider default behavior. */
|
||||
reasoningEffort?: string
|
||||
}
|
||||
|
||||
/** One adapter-owned reasoning effort displayed for an exact model route. */
|
||||
export interface ModelReasoningEffort {
|
||||
/** Opaque value submitted back to the owning adapter. */
|
||||
id: string
|
||||
/** Adapter-supplied display name. */
|
||||
name: string
|
||||
/** Optional adapter-supplied description. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Selectable reasoning metadata for one exact model route. */
|
||||
export interface ModelReasoning {
|
||||
/** Efforts in adapter-preferred display order. */
|
||||
efforts: ModelReasoningEffort[]
|
||||
/** Adapter-configured default; absence preserves the provider default. */
|
||||
defaultEffort?: string
|
||||
}
|
||||
|
||||
/** One model displayed inside its provider group. */
|
||||
export interface ModelCatalogModel {
|
||||
/** Provider-owned model id. */
|
||||
id: string
|
||||
/** Provider-supplied display name. */
|
||||
name: string
|
||||
/** Optional provider-supplied description. */
|
||||
description?: string
|
||||
/** The current model was inserted because the advisory catalog omitted it. */
|
||||
unlisted?: true
|
||||
/** Exact-route reasoning metadata when the adapter exposes it. */
|
||||
reasoning?: ModelReasoning
|
||||
}
|
||||
|
||||
/** One provider and the models it advertised successfully. */
|
||||
export interface ModelProviderGroup {
|
||||
/** Provider route id used for requests. */
|
||||
id: string
|
||||
/** Provider display name. */
|
||||
name: string
|
||||
/** Models in provider-preferred order. */
|
||||
models: ModelCatalogModel[]
|
||||
}
|
||||
|
||||
/** A provider whose asynchronous catalog lookup failed. */
|
||||
export interface ModelCatalogFailure {
|
||||
/** Provider route id. */
|
||||
id: string
|
||||
/** Provider display name. */
|
||||
name: string
|
||||
/** Lookup failure diagnostic. */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Detached model-directory snapshot for one session. */
|
||||
export interface SessionModels {
|
||||
/** Target selected for the session's next assembled step. */
|
||||
current: ModelTarget
|
||||
/** Successfully loaded provider groups. */
|
||||
groups: ModelProviderGroup[]
|
||||
/** Provider-local failures; successful groups remain usable. */
|
||||
failures: ModelCatalogFailure[]
|
||||
}
|
||||
|
||||
/** Session list entry (v1 builds no index: list does readdir+stat). */
|
||||
export interface SessionSummary {
|
||||
sessionId: SessionId
|
||||
@@ -38,6 +131,14 @@ export interface SessionSummary {
|
||||
updatedAt: number
|
||||
/** Status of the attached agent; always false for cold (unattached) sessions. */
|
||||
running: boolean
|
||||
/**
|
||||
* Derived emptiness bit: true while the session log holds zero events (no
|
||||
* user message yet). Clients hide blank sessions from lists and reuse them
|
||||
* for New Session on the same workspace. Always false for cold sessions —
|
||||
* lazy persistence keeps a never-appended session out of the store, so a
|
||||
* listed cold session necessarily has events.
|
||||
*/
|
||||
blank: boolean
|
||||
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
|
||||
parentSessionId?: SessionId
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
@@ -49,8 +150,16 @@ export interface SessionsApi {
|
||||
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
|
||||
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
|
||||
|
||||
/** Creates a new session (and its agent, idle and standing by). */
|
||||
create(request: RpcRequest<{ cwd?: string }>): Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
/**
|
||||
* Creates a real session and its idle agent. At most one of `workspaceId` /
|
||||
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
|
||||
* preallocate `sessionId`: retries with the same id and cwd return the same
|
||||
* session, while a different cwd fails with `session-conflict`. Workspace
|
||||
* creation attaches the session after publication; an attach failure
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to message boundaries: one page =
|
||||
@@ -60,9 +169,30 @@ export interface SessionsApi {
|
||||
* 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
|
||||
* rebuilds the surface from the events with the shared fold.
|
||||
* The tail page — and only the tail page — additionally carries `projections`
|
||||
* when the deployment mounts the session-projection registry: every moment
|
||||
* the client needs a fresh baseline already pulls the tail page, and
|
||||
* loadOlder (the only beforeSeq path) is the only path that never needs one.
|
||||
* A deployment without the registry serves histories without the block.
|
||||
*/
|
||||
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
|
||||
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
|
||||
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
|
||||
|
||||
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
|
||||
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
|
||||
|
||||
/**
|
||||
* Selects the complete target for this session. Exact model metadata
|
||||
* validates an optional reasoning effort, while catalog membership remains
|
||||
* advisory.
|
||||
*/
|
||||
selectModel(request: RpcRequest<{
|
||||
sessionId: SessionId
|
||||
provider: string
|
||||
model: string
|
||||
reasoningEffort?: string
|
||||
}>):
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>>
|
||||
|
||||
/**
|
||||
* Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer.
|
||||
|
||||
27
packages/host/apiproxy/src/api/skills.schema.ts
Normal file
27
packages/host/apiproxy/src/api/skills.schema.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* skills domain zod schemas (names derived from map keys: skillListRequestSchema /
|
||||
* skillListValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { SkillEntry } from './skills.ts'
|
||||
|
||||
/** SkillEntry row of skill.list. */
|
||||
export const skillEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
whenToUse: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<SkillEntry>>
|
||||
|
||||
/** skill.list request payload. */
|
||||
export const skillListRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'skill.list'>>>
|
||||
|
||||
/** skill.list response value. */
|
||||
export const skillListValueSchema = z.object({
|
||||
skills: z.array(skillEntrySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
|
||||
25
packages/host/apiproxy/src/api/skills.ts
Normal file
25
packages/host/apiproxy/src/api/skills.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* skills domain contract: read-only skill catalog lookup addressed by session.
|
||||
* The session's header cwd resolves to the canonical project root host-side —
|
||||
* the client never submits a raw path, and skill lookup never creates or
|
||||
* resumes an Agent.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */
|
||||
export interface SkillEntry {
|
||||
/** Kebab-case identifier referenced as `<skill>name</skill>` in prompts. */
|
||||
readonly name: string
|
||||
/** Short routing description. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
}
|
||||
|
||||
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
|
||||
export interface SkillsApi {
|
||||
/** Lists model-invocable skills for the addressed session's project root. */
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
|
||||
}
|
||||
82
packages/host/apiproxy/src/api/workspace.schema.ts
Normal file
82
packages/host/apiproxy/src/api/workspace.schema.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* workspace domain zod schemas (names derived from map keys). The
|
||||
* WorkspaceId brand cast lives in sessions.schema (see the note there) and
|
||||
* is re-exported here as the domain-local name.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
import { sessionIdSchema, workspaceIdSchema } from './sessions.schema.ts'
|
||||
|
||||
export { workspaceIdSchema } from './sessions.schema.ts'
|
||||
|
||||
/** WorkspaceView row of every workspace.* response. */
|
||||
export const workspaceViewSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
path: z.string(),
|
||||
title: z.string(),
|
||||
sessionIds: z.array(sessionIdSchema),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}) satisfies z.ZodType<Wire<WorkspaceView>>
|
||||
|
||||
/** workspace.list request payload (empty object literal). */
|
||||
export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'workspace.list'>>>
|
||||
|
||||
/** workspace.list response value. */
|
||||
export const workspaceListValueSchema = z.object({
|
||||
items: z.array(workspaceViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
|
||||
|
||||
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
|
||||
export const workspaceCreateRequestSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}).refine(
|
||||
payload => (payload.path === undefined) !== (payload.name === undefined),
|
||||
{ message: 'workspace.create requires exactly one of path / name' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
|
||||
|
||||
/** workspace.create response value. */
|
||||
export const workspaceCreateValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
created: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>
|
||||
|
||||
/** workspace.rename request payload: the new title must be non-blank. */
|
||||
export const workspaceRenameRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
title: z.string(),
|
||||
}).refine(
|
||||
payload => payload.title.trim() !== '',
|
||||
{ message: 'workspace.rename requires a non-blank title' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>>
|
||||
|
||||
/** workspace.rename response value. */
|
||||
export const workspaceRenameValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
|
||||
|
||||
/** workspace.delete request payload. */
|
||||
export const workspaceDeleteRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.delete'>>>
|
||||
|
||||
/** workspace.delete response value. */
|
||||
export const workspaceDeleteValueSchema = z.object({
|
||||
deleted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
|
||||
|
||||
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
|
||||
export const workspaceInsertSessionBeforeRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
sessionId: sessionIdSchema,
|
||||
beforeSessionId: sessionIdSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>>
|
||||
|
||||
/** workspace.insertSessionBefore response value. */
|
||||
export const workspaceInsertSessionBeforeValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
89
packages/host/apiproxy/src/api/workspace.ts
Normal file
89
packages/host/apiproxy/src/api/workspace.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* workspace domain contract. Wire projection of the host-side workspace
|
||||
* entity (@deepseek-ai/dsh-workspace): a stable id over a directory path,
|
||||
* a display title, and the ordered session account. Method signatures are the
|
||||
* source of truth, same as the sessions domain.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
* Wire-side workspace id brand. Deliberately re-declared here rather than
|
||||
* imported from dsh-workspace: api/ must stay browser-importable with zero
|
||||
* host-package dependencies, and the brand string matches, so both sides
|
||||
* agree structurally.
|
||||
*/
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
|
||||
/** One workspace row: the record projection every workspace.* value carries. */
|
||||
export interface WorkspaceView {
|
||||
workspaceId: WorkspaceId
|
||||
/** Canonical directory path (host-side realpath canon). */
|
||||
path: string
|
||||
/** Unique display title (defaults to the path basename at create). */
|
||||
title: string
|
||||
/**
|
||||
* Sessions accounted under this workspace, in manually owned order
|
||||
* (attach prepends, insertSessionBefore reorders; activity never does).
|
||||
*/
|
||||
sessionIds: SessionId[]
|
||||
/** ISO-8601 creation instant. */
|
||||
createdAt: string
|
||||
/** ISO-8601 last-mutation instant. */
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */
|
||||
export interface WorkspaceApi {
|
||||
/** Lists all workspaces in the registry's durable display order. */
|
||||
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[] }>>
|
||||
|
||||
/**
|
||||
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
|
||||
* `name` (schema-enforced): `path` registers an EXISTING directory (no
|
||||
* mkdir — a missing or non-directory path fails with `workspace-invalid-path`);
|
||||
* `name` is a single path segment the host mkdirs under its default project
|
||||
* root before registering. Either spelling resolving to a directory already
|
||||
* owned by a workspace returns that workspace (`created: false`) for the
|
||||
* existing-folder spelling. Create-by-name rejects an existing title with
|
||||
* `workspace-name-conflict`; a new path whose basename duplicates another
|
||||
* Workspace title is rejected by the registry with the same code.
|
||||
* A new name-created workspace uses `name` as both directory name and title;
|
||||
* a path-created workspace uses the registry's basename title default.
|
||||
*/
|
||||
create(request: RpcRequest<{ path?: string; name?: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
|
||||
|
||||
/**
|
||||
* Renames a workspace. `title` is trimmed and must be non-empty
|
||||
* (schema-enforced). An unknown id fails with `workspace-not-found`; a
|
||||
* title equal to another workspace's fails with `workspace-name-conflict`.
|
||||
* Renaming to the current title is a no-op success (no durable write).
|
||||
*/
|
||||
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
|
||||
/**
|
||||
* Removes one Workspace registration. The directory, every user file, and
|
||||
* every session log remain untouched; those Sessions consequently become
|
||||
* ungrouped. An unknown id fails with `workspace-not-found`.
|
||||
*/
|
||||
delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
|
||||
Promise<RpcResponse<{ deleted: true }>>
|
||||
|
||||
/**
|
||||
* Moves an accounted session within its workspace's manual order,
|
||||
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
|
||||
* before that anchor; omitted appends to the end. An unknown workspace
|
||||
* fails with `workspace-not-found`; a session or anchor not accounted by
|
||||
* the workspace fails with `workspace-move-invalid`. A move to the current
|
||||
* position is a no-op success.
|
||||
*/
|
||||
insertSessionBefore(request: RpcRequest<{
|
||||
workspaceId: WorkspaceId
|
||||
sessionId: SessionId
|
||||
beforeSessionId?: SessionId
|
||||
}>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
}
|
||||
@@ -13,16 +13,28 @@ import { RpcId } from '../api/rpc.ts'
|
||||
import type { Wire } from '../api/rpc.schema.ts'
|
||||
import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
|
||||
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
|
||||
import { hostDescribeValueSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
hostDescribeValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
|
||||
} from '../api/host.schema.ts'
|
||||
import {
|
||||
sessionCancelValueSchema,
|
||||
sessionCreateValueSchema,
|
||||
sessionHistoryValueSchema,
|
||||
sessionListValueSchema,
|
||||
sessionModelsValueSchema,
|
||||
sessionPromptValueSchema,
|
||||
sessionSelectModelValueSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
goalGetValueSchema,
|
||||
workspaceCreateValueSchema,
|
||||
workspaceDeleteValueSchema,
|
||||
workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListValueSchema,
|
||||
workspaceRenameValueSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
goalPauseValueSchema,
|
||||
@@ -51,11 +63,29 @@ export interface IApiClient {
|
||||
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
|
||||
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
|
||||
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
|
||||
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
|
||||
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
}
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
|
||||
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
|
||||
}
|
||||
workspace: {
|
||||
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
|
||||
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
|
||||
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
|
||||
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
|
||||
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
}
|
||||
commands: {
|
||||
list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.list'>>>
|
||||
execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.execute'>>>
|
||||
}
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
@@ -82,10 +112,21 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.list': sessionListValueSchema,
|
||||
'session.create': sessionCreateValueSchema,
|
||||
'session.history': sessionHistoryValueSchema,
|
||||
'session.models': sessionModelsValueSchema,
|
||||
'session.selectModel': sessionSelectModelValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'goal.get': goalGetValueSchema,
|
||||
'host.pickDirectory': hostPickDirectoryValueSchema,
|
||||
'host.openPath': hostOpenPathValueSchema,
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
'workspace.delete': workspaceDeleteValueSchema,
|
||||
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
@@ -171,13 +212,22 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
* Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
|
||||
* timeout merged with the caller's optional external signal, non-2xx → transport throw.
|
||||
*/
|
||||
private async postJson(path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined): Promise<Response> {
|
||||
const timeout = AbortSignal.timeout(this.timeoutMs)
|
||||
private async postJson(
|
||||
path: string,
|
||||
body: ClientRequest | ClientResponse,
|
||||
signal: AbortSignal | undefined,
|
||||
useDefaultTimeout = true,
|
||||
): Promise<Response> {
|
||||
const requestSignal = useDefaultTimeout
|
||||
? signal === undefined
|
||||
? AbortSignal.timeout(this.timeoutMs)
|
||||
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
|
||||
: signal
|
||||
const response = await this.doFetch(new URL(path, this.resolveBase()), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]),
|
||||
...requestSignal === undefined ? {} : { signal: requestSignal },
|
||||
})
|
||||
if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
|
||||
return response
|
||||
@@ -192,10 +242,11 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
method: K,
|
||||
payload: RequestPayload<K>,
|
||||
signal?: AbortSignal,
|
||||
useDefaultTimeout = true,
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
|
||||
this.onEnvelope(message)
|
||||
const response = await this.postJson(`/api/${method}`, message, signal)
|
||||
const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout)
|
||||
const full = serverResponseSchema.parse(await response.json())
|
||||
this.onEnvelope(full)
|
||||
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
|
||||
@@ -270,12 +321,35 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('session.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('session.create', payload, signal),
|
||||
history: (payload, signal) => this.callUnary('session.history', payload, signal),
|
||||
models: (payload, signal) => this.callUnary('session.models', payload, signal),
|
||||
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
// A native system dialog is user-paced and may legitimately stay open
|
||||
// longer than the normal unary deadline. Caller/connection aborts remain.
|
||||
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
|
||||
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
|
||||
}
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
|
||||
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
|
||||
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
|
||||
}
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload, signal) => this.callUnary('command.list', payload, signal),
|
||||
execute: (payload, signal) => this.callUnary('command.execute', payload, signal),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
|
||||
@@ -19,11 +19,23 @@ import {
|
||||
sessionCreateRequestSchema,
|
||||
sessionHistoryRequestSchema,
|
||||
sessionListRequestSchema,
|
||||
sessionModelsRequestSchema,
|
||||
sessionPromptRequestSchema,
|
||||
sessionSelectModelRequestSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
goalGetRequestSchema,
|
||||
hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema,
|
||||
} from '../api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema,
|
||||
workspaceDeleteRequestSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema,
|
||||
workspaceListRequestSchema,
|
||||
workspaceRenameRequestSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
goalPauseRequestSchema,
|
||||
@@ -38,11 +50,13 @@ import {
|
||||
* payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
|
||||
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
|
||||
* documented on Wire); the dispatch point carries the one Wire→exact cast.
|
||||
* Every invoke receives the carrier Request's signal; methods whose contract
|
||||
* declares a signal parameter (command.execute) forward it, the rest ignore it.
|
||||
*/
|
||||
type UnaryRoutes = {
|
||||
[K in keyof RpcMethodMap]: {
|
||||
schema: z.ZodType<Wire<RequestPayload<K>>>
|
||||
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>): Promise<RpcResponse<ResponseValue<K>>>
|
||||
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +64,21 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
|
||||
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
|
||||
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
|
||||
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
|
||||
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
'goal.get': { schema: goalGetRequestSchema, invoke: (api, r) => api.goals.get(r) },
|
||||
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
|
||||
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
|
||||
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
|
||||
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
|
||||
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
|
||||
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
|
||||
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
|
||||
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
|
||||
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
|
||||
@@ -95,14 +120,16 @@ function fullResponse(narrow: RpcResponse<unknown>): Response {
|
||||
// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
|
||||
// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
||||
async function handleUnary<K extends keyof RpcMethodMap>(api: ApiProxy, method: K, message: ClientRequest): Promise<Response> {
|
||||
async function handleUnary<K extends keyof RpcMethodMap>(
|
||||
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const route = UNARY_ROUTES[method]
|
||||
const payload = route.schema.safeParse(message.payload)
|
||||
if (!payload.success) {
|
||||
return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } })
|
||||
}
|
||||
try {
|
||||
return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }))
|
||||
return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal))
|
||||
} catch (error: unknown) {
|
||||
// The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer.
|
||||
return new Response(`handler failure: ${String(error)}`, { status: 500 })
|
||||
@@ -207,7 +234,7 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
|
||||
if (message.method !== method) {
|
||||
return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } })
|
||||
}
|
||||
return handleUnary(api, method, message)
|
||||
return handleUnary(api, method, message, req.signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,85 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-apiproxy — the front layer every client shape shares:
|
||||
* the ApiProxy contract (api/: types + zod schemas, browser-safe) and the
|
||||
* fetch carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
|
||||
* platform subclasses on the client side). Host assembly (bootHost/createApiProxy/startHost)
|
||||
* lives in @deepseek-ai/dsh-host-runtime.
|
||||
* @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares:
|
||||
* the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch
|
||||
* carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
|
||||
* platform subclasses on the client side), and the host-side implementation
|
||||
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
|
||||
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
|
||||
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
|
||||
export type * from './api/index.ts'
|
||||
export { RpcId } from './api/rpc.ts'
|
||||
export { toFetchHandler } from './fetch/handler.ts'
|
||||
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
|
||||
export type { IApiClient } from './fetch/client.ts'
|
||||
export { createApiProxy } from './api-proxy.ts'
|
||||
export type { ApiProxyDefaults } from './api-proxy.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
|
||||
apiProxy: ApiProxy
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
|
||||
export interface Config {
|
||||
/** Default provider route for created/resumed agents. */
|
||||
provider: string
|
||||
/** Default model id. */
|
||||
model: string
|
||||
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The API gateway service: implements the ApiProxy contract over the composed
|
||||
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default
|
||||
* project directory and the fallback parent for name-created Workspaces.
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = ['agents', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
workspaceRoot: z.string(),
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
readonly workspace: ApiProxy['workspace']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'apiProxy')
|
||||
const cwd = process.cwd()
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
cwd,
|
||||
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.workspace = api.workspace
|
||||
this.host = api.host
|
||||
this.commands = api.commands
|
||||
this.skills = api.skills
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
// the unbound-method lint without changing behavior.
|
||||
this.respond = api.respond.bind(api)
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiProxyService
|
||||
|
||||
@@ -15,11 +15,12 @@ export const name = 'host-apiproxy-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package is the wire contract layer (types,
|
||||
* schemas, fetch carrier glue) — it emits no cordis events and owns no
|
||||
* mutable cross-plugin relation. rpcId round-trip and schema acceptance are
|
||||
* enforced at the carrier boundary and exercised by the protocol-isomorphism
|
||||
* suite; the live implementation relations belong to dsh-host-runtime.
|
||||
* No runtime invariant: this package is the wire contract layer plus the
|
||||
* host-side gateway over services owned elsewhere — it emits no cordis events
|
||||
* of its own; the session/agent event streams it projects are asserted by
|
||||
* their owning packages' companions. rpcId round-trip and schema acceptance
|
||||
* are enforced at the carrier boundary and exercised by the
|
||||
* protocol-isomorphism suite.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
38
packages/host/apiproxy/src/native-command.ts
Normal file
38
packages/host/apiproxy/src/native-command.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** Shared no-shell `execFile` runner for native host dialogs and openers. */
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type NativeCommandRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
signal: AbortSignal,
|
||||
) => Promise<{ stdout: string; stderr: string }>
|
||||
|
||||
/**
|
||||
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
|
||||
* @param command - executable path or PATH name.
|
||||
* @param args - argv (never a shell string).
|
||||
* @param signal - caller/connection lifetime; abort terminates the child.
|
||||
* @returns captured stdout/stderr on exit 0.
|
||||
*/
|
||||
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
[...args],
|
||||
{ encoding: 'utf8', signal, windowsHide: true },
|
||||
(error, stdout, stderr) => {
|
||||
if (error !== null) {
|
||||
const failure = Object.assign(new Error(error.message, { cause: error }), {
|
||||
code: error.code,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
reject(failure)
|
||||
return
|
||||
}
|
||||
resolve({ stdout, stderr })
|
||||
},
|
||||
)
|
||||
})
|
||||
111
packages/host/apiproxy/src/native-directory-picker.ts
Normal file
111
packages/host/apiproxy/src/native-directory-picker.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/** Cross-platform native single-directory picker used by the local GUI carrier. */
|
||||
|
||||
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type DirectoryPickerRunner = NativeCommandRunner
|
||||
|
||||
/** Injectable platform facts for deterministic adapter tests. */
|
||||
export interface DirectoryPickerInternals {
|
||||
platform?: NodeJS.Platform
|
||||
run?: DirectoryPickerRunner
|
||||
}
|
||||
|
||||
function outputPath(stdout: string): string | null {
|
||||
const path = stdout.replace(/[\r\n]+$/, '')
|
||||
return path === '' ? null : path
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | number | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined
|
||||
const code = (error as { code?: unknown }).code
|
||||
return typeof code === 'string' || typeof code === 'number' ? code : undefined
|
||||
}
|
||||
|
||||
function errorStderr(error: unknown): string {
|
||||
if (typeof error !== 'object' || error === null || !('stderr' in error)) return ''
|
||||
const stderr = (error as { stderr?: unknown }).stderr
|
||||
return typeof stderr === 'string' ? stderr : ''
|
||||
}
|
||||
|
||||
function isMissingCommand(error: unknown): boolean {
|
||||
return errorCode(error) === 'ENOENT'
|
||||
}
|
||||
|
||||
function rethrowIfAborted(signal: AbortSignal, error: unknown): void {
|
||||
if (signal.aborted) throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the platform directory picker.
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
* @returns the selected path, or null when the user cancels.
|
||||
*/
|
||||
export async function pickNativeDirectory(
|
||||
signal: AbortSignal,
|
||||
internals: DirectoryPickerInternals = {},
|
||||
): Promise<string | null> {
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
|
||||
if (platform === 'darwin') {
|
||||
try {
|
||||
const result = await run('osascript', [
|
||||
'-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
|
||||
'-e', 'POSIX path of selectedFolder',
|
||||
], signal)
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
if (!signal.aborted && errorCode(error) === 1
|
||||
&& /(?:User canceled|-128)/i.test(errorStderr(error))) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
||||
"$dialog.Description = 'Select Workspace Directory'",
|
||||
'$dialog.ShowNewFolderButton = $true',
|
||||
'$result = $dialog.ShowDialog()',
|
||||
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
|
||||
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
||||
' [Console]::WriteLine($dialog.SelectedPath)',
|
||||
'}',
|
||||
].join('; ')
|
||||
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
|
||||
return outputPath(result.stdout)
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
try {
|
||||
const result = await run('zenity', [
|
||||
'--file-selection', '--directory', '--title=Select Workspace Directory',
|
||||
], signal)
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
rethrowIfAborted(signal, error)
|
||||
if (errorCode(error) === 1) return null
|
||||
if (!isMissingCommand(error)) throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await run('kdialog', [
|
||||
'--getexistingdirectory', '.', '--title', 'Select Workspace Directory',
|
||||
], signal)
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
rethrowIfAborted(signal, error)
|
||||
if (errorCode(error) === 1) return null
|
||||
if (isMissingCommand(error)) {
|
||||
throw new Error('no supported native directory picker found (install zenity or kdialog)')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`native directory picker is unsupported on ${platform}`)
|
||||
}
|
||||
53
packages/host/apiproxy/src/native-path-opener.ts
Normal file
53
packages/host/apiproxy/src/native-path-opener.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/** Cross-platform open-with-default-application used by the local GUI carrier. */
|
||||
|
||||
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type PathOpenerRunner = NativeCommandRunner
|
||||
|
||||
/** Injectable platform facts for deterministic adapter tests. */
|
||||
export interface PathOpenerInternals {
|
||||
platform?: NodeJS.Platform
|
||||
run?: PathOpenerRunner
|
||||
}
|
||||
|
||||
/** PowerShell single-quoted literal (doubles embedded quotes). */
|
||||
function powershellLiteral(path: string): string {
|
||||
return `'${path.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application.
|
||||
* @param path - absolute or host-resolvable path (caller owns resolution).
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
*/
|
||||
export async function openNativePath(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
|
||||
if (platform === 'darwin') {
|
||||
await run('open', [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
await run('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
|
||||
], signal)
|
||||
return
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
await run('xdg-open', [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`native path opener is unsupported on ${platform}`)
|
||||
}
|
||||
137
packages/host/apiproxy/tests/api-proxy-cold.spec.ts
Normal file
137
packages/host/apiproxy/tests/api-proxy-cold.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Cold-session and degenerate-composition paths of the host ApiProxy:
|
||||
* sessions.list merging persisted-but-unattached summaries (mtime source,
|
||||
* createdAt fallbacks, lineage projection), the resume error split when
|
||||
* the composition has no persistence gate and no agent factory, and the
|
||||
* agent-busy mapping of a synchronous prompt rejection.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { 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(`cold-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
|
||||
}
|
||||
|
||||
describe('sessions.list cold merge', () => {
|
||||
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
||||
const logPath = join(root, 'a.log')
|
||||
writeFileSync(logPath, 'log-bytes')
|
||||
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
|
||||
const metas = [
|
||||
header('session-a', 1000),
|
||||
header('session-b', 2000, { parentSession: sid('session-parent') }),
|
||||
header('session-c', 1500),
|
||||
]
|
||||
// Structural fake of the persistence face list() consumes: list + locate.
|
||||
// locate: a real per-session file (mtime wins), a backend without one
|
||||
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
|
||||
// → createdAt).
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve(metas),
|
||||
locate: (meta: SessionHeader) => {
|
||||
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
|
||||
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const response = await api.sessions.list(request({}))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const items = response.result.value.items
|
||||
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
|
||||
const [a, b, c] = items
|
||||
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
|
||||
expect(a?.running).toBe(false)
|
||||
// Cold summaries are never blank: lazy persistence keeps never-appended
|
||||
// sessions out of list(), so a listed session necessarily has events.
|
||||
expect(items.every(item => !item.blank)).toBe(true)
|
||||
expect(a?.cwd).toBe('/proj')
|
||||
expect(a?.parentSessionId).toBeUndefined()
|
||||
expect(b?.updatedAt).toBe(2000)
|
||||
expect(b?.parentSessionId).toBe('session-parent')
|
||||
expect(c?.updatedAt).toBe(1500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('degenerate composition (no persistence, no factory)', () => {
|
||||
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const listed = await api.sessions.list(request({}))
|
||||
expect(listed.result.ok).toBe(true)
|
||||
if (listed.result.ok) expect(listed.result.value.items).toEqual([])
|
||||
|
||||
// No persistence → the servable gate passes silently; the factory-less
|
||||
// registry then rejects resume, which is NOT a SessionNotFound.
|
||||
const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) {
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.prompt synchronous rejection', () => {
|
||||
it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const session = ctx.sessions.create(sid('session-throwing'))
|
||||
// A live structural stub whose delivery verbs throw synchronously, the
|
||||
// shape a disposed loop presents at this seam.
|
||||
ctx.agents.register({
|
||||
id: session.id,
|
||||
session,
|
||||
status: 'idle',
|
||||
ctx,
|
||||
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
||||
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
||||
} as unknown as Agent)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
for (const mode of ['queue', 'steer'] as const) {
|
||||
const response = await api.sessions.prompt(request({
|
||||
sessionId: session.id, mode, content: [{ type: 'text' as const, text: 'x' }],
|
||||
}))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) {
|
||||
expect(response.result.error.code).toBe('agent-busy')
|
||||
expect(response.result.error.message).toBe('prompt rejected')
|
||||
expect(response.result.error.details).toEqual({
|
||||
reason: 'Error: agent "session-throwing" lifecycle disposed',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
339
packages/host/apiproxy/tests/api-proxy-commands.spec.ts
Normal file
339
packages/host/apiproxy/tests/api-proxy-commands.spec.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
/**
|
||||
* Command/skill RPC handlers and the two new frames over createApiProxy:
|
||||
* command.list serves the addressed agent's effective catalog (missing
|
||||
* registry = loud internal error), command.execute dispatches through the
|
||||
* registry with the carrier signal, skill.list resolves cwd from the session
|
||||
* header (never via the Agent registry), the host stream broadcasts
|
||||
* commands-changed, and the mux stream carries live queued frames plus the
|
||||
* open-time queue snapshot.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import type { HostFrame, MuxFrame } from '../src/api/index.ts'
|
||||
import type { 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' }
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
let nextRpc = 1
|
||||
|
||||
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 } {
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
return response.result.error
|
||||
}
|
||||
|
||||
/** Composition floor for the command/skill paths (no LLM, no persistence). */
|
||||
async function harness(options: { commands?: boolean; skills?: boolean } = {}): 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)
|
||||
if (options.skills !== false) await ctx.plugin(SkillService, {})
|
||||
if (options.commands !== false) await ctx.plugin(CommandService)
|
||||
// Host-stream opener reads the committed-workspace baseline; the stub
|
||||
// suffices here — the real workspace composition is api-proxy-workspace.spec's.
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
|
||||
function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
|
||||
const session = ctx.sessions.create(sessionId)
|
||||
const agent = { id: session.id, session, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Drain `count` frames from a stream, then abort it. */
|
||||
async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number, abort: AbortController): Promise<F[]> {
|
||||
const frames: F[] = []
|
||||
for await (const frame of iterable) {
|
||||
frames.push(frame.payload)
|
||||
if (frames.length >= count) abort.abort()
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('command.list', () => {
|
||||
it('serves the addressed agent\'s name-sorted catalog', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) })
|
||||
ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '<x>' }, handler: () => ({ kind: 'success' }) })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const value = expectOk(await api.commands.list(request({ sessionId: agent.id })))
|
||||
expect(value.commands).toEqual([
|
||||
{ name: 'alpha', description: 'a', input: { hint: '<x>' } },
|
||||
{ name: 'zeta', description: 'z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fails loud with internal when the command registry is not mounted', async () => {
|
||||
const ctx = await harness({ commands: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId })))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('command registry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('command.execute', () => {
|
||||
it('executes a known command against the addressed agent and detaches the result', async () => {
|
||||
const ctx = await harness()
|
||||
let received: string | undefined
|
||||
ctx.commands.register({
|
||||
name: 'goal',
|
||||
description: 'set goal',
|
||||
handler: (invocation) => {
|
||||
received = invocation.rawInput
|
||||
return { kind: 'success', text: `goal:${invocation.agent.id}` }
|
||||
},
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
|
||||
expect(value).toMatchObject({ matched: true })
|
||||
expect(value.commandId).toBeTruthy()
|
||||
expect(received).toBe(' ship it')
|
||||
// Pure admission on the wire: the outcome rides the durably logged
|
||||
// lifecycle pair instead of the response.
|
||||
const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
|
||||
expect(lifecycle).toMatchObject([
|
||||
{ type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } },
|
||||
{ type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns matched:false when syntax or name does not resolve', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const signal = new AbortController().signal
|
||||
expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false })
|
||||
expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false })
|
||||
})
|
||||
|
||||
it('maps a session miss to session-not-found and a registry gap to internal', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const missing = expectErr(await api.commands.execute(
|
||||
request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal))
|
||||
expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
|
||||
|
||||
const bare = await harness({ commands: false })
|
||||
const bareApi = createApiProxy(bare, DEFAULTS)
|
||||
expect(expectErr(await bareApi.commands.execute(
|
||||
request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal')
|
||||
})
|
||||
|
||||
it('reports an aborted handler as cancelled and a throwing handler as internal', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.commands.register({
|
||||
name: 'hang',
|
||||
description: 'never settles on its own',
|
||||
handler: () => new Promise(() => { /* settled only by abort */ }),
|
||||
})
|
||||
ctx.commands.register({
|
||||
name: 'boom',
|
||||
description: 'throws',
|
||||
handler: () => { throw new Error('kaboom') },
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal)
|
||||
controller.abort()
|
||||
expect(expectErr(await pending).code).toBe('cancelled')
|
||||
|
||||
const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal))
|
||||
expect(thrown.code).toBe('internal')
|
||||
expect(thrown.message).toContain('kaboom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill.list', () => {
|
||||
it('lists skills for the session cwd taken from the header', async () => {
|
||||
const ctx = await harness()
|
||||
const seenCwds: (string | undefined)[] = []
|
||||
ctx.skills.registerProvider({
|
||||
name: 'probe',
|
||||
list: (options) => {
|
||||
seenCwds.push(options.cwd)
|
||||
return Promise.resolve([{
|
||||
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
}])
|
||||
},
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
// No agent is registered for this session: header resolution must not
|
||||
// touch (or resume through) the Agent registry.
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
|
||||
expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }])
|
||||
expect(seenCwds).toEqual(['/proj'])
|
||||
expect(ctx.agents.get(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud on an unattached session id (business error, no resume attempt)', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId })))
|
||||
expect(error.code).toBe('session-not-found')
|
||||
})
|
||||
|
||||
it('fails loud with internal when the skill registry is not mounted', async () => {
|
||||
const ctx = await harness({ skills: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const error = expectErr(await api.skills.list(request({ sessionId: session.id })))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('skill registry is absent')
|
||||
})
|
||||
|
||||
it('folds a provider failure into internal', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.skills.registerProvider({
|
||||
name: 'broken',
|
||||
list: () => Promise.reject(new Error('directory exploded')),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const response = await api.skills.list(request({ sessionId: session.id }))
|
||||
// dsh-skill contains one provider's failure (logs and serves the rest), so
|
||||
// this surfaces as an empty ok catalog rather than an error.
|
||||
const value = expectOk(response)
|
||||
expect(value.skills).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('host/commands-changed frame', () => {
|
||||
it('broadcasts on registry change', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
|
||||
const collected = collect<HostFrame>(stream, 1, abort)
|
||||
ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
|
||||
expect(await collected).toEqual([{ type: 'host/commands-changed' }])
|
||||
})
|
||||
})
|
||||
|
||||
/** Build one frozen inbox message for the live `agent/inbox/*` events. */
|
||||
function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
|
||||
return freezeMessage({
|
||||
id: MessageId(id),
|
||||
role: 'user',
|
||||
content: [{ type: 'text' as const, text }],
|
||||
source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
|
||||
})
|
||||
}
|
||||
|
||||
describe('session/queued frames', () => {
|
||||
it('forwards live enqueue events and replays the snapshot on a later mux open', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const live = new AbortController()
|
||||
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
|
||||
// subscribed baseline + 2 queued frames
|
||||
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
|
||||
|
||||
const queued = inboxMessage('m-1', 'queued prompt')
|
||||
const steering = inboxMessage('m-2', 'queued prompt')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
|
||||
|
||||
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
|
||||
expect(liveFrames).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, message: queued, steering: false },
|
||||
{ type: 'session/queued', sessionId: agent.id, message: steering, steering: true },
|
||||
])
|
||||
|
||||
// A fresh mux connection replays the still-pending entries as its baseline.
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 3, replay)
|
||||
expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames)
|
||||
})
|
||||
|
||||
it('retires mirror entries on their terminal dequeue', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const queued = inboxMessage('m-3', 'x')
|
||||
const steering = inboxMessage('m-4', 'x', 'r-1')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
|
||||
ctx.emit('agent/inbox/dequeue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering, 'steering')
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort)
|
||||
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retires the matching placement when one message identity is queued and steering', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const repeated = inboxMessage('m-repeat', 'same prompt')
|
||||
ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering')
|
||||
ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering')
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-repeat'), payload: {} }, abort.signal), 2, abort)
|
||||
expect(frames.filter(f => f.type === 'session/queued')).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, message: repeated, steering: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('retires mirror entries on a batch discard (cancel path)', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const doomed = inboxMessage('m-5', 'doomed')
|
||||
const survivor = inboxMessage('m-6', 'survivor')
|
||||
ctx.emit('agent/inbox/enqueue', agent, doomed, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, survivor, 'queued')
|
||||
ctx.emit('agent/inbox/discard', agent, [doomed])
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort)
|
||||
const remaining = frames.filter(f => f.type === 'session/queued')
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0]).toMatchObject({ message: survivor })
|
||||
})
|
||||
})
|
||||
233
packages/host/apiproxy/tests/api-proxy-models.spec.ts
Normal file
233
packages/host/apiproxy/tests/api-proxy-models.spec.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Web session model-directory and selection behavior: dynamic provider grouping,
|
||||
* provider-local catalog failures, logged-target restoration, advisory unlisted
|
||||
* models, and the prompt-assembly boundary for a running selection change.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
|
||||
LlmResolvedModelInfo, StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { 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 '../src/api-proxy.ts'
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
class CatalogAdapter extends LlmAdapter {
|
||||
constructor(
|
||||
private readonly name: string,
|
||||
private readonly models: readonly LlmModelInfo[] | Error,
|
||||
private readonly reasoning?: LlmModelReasoningInfo,
|
||||
private readonly exactError?: Error,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: this.name }
|
||||
}
|
||||
|
||||
override listModels(): Promise<readonly LlmModelInfo[]> {
|
||||
return this.models instanceof Error
|
||||
? Promise.reject(this.models)
|
||||
: Promise.resolve(this.models)
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
if (this.exactError !== undefined) return Promise.reject(this.exactError)
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
|
||||
})
|
||||
}
|
||||
|
||||
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
// Catalog tests never enter provider streaming.
|
||||
}
|
||||
}
|
||||
|
||||
const REASONING: LlmModelReasoningInfo = {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('off'), name: 'Off' },
|
||||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||||
{ id: ReasoningEffortId('max'), name: 'Max' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('high'),
|
||||
}
|
||||
|
||||
async function harness(logged?: {
|
||||
provider: string
|
||||
model: string
|
||||
reasoningEffort?: ReasoningEffortId
|
||||
}): Promise<{
|
||||
ctx: Context
|
||||
agent: Agent
|
||||
sessionId: SessionId
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
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' },
|
||||
], REASONING))
|
||||
ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
|
||||
ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
|
||||
{ provider: 'metadata-broken', id: 'listed', name: 'Listed' },
|
||||
], undefined, new Error('reasoning metadata offline')))
|
||||
ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
|
||||
ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
|
||||
{ provider: 'duplicate', id: 'same', name: 'Same' },
|
||||
{ provider: 'duplicate', id: 'same', name: 'Same Again' },
|
||||
]))
|
||||
const session = ctx.sessions.create()
|
||||
if (logged !== undefined) {
|
||||
session.append('request/header', { header: { config: logged }, reason: 'initial' })
|
||||
}
|
||||
const agent = {
|
||||
id: session.id,
|
||||
session,
|
||||
status: 'running',
|
||||
ctx,
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, agent, sessionId: session.id }
|
||||
}
|
||||
|
||||
function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
|
||||
if (!response.result.ok) throw new Error('expected successful response')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
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',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.current).toEqual({
|
||||
provider: 'deepseek',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
expect(catalog.groups).toEqual([{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
|
||||
{
|
||||
id: 'deepseek-reasoner',
|
||||
name: 'DeepSeek Reasoner',
|
||||
description: 'Reasoning model',
|
||||
reasoning: REASONING,
|
||||
},
|
||||
{
|
||||
id: 'private-preview',
|
||||
name: 'private-preview',
|
||||
unlisted: true,
|
||||
reasoning: REASONING,
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(catalog.failures).toEqual([
|
||||
{ id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
|
||||
{ id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
|
||||
{
|
||||
id: 'duplicate',
|
||||
name: 'Duplicate Provider',
|
||||
message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
|
||||
},
|
||||
])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
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 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' })
|
||||
expect((await ctx.systemPrompt.assemble()).variables)
|
||||
.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
|
||||
const selected = expectValue(await api.sessions.selectModel(request({
|
||||
sessionId,
|
||||
provider: 'deepseek',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})))
|
||||
expect(selected.selected).toEqual({
|
||||
provider: 'deepseek',
|
||||
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' })
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables)
|
||||
.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toMatchObject({
|
||||
provider: 'deepseek',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
|
||||
const unsupported = await api.sessions.selectModel(request({
|
||||
sessionId,
|
||||
provider: 'deepseek',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: 'medium',
|
||||
}))
|
||||
expect(unsupported.result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'model-unavailable',
|
||||
message: 'provider "deepseek" model "private-preview" does not support reasoning effort "medium"',
|
||||
},
|
||||
})
|
||||
|
||||
const rejected = await api.sessions.selectModel(request({
|
||||
sessionId,
|
||||
provider: 'missing',
|
||||
model: 'model',
|
||||
}))
|
||||
expect(rejected.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'model-unavailable',
|
||||
message: 'no adapter registered for provider "missing"',
|
||||
details: { provider: 'missing', model: 'model' },
|
||||
},
|
||||
})
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
185
packages/host/apiproxy/tests/api-proxy-projections.spec.ts
Normal file
185
packages/host/apiproxy/tests/api-proxy-projections.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Projection carrier paths of the host ApiProxy: the history tail page's
|
||||
* projections block reads the registry's watermark snapshot (asOfSeq = last
|
||||
* event seq, one consistent cut); loadOlder pages never carry the block; a
|
||||
* composition without the registry serves histories without it; a disposed
|
||||
* registration's key leaves subsequent responses; and every unit change is
|
||||
* pushed to mux consumers as a session/projection frame minted here.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
'test/last-user': { text: string } | null
|
||||
}
|
||||
}
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`proj-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
/** Whole-value unit folding the latest user/message text; null before the first. */
|
||||
type LastUserState = { text: string } | null
|
||||
const lastUserUnit = (): ProjectionDefinition<'test/last-user', LastUserState> => ({
|
||||
key: 'test/last-user',
|
||||
schema: z.union([z.object({ text: z.string() }), z.null()]),
|
||||
init: () => null,
|
||||
apply: (state, event) => (event.type === 'user/message'
|
||||
? { text: (event.data.content[0] as { text?: string }).text ?? '' }
|
||||
: state),
|
||||
view: state => state,
|
||||
stateVersion: 1,
|
||||
})
|
||||
|
||||
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
|
||||
const session = ctx.sessions.create()
|
||||
// history resolves the agent first; a live structural stub is enough (only
|
||||
// .session is read on this path).
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
return { ctx, session }
|
||||
}
|
||||
|
||||
/** Append `count` user messages so the log has paginable message boundaries. */
|
||||
function seedMessages(session: Session, count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `m${i}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
describe('session.history projections block', () => {
|
||||
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register(lastUserUnit())
|
||||
seedMessages(session, 3)
|
||||
const response = await api(ctx).sessions.history(request({ sessionId: session.id }))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const { events, projections } = response.result.value
|
||||
expect(projections).toBeDefined()
|
||||
expect(projections?.asOfSeq).toBe(session.seq - 1)
|
||||
expect(projections?.values['test/last-user']).toEqual({ text: 'm2' })
|
||||
// asOfSeq IS the window tail: the last served event carries it.
|
||||
expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
|
||||
})
|
||||
|
||||
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register(lastUserUnit())
|
||||
seedMessages(session, 5)
|
||||
const older = await api(ctx).sessions.history(request({ sessionId: session.id, beforeSeq: 3, maxMessages: 2 }))
|
||||
expect(older.result.ok).toBe(true)
|
||||
if (!older.result.ok) throw new Error('unreachable')
|
||||
expect('projections' in older.result.value).toBe(false)
|
||||
})
|
||||
|
||||
it('serves no block when the composition has no projection registry', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
seedMessages(session, 2)
|
||||
const response = await api(ctx).sessions.history(request({ sessionId: session.id }))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect('projections' in response.result.value).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const dispose = ctx.sessionProjections.register(lastUserUnit())
|
||||
seedMessages(session, 1)
|
||||
const proxy = api(ctx)
|
||||
const before = await proxy.sessions.history(request({ sessionId: session.id }))
|
||||
if (!before.result.ok) throw new Error('unreachable')
|
||||
expect(before.result.value.projections?.values['test/last-user']).toEqual({ text: 'm0' })
|
||||
|
||||
dispose()
|
||||
const after = await proxy.sessions.history(request({ sessionId: session.id }))
|
||||
if (!after.result.ok) throw new Error('unreachable')
|
||||
// The registry is still mounted, so the block itself stays (asOfSeq cut
|
||||
// with zero keys); the disposed key reads as capability absence.
|
||||
expect(after.result.value.projections?.asOfSeq).toBe(session.seq - 1)
|
||||
expect(after.result.value.projections?.values).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/projection push frame', () => {
|
||||
/** Drain frames until `count` session/projection frames arrived. */
|
||||
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
|
||||
const frames: MuxFrame[] = []
|
||||
for await (const envelope of iterable) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.filter(f => f.type === 'session/projection').length >= count) abort.abort()
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
it('broadcasts a frame per changed unit with the causing seq, and none for same-reference applies', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register(lastUserUnit())
|
||||
const proxy = api(ctx)
|
||||
// The gateway's onChanged subscription lives in an inject child whose
|
||||
// fiber activates asynchronously; yield until it lands before appending.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-proj-mux'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 2, abort)
|
||||
|
||||
seedMessages(session, 1)
|
||||
// Same-reference apply: turn/start does not concern the unit — no frame.
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
seedMessages(session, 1)
|
||||
|
||||
const frames = await collected
|
||||
const pushes = frames.filter(
|
||||
(f): f is Extract<MuxFrame, { type: 'session/projection' }> => f.type === 'session/projection',
|
||||
)
|
||||
expect(pushes).toEqual([
|
||||
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 },
|
||||
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 },
|
||||
])
|
||||
// Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible).
|
||||
const tail = await proxy.sessions.history(request({ sessionId: session.id }))
|
||||
if (!tail.result.ok) throw new Error('unreachable')
|
||||
expect(tail.result.value.projections?.asOfSeq).toBe(pushes.at(-1)?.seq)
|
||||
})
|
||||
|
||||
it('emits no projection frames when the composition has no registry', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-noproj-mux'), payload: {} }, abort.signal)
|
||||
const frames: MuxFrame[] = []
|
||||
const drained = (async () => {
|
||||
for await (const envelope of stream) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.filter(f => f.type === 'session/event').length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
seedMessages(session, 2)
|
||||
await drained
|
||||
expect(frames.some(f => f.type === 'session/projection')).toBe(false)
|
||||
})
|
||||
})
|
||||
257
packages/host/apiproxy/tests/api-proxy-view.spec.ts
Normal file
257
packages/host/apiproxy/tests/api-proxy-view.spec.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Tool-card view computation over the mux live path: three standard card types
|
||||
* arrive on the frame, a presenterless tool ships no view field, a call-only
|
||||
* presenter keeps raw result content out of the view payload, and a throwing
|
||||
* presenter soft-falls to no view (the event still ships). Result pairing
|
||||
* works both through the live open-call table and the backscan fallback after
|
||||
* turn/end cleared it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
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 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'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
|
||||
|
||||
function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
|
||||
return defineContentToolFixture({
|
||||
name,
|
||||
description: `tool ${name}`,
|
||||
parameters: {},
|
||||
execute: () => reply(`ran:${name}`),
|
||||
...presenters,
|
||||
})
|
||||
}
|
||||
|
||||
async function harness(): Promise<{ ctx: 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)
|
||||
ctx.tools.register(tool('gen', {
|
||||
presentCall: () => ({ card: 'generic', title: 'gen call' }),
|
||||
presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
|
||||
}))
|
||||
ctx.tools.register(tool('term', {
|
||||
presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'done' }),
|
||||
}))
|
||||
ctx.tools.register(tool('diffy', {
|
||||
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
|
||||
}))
|
||||
ctx.tools.register(tool('call-only', {
|
||||
presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
|
||||
}))
|
||||
ctx.tools.register(tool('plain', {}))
|
||||
ctx.tools.register(tool('boom', {
|
||||
presentCall: () => { throw new Error('presenter exploded') },
|
||||
}))
|
||||
return { ctx }
|
||||
}
|
||||
|
||||
/** Drain frames from an open mux stream until `count` session/event frames arrived. */
|
||||
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
|
||||
const frames: MuxFrame[] = []
|
||||
for await (const frame of iterable) {
|
||||
frames.push(frame.payload)
|
||||
if (frames.filter(f => f.type === 'session/event').length >= count) abort.abort()
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('mux live view computation', () => {
|
||||
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 9, abort)
|
||||
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
|
||||
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-call-only'),
|
||||
content: [{ type: 'text', text: rawResult }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-gen'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
const events = frames.filter(f => f.type === 'session/event')
|
||||
const byCall = new Map(events
|
||||
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
|
||||
.map(f => [
|
||||
`${f.event.type}:${f.event.type === 'tool/call'
|
||||
? f.event.data.callId
|
||||
: (f.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
|
||||
f,
|
||||
]))
|
||||
|
||||
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
|
||||
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
|
||||
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
|
||||
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
|
||||
for: 'call',
|
||||
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
|
||||
})
|
||||
const callOnlyResult = byCall.get('tool/result:c-call-only')
|
||||
expect('view' in (callOnlyResult ?? {})).toBe(false)
|
||||
const serializedResult = JSON.stringify(callOnlyResult)
|
||||
expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
|
||||
expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
|
||||
// No presenter → the frame carries no view property at all.
|
||||
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
|
||||
// Throwing presenter → soft-fall: event ships, no view.
|
||||
expect(byCall.get('tool/call:c-boom')).toBeDefined()
|
||||
expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
|
||||
// Result pairing through the live table: presentResult saw the call's args.
|
||||
expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
|
||||
})
|
||||
|
||||
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
// history resolves the agent first; a live structural stub is enough (only
|
||||
// .session is read on this path).
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
|
||||
// meta rides through to presentResult's ToolResult (the spread arm).
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-term'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}),
|
||||
meta: { n: 1 },
|
||||
}, { surfaceOp: 'append' })
|
||||
// Unpaired result: no tool/call with this id anywhere in the page.
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-orphan'),
|
||||
content: [{ type: 'text', text: 'x' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-bad'),
|
||||
content: [{ type: 'text', text: 'y' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
// Presenterless tool: pairing succeeds but presentResult is absent.
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-plain'),
|
||||
content: [{ type: 'text', text: 'z' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } })
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const entries = response.result.value.events
|
||||
const byKey = new Map(entries
|
||||
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
|
||||
.map(entry => [
|
||||
`${entry.event.type}:${entry.event.type === 'tool/call'
|
||||
? entry.event.data.callId
|
||||
: (entry.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
|
||||
entry,
|
||||
]))
|
||||
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
|
||||
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
|
||||
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
|
||||
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
|
||||
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
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' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
|
||||
|
||||
let session: Session | undefined
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create('session-doomed' as SessionId)
|
||||
}, { inject: ['sessions'] }))
|
||||
session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
|
||||
// Disposing the owning fiber detaches the session mid-stream; the
|
||||
// session/disposed listener must clear its open-call table entry.
|
||||
await fiber.dispose()
|
||||
|
||||
const frames = await collect(stream, 2, abort)
|
||||
const call = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/call')
|
||||
expect(call?.type === 'session/event' && call.view?.for).toBe('call')
|
||||
})
|
||||
|
||||
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 4, abort)
|
||||
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The turn/end above cleared the live table; pairing must fall back to
|
||||
// scanning the session's in-memory events.
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-late'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result')
|
||||
expect(result?.type === 'session/event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
|
||||
})
|
||||
})
|
||||
355
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
Normal file
355
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
|
||||
import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } 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'
|
||||
import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
|
||||
let nextRpc = 1
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`workspace-${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
|
||||
}
|
||||
|
||||
async function nextHostFrame(
|
||||
stream: AsyncIterator<RpcRequest<HostFrame>>,
|
||||
): Promise<RpcRequest<HostFrame>> {
|
||||
const next = await stream.next()
|
||||
if (next.done === true) throw new Error('Host stream ended before the expected increment')
|
||||
return next.value
|
||||
}
|
||||
|
||||
function stubAgent(session: Session): Agent {
|
||||
return {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
|
||||
async function harness(
|
||||
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
||||
extras: {
|
||||
pickDirectory?: (signal: AbortSignal) => Promise<string | null>
|
||||
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
} = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
||||
const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', storageDomain)
|
||||
ctx.provide('storageDomain', storageDomain)
|
||||
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
|
||||
await ctx.plugin(WorkspaceRegistry)
|
||||
|
||||
const factory: AgentFactory = {
|
||||
async createAgent(_ownerCtx, options) {
|
||||
const session = ctx.sessions.create(
|
||||
options.sessionId,
|
||||
options.meta === undefined ? {} : { meta: options.meta },
|
||||
)
|
||||
const agent = stubAgent(session)
|
||||
const unregister = ctx.agents.register(agent)
|
||||
return {
|
||||
agent,
|
||||
dispose: () => {
|
||||
unregister()
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
},
|
||||
async resume() {
|
||||
throw new Error('test harness has no persisted sessions')
|
||||
},
|
||||
}
|
||||
ctx.agents.setFactory(factory)
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
...extras.pickDirectory === undefined ? {} : { pickDirectory: extras.pickDirectory },
|
||||
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
|
||||
})
|
||||
return { api, ctx, storageDomain, workspaceRoot }
|
||||
}
|
||||
|
||||
describe('host.pickDirectory', () => {
|
||||
it('returns a selected path or explicit cancellation from the injected native boundary', async () => {
|
||||
const selected = await harness(undefined, { pickDirectory: async () => '/tmp/project' })
|
||||
expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
|
||||
.toEqual({ ok: true, value: { path: '/tmp/project' } })
|
||||
|
||||
const cancelled = await harness(undefined, { pickDirectory: async () => null })
|
||||
expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
|
||||
.toEqual({ ok: true, value: { path: null } })
|
||||
})
|
||||
|
||||
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
|
||||
const { api } = await harness(undefined, {
|
||||
pickDirectory: signal => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
}),
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const pending = api.host.pickDirectory(request({}), abort.signal)
|
||||
abort.abort()
|
||||
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('host.openPath', () => {
|
||||
it('opens through the injected native boundary', async () => {
|
||||
const opened: string[] = []
|
||||
const { api } = await harness(undefined, {
|
||||
openPath: async (path) => { opened.push(path) },
|
||||
})
|
||||
expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
|
||||
.toEqual({ ok: true, value: { opened: true } })
|
||||
expect(opened).toEqual(['/tmp/a.txt'])
|
||||
})
|
||||
|
||||
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
|
||||
const { api } = await harness(undefined, {
|
||||
openPath: (_path, signal) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
}),
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal)
|
||||
abort.abort()
|
||||
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace.create', () => {
|
||||
it('serializes concurrent names and rejects the duplicate', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
const responses = await Promise.all([
|
||||
api.workspace.create(request({ name: 'alpha' })),
|
||||
api.workspace.create(request({ name: 'alpha' })),
|
||||
])
|
||||
const created = responses.find(response => response.result.ok)
|
||||
const duplicate = responses.find(response => !response.result.ok)
|
||||
|
||||
expect(created).toBeDefined()
|
||||
expect(expectOk(created!)).toMatchObject({
|
||||
created: true,
|
||||
workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
|
||||
})
|
||||
expect(duplicate?.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
|
||||
})
|
||||
expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adopts only existing directories and rejects unsafe names', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
const existing = join(workspaceRoot, 'existing')
|
||||
mkdirSync(existing)
|
||||
const first = expectOk(await api.workspace.create(request({ path: existing })))
|
||||
const repeated = expectOk(await api.workspace.create(request({ path: existing })))
|
||||
expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
|
||||
expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } })
|
||||
|
||||
expectOk(await api.workspace.rename(request({
|
||||
workspaceId: first.workspace.workspaceId,
|
||||
title: 'renamed-existing',
|
||||
})))
|
||||
const reopened = expectOk(await api.workspace.create(request({ path: existing })))
|
||||
expect(reopened.workspace.title).toBe('renamed-existing')
|
||||
|
||||
const missing = join(workspaceRoot, 'missing')
|
||||
const missingResult = await api.workspace.create(request({ path: missing }))
|
||||
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
|
||||
expect(existsSync(missing)).toBe(false)
|
||||
|
||||
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
|
||||
const invalid = await api.workspace.create(request({ name }))
|
||||
expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects different paths that derive the same Workspace title', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
const first = join(workspaceRoot, 'one', 'project')
|
||||
const second = join(workspaceRoot, 'two', 'project')
|
||||
mkdirSync(first, { recursive: true })
|
||||
mkdirSync(second, { recursive: true })
|
||||
expectOk(await api.workspace.create(request({ path: first })))
|
||||
const conflict = await api.workspace.create(request({ path: second }))
|
||||
expect(conflict.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-name-conflict', details: { name: 'project' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('session creation and Workspace membership', () => {
|
||||
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
||||
const sessionId = SessionId('session-workspace-preallocated')
|
||||
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
|
||||
expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1)
|
||||
|
||||
const ungrouped = SessionId('session-cwd-only')
|
||||
expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped })))
|
||||
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped)
|
||||
|
||||
const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId }))
|
||||
expect(conflict.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } },
|
||||
})
|
||||
const missing = await api.sessions.create(request({
|
||||
workspaceId: 'missing-workspace' as WorkspaceId,
|
||||
sessionId: SessionId('session-missing-workspace'),
|
||||
}))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
|
||||
})
|
||||
|
||||
it('retains a published session when attachment fails and repairs it on retry', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
||||
const workspace = ctx.workspace.list()[0]
|
||||
if (workspace === undefined) throw new Error('workspace missing from registry')
|
||||
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
|
||||
const sessionId = SessionId('session-attach-retry')
|
||||
|
||||
const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))
|
||||
expect(failed.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } },
|
||||
})
|
||||
expect(ctx.agents.get(sessionId)).toBeDefined()
|
||||
|
||||
expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
|
||||
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Host Workspace increments', () => {
|
||||
it('streams committed Workspace and Session increments after empty baselines', async () => {
|
||||
const { api } = await harness()
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
|
||||
|
||||
const abort = new AbortController()
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const workspaceIncrement = nextHostFrame(stream)
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
||||
expect(await workspaceIncrement).toMatchObject({
|
||||
payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
|
||||
})
|
||||
|
||||
const sessionId = SessionId('session-streamed-workspace')
|
||||
const pending = nextHostFrame(stream)
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
const increments: HostFrame[] = []
|
||||
increments.push((await pending).payload)
|
||||
while (increments.length < 2) {
|
||||
const next = await stream.next()
|
||||
if (next.done === true) throw new Error('Host stream ended before both increments')
|
||||
increments.push(next.value.payload)
|
||||
}
|
||||
expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({
|
||||
// A just-created session has no events: the frame constantly carries blank:true.
|
||||
type: 'host/session-added', sessionId, blank: true, cwd: workspace.path,
|
||||
})
|
||||
const workspaceChanged = increments.find(
|
||||
(increment): increment is Extract<HostFrame, { type: 'host/workspace-changed' }> =>
|
||||
increment.type === 'host/workspace-changed',
|
||||
)
|
||||
expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId])
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('does not publish a Workspace whose registry-order commit fails', async () => {
|
||||
const { api, storageDomain } = await harness()
|
||||
const domain = storageDomain.get('workspace')
|
||||
if (domain === undefined) throw new Error('workspace domain is not open')
|
||||
vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
|
||||
const abort = new AbortController()
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const next = stream.next()
|
||||
|
||||
const failed = await api.workspace.create(request({ name: 'ghost' }))
|
||||
expect(failed.result.ok).toBe(false)
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
abort.abort()
|
||||
expect(await next).toMatchObject({ done: true })
|
||||
})
|
||||
|
||||
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
|
||||
const sessionId = SessionId('session-kept-after-workspace-delete')
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
|
||||
const abort = new AbortController()
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const removed = nextHostFrame(stream)
|
||||
expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId })))
|
||||
expect(await removed).toMatchObject({
|
||||
payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId },
|
||||
})
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
||||
expect(ctx.agents.get(sessionId)).toBeDefined()
|
||||
expect(existsSync(workspace.path)).toBe(true)
|
||||
|
||||
const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))
|
||||
expect(missing.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
|
||||
})
|
||||
|
||||
const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
|
||||
expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
|
||||
expect(reregistered.path).toBe(workspace.path)
|
||||
expect(reregistered.sessionIds).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
||||
abort.abort()
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,8 @@ function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>>
|
||||
function scriptedApi(overrides: {
|
||||
sessions?: Partial<ApiProxy['sessions']>
|
||||
host?: Partial<ApiProxy['host']>
|
||||
commands?: Partial<ApiProxy['commands']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
events?: Partial<ApiProxy['events']>
|
||||
goals?: Partial<ApiProxy['goals']>
|
||||
respond?: ApiProxy['respond']
|
||||
@@ -31,14 +33,43 @@ function scriptedApi(overrides: {
|
||||
sessions: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
create: r => ok(r, { sessionId: sid('s-new') }),
|
||||
history: r => ok(r, { events: [], hasMore: false }),
|
||||
history: r => ok(r, {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
models: r => ok(r, {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
}),
|
||||
selectModel: r => ok(r, {
|
||||
selected: { provider: r.payload.provider, model: r.payload.model },
|
||||
}),
|
||||
prompt: r => ok(r, { accepted: true as const }),
|
||||
cancel: r => ok(r, { accepted: true as const }),
|
||||
...overrides.sessions,
|
||||
},
|
||||
host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host },
|
||||
host: {
|
||||
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
|
||||
pickDirectory: r => ok(r, { path: null }),
|
||||
openPath: r => ok(r, { opened: true as const }),
|
||||
...overrides.host,
|
||||
},
|
||||
workspace: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
|
||||
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
delete: r => ok(r, { deleted: true as const }),
|
||||
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
},
|
||||
commands: {
|
||||
list: r => ok(r, { commands: [] }),
|
||||
execute: r => ok(r, { matched: false }),
|
||||
...overrides.commands,
|
||||
},
|
||||
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
|
||||
goals: {
|
||||
get: err,
|
||||
create: err,
|
||||
edit: err,
|
||||
pause: err,
|
||||
@@ -63,7 +94,7 @@ describe('unary round trip', () => {
|
||||
sessions: {
|
||||
list: (r) => {
|
||||
seen = r
|
||||
return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false }] })
|
||||
return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false, blank: false }] })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -72,7 +103,22 @@ describe('unary round trip', () => {
|
||||
expect(seen?.payload).toEqual({ cursor: 'c1' })
|
||||
expect(seen?.rpcId).toBeTruthy()
|
||||
expect(response.rpcId).toBe(seen?.rpcId)
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } })
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
|
||||
expect(renamed.result.ok).toBe(true)
|
||||
const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' })
|
||||
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
const deleted = await c.workspace.delete({ workspaceId: 'w1' as never })
|
||||
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
|
||||
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
|
||||
expect(anchored.result.ok).toBe(true)
|
||||
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
|
||||
expect(appended.result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('passes business errors through as 200 + err result, not a throw', async () => {
|
||||
@@ -203,6 +249,23 @@ describe('unary round trip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace domain round trip', () => {
|
||||
it('routes both workspace methods through their handler rows and value schemas', async () => {
|
||||
const c = client(scriptedApi())
|
||||
const list = await c.workspace.list({})
|
||||
expect(list.result).toEqual({ ok: true, value: { items: [] } })
|
||||
const created = await c.workspace.create({ path: '/t' })
|
||||
expect(created.result.ok).toBe(true)
|
||||
if (created.result.ok) expect(created.result.value.created).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a create payload violating the exactly-one refine at the handler', async () => {
|
||||
const response = await client(scriptedApi()).workspace.create({})
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSE stream path', () => {
|
||||
it('yields frames in order and skips the comment preamble', async () => {
|
||||
const frames: MuxFrame[] = [
|
||||
@@ -254,7 +317,7 @@ describe('SSE stream path', () => {
|
||||
const api = scriptedApi({
|
||||
events: {
|
||||
async *host(request): AsyncGenerator<RpcRequest<HostFrame>> {
|
||||
yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1') } }
|
||||
yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1'), blank: true } }
|
||||
throw new Error('impl died mid-stream')
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts'
|
||||
import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts'
|
||||
@@ -25,11 +26,47 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
|
||||
},
|
||||
async history(request) {
|
||||
if (request.payload.sessionId === ('with-projections' as never)) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { events: [], hasMore: false, projections: { asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' as const }] } } } },
|
||||
}
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } },
|
||||
}
|
||||
},
|
||||
async models(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
async selectModel(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
selected: {
|
||||
provider: request.payload.provider,
|
||||
model: request.payload.model,
|
||||
...request.payload.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: request.payload.reasoningEffort },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
async prompt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
@@ -41,6 +78,62 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async describe(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
|
||||
},
|
||||
async pickDirectory(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }
|
||||
},
|
||||
async openPath(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
|
||||
},
|
||||
async create(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } },
|
||||
}
|
||||
},
|
||||
async rename(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
async delete(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } }
|
||||
},
|
||||
async insertSessionBefore(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } } }
|
||||
},
|
||||
async execute(request, signal) {
|
||||
if (request.payload.line === '/hang') {
|
||||
// Cooperative hang: settles only through the carrier signal (sticky
|
||||
// abort checked first — listeners never fire retroactively).
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
|
||||
}
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } }
|
||||
}
|
||||
if (request.payload.line.startsWith('/plan')) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, commandId: CommandId('cmd-x') } } }
|
||||
}
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
async get(request) {
|
||||
@@ -75,8 +168,8 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
}
|
||||
}
|
||||
|
||||
function client(api: ApiProxy = fakeApi()): InProcessApiClient {
|
||||
return new InProcessApiClient(toFetchHandler(api))
|
||||
function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient {
|
||||
return new InProcessApiClient(toFetchHandler(api), timeoutMs)
|
||||
}
|
||||
|
||||
async function collect<F>(stream: AsyncIterable<RpcRequest<F>>): Promise<RpcRequest<F>[]> {
|
||||
@@ -92,6 +185,16 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect(response.rpcId).toMatch(/[0-9a-f-]{36}/)
|
||||
})
|
||||
|
||||
it('carries the tail-page projections block through the wire schema (Zod must not strip it)', async () => {
|
||||
const response = await client().sessions.history({ sessionId: 'with-projections' as never })
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (response.result.ok) {
|
||||
expect(response.result.value.projections).toEqual(
|
||||
{ asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' }] } },
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries a business error as 200 + error result', async () => {
|
||||
const response = await client().sessions.history({ sessionId: 'missing' as never })
|
||||
expect(response.result.ok).toBe(false)
|
||||
@@ -101,10 +204,99 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
it('covers create/prompt/cancel/describe passthrough', async () => {
|
||||
const c = client()
|
||||
expect((await c.sessions.create({})).result.ok).toBe(true)
|
||||
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
const selected = await c.sessions.selectModel({
|
||||
sessionId: 's' as never,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
expect(selected.result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
selected: {
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: 'max',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
|
||||
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
expect((await c.host.describe({})).result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips the native picker without the default unary timeout', async () => {
|
||||
const api = fakeApi()
|
||||
api.host.pickDirectory = async (request) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 15))
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/project' } } }
|
||||
}
|
||||
const response = await client(api, 1).host.pickDirectory({})
|
||||
expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } })
|
||||
})
|
||||
|
||||
it('round-trips host.openPath through the wire form', async () => {
|
||||
const api = fakeApi()
|
||||
let opened: string | undefined
|
||||
api.host.openPath = async (request) => {
|
||||
opened = request.payload.path
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
|
||||
}
|
||||
const response = await client(api).host.openPath({ path: '/tmp/a.txt' })
|
||||
expect(opened).toBe('/tmp/a.txt')
|
||||
expect(response.result).toEqual({ ok: true, value: { opened: true } })
|
||||
})
|
||||
|
||||
it('round-trips command.list / command.execute / skill.list through the wire form', async () => {
|
||||
const c = client()
|
||||
const list = await c.commands.list({ sessionId: 's' as never })
|
||||
expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } })
|
||||
const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' })
|
||||
expect(hit.result).toEqual({ ok: true, value: { matched: true, commandId: 'cmd-x' } })
|
||||
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
|
||||
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
|
||||
const skills = await c.skills.list({ sessionId: 's' as never })
|
||||
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into command.execute', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } })
|
||||
// The fake's /hang settles only when the invoke-level signal aborts: a
|
||||
// completed response with the cancelled error proves req.signal reached it.
|
||||
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal }))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
|
||||
expect(parsed.rpcId).toBe('r-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into host.pickDirectory', async () => {
|
||||
const api = fakeApi()
|
||||
api.host.pickDirectory = async (request, signal) => {
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
|
||||
}
|
||||
}
|
||||
const handler = toFetchHandler(api)
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} })
|
||||
const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', {
|
||||
method: 'POST', body, signal: controller.signal,
|
||||
}))
|
||||
controller.abort()
|
||||
const parsed = await (await pending).json() as { result: { error?: { code: string } } }
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handler carrier-layer statuses', () => {
|
||||
|
||||
139
packages/host/apiproxy/tests/native-directory-picker.spec.ts
Normal file
139
packages/host/apiproxy/tests/native-directory-picker.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
type ExecFileCallback = (
|
||||
error: (Error & { code?: string | number }) | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void
|
||||
type ExecFileMock = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: { encoding: string; signal: AbortSignal; windowsHide: boolean },
|
||||
callback: ExecFileCallback,
|
||||
) => void
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() }))
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts'
|
||||
|
||||
function failure(code: string | number, stderr = ''): Error {
|
||||
return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr })
|
||||
}
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
describe('native directory picker', () => {
|
||||
it('uses the macOS folder chooser and maps user cancellation to null', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/')
|
||||
expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal))
|
||||
|
||||
run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)'))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull()
|
||||
|
||||
run.mockRejectedValueOnce(failure(2, 'permission denied'))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a primitive error', 'failed'],
|
||||
['an invalid code type', { code: true }],
|
||||
['a missing stderr property', { code: 1 }],
|
||||
['a non-string stderr property', { code: 1, stderr: 42 }],
|
||||
])('does not mistake %s for macOS cancellation', async (_label, reason) => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => { throw reason })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project')
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'")
|
||||
run.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull()
|
||||
run.mockRejectedValueOnce(failure(1, 'Add-Type failed'))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
|
||||
})
|
||||
|
||||
it('runs the default command adapter without a shell and preserves command failures', async () => {
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(null, 'C:\\work\\default\r\n', '')
|
||||
})
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
|
||||
const [command, args, options] = execFileMock.mock.calls[0]!
|
||||
expect(command).toBe('powershell.exe')
|
||||
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
|
||||
expect(options.encoding).toBe('utf8')
|
||||
expect(options.windowsHide).toBe(true)
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||
|
||||
const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(commandError, 'partial output', 'failure details')
|
||||
})
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
|
||||
message: 'powershell failed', cause: commandError, code: 7,
|
||||
stdout: 'partial output', stderr: 'failure details',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the current process platform when no platform override is supplied', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform')
|
||||
})
|
||||
|
||||
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>()
|
||||
.mockRejectedValueOnce(failure('ENOENT'))
|
||||
.mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project')
|
||||
expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog'])
|
||||
|
||||
const zenity = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/home/test/direct\n', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenity }))
|
||||
.resolves.toBe('/home/test/direct')
|
||||
expect(zenity).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('maps Linux cancellation to null and reports a missing desktop picker', async () => {
|
||||
const cancelled = vi.fn<DirectoryPickerRunner>(async () => { throw failure(1) })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: cancelled })).resolves.toBeNull()
|
||||
|
||||
const missing = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ENOENT') })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing }))
|
||||
.rejects.toThrow('install zenity or kdialog')
|
||||
|
||||
const kdialogCancelled = vi.fn<DirectoryPickerRunner>()
|
||||
.mockRejectedValueOnce(failure('ENOENT'))
|
||||
.mockRejectedValueOnce(failure(1))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled }))
|
||||
.resolves.toBeNull()
|
||||
|
||||
const zenityFailed = vi.fn<DirectoryPickerRunner>(async () => { throw failure(2) })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed }))
|
||||
.rejects.toThrow('command failed')
|
||||
|
||||
const kdialogFailed = vi.fn<DirectoryPickerRunner>()
|
||||
.mockRejectedValueOnce(failure('ENOENT'))
|
||||
.mockRejectedValueOnce(failure(2))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogFailed }))
|
||||
.rejects.toThrow('command failed')
|
||||
})
|
||||
|
||||
it('does not convert caller aborts into user cancellation', async () => {
|
||||
const abort = new AbortController()
|
||||
abort.abort(new Error('closed'))
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ABORT_ERR') })
|
||||
await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed')
|
||||
})
|
||||
|
||||
it('reports unsupported platforms', async () => {
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'aix' })).rejects.toThrow('unsupported on aix')
|
||||
})
|
||||
})
|
||||
82
packages/host/apiproxy/tests/native-path-opener.spec.ts
Normal file
82
packages/host/apiproxy/tests/native-path-opener.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
type ExecFileCallback = (
|
||||
error: (Error & { code?: string | number }) | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void
|
||||
type ExecFileMock = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: { encoding: string; signal: AbortSignal; windowsHide: boolean },
|
||||
callback: ExecFileCallback,
|
||||
) => void
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() }))
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openNativePath, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
describe('native path opener', () => {
|
||||
it('opens with macOS open(1)', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath('/Users/test/file.txt', signal(), { platform: 'darwin', run })
|
||||
expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\o''reilly.txt'"],
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
})
|
||||
|
||||
it('opens with Linux xdg-open', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })
|
||||
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('rejects unsupported platforms', async () => {
|
||||
await expect(openNativePath('/x', signal(), { platform: 'freebsd' as NodeJS.Platform }))
|
||||
.rejects.toThrow('unsupported on freebsd')
|
||||
})
|
||||
|
||||
it('uses the current process platform when no platform override is supplied', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath('/tmp/platform-default.txt', signal(), { run })
|
||||
const expected = process.platform === 'win32'
|
||||
? 'powershell.exe'
|
||||
: process.platform === 'linux'
|
||||
? 'xdg-open'
|
||||
: 'open'
|
||||
expect(run.mock.calls[0]?.[0]).toBe(expected)
|
||||
})
|
||||
|
||||
it('runs the default command adapter without a shell and preserves command failures', async () => {
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(null, '', '')
|
||||
})
|
||||
await openNativePath('/tmp/default.txt', signal(), { platform: 'darwin' })
|
||||
const [command, args, options] = execFileMock.mock.calls[0]!
|
||||
expect(command).toBe('open')
|
||||
expect(args).toEqual(['/tmp/default.txt'])
|
||||
expect(options.encoding).toBe('utf8')
|
||||
expect(options.windowsHide).toBe(true)
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||
|
||||
const commandError = Object.assign(new Error('open failed'), { code: 1 })
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(commandError, 'partial output', 'failure details')
|
||||
})
|
||||
await expect(openNativePath('/tmp/missing.txt', signal(), { platform: 'darwin' })).rejects.toMatchObject({
|
||||
message: 'open failed', cause: commandError, code: 1,
|
||||
stdout: 'partial output', stderr: 'failure details',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { RpcId, transportError } from '../src/api/rpc.ts'
|
||||
import {
|
||||
clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
|
||||
rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema,
|
||||
@@ -8,10 +8,23 @@ import { z } from 'zod'
|
||||
import {
|
||||
contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
|
||||
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
|
||||
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema,
|
||||
sessionPromptValueSchema, sessionSummarySchema,
|
||||
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
|
||||
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
|
||||
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
|
||||
} from '../src/api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
||||
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
} from '../src/api/workspace.schema.ts'
|
||||
import {
|
||||
commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema,
|
||||
commandListRequestSchema, commandListValueSchema,
|
||||
} from '../src/api/commands.schema.ts'
|
||||
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
||||
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
||||
@@ -27,10 +40,29 @@ describe('RpcId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('transportError', () => {
|
||||
it('folds Error and non-Error throws into the internal error branch', () => {
|
||||
expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } })
|
||||
expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rpcErrorSchema', () => {
|
||||
it('accepts every code branch with its required details', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
||||
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
|
||||
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid')
|
||||
expect(rpcErrorSchema.parse({
|
||||
code: 'model-unavailable',
|
||||
message: 'm',
|
||||
details: { provider: 'p', model: 'm' },
|
||||
}).code).toBe('model-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
|
||||
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
|
||||
@@ -87,11 +119,23 @@ describe('sessions domain schemas', () => {
|
||||
it('validates ids, summaries, and the event passthrough envelope', () => {
|
||||
expect(sessionIdSchema.parse('s1')).toBe('s1')
|
||||
expect(() => sessionIdSchema.parse('')).toThrow()
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toMatchObject({ sessionId: 's1' })
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
|
||||
const event = sessionEventSchema.parse({ type: 'user/message', seq: 0, time: 1, data: { any: true } })
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true })
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
|
||||
// blank is mandatory: a summary without it fails the parse.
|
||||
expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow()
|
||||
const event = sessionEventSchema.parse({
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { any: true },
|
||||
})
|
||||
expect(event).toMatchObject({ type: 'user/message' })
|
||||
expect(() => sessionEventSchema.parse({ type: 'user/message', seq: -1, time: 1, data: {} })).toThrow()
|
||||
expect(() => sessionEventSchema.parse({
|
||||
type: 'user/message',
|
||||
seq: -1,
|
||||
time: 1,
|
||||
data: {},
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
it('validates the per-method request/value pairs', () => {
|
||||
@@ -99,10 +143,68 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
|
||||
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
|
||||
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
|
||||
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
|
||||
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/)
|
||||
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
|
||||
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
|
||||
expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
|
||||
expect(sessionHistoryValueSchema.parse({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', 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' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek V4 Flash',
|
||||
description: 'fast',
|
||||
unlisted: true,
|
||||
reasoning: {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
{ id: 'max', name: 'Max', description: 'Largest budget' },
|
||||
],
|
||||
defaultEffort: 'off',
|
||||
},
|
||||
}],
|
||||
}],
|
||||
failures: [{ id: 'broken', name: 'Broken', message: 'offline' }],
|
||||
}).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash')
|
||||
expect(sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
}).reasoningEffort).toBe('max')
|
||||
expect(sessionSelectModelValueSchema.parse({
|
||||
selected: { provider: 'deepseek', model: 'deepseek-v4-pro', reasoningEffort: 'max' },
|
||||
}).selected.reasoningEffort).toBe('max')
|
||||
expect(() => sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
provider: '',
|
||||
model: 'm',
|
||||
})).toThrow()
|
||||
expect(() => sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
reasoningEffort: '',
|
||||
})).toThrow()
|
||||
expect(() => sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek', model: 'm' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'm', name: 'M', reasoning: { efforts: [] } }],
|
||||
}],
|
||||
failures: [],
|
||||
})).toThrow()
|
||||
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
|
||||
expect(prompt.mode).toBe('queue')
|
||||
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
|
||||
@@ -127,6 +229,98 @@ describe('host domain schemas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace domain schemas', () => {
|
||||
const view = {
|
||||
workspaceId: 'w1', path: '/p', title: 'p', sessionIds: ['s1'],
|
||||
createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z',
|
||||
}
|
||||
|
||||
it('validates ids, the view row, and list request/value', () => {
|
||||
expect(workspaceIdSchema.parse('w1')).toBe('w1')
|
||||
expect(() => workspaceIdSchema.parse('')).toThrow()
|
||||
expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1'])
|
||||
expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow()
|
||||
expect(workspaceListRequestSchema.parse({})).toEqual({})
|
||||
expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('create requires exactly one of path/name (both refine arms)', () => {
|
||||
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
|
||||
expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n')
|
||||
expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
|
||||
expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
|
||||
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
|
||||
})
|
||||
|
||||
it('rename requires a non-blank title (both refine arms)', () => {
|
||||
expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new')
|
||||
expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/)
|
||||
expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
|
||||
it('validates workspace deletion payload and receipt', () => {
|
||||
expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1')
|
||||
expect(() => workspaceDeleteRequestSchema.parse({})).toThrow()
|
||||
expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true })
|
||||
expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
|
||||
expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commands domain schemas', () => {
|
||||
it('validates the catalog request/value pair', () => {
|
||||
expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
// The wire is session-addressed only: a sessionId-less payload fails.
|
||||
expect(() => commandListRequestSchema.parse({})).toThrow()
|
||||
expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([])
|
||||
const value = commandListValueSchema.parse({ commands: [
|
||||
{ name: 'plan', description: 'Toggle plan mode' },
|
||||
{ name: 'goal', description: 'Set the goal', input: { hint: '<goal>' } },
|
||||
] })
|
||||
expect(value.commands[1]?.input?.hint).toBe('<goal>')
|
||||
expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined()
|
||||
expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow()
|
||||
expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow()
|
||||
})
|
||||
|
||||
it('validates the execute request/value pair with both matched branches', () => {
|
||||
expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off')
|
||||
// Both members are mandatory: dropping either fails the parse.
|
||||
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
|
||||
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
|
||||
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
|
||||
// Pure admission: matched plus the optional lifecycle pairing id
|
||||
// (outcomes ride the logged lifecycle events, never this response).
|
||||
expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' }))
|
||||
.toEqual({ matched: true, commandId: 'cmd-1' })
|
||||
expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true })
|
||||
expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow()
|
||||
expect(() => commandExecuteValueSchema.parse({})).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills domain schemas', () => {
|
||||
it('validates the list request/value pair', () => {
|
||||
expect(skillListRequestSchema.parse({ sessionId: 's1' })).toEqual({ sessionId: 's1' })
|
||||
// The wire is session-addressed only: a sessionId-less payload fails.
|
||||
expect(() => skillListRequestSchema.parse({})).toThrow()
|
||||
expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([])
|
||||
const value = skillListValueSchema.parse({ skills: [
|
||||
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' },
|
||||
{ name: 'bare', description: 'No guidance' },
|
||||
] })
|
||||
expect(value.skills[0]?.whenToUse).toBe('when committing')
|
||||
expect(value.skills[1]?.whenToUse).toBeUndefined()
|
||||
expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('goals domain schemas', () => {
|
||||
it('requires at least one replacement field for goal.edit', () => {
|
||||
const ref = { id: 'g1', revision: 1 }
|
||||
@@ -145,20 +339,44 @@ describe('events frame schemas', () => {
|
||||
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
|
||||
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
|
||||
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
|
||||
{ type: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false },
|
||||
{ type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
|
||||
for (const invalid of [
|
||||
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
|
||||
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
|
||||
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
|
||||
})
|
||||
|
||||
it('rejects an empty question batch (ask() guarantees at least one, so an empty frame is host breakage)', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a queued frame missing its members', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts every host frame branch', () => {
|
||||
const frames = [
|
||||
{ type: 'host/session-added', sessionId: 's', parentSessionId: 'p' },
|
||||
{ type: 'host/session-added', sessionId: 's' },
|
||||
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' },
|
||||
{ type: 'host/session-added', sessionId: 's', blank: true },
|
||||
{ type: 'host/session-removed', sessionId: 's' },
|
||||
{ type: 'host/session-status', sessionId: 's', running: true },
|
||||
{ type: 'host/agent-error', sessionId: 's', message: 'boom' },
|
||||
{ type: 'host/workspace-changed', workspace: {
|
||||
workspaceId: 'w', path: '/w', title: 'w', sessionIds: [],
|
||||
createdAt: '0', updatedAt: '0',
|
||||
} },
|
||||
{ type: 'host/workspace-removed', workspaceId: 'w' },
|
||||
{ type: 'host/commands-changed' },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
|
||||
@@ -8,24 +8,48 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../workspace/workspace"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user