Merge finalized Web transcript parent
# Conflicts: # apps/cli/README.i18n.yaml # packages/client/runtime/README.i18n.yaml # packages/client/ui-trajectory/README.i18n.yaml # packages/client/ui-trajectory/README.zh.md
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: cf8cf0aaa7e20436644b92a996b6f7a817b1dd31
|
||||
README.zh.md: 91b3d1d0577d4a9a7df97d3c785e28bb21e47fd1
|
||||
README.md: 926c631e4784d5ca9b52b1af93487ce4887511b1
|
||||
README.zh.md: 030e6f9dab7e659e8ea6bc2543c676f35ade4b39
|
||||
|
||||
@@ -24,11 +24,15 @@ Pending queued input is a live control-plane contract, not session history. The
|
||||
|
||||
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()`.
|
||||
|
||||
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
|
||||
A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot without discarding the learned provider page size. Limit probes and stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier request signal cancels persistence listing, cold-summary collection, and every search call, including a limit or stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an `internal` business error so clients can retain metadata-only matches.
|
||||
|
||||
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
@@ -49,5 +53,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations (the former `host.listModels` reservation shipped as `llm.models`); an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
|
||||
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
|
||||
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
|
||||
|
||||
@@ -24,11 +24,15 @@
|
||||
|
||||
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()` 中。
|
||||
|
||||
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
|
||||
陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始,但不会丢弃探测所得的提供方页面大小。上限探测与陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。
|
||||
|
||||
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由某个已暴露提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
@@ -49,5 +53,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项(先前预留的 `host.listModels` 已作为 `llm.models` 交付);未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
|
||||
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
|
||||
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
|
||||
@@ -26,9 +27,14 @@ import {
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary,
|
||||
SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
SessionSummary, SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
truncateUnicodeCodePoints,
|
||||
} from './api/session-search.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
|
||||
@@ -66,9 +72,20 @@ import { openNativePath } from './native-path-opener.ts'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
|
||||
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
|
||||
|
||||
/** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */
|
||||
const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
|
||||
/** Conversation message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
/** Read live abort state across awaits without treating it as synchronously immutable. */
|
||||
function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages append-origin messages
|
||||
* backwards from the window tail. Replacement copies never entered the
|
||||
@@ -266,15 +283,22 @@ function summarize(session: Session, running: boolean): SessionSummary {
|
||||
* updatedAt is the log file's mtime; backends without a per-session file
|
||||
* (locate() undefined) fall back to the header's createdAt.
|
||||
*/
|
||||
async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
|
||||
async function summarizeCold(
|
||||
persistence: SessionPersistence,
|
||||
meta: SessionHeader,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionSummary> {
|
||||
signal?.throwIfAborted()
|
||||
let updatedAt = meta.createdAt
|
||||
const location = persistence.locate(meta)
|
||||
signal?.throwIfAborted()
|
||||
if (location !== undefined) {
|
||||
try {
|
||||
updatedAt = (await stat(location.path)).mtimeMs
|
||||
} catch {
|
||||
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
return {
|
||||
sessionId: meta.id,
|
||||
@@ -962,6 +986,62 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session.list baseline shared by listing and search visibility.
|
||||
* Attached sessions come from memory; servable cold sessions merge from
|
||||
* persistence, and the final order is newest-first.
|
||||
*/
|
||||
async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
|
||||
signal?.throwIfAborted()
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
const projections = listProjectionsFor(ctx, session.header, session)
|
||||
return {
|
||||
...summarize(session, agent?.status === 'running'),
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
})
|
||||
signal?.throwIfAborted()
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list(signal))
|
||||
.filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
signal?.throwIfAborted()
|
||||
for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
|
||||
signal?.throwIfAborted()
|
||||
const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
|
||||
const settled = await Promise.allSettled(
|
||||
batch.map(async (meta) => {
|
||||
// Cold rows read the persisted projection cache only — never a
|
||||
// log load; a session without a cache row simply has no column.
|
||||
const projections = listProjectionsFor(ctx, meta, undefined)
|
||||
return {
|
||||
...await summarizeCold(persistence, meta, signal),
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
}),
|
||||
)
|
||||
const summaries: SessionSummary[] = []
|
||||
let rejected = false
|
||||
let failure: unknown
|
||||
for (const result of settled) {
|
||||
if (result.status === 'fulfilled') {
|
||||
summaries.push(result.value)
|
||||
} else if (!rejected) {
|
||||
rejected = true
|
||||
failure = result.reason
|
||||
}
|
||||
}
|
||||
if (rejected) throw failure
|
||||
signal?.throwIfAborted()
|
||||
items.push(...summaries)
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return items
|
||||
}
|
||||
|
||||
/** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
|
||||
function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
|
||||
const goals = ctx.get('goals')
|
||||
@@ -1104,30 +1184,137 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Legacy logs without a cwd (pre-project stance) are not served — every
|
||||
// session now records its project at create time.
|
||||
async list(request) {
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
const projections = listProjectionsFor(ctx, session.header, session)
|
||||
return {
|
||||
...summarize(session, agent?.status === 'running'),
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
return ok(request, { items: await listVisibleSessionSummaries() })
|
||||
},
|
||||
|
||||
async search(request, signal) {
|
||||
const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, {
|
||||
code: 'cancelled',
|
||||
message: 'session search was aborted',
|
||||
details: {},
|
||||
})
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
items.push(...await Promise.all(cold.map(async (meta) => {
|
||||
// Cold rows read the persisted projection cache only — never a
|
||||
// log load; a session without a cache row simply has no column.
|
||||
const projections = listProjectionsFor(ctx, meta, undefined)
|
||||
return {
|
||||
...await summarizeCold(persistence, meta),
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
})))
|
||||
if (isAborted(signal)) return cancelled()
|
||||
const sessionQuery = ctx.get('sessionQuery')
|
||||
if (sessionQuery === undefined) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
try {
|
||||
const visible = await listVisibleSessionSummaries(signal)
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (visible.length === 0) return ok(request, { items: [], hasMore: false })
|
||||
const visibleIds = new Set(visible.map(item => item.sessionId))
|
||||
const authorized: SessionSearchItem[] = []
|
||||
const acceptedIds = new Set<SessionId>()
|
||||
const seenCursors = new Set<SessionSearchCursor>()
|
||||
let cursor: SessionSearchCursor | undefined
|
||||
let providerCallCount = 0
|
||||
let providerPageLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) {
|
||||
throw new Error(
|
||||
`session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`,
|
||||
)
|
||||
}
|
||||
providerCallCount++
|
||||
const requestedCursor = cursor
|
||||
const requestedPageLimit = providerPageLimit
|
||||
let page
|
||||
try {
|
||||
page = await sessionQuery.searchSessions({
|
||||
query: request.payload.query,
|
||||
eventFilters: [
|
||||
{ kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] },
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: requestedPageLimit,
|
||||
...requestedCursor === undefined ? {} : { cursor: requestedCursor },
|
||||
}, { signal })
|
||||
} catch (error: unknown) {
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (
|
||||
requestedCursor === undefined
|
||||
&& error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_INVALID_LIMIT'
|
||||
&& requestedPageLimit > 1
|
||||
) {
|
||||
providerPageLimit = Math.max(1, Math.floor(requestedPageLimit / 2))
|
||||
continue
|
||||
}
|
||||
if (
|
||||
requestedCursor !== undefined
|
||||
&& error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_STALE_CURSOR'
|
||||
) {
|
||||
authorized.length = 0
|
||||
acceptedIds.clear()
|
||||
seenCursors.clear()
|
||||
cursor = undefined
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (isAborted(signal)) return cancelled()
|
||||
const providerItemCount = page.items.length
|
||||
if (providerItemCount > requestedPageLimit) {
|
||||
throw new Error(
|
||||
`session search provider returned ${providerItemCount} items; maximum is ${requestedPageLimit}`,
|
||||
)
|
||||
}
|
||||
// Host visibility is the authorization boundary. Consume the
|
||||
// provider's globally ranked stream rather than binding every
|
||||
// visible id into one SQLite statement, then re-check complete
|
||||
// provenance before emitting any snippet.
|
||||
for (const hit of page.items) {
|
||||
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
|
||||
if (
|
||||
!visibleIds.has(hit.header.id)
|
||||
|| hit.bestMatch.sessionId !== hit.header.id
|
||||
|| hit.bestMatch.surface !== 'current'
|
||||
|| !MESSAGE_TYPES.has(hit.bestMatch.type)
|
||||
|| acceptedIds.has(hit.header.id)
|
||||
) continue
|
||||
const snippet = truncateUnicodeCodePoints(
|
||||
hit.bestMatch.snippet,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
)
|
||||
acceptedIds.add(hit.header.id)
|
||||
authorized.push({
|
||||
sessionId: hit.header.id,
|
||||
snippet,
|
||||
})
|
||||
}
|
||||
const nextCursor = page.nextCursor
|
||||
if (nextCursor !== undefined) {
|
||||
if (seenCursors.has(nextCursor)) {
|
||||
throw new Error('session search provider repeated a continuation cursor')
|
||||
}
|
||||
seenCursors.add(nextCursor)
|
||||
}
|
||||
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || nextCursor === undefined) break
|
||||
cursor = nextCursor
|
||||
}
|
||||
return ok(request, {
|
||||
items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
|
||||
hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isAborted(signal)
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')
|
||||
) return cancelled()
|
||||
// XXX: Redact provider details before exposing this gateway beyond
|
||||
// its current single-user local deployment.
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `session search failed: ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return ok(request, { items })
|
||||
},
|
||||
|
||||
async create(request) {
|
||||
|
||||
@@ -35,7 +35,8 @@ export interface ApiProxy {
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
|
||||
SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
@@ -67,5 +68,11 @@ export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
|
||||
// ---- Fixed session-search product bounds ----
|
||||
export {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
} from './session-search.ts'
|
||||
|
||||
// ---- Method registry and derived generics ----
|
||||
export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { RpcResponse } from './rpc.ts'
|
||||
*/
|
||||
export interface RpcMethodMap {
|
||||
'session.list': SessionsApi['list']
|
||||
'session.search': SessionsApi['search']
|
||||
'session.create': SessionsApi['create']
|
||||
'session.history': SessionsApi['history']
|
||||
'session.models': SessionsApi['models']
|
||||
|
||||
22
packages/host/apiproxy/src/api/session-search.ts
Normal file
22
packages/host/apiproxy/src/api/session-search.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Maximum number of sessions returned by one sidebar search. */
|
||||
export const SESSION_SEARCH_RESULT_LIMIT = 20
|
||||
|
||||
/** Maximum snippet length in Unicode code points. */
|
||||
export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
|
||||
|
||||
/**
|
||||
* Return the longest prefix containing at most `maximum` Unicode code points.
|
||||
* @param value - text to bound.
|
||||
* @param maximum - non-negative code-point limit.
|
||||
* @returns `value` unchanged when it fits, otherwise a code-point-safe prefix.
|
||||
*/
|
||||
export function truncateUnicodeCodePoints(value: string, maximum: number): string {
|
||||
let count = 0
|
||||
let end = 0
|
||||
for (const codePoint of value) {
|
||||
if (count === maximum) return value.slice(0, end)
|
||||
count++
|
||||
end += codePoint.length
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -12,10 +12,15 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
|
||||
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
truncateUnicodeCodePoints,
|
||||
} from './session-search.ts'
|
||||
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
@@ -62,6 +67,33 @@ export const sessionListValueSchema: z.ZodType<Wire<ResponseValue<'session.list'
|
||||
items: z.array(sessionSummarySchema),
|
||||
})
|
||||
|
||||
/** Fixed wire bound for one interactive sidebar query. */
|
||||
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
|
||||
|
||||
/** session.search request payload. */
|
||||
export const sessionSearchRequestSchema = z.object({
|
||||
query: z.string().trim().min(1).max(SESSION_SEARCH_QUERY_MAX_CHARS)
|
||||
.refine(query => !query.includes('\0'), { message: 'search query must not contain NUL' }),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.search'>>>
|
||||
|
||||
/** One session.search result. */
|
||||
export const sessionSearchItemSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
snippet: z.string().refine(
|
||||
snippet => truncateUnicodeCodePoints(
|
||||
snippet,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
) === snippet,
|
||||
{ message: `search snippet must contain at most ${SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS} Unicode code points` },
|
||||
),
|
||||
}) satisfies z.ZodType<Wire<SessionSearchItem>>
|
||||
|
||||
/** session.search response value. */
|
||||
export const sessionSearchValueSchema = z.object({
|
||||
items: z.array(sessionSearchItemSchema).max(SESSION_SEARCH_RESULT_LIMIT),
|
||||
hasMore: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.search'>>>
|
||||
|
||||
/** session.create request payload (at most one of workspaceId / cwd). */
|
||||
export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
|
||||
@@ -169,11 +169,28 @@ export interface SessionSummary {
|
||||
projections?: SessionProjectionsBlock
|
||||
}
|
||||
|
||||
/** One session-content search result; display metadata stays owned by `session.list`. */
|
||||
export interface SessionSearchItem {
|
||||
sessionId: SessionId
|
||||
/** Plain-text excerpt around the strongest matching visible message. */
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
|
||||
export interface SessionsApi {
|
||||
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
|
||||
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
|
||||
|
||||
/**
|
||||
* Searches the current user/assistant/steering message surface across
|
||||
* sessions visible to `list`. Results contain at most 20 sessions and carry
|
||||
* no continuation cursor; `hasMore` asks the client to refine the query.
|
||||
*/
|
||||
search(
|
||||
request: RpcRequest<{ query: string }>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>>
|
||||
|
||||
/**
|
||||
* Creates a real session and its idle agent. At most one of `workspaceId` /
|
||||
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
sessionModelsValueSchema,
|
||||
sessionPromptValueSchema,
|
||||
sessionRenameValueSchema,
|
||||
sessionSearchValueSchema,
|
||||
sessionSelectModelValueSchema,
|
||||
sessionUpdateQueueValueSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
@@ -58,9 +59,10 @@ import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
* methods take the business payload directly — the carrier mints the rpcId and wraps the
|
||||
* envelope. Business code needing the call's rpcId reads it from the RpcResponse echo.
|
||||
* Unary methods and respond accept an optional external AbortSignal as the last parameter
|
||||
* (merged with the instance timeout via AbortSignal.any; same "signal rides beside the
|
||||
* request, never on the wire" discipline as the stream signatures).
|
||||
* Unary methods and respond accept an optional external AbortSignal as the last parameter.
|
||||
* Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
|
||||
* carry only that external signal. In both cases the signal rides beside the request, never
|
||||
* on the wire, like the stream signatures.
|
||||
* Stream methods accept an optional onOpen callback: it fires once the SSE transport is
|
||||
* readable (response headers received, before any frame) — the "stream established" signal
|
||||
* connection controllers need for the readiness handshake. Generators are lazy, so the
|
||||
@@ -72,6 +74,7 @@ import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema
|
||||
export interface IApiClient {
|
||||
sessions: {
|
||||
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
|
||||
search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.search'>>>
|
||||
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
|
||||
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
|
||||
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
|
||||
@@ -140,6 +143,7 @@ export interface IApiClient {
|
||||
*/
|
||||
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
|
||||
'session.list': sessionListValueSchema,
|
||||
'session.search': sessionSearchValueSchema,
|
||||
'session.create': sessionCreateValueSchema,
|
||||
'session.history': sessionHistoryValueSchema,
|
||||
'session.models': sessionModelsValueSchema,
|
||||
@@ -179,9 +183,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'llm.models': llmModelsValueSchema,
|
||||
}
|
||||
|
||||
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Whether a unary call uses the transport health deadline or only caller/connection cancellation. */
|
||||
type UnaryTimeoutPolicy = 'default' | 'caller-signal-only'
|
||||
|
||||
/** URL base for in-process handler injection (fake authority, opencode precedent). */
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
|
||||
@@ -199,7 +206,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
private flushScheduled = false
|
||||
private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
|
||||
|
||||
/** @param timeoutMs - unary timeout; streams never time out (long-lived by nature). */
|
||||
/** @param timeoutMs - timeout for bounded unary calls; user-paced calls and streams do not use it. */
|
||||
constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
|
||||
|
||||
/** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
|
||||
@@ -254,15 +261,15 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
/**
|
||||
* Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
|
||||
* timeout merged with the caller's optional external signal, non-2xx → transport throw.
|
||||
* optional default timeout merged with the caller's external signal, non-2xx → transport throw.
|
||||
*/
|
||||
private async postJson(
|
||||
path: string,
|
||||
body: ClientRequest | ClientResponse,
|
||||
signal: AbortSignal | undefined,
|
||||
useDefaultTimeout = true,
|
||||
timeoutPolicy: UnaryTimeoutPolicy = 'default',
|
||||
): Promise<Response> {
|
||||
const requestSignal = useDefaultTimeout
|
||||
const requestSignal = timeoutPolicy === 'default'
|
||||
? signal === undefined
|
||||
? AbortSignal.timeout(this.timeoutMs)
|
||||
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
|
||||
@@ -286,11 +293,11 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
method: K,
|
||||
payload: RequestPayload<K>,
|
||||
signal?: AbortSignal,
|
||||
useDefaultTimeout = true,
|
||||
timeoutPolicy: UnaryTimeoutPolicy = 'default',
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
|
||||
this.onEnvelope(message)
|
||||
const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout)
|
||||
const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy)
|
||||
const full = serverResponseSchema.parse(await response.json())
|
||||
this.onEnvelope(full)
|
||||
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
|
||||
@@ -363,6 +370,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload, signal) => this.callUnary('session.list', payload, signal),
|
||||
search: (payload, signal) => this.callUnary('session.search', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('session.create', payload, signal),
|
||||
history: (payload, signal) => this.callUnary('session.history', payload, signal),
|
||||
models: (payload, signal) => this.callUnary('session.models', payload, signal),
|
||||
@@ -378,7 +386,9 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
// A native system dialog is user-paced and may legitimately stay open
|
||||
// longer than the normal unary deadline. Caller/connection aborts remain.
|
||||
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
|
||||
pickDirectory: (payload, signal) => this.callUnary(
|
||||
'host.pickDirectory', payload, signal, 'caller-signal-only',
|
||||
),
|
||||
listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal),
|
||||
createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal),
|
||||
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
|
||||
@@ -394,7 +404,11 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload, signal) => this.callUnary('command.list', payload, signal),
|
||||
execute: (payload, signal) => this.callUnary('command.execute', payload, signal),
|
||||
// Command handlers are user-driven operations and may legitimately exceed
|
||||
// the transport health deadline. Caller/connection aborts remain.
|
||||
execute: (payload, signal) => this.callUnary(
|
||||
'command.execute', payload, signal, 'caller-signal-only',
|
||||
),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
sessionModelsRequestSchema,
|
||||
sessionPromptRequestSchema,
|
||||
sessionRenameRequestSchema,
|
||||
sessionSearchRequestSchema,
|
||||
sessionSelectModelRequestSchema,
|
||||
sessionUpdateQueueRequestSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
@@ -63,7 +64,8 @@ import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.sc
|
||||
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
|
||||
* documented on Wire); the dispatch point carries the one Wire→exact cast.
|
||||
* Every invoke receives the carrier Request's signal; methods whose contract
|
||||
* declares a signal parameter (command.execute) forward it, the rest ignore it.
|
||||
* declares a signal parameter (session.search and command.execute) forward it,
|
||||
* the rest ignore it.
|
||||
*/
|
||||
type UnaryRoutes = {
|
||||
[K in keyof RpcMethodMap]: {
|
||||
@@ -74,6 +76,7 @@ type UnaryRoutes = {
|
||||
|
||||
const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
|
||||
'session.search': { schema: sessionSearchRequestSchema, invoke: (api, r, signal) => api.sessions.search(r, signal) },
|
||||
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
|
||||
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
|
||||
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
|
||||
|
||||
880
packages/host/apiproxy/tests/api-proxy-search.spec.ts
Normal file
880
packages/host/apiproxy/tests/api-proxy-search.spec.ts
Normal file
@@ -0,0 +1,880 @@
|
||||
/**
|
||||
* Host session.search projection: list-equivalent visibility, fixed message
|
||||
* filters and result bound, cancellation mapping, and unavailable/failure
|
||||
* behavior.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import {
|
||||
SessionQueryError,
|
||||
type SessionSearchHit,
|
||||
type SessionSearchRequest,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, stat: vi.fn(actual.stat) }
|
||||
})
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
|
||||
function request(query: string): RpcRequest<{ query: string }> {
|
||||
return { rpcId: RpcId(`search-${query}`), payload: { query } }
|
||||
}
|
||||
|
||||
function header(id: string, cwd: string | null = '/project'): SessionHeader {
|
||||
return {
|
||||
version: 0,
|
||||
id: sid(id),
|
||||
createdAt: 100,
|
||||
...(cwd === null ? {} : { cwd }),
|
||||
}
|
||||
}
|
||||
|
||||
function hit(id: string, index = 0): SessionSearchHit {
|
||||
const session = header(id)
|
||||
return {
|
||||
header: session,
|
||||
live: true,
|
||||
persisted: false,
|
||||
bestMatch: {
|
||||
sessionId: session.id,
|
||||
seq: index,
|
||||
type: 'user/message',
|
||||
time: 200 + index,
|
||||
surface: 'current',
|
||||
snippet: `match ${index}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function baseContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('session.search', () => {
|
||||
it('searches only list-visible ids and current conversation-message events', async () => {
|
||||
const ctx = await baseContext()
|
||||
const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') })
|
||||
live.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'live text' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const cold = header('cold', '/cold')
|
||||
const legacy = header('legacy', null)
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([cold, legacy]),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
|
||||
const searchSessions = vi.fn((
|
||||
_request: SessionSearchRequest,
|
||||
_exec?: { signal?: AbortSignal },
|
||||
) => Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
header: legacy,
|
||||
live: false,
|
||||
persisted: true,
|
||||
bestMatch: {
|
||||
sessionId: legacy.id,
|
||||
seq: 3,
|
||||
type: 'user/message' as const,
|
||||
time: 190,
|
||||
surface: 'current' as const,
|
||||
snippet: 'must remain hidden',
|
||||
},
|
||||
},
|
||||
{
|
||||
header: cold,
|
||||
live: false,
|
||||
persisted: true,
|
||||
bestMatch: {
|
||||
sessionId: cold.id,
|
||||
seq: 4,
|
||||
type: 'assistant/message' as const,
|
||||
time: 200,
|
||||
surface: 'current' as const,
|
||||
snippet: 'the matching answer',
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
const api = createApiProxy(ctx, defaults)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
const response = await api.sessions.search(request('matching answer'), signal)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'cold', snippet: 'the matching answer' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
const [query, exec] = searchSessions.mock.calls[0] as unknown as [
|
||||
SessionSearchRequest,
|
||||
{ signal: AbortSignal },
|
||||
]
|
||||
expect(query).toEqual({
|
||||
query: 'matching answer',
|
||||
eventFilters: [
|
||||
{
|
||||
kind: 'type',
|
||||
values: ['user/message', 'assistant/message', 'steering/message'],
|
||||
},
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: 20,
|
||||
})
|
||||
expect(exec.signal).toBe(signal)
|
||||
})
|
||||
|
||||
it('returns an empty page without invoking the index when no session is visible', async () => {
|
||||
const ctx = await baseContext()
|
||||
const searchSessions = vi.fn()
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
const api = createApiProxy(ctx, defaults)
|
||||
|
||||
const response = await api.sessions.search(
|
||||
request('anything'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [], hasMore: false },
|
||||
})
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects snippets whose provider provenance violates the Host filters', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = hit('visible')
|
||||
ctx.sessions.create(visible.header.id, { meta: visible.header })
|
||||
const withBestMatch = (
|
||||
index: number,
|
||||
bestMatch: Partial<SessionSearchHit['bestMatch']>,
|
||||
): SessionSearchHit => {
|
||||
const base = hit('visible', index)
|
||||
return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } }
|
||||
}
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions: () => Promise.resolve({
|
||||
items: [
|
||||
withBestMatch(0, { sessionId: sid('hidden') }),
|
||||
withBestMatch(1, { surface: 'shadowed' }),
|
||||
withBestMatch(2, { type: 'tool/result' }),
|
||||
withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }),
|
||||
],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('match'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'visible', snippet: 'allowed snippet' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('pages the globally ranked stream until the 20-item Host boundary is known', async () => {
|
||||
const ctx = await baseContext()
|
||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of items) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: [hit('hidden-ranked-first'), ...items.slice(0, 19)],
|
||||
nextCursor: 'page-2',
|
||||
})
|
||||
.mockResolvedValueOnce({ items: items.slice(19) })
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions,
|
||||
} as never)
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('match'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: true },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.items).toHaveLength(20)
|
||||
expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' })
|
||||
})
|
||||
|
||||
it('learns a provider maxLimit of 10 and collects the 20-item result plus lookahead', async () => {
|
||||
const ctx = await baseContext()
|
||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of items) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const invalidLimit = new SessionQueryError(
|
||||
'provider accepts at most 10 items',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
const limit = providerRequest.limit
|
||||
if (limit === undefined) throw new Error('Host search must request an explicit provider limit')
|
||||
if (limit > 10) return Promise.reject(invalidLimit)
|
||||
const offset = providerRequest.cursor === undefined
|
||||
? 0
|
||||
: Number.parseInt(providerRequest.cursor.slice('offset-'.length), 10)
|
||||
const end = Math.min(items.length, offset + limit)
|
||||
return Promise.resolve({
|
||||
items: items.slice(offset, end),
|
||||
...end < items.length ? { nextCursor: `offset-${end}` } : {},
|
||||
})
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('adaptive-page-limit'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: true },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.items.map(item => item.sessionId))
|
||||
.toEqual(items.slice(0, 20).map(item => item.header.id))
|
||||
expect(searchSessions.mock.calls.map(([providerRequest]) => ({
|
||||
limit: providerRequest.limit,
|
||||
cursor: providerRequest.cursor,
|
||||
}))).toEqual([
|
||||
{ limit: 20, cursor: undefined },
|
||||
{ limit: 10, cursor: undefined },
|
||||
{ limit: 10, cursor: 'offset-10' },
|
||||
{ limit: 10, cursor: 'offset-20' },
|
||||
])
|
||||
})
|
||||
|
||||
it('counts a page-limit probe inside the 100-call budget', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const invalidLimit = new SessionQueryError(
|
||||
'provider accepts at most 10 items',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
if (searchSessions.mock.calls.length === 1) {
|
||||
expect(providerRequest).toMatchObject({ limit: 20 })
|
||||
return Promise.reject(invalidLimit)
|
||||
}
|
||||
expect(providerRequest.limit).toBe(10)
|
||||
return Promise.resolve({
|
||||
items: [],
|
||||
nextCursor: `page-${searchSessions.mock.calls.length}`,
|
||||
})
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('endless-pages'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('100-call work budget')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(100)
|
||||
})
|
||||
|
||||
it('restarts a stale continuation with its learned limit and original visibility snapshot', async () => {
|
||||
const ctx = await baseContext()
|
||||
const oldOnly = hit('old-only', 0)
|
||||
const shared = hit('shared', 1)
|
||||
const freshFirst = hit('fresh-first', 2)
|
||||
const freshLast = hit('fresh-last', 3)
|
||||
for (const item of [oldOnly, shared, freshFirst, freshLast]) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const late = hit('late-visible', 4)
|
||||
const stale = new SessionQueryError(
|
||||
'provider generation changed',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)
|
||||
const invalidLimit = new SessionQueryError(
|
||||
'provider accepts at most 10 items',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
switch (searchSessions.mock.calls.length) {
|
||||
case 1:
|
||||
expect(providerRequest).toMatchObject({ limit: 20 })
|
||||
expect(providerRequest).not.toHaveProperty('cursor')
|
||||
return Promise.reject(invalidLimit)
|
||||
case 2:
|
||||
expect(providerRequest).toMatchObject({ limit: 10 })
|
||||
expect(providerRequest).not.toHaveProperty('cursor')
|
||||
return Promise.resolve({
|
||||
items: [oldOnly, shared],
|
||||
nextCursor: 'old-cursor',
|
||||
})
|
||||
case 3:
|
||||
expect(providerRequest).toMatchObject({ limit: 10 })
|
||||
expect(providerRequest.cursor).toBe('old-cursor')
|
||||
ctx.sessions.create(late.header.id, { meta: late.header })
|
||||
return Promise.reject(stale)
|
||||
case 4:
|
||||
expect(providerRequest).toMatchObject({ limit: 10 })
|
||||
expect(providerRequest).not.toHaveProperty('cursor')
|
||||
return Promise.resolve({
|
||||
items: [freshFirst, shared],
|
||||
nextCursor: 'old-cursor',
|
||||
})
|
||||
case 5:
|
||||
expect(providerRequest).toMatchObject({ limit: 10 })
|
||||
expect(providerRequest.cursor).toBe('old-cursor')
|
||||
return Promise.resolve({ items: [freshLast, late] })
|
||||
default:
|
||||
return Promise.reject(new Error('unexpected provider call'))
|
||||
}
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('stale-restart'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [
|
||||
{ sessionId: 'fresh-first', snippet: 'match 2' },
|
||||
{ sessionId: 'shared', snippet: 'match 1' },
|
||||
{ sessionId: 'fresh-last', snippet: 'match 3' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('counts continuous stale restarts against the 100-call budget', async () => {
|
||||
const ctx = await baseContext()
|
||||
const partial = hit('partial')
|
||||
ctx.sessions.create(partial.header.id, { meta: partial.header })
|
||||
const stale = new SessionQueryError(
|
||||
'provider generation changed',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
if (searchSessions.mock.calls.length > 100) {
|
||||
return Promise.reject(new Error('provider was called after the shared budget'))
|
||||
}
|
||||
if (providerRequest.cursor !== undefined) return Promise.reject(stale)
|
||||
return Promise.resolve({
|
||||
items: [partial],
|
||||
nextCursor: `cursor-${searchSessions.mock.calls.length}`,
|
||||
})
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('stale-churn'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toContain('100-call work budget')
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(100)
|
||||
})
|
||||
|
||||
it('gives abort priority over a coincident stale continuation failure', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const controller = new AbortController()
|
||||
const stale = new SessionQueryError(
|
||||
'provider generation changed',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'stale-cursor' })
|
||||
.mockImplementationOnce(() => {
|
||||
controller.abort()
|
||||
return Promise.reject(stale)
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('abort-stale'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not retry a stale first-page failure', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const searchSessions = vi.fn(() => Promise.reject(new SessionQueryError(
|
||||
'provider generation changed before paging',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('first-page-stale'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not adapt an invalid-limit continuation failure', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
|
||||
.mockRejectedValueOnce(new SessionQueryError(
|
||||
'continuation limit is invalid',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('continuation-invalid-limit'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
expect(searchSessions.mock.calls.map(([providerRequest]) => (
|
||||
providerRequest as SessionSearchRequest
|
||||
).limit))
|
||||
.toEqual([20, 20])
|
||||
})
|
||||
|
||||
it('stops page-limit adaptation at one item', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => Promise.reject(
|
||||
new SessionQueryError(
|
||||
`provider rejects ${providerRequest.limit}`,
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
),
|
||||
))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('minimum-page-limit'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit))
|
||||
.toEqual([20, 10, 5, 2, 1])
|
||||
})
|
||||
|
||||
it('gives abort priority over a coincident invalid first-page limit', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const controller = new AbortController()
|
||||
const searchSessions = vi.fn(() => {
|
||||
controller.abort()
|
||||
return Promise.reject(new SessionQueryError(
|
||||
'provider rejects 20',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
))
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('abort-invalid-limit'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects an oversized provider page', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
|
||||
const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('oversized-page'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('returned 21 items; maximum is 20')
|
||||
})
|
||||
|
||||
it('uses the learned provider limit for the overproduction guard', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const oversized = Array.from({ length: 11 }, (_, index) => hit(`oversized-${index}`))
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
if (providerRequest.limit === 20) {
|
||||
return Promise.reject(new SessionQueryError(
|
||||
'provider accepts at most 10 items',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
))
|
||||
}
|
||||
return Promise.resolve({ items: oversized })
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('adapted-oversized-page'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('returned 11 items; maximum is 10')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = hit('visible')
|
||||
ctx.sessions.create(visible.header.id, { meta: visible.header })
|
||||
const expected = `${'x'.repeat(239)}😀`
|
||||
const overlong = {
|
||||
...visible,
|
||||
bestMatch: {
|
||||
...visible.bestMatch,
|
||||
snippet: `${expected}${'y'.repeat(10_000)}`,
|
||||
},
|
||||
}
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions: () => Promise.resolve({ items: [overlong] }),
|
||||
} as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('bounded-snippet'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'visible', snippet: expected }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the provider repeats a continuation cursor', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('repeated-cursor'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('repeated a continuation cursor')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('validates a repeated cursor before accepting the authorized lookahead', async () => {
|
||||
const ctx = await baseContext()
|
||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of items) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
|
||||
.mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('repeated-lookahead-cursor'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.message).toContain('repeated a continuation cursor')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not count duplicate session ids toward the result or lookahead boundary', async () => {
|
||||
const ctx = await baseContext()
|
||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of items) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' })
|
||||
.mockResolvedValueOnce({ items: items.slice(20) })
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('duplicate-pages'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: true },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.items.map(item => item.sessionId)).toEqual(
|
||||
items.slice(0, 20).map(item => item.header.id),
|
||||
)
|
||||
expect(searchSessions).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('cancels on a continuation page and passes the carrier signal to both calls', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const controller = new AbortController()
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
|
||||
.mockImplementationOnce(() => {
|
||||
controller.abort()
|
||||
return Promise.resolve({ items: [] })
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('cancel-continuation'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
for (const call of searchSessions.mock.calls) {
|
||||
expect(call[1]).toEqual({ signal: controller.signal })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps visibility sets above SQLite variable limits out of provider bindings', async () => {
|
||||
const ctx = await baseContext()
|
||||
const cold = Array.from(
|
||||
{ length: 32_751 },
|
||||
(_, index) => header(`cold-${index}`, `/cold-${index}`),
|
||||
)
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve(cold),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({
|
||||
items: [hit('cold-32750')],
|
||||
}))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('large corpus'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'cold-32750', snippet: 'match 0' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters')
|
||||
})
|
||||
|
||||
it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
|
||||
const ctx = await baseContext()
|
||||
const controller = new AbortController()
|
||||
const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
|
||||
const list = vi.fn((signal?: AbortSignal) => {
|
||||
expect(signal).toBe(controller.signal)
|
||||
return Promise.resolve(cold)
|
||||
})
|
||||
let locateCalls = 0
|
||||
ctx.provide('sessionPersistence', {
|
||||
list,
|
||||
locate: () => {
|
||||
locateCalls++
|
||||
controller.abort()
|
||||
return undefined
|
||||
},
|
||||
} as never)
|
||||
const searchSessions = vi.fn()
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('cancel-during-visibility'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
expect(locateCalls).toBe(1)
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('awaits every started cold-summary stat before returning cancellation', async () => {
|
||||
const ctx = await baseContext()
|
||||
const controller = new AbortController()
|
||||
const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
|
||||
const statGates = cold.map(() => Promise.withResolvers<{ mtimeMs: number }>())
|
||||
const statMock = vi.mocked(stat)
|
||||
statMock.mockClear()
|
||||
for (const gate of statGates) {
|
||||
statMock.mockImplementationOnce((() => gate.promise) as never)
|
||||
}
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve(cold),
|
||||
locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }),
|
||||
} as never)
|
||||
const searchSessions = vi.fn()
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
let settled = false
|
||||
const responsePromise = createApiProxy(ctx, defaults).sessions.search(
|
||||
request('cancel-during-cold-stats'),
|
||||
controller.signal,
|
||||
).finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(statMock).toHaveBeenCalledTimes(16)
|
||||
})
|
||||
|
||||
controller.abort()
|
||||
statGates[0]!.resolve({ mtimeMs: 101 })
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
expect(settled).toBe(false)
|
||||
|
||||
for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 })
|
||||
const response = await responsePromise
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps missing composition, query cancellation, and provider failure', async () => {
|
||||
const missingCtx = await baseContext()
|
||||
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const missingApi = createApiProxy(missingCtx, defaults)
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort()
|
||||
const cancelledBeforeLookup = await missingApi.sessions.search(
|
||||
request('cancel-before-lookup'),
|
||||
preAborted.signal,
|
||||
)
|
||||
expect(cancelledBeforeLookup.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
|
||||
const missing = await missingApi.sessions.search(
|
||||
request('needle'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(missing.result.ok).toBe(false)
|
||||
if (missing.result.ok) throw new Error('unreachable')
|
||||
expect(missing.result.error.code).toBe('internal')
|
||||
expect(missing.result.error.message).toContain('does not mount')
|
||||
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED')
|
||||
const searchSessions = vi.fn()
|
||||
.mockRejectedValueOnce(aborted)
|
||||
.mockRejectedValueOnce(new Error('database unavailable'))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
const api = createApiProxy(ctx, defaults)
|
||||
|
||||
const cancelled = await api.sessions.search(
|
||||
request('first'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(cancelled.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
|
||||
const failed = await api.sessions.search(
|
||||
request('second'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(failed.result.ok).toBe(false)
|
||||
if (failed.result.ok) throw new Error('unreachable')
|
||||
expect(failed.result.error.code).toBe('internal')
|
||||
expect(failed.result.error.message).toContain('database unavailable')
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,7 @@ function scriptedApi(overrides: {
|
||||
return {
|
||||
sessions: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
search: r => ok(r, { items: [], hasMore: false }),
|
||||
create: r => ok(r, { sessionId: sid('s-new') }),
|
||||
history: r => ok(r, {
|
||||
events: [],
|
||||
@@ -141,6 +142,44 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('round-trips a trimmed session search query and its bounded result metadata', async () => {
|
||||
let seen: RpcRequest<{ query: string }> | undefined
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
search: (request) => {
|
||||
seen = request
|
||||
return ok(request, {
|
||||
items: [{ sessionId: sid('s1'), snippet: 'matching message text' }],
|
||||
hasMore: true,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const response = await client(api).sessions.search({ query: ' message text ' })
|
||||
expect(seen?.payload).toEqual({ query: 'message text' })
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 's1', snippet: 'matching message text' }],
|
||||
hasMore: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an overlong session-search snippet at the client value boundary', async () => {
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
search: request => ok(request, {
|
||||
items: [{ sessionId: sid('s1'), snippet: '😀'.repeat(241) }],
|
||||
hasMore: false,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(client(api).sessions.search({ query: 'message' }))
|
||||
.rejects.toThrow(/240 Unicode code points/)
|
||||
})
|
||||
|
||||
it('routes session fork with its optional cut anchor through the wire', async () => {
|
||||
let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined
|
||||
const api = scriptedApi({
|
||||
|
||||
@@ -22,6 +22,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
if (overrides.crashOn === 'session.list') throw new Error('impl crashed')
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
|
||||
},
|
||||
async search(request, signal) {
|
||||
if (request.payload.query === 'hang') {
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
|
||||
}
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { items: [{ sessionId: 's1' as never, snippet: 'fixture match' }], hasMore: false },
|
||||
},
|
||||
}
|
||||
},
|
||||
async create(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
|
||||
},
|
||||
@@ -248,6 +268,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
|
||||
it('covers create/prompt/updateQueue/cancel/describe passthrough', async () => {
|
||||
const c = client()
|
||||
expect((await c.sessions.search({ query: 'fixture' })).result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false },
|
||||
})
|
||||
expect((await c.sessions.create({})).result.ok).toBe(true)
|
||||
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
const selected = await c.sessions.selectModel({
|
||||
@@ -325,6 +349,68 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
|
||||
})
|
||||
|
||||
it('lets command.execute finish after the 30-second default unary deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => {
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => {
|
||||
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
||||
}, milliseconds)
|
||||
return controller.signal
|
||||
})
|
||||
try {
|
||||
const api = fakeApi()
|
||||
api.commands.execute = async (request) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 30_001))
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } },
|
||||
}
|
||||
}
|
||||
const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' })
|
||||
const assertion = expect(execution).resolves.toMatchObject({
|
||||
result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } },
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
vi.advanceTimersByTimeAsync(30_001),
|
||||
assertion,
|
||||
])
|
||||
expect(timeoutSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
timeoutSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps caller and connection aborts on command.execute', async () => {
|
||||
const api = fakeApi()
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
api.commands.execute = async (request, signal) => {
|
||||
started.resolve(signal)
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const execution = client(api).commands.execute(
|
||||
{ sessionId: 's' as never, line: '/hang' },
|
||||
controller.signal,
|
||||
)
|
||||
const handlerSignal = await started.promise
|
||||
|
||||
controller.abort(new Error('connection closed'))
|
||||
|
||||
await expect(execution).rejects.toThrow('connection closed')
|
||||
expect(handlerSignal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into command.execute', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
@@ -339,6 +425,29 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into session.search', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'r-search-sig',
|
||||
method: 'session.search',
|
||||
payload: { query: 'hang' },
|
||||
})
|
||||
const pending = handler.fetch(new Request(
|
||||
'http://x/api/session.search',
|
||||
{ method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
|
||||
))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as {
|
||||
rpcId: string
|
||||
result: { error?: { code: string } }
|
||||
}
|
||||
expect(parsed.rpcId).toBe('r-search-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into host.pickDirectory', async () => {
|
||||
const api = fakeApi()
|
||||
api.host.pickDirectory = async (request, signal) => {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
|
||||
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
|
||||
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
|
||||
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
|
||||
sessionSearchRequestSchema, sessionSearchValueSchema, sessionSelectModelRequestSchema,
|
||||
sessionSelectModelValueSchema, sessionSummarySchema,
|
||||
sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema,
|
||||
} from '../src/api/sessions.schema.ts'
|
||||
import {
|
||||
@@ -150,6 +151,36 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionListRequestSchema.parse({})).toEqual({})
|
||||
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
|
||||
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
|
||||
expect(sessionSearchRequestSchema.parse({ query: ' exact phrase ' })).toEqual({ query: 'exact phrase' })
|
||||
expect(() => sessionSearchRequestSchema.parse({ query: ' ' })).toThrow()
|
||||
expect(() => sessionSearchRequestSchema.parse({ query: 'bad\0query' })).toThrow(/NUL/)
|
||||
expect(() => sessionSearchRequestSchema.parse({ query: 'x'.repeat(501) })).toThrow()
|
||||
expect(sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: 's1', snippet: 'matching text' }],
|
||||
hasMore: true,
|
||||
})).toEqual({
|
||||
items: [{ sessionId: 's1', snippet: 'matching text' }],
|
||||
hasMore: true,
|
||||
})
|
||||
expect(sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: 's1', snippet: '😀'.repeat(240) }],
|
||||
hasMore: false,
|
||||
}).items[0]?.snippet).toBe('😀'.repeat(240))
|
||||
expect(() => sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: 's1', snippet: '😀'.repeat(241) }],
|
||||
hasMore: false,
|
||||
})).toThrow(/240 Unicode code points/)
|
||||
expect(() => sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: '', snippet: 'matching text' }],
|
||||
hasMore: false,
|
||||
})).toThrow()
|
||||
expect(() => sessionSearchValueSchema.parse({
|
||||
items: Array.from(
|
||||
{ length: 21 },
|
||||
(_, index) => ({ sessionId: `s${index}`, snippet: 'matching text' }),
|
||||
),
|
||||
hasMore: true,
|
||||
})).toThrow()
|
||||
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
|
||||
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
|
||||
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
{
|
||||
"path": "../../session-projection/session-projection-cache"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
|
||||
@@ -56,6 +56,7 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx
|
||||
' config:',
|
||||
` host: '${bindHost}'`,
|
||||
' port: 0',
|
||||
' portConflict: increment',
|
||||
` distIndex: '${distIndex}'`,
|
||||
`- name: '${AUTO}'`,
|
||||
'',
|
||||
|
||||
Reference in New Issue
Block a user