Merge remote-tracking branch 'origin/master' into worktree/drop-create-by-name
# Conflicts: # .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md # apps/cli/reference/README.i18n.yaml # docs/config-catalog.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-approval.spec.ts # packages/host/apiproxy/tests/api-proxy-blank.spec.ts # packages/host/apiproxy/tests/api-proxy-cold.spec.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-config.spec.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/api-proxy-question.spec.ts # packages/host/apiproxy/tests/api-proxy-rename.spec.ts # packages/host/apiproxy/tests/api-proxy-search.spec.ts # packages/host/apiproxy/tests/api-proxy-subagents.spec.ts # packages/host/apiproxy/tests/api-proxy-view.spec.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/todo/tool-todo/tests/projection.spec.ts # scripts/hero-composer-dom-continuity.mjs
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: 9c63d1e77accca01b2a26abe67b98922ab42be0c
|
||||
README.zh.md: 2d4a70234a45ad2abe60df483884ed422ad2c08c
|
||||
README.md: d5afd21033afd8291c8059fb225deee3965f8b65
|
||||
README.zh.md: cde0b4fd286579f5b389bc601750ca8303b35f15
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml).
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
|
||||
|
||||
## The shared Agent default (`agent-default-model` Settings section)
|
||||
|
||||
`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it.
|
||||
|
||||
A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created.
|
||||
|
||||
`session.selectModel` saves an accepted switch as the deployment default; there is no separate gesture. It stores the resolved `ModelSelection`, including an adapter-materialized default effort. The complete-section write clears a stored effort when the selected model has none. A storage failure is logged without undoing the session selection. A deployment with no settings provider keeps the composition entry and the switch remains session-local.
|
||||
|
||||
The section's `reasoningEffort` has no counterpart in the agent-default-model plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model.
|
||||
|
||||
The stored selection is independent of catalog membership. A default naming an unavailable provider still reaches `session.models` as the session's `current`, allowing the selector to request a replacement instead of silently choosing another model. Conversely, an adapter may serve a model that its catalog does not advertise.
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
@@ -12,15 +24,15 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
|
||||
|
||||
Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`.
|
||||
|
||||
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
|
||||
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it.
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
|
||||
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
|
||||
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable.
|
||||
|
||||
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
|
||||
|
||||
@@ -32,15 +44,19 @@ A stale continuation discards every partial result, deduplication entry, and cur
|
||||
|
||||
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, and `xdg-open` on desktop Linux). WSL translates the Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item` instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. 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. 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 `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.
|
||||
|
||||
`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path.
|
||||
|
||||
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 composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `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 registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `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. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,9 +2,21 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。
|
||||
所有客户端形态共用的 API 网关:TS 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
|
||||
|
||||
## 契约层(`/api`)
|
||||
## 共享 Agent 默认值(`agent-default-model` Settings 分节)
|
||||
|
||||
`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`:base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。
|
||||
|
||||
会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。
|
||||
|
||||
`session.selectModel` 会把接受的切换保存为部署默认值;没有单独的选择动作。它存储已解析的 `ModelSelection`,包括适配器实体化的默认推理强度。完整分节写入会在所选模型没有推理强度时清除已存值。存储失败只记日志,不会撤销会话选择。没有设置提供方的部署保留组合条目,切换只对当前会话生效。
|
||||
|
||||
Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中刻意没有对应字段:seam 按字段把用户层合并到组合条目之上,因此缺席的键无法覆盖已有键,组合层中的推理强度会在以后选择没有推理强度的模型时继续存在。推理强度的部署默认值属于按模型解析的适配器 profile。
|
||||
|
||||
存储的选择独立于目录成员关系。默认值指向不可用的提供方时,它仍会作为会话的 `current` 送到 `session.models`,让选择器请求用户重新选择,而不是静默选用其他模型。反过来,适配器也可以服务其目录中未公布的模型。
|
||||
|
||||
## 约定层(`/api`)
|
||||
|
||||
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站「简单请求」(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。
|
||||
|
||||
@@ -12,17 +24,17 @@
|
||||
|
||||
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
|
||||
|
||||
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。
|
||||
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。
|
||||
|
||||
会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定将在下一提示词组装边界使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。
|
||||
|
||||
待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
@@ -32,19 +44,23 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
目录选择委托给组合的 `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`)。WSL 会通过 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `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` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`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` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`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` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包定义客户端与宿主间的协议契约和载体,其中没有任何内容会进入模型请求。
|
||||
无。该包定义客户端与宿主间的协议约定和载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -57,4 +73,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`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)。
|
||||
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
@@ -63,13 +65,17 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
"@deepseek-ai/dsh-agent-presets": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
88
packages/host/apiproxy/src/api/agent-presets.schema.ts
Normal file
88
packages/host/apiproxy/src/api/agent-presets.schema.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* agent-presets domain zod schemas (names derived from map keys:
|
||||
* agentPresetListRequestSchema / agentPresetListValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { AgentPresetEntry } from './agent-presets.ts'
|
||||
|
||||
/** AgentPresetEntry row of agentPreset.list. */
|
||||
export const agentPresetEntrySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
isDefault: z.boolean(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
broken: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<AgentPresetEntry>>
|
||||
|
||||
/** agentPreset.list request payload. */
|
||||
export const agentPresetListRequestSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.list'>>>
|
||||
|
||||
/** agentPreset.list response value. */
|
||||
export const agentPresetListValueSchema = z.object({
|
||||
presets: z.array(agentPresetEntrySchema),
|
||||
authorable: z.boolean(),
|
||||
hasDocument: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
|
||||
|
||||
/** agentPreset.select request payload. */
|
||||
export const agentPresetSelectRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.select'>>>
|
||||
|
||||
/** agentPreset.select response value. */
|
||||
export const agentPresetSelectValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.select'>>>
|
||||
|
||||
/** agentPreset.read request payload. */
|
||||
export const agentPresetReadRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.read response value. */
|
||||
export const agentPresetReadValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
content: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.copy request payload. */
|
||||
export const agentPresetCopyRequestSchema = z.object({
|
||||
from: z.string().min(1),
|
||||
agentPreset: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.copy'>>>
|
||||
|
||||
/** agentPreset.copy response value. */
|
||||
export const agentPresetCopyValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.copy'>>>
|
||||
|
||||
/** agentPreset.openDocument request payload. */
|
||||
export const agentPresetOpenDocumentRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.openDocument'>>>
|
||||
|
||||
/** agentPreset.openDocument response value. */
|
||||
export const agentPresetOpenDocumentValueSchema = z.union([
|
||||
z.object({ opened: z.literal(true) }),
|
||||
z.object({ opened: z.literal(false), path: z.string() }),
|
||||
]) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.openDocument'>>>
|
||||
|
||||
/** agentPreset.remove request payload. */
|
||||
export const agentPresetRemoveRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.remove'>>>
|
||||
|
||||
/** agentPreset.remove response value. */
|
||||
export const agentPresetRemoveValueSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.remove'>>>
|
||||
116
packages/host/apiproxy/src/api/agent-presets.ts
Normal file
116
packages/host/apiproxy/src/api/agent-presets.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* agent-presets domain contract: the roster a browser offers when starting a
|
||||
* session, plus the authoring calls behind it.
|
||||
*
|
||||
* `list` is ordinary: it carries ids and trust, and every preset picker needs
|
||||
* it. The authoring calls are privileged and loopback-pinned — a composition
|
||||
* names the plugins a session runs, so reading one is reconnaissance, and
|
||||
* although authoring is copy-only (no caller supplies composition text or a
|
||||
* path), copying and deleting still rearrange what the deployment offers.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** One preset the deployment can compose a session's agent from. */
|
||||
export interface AgentPresetEntry {
|
||||
/** Stable identifier, also the display name until presets carry metadata. */
|
||||
readonly id: string
|
||||
/**
|
||||
* Whether the preset ships with the deployment or was authored locally.
|
||||
* A `user` preset is exactly as privileged as the plugins it names, so a
|
||||
* surface offering one should say so rather than present it as vetted.
|
||||
*/
|
||||
readonly trust: 'system' | 'user'
|
||||
/** Whether a session that names no preset gets this one. */
|
||||
readonly isDefault: boolean
|
||||
/**
|
||||
* Display name the preset published, absent when it published none. A
|
||||
* surface falls back to {@link id}; it is never a second identity, and it
|
||||
* never decides trust — a locally authored preset cannot name itself into
|
||||
* the shipped set.
|
||||
*/
|
||||
readonly name?: string
|
||||
/** One sentence on what the preset is for, when it published one. */
|
||||
readonly description?: string
|
||||
/**
|
||||
* Why this preset cannot compose a session, absent when it can. A broken
|
||||
* preset stays listed — its directory still occupies the id, so a surface
|
||||
* must be able to show and delete it — but offering it for selection would
|
||||
* only defer this reason to a failed session start.
|
||||
*/
|
||||
readonly broken?: string
|
||||
}
|
||||
|
||||
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
|
||||
export interface AgentPresetsApi {
|
||||
/**
|
||||
* Lists every preset the deployment currently supplies, in root-precedence
|
||||
* order — the roots as configured, each root's own presets sorted by id,
|
||||
* and the first root to supply an id wins. The order is not globally
|
||||
* sorted: a user root's preset sits in that root's block, not among the
|
||||
* shipped ids.
|
||||
* An empty roster means the deployment composes no presets at all, and
|
||||
* every session shares the host composition. `authorable` reports whether
|
||||
* the deployment configures a root new presets can be written to, and
|
||||
* `hasDocument` whether `openDocument` can hand a preset directory to a
|
||||
* native opener — both deployment facts rather than per-preset ones, and
|
||||
* neither exposes a Host path.
|
||||
*/
|
||||
list(request: RpcRequest<{}>):
|
||||
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean; hasDocument: boolean }>>
|
||||
|
||||
/**
|
||||
* Recompose one session's agent from a different preset.
|
||||
*
|
||||
* Allowed only while the session is blank — no turn has run. Once a
|
||||
* conversation starts, its history was produced under that preset's tools,
|
||||
* and swapping them would leave logged tool calls the new composition cannot
|
||||
* make; the attempt answers `agent-preset-locked`.
|
||||
*/
|
||||
select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/**
|
||||
* Read one preset's composition text, for the read-only viewer.
|
||||
*
|
||||
* Privileged: a composition names the plugins a session runs, so reading
|
||||
* one is reconnaissance.
|
||||
*/
|
||||
read(request: RpcRequest<{ agentPreset: string }>):
|
||||
Promise<RpcResponse<{
|
||||
agentPreset: string
|
||||
trust: 'system' | 'user'
|
||||
content: string
|
||||
name?: string
|
||||
description?: string
|
||||
}>>
|
||||
|
||||
/**
|
||||
* Create a locally authored preset by copying an existing one whole.
|
||||
*
|
||||
* The only authoring write. No composition text and no path crosses the
|
||||
* wire: `from` and `agentPreset` are ids the Host resolves against its own
|
||||
* roots, so a copy is exactly as loadable as its source and grants nothing
|
||||
* the roster did not already carry. The copy keeps the source's description
|
||||
* (the file is the author's to edit afterwards) but not its name — `name`
|
||||
* here or the id fallback is what distinguishes the rows.
|
||||
*/
|
||||
copy(request: RpcRequest<{ from: string; agentPreset: string; name?: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/**
|
||||
* Hand one locally authored preset's DIRECTORY to the platform opener, for
|
||||
* editing the files that are now the only composition editor. The request
|
||||
* carries an id, never a path — the Host resolves it — so no browser
|
||||
* payload can select an arbitrary filesystem target. Where the deployment
|
||||
* has no native opener (`hasDocument: false` on `list`), the reply carries
|
||||
* the resolved directory for the surface to show as text instead. Shipped
|
||||
* presets are refused: their install is not the user's to manage.
|
||||
*/
|
||||
openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>>
|
||||
|
||||
/** Delete a locally authored preset. Shipped presets are refused. */
|
||||
remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
origin: z.literal('subagent').optional(),
|
||||
cwd: z.string().optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
}),
|
||||
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
|
||||
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
|
||||
@@ -81,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),
|
||||
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
|
||||
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
|
||||
z.object({ type: z.literal('host/models-changed') }),
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface EventsApi {
|
||||
* attached session, then replays each session's still-pending approval/question requested
|
||||
* frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the
|
||||
* generic projection pair (history-tail projections block + session/projection frames).
|
||||
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
|
||||
* since: resume hook, unimplemented in v1 (ignored if passed); reconnection = reopen the
|
||||
* stream + refetch history.
|
||||
*/
|
||||
mux(request: RpcRequest<{ since?: Record<SessionId, number> }>, signal: AbortSignal): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
@@ -116,6 +116,7 @@ export type HostFrame =
|
||||
parentSessionId?: SessionId
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
agentPreset?: string
|
||||
}
|
||||
| { type: 'host/session-removed'; sessionId: SessionId }
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
@@ -129,6 +130,18 @@ export type HostFrame =
|
||||
* background rather than diffing.
|
||||
*/
|
||||
| { type: 'host/commands-changed' }
|
||||
/**
|
||||
* One blank session was recomposed onto another agent preset (the logged
|
||||
* `agent-preset/selected` commit point, read off the session stream). The
|
||||
* registry-wide `host/commands-changed` cannot stand in for it: recomposing
|
||||
* re-parents that agent's scope without registering anything, so a
|
||||
* preset already mounted for another session produces no registry change
|
||||
* at all. Clients refetch the catalogs this session's composition decides
|
||||
* (`command.list`, `skill.list`) for this sessionId alone, and fold the
|
||||
* preset id into their session row — the RPC echo reaches only the client
|
||||
* that issued the switch, so the row is where every other one learns it.
|
||||
*/
|
||||
| { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string }
|
||||
/**
|
||||
* One settings namespace's resolved value changed (`settings/updated`
|
||||
* passthrough) — an RPC write, an external `settings.yaml` edit, or a
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { AgentPresetsApi } from './agent-presets.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { SubagentsApi } from './subagents.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
@@ -25,6 +26,7 @@ export interface ApiProxy {
|
||||
workspace: WorkspaceApi
|
||||
commands: CommandsApi
|
||||
skills: SkillsApi
|
||||
agentPresets: AgentPresetsApi
|
||||
events: EventsApi
|
||||
goals: GoalsApi
|
||||
settings: SettingsApi
|
||||
@@ -37,22 +39,25 @@ export interface ApiProxy {
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
|
||||
SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type {
|
||||
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
|
||||
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
|
||||
SubagentPromptReceipt, SubagentsApi,
|
||||
} from './subagents.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
export type { CredentialsApi, CredentialView } from './credentials.ts'
|
||||
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
// ---- Message layer: narrow forms (domain-signature view) ----
|
||||
@@ -71,6 +76,11 @@ export type {
|
||||
// ---- Errors and ids ----
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
export {
|
||||
clientRequestSchema,
|
||||
serverRequestSchema,
|
||||
serverResponseSchema,
|
||||
} from './rpc.schema.ts'
|
||||
|
||||
// ---- Fixed session-search product bounds ----
|
||||
export {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const configurableProviderViewSchema = z.object({
|
||||
settingsNs: z.string(),
|
||||
settingsPath: z.array(z.string()),
|
||||
active: z.boolean(),
|
||||
declared: z.boolean().optional(),
|
||||
}) satisfies z.ZodType<Wire<ConfigurableProviderView>>
|
||||
|
||||
/** llm.providers request payload. */
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* surfaces. `llm.providers` merges the configurable-provider directory
|
||||
* (which providers CAN be configured, and where their settings live) with the
|
||||
* live route registry; `llm.models` is the session-independent model catalog
|
||||
* (the same groups as `session.models`, without the per-session current
|
||||
* target). Both invalidate on the `host/models-changed` frame.
|
||||
* (the same groups as `session.models`, without a per-session selection).
|
||||
* Both invalidate on the `host/models-changed` frame.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
@@ -22,6 +22,12 @@ export interface ConfigurableProviderView {
|
||||
settingsPath: string[]
|
||||
/** Whether the route is currently registered (its models are requestable). */
|
||||
active: boolean
|
||||
/**
|
||||
* Whether the owning adapter knows this route only because configuration
|
||||
* declared it. Absent when the adapter draws no such distinction, so a
|
||||
* surface must treat absence as "unknown", not as "shipped".
|
||||
*/
|
||||
declared?: boolean
|
||||
}
|
||||
|
||||
/** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { AgentPresetsApi } from './agent-presets.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
@@ -36,6 +37,7 @@ export interface RpcMethodMap {
|
||||
'subagent.list': SubagentsApi['list']
|
||||
'subagent.history': SubagentsApi['history']
|
||||
'subagent.prompt': SubagentsApi['prompt']
|
||||
'subagent.interrupt': SubagentsApi['interrupt']
|
||||
'host.describe': HostApi['describe']
|
||||
'host.pickDirectory': HostApi['pickDirectory']
|
||||
'host.listDirectory': HostApi['listDirectory']
|
||||
@@ -50,6 +52,12 @@ export interface RpcMethodMap {
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
'agentPreset.list': AgentPresetsApi['list']
|
||||
'agentPreset.select': AgentPresetsApi['select']
|
||||
'agentPreset.read': AgentPresetsApi['read']
|
||||
'agentPreset.copy': AgentPresetsApi['copy']
|
||||
'agentPreset.openDocument': AgentPresetsApi['openDocument']
|
||||
'agentPreset.remove': AgentPresetsApi['remove']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
|
||||
@@ -46,6 +46,11 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
|
||||
z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-exists': { path: string }
|
||||
'directory-create-failed': { path: string }
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-preset-read-only': { agentPreset: string; reason: string }
|
||||
'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
|
||||
'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string }
|
||||
'agent-preset-not-found': { agentPreset: string; available: string[] }
|
||||
'agent-preset-invalid': { agentPreset: string; reason: string }
|
||||
'agent-busy': { reason: string }
|
||||
'queue-item-not-found': { itemId: MessageId }
|
||||
'steer-unavailable': { itemId: MessageId }
|
||||
|
||||
@@ -12,7 +12,7 @@ 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, SessionSearchItem, SessionSummary,
|
||||
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
@@ -55,6 +55,7 @@ export const sessionSummarySchema = z.object({
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
origin: z.literal('subagent').optional(),
|
||||
cwd: z.string().optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
|
||||
}) as unknown as z.ZodType<Wire<SessionSummary>>
|
||||
|
||||
@@ -100,6 +101,7 @@ export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
sessionId: sessionIdSchema.optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
}).refine(
|
||||
payload => payload.workspaceId === undefined || payload.cwd === undefined,
|
||||
{ message: 'session.create accepts workspaceId or cwd, not both' },
|
||||
@@ -108,6 +110,7 @@ export const sessionCreateRequestSchema = z.object({
|
||||
/** session.create response value. */
|
||||
export const sessionCreateValueSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
agentPreset: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.create'>>>
|
||||
|
||||
/** session.rename request payload (raw title; host-side normalization decides acceptance). */
|
||||
@@ -140,12 +143,12 @@ export const sessionHistoryRequestSchema = z.object({
|
||||
maxMessages: z.number().int().positive().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
|
||||
|
||||
/** Complete provider/model target. */
|
||||
export const modelTargetSchema = z.object({
|
||||
/** Complete provider/model selection. */
|
||||
export const modelSelectionSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
reasoningEffort: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelTarget>>
|
||||
}) satisfies z.ZodType<Wire<ModelSelection>>
|
||||
|
||||
/** One adapter-owned reasoning effort. */
|
||||
export const modelReasoningEffortSchema = z.object({
|
||||
@@ -224,7 +227,8 @@ export const sessionModelsRequestSchema = z.object({
|
||||
|
||||
/** session.models response value. */
|
||||
export const sessionModelsValueSchema = z.object({
|
||||
current: modelTargetSchema,
|
||||
current: modelSelectionSchema,
|
||||
routable: z.boolean(),
|
||||
groups: z.array(modelProviderGroupSchema),
|
||||
failures: z.array(modelCatalogFailureSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
|
||||
@@ -239,7 +243,7 @@ export const sessionSelectModelRequestSchema = z.object({
|
||||
|
||||
/** session.selectModel response value. */
|
||||
export const sessionSelectModelValueSchema = z.object({
|
||||
selected: modelTargetSchema,
|
||||
selected: modelSelectionSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.selectModel'>>>
|
||||
|
||||
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
|
||||
|
||||
@@ -53,8 +53,8 @@ export interface SessionProjectionsBlock {
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/** Complete model target selected for one session. */
|
||||
export interface ModelTarget {
|
||||
/** Complete model selection for one session. */
|
||||
export interface ModelSelection {
|
||||
/** Registered provider route. */
|
||||
provider: string
|
||||
/** Provider-owned model id. */
|
||||
@@ -115,8 +115,17 @@ export interface ModelCatalogFailure {
|
||||
|
||||
/** Detached model-directory snapshot for one session. */
|
||||
export interface SessionModels {
|
||||
/** Target selected for the session's next assembled step. */
|
||||
current: ModelTarget
|
||||
/** Model selection for the session's next assembled step. */
|
||||
current: ModelSelection
|
||||
/**
|
||||
* Whether an adapter currently serves `current.provider`, and therefore
|
||||
* whether this session can start a turn at all. Deliberately NOT derivable
|
||||
* from `groups`: catalog membership is advisory, so a route serving a model
|
||||
* it stopped advertising is absent from the groups yet perfectly usable,
|
||||
* while a route whose adapter is gone can serve nothing. A surface that
|
||||
* blocks input must read this rather than the groups.
|
||||
*/
|
||||
routable: boolean
|
||||
/** Successfully loaded provider groups. */
|
||||
groups: ModelProviderGroup[]
|
||||
/** Provider-local failures; successful groups remain usable. */
|
||||
@@ -156,6 +165,13 @@ export interface SessionSummary {
|
||||
origin?: 'subagent'
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* Agent preset this session's agent was composed from (header passthrough);
|
||||
* absent when the deployment composes no presets. A surface offering a
|
||||
* switch reads this to show what the session actually runs rather than what
|
||||
* the deployment currently defaults to.
|
||||
*/
|
||||
agentPreset?: string
|
||||
/**
|
||||
* Projection baseline for this row, with zero log loads: attached sessions
|
||||
* read the registry's live watermark cut; cold sessions read the persisted
|
||||
@@ -199,15 +215,22 @@ export interface SessionsApi {
|
||||
* session, while a different cwd fails with `session-conflict`. Workspace
|
||||
* creation attaches the session after publication; an attach failure
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*
|
||||
* `agentPreset` names the composition the new session's agent is built
|
||||
* from; omitted, the effective default applies — the user's stored choice
|
||||
* where one exists, else the deployment's own. The resolved id is stored on
|
||||
* the session header, so a later resume rebuilds the same agent. An unknown
|
||||
* id fails with `agent-preset-not-found`, and a preset whose composition
|
||||
* cannot be mounted fails with `agent-preset-invalid`.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId; agentPreset?: string }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to append-origin message
|
||||
* boundaries: one page = all raw events owned by a whole number of such messages (including
|
||||
* their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
|
||||
* `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail
|
||||
* `maxMessages`, so a compaction's `compact/summary` record stays on the page of its replacement. The tail
|
||||
* page (beforeSeq absent) additionally carries the in-flight
|
||||
* partial — chunk events already emitted for the last unfinalized message.
|
||||
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
|
||||
@@ -231,7 +254,7 @@ export interface SessionsApi {
|
||||
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
|
||||
|
||||
/**
|
||||
* Selects the complete target for this session. Exact model metadata
|
||||
* Selects the complete model selection for this session. Exact model metadata
|
||||
* validates an optional reasoning effort, while catalog membership remains
|
||||
* advisory. Session-backed subagents reject with `agent-busy`.
|
||||
*/
|
||||
@@ -241,7 +264,7 @@ export interface SessionsApi {
|
||||
model: string
|
||||
reasoningEffort?: string
|
||||
}>):
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>>
|
||||
Promise<RpcResponse<{ selected: ModelSelection }>>
|
||||
|
||||
/**
|
||||
* Renames a session: appends a `session/title` event with the `user`
|
||||
|
||||
@@ -14,6 +14,7 @@ export const skillEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
whenToUse: z.string().optional(),
|
||||
modelInvocable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<SkillEntry>>
|
||||
|
||||
/** skill.list request payload. */
|
||||
|
||||
@@ -10,16 +10,24 @@ import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */
|
||||
export interface SkillEntry {
|
||||
/** Kebab-case identifier referenced as `<skill>name</skill>` in prompts. */
|
||||
/** Kebab-case identifier the user references as `/name` in the composer. */
|
||||
readonly name: string
|
||||
/** Short routing description. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
/** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */
|
||||
readonly modelInvocable: boolean
|
||||
}
|
||||
|
||||
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
|
||||
/**
|
||||
* Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing
|
||||
* is the domain's only RPC: invocation itself is a plain `session.prompt`
|
||||
* whose leading `/name` token the host recognizes at the pre-step boundary
|
||||
* (`dsh-tool-skill` injects the rendered body there), so every client shares
|
||||
* one deterministic path with no dedicated invocation wire.
|
||||
*/
|
||||
export interface SkillsApi {
|
||||
/** Lists skills usable by the browser's user-selected model-reference path. */
|
||||
/** Lists the user-invocable skill catalog for the session's project. */
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
|
||||
}
|
||||
|
||||
@@ -69,6 +69,18 @@ export const subagentPromptRequestSchema = z.object({
|
||||
content: z.array(contentBlockSchema),
|
||||
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
|
||||
|
||||
/** subagent.interrupt request payload. */
|
||||
export const subagentInterruptRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
childSessionId: sessionIdSchema,
|
||||
mode: z.literal('continuable'),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.interrupt'>>>
|
||||
|
||||
/** subagent.interrupt response value. */
|
||||
export const subagentInterruptValueSchema = z.object({
|
||||
accepted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.interrupt'>>>
|
||||
|
||||
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
|
||||
|
||||
/** subagent.prompt response value. */
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface SubagentPromptReceipt {
|
||||
messageId: MessageId
|
||||
}
|
||||
|
||||
/** Uniform acknowledgement that one interrupt request was admitted. */
|
||||
export interface SubagentInterruptReceipt {
|
||||
accepted: true
|
||||
}
|
||||
|
||||
/** Durable parent/child address that selects subagent transport in the client. */
|
||||
export type SubagentAddress =
|
||||
& {
|
||||
@@ -94,4 +99,17 @@ export interface SubagentsApi {
|
||||
>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<SubagentPromptReceipt>>
|
||||
|
||||
/**
|
||||
* Interrupts a live continuable child's current turn under the address's
|
||||
* durable direct-parent authority, without requiring a live parent Agent,
|
||||
* consulting the catalog, or resuming anything. Fire-and-return: `accepted`
|
||||
* acknowledges the admitted cancel signal, not target quiescence, so the
|
||||
* child may remain visibly running briefly. Unclaimed queued follow-ups are
|
||||
* kept and parked; an absent, idle, or already-completed target is likewise
|
||||
* `accepted`.
|
||||
*/
|
||||
interrupt(
|
||||
request: RpcRequest<Extract<SubagentAddress, { mode: 'continuable' }>>,
|
||||
): Promise<RpcResponse<SubagentInterruptReceipt>>
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
|
||||
agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
@@ -58,6 +62,7 @@ import {
|
||||
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
|
||||
import {
|
||||
subagentHistoryValueSchema,
|
||||
subagentInterruptValueSchema,
|
||||
subagentListValueSchema,
|
||||
subagentPromptValueSchema,
|
||||
} from '../api/subagents.schema.ts'
|
||||
@@ -96,6 +101,7 @@ export interface IApiClient {
|
||||
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
|
||||
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
|
||||
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
|
||||
interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
|
||||
}
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
@@ -119,6 +125,14 @@ export interface IApiClient {
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
}
|
||||
agentPresets: {
|
||||
list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>>
|
||||
select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.select'>>>
|
||||
read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.read'>>>
|
||||
copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.copy'>>>
|
||||
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
|
||||
remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
|
||||
@@ -171,6 +185,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'subagent.list': subagentListValueSchema,
|
||||
'subagent.history': subagentHistoryValueSchema,
|
||||
'subagent.prompt': subagentPromptValueSchema,
|
||||
'subagent.interrupt': subagentInterruptValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'host.pickDirectory': hostPickDirectoryValueSchema,
|
||||
'host.listDirectory': hostListDirectoryValueSchema,
|
||||
@@ -185,6 +200,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'agentPreset.list': agentPresetListValueSchema,
|
||||
'agentPreset.select': agentPresetSelectValueSchema,
|
||||
'agentPreset.read': agentPresetReadValueSchema,
|
||||
'agentPreset.copy': agentPresetCopyValueSchema,
|
||||
'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
|
||||
'agentPreset.remove': agentPresetRemoveValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
@@ -407,6 +428,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
|
||||
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
|
||||
interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
@@ -443,6 +465,20 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
}
|
||||
|
||||
// Annotated like every sibling, and load-bearing rather than cosmetic:
|
||||
// inferring this member inlines `AgentPresetEntry` into the emitted
|
||||
// declaration by the specifier TS picks — the host `index.ts` — which drags
|
||||
// the whole gateway, and with it the host `Context` merges, into every
|
||||
// Client program that imports this carrier.
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload, signal) => this.callUnary('agentPreset.list', payload, signal),
|
||||
select: (payload, signal) => this.callUnary('agentPreset.select', payload, signal),
|
||||
read: (payload, signal) => this.callUnary('agentPreset.read', payload, signal),
|
||||
copy: (payload, signal) => this.callUnary('agentPreset.copy', payload, signal),
|
||||
openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal),
|
||||
remove: (payload, signal) => this.callUnary('agentPreset.remove', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
create: (payload, signal) => this.callUnary('goal.create', payload, signal),
|
||||
edit: (payload, signal) => this.callUnary('goal.edit', payload, signal),
|
||||
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
|
||||
agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
@@ -60,6 +64,7 @@ import {
|
||||
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
|
||||
import {
|
||||
subagentHistoryRequestSchema,
|
||||
subagentInterruptRequestSchema,
|
||||
subagentListRequestSchema,
|
||||
subagentPromptRequestSchema,
|
||||
} from '../api/subagents.schema.ts'
|
||||
@@ -95,6 +100,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
|
||||
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
|
||||
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
|
||||
'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) },
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
|
||||
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
|
||||
@@ -109,6 +115,12 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
|
||||
'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },
|
||||
'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) },
|
||||
'agentPreset.copy': { schema: agentPresetCopyRequestSchema, invoke: (api, r) => api.agentPresets.copy(r) },
|
||||
'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
|
||||
'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(r) },
|
||||
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
|
||||
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
|
||||
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
|
||||
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
|
||||
* routes — physical carriers wrap `ctx.apiProxy` themselves.
|
||||
*
|
||||
* The gateway consumes `ctx.agentDefaultModel`, the transport-independent default
|
||||
* shared with direct entry points. Switching models persists through that
|
||||
* service; sessions that have already logged a selection remain unchanged.
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
|
||||
@@ -28,12 +33,16 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway plugin config: host-level agent routing. */
|
||||
/** Gateway plugin config for native Host integration. */
|
||||
export interface Config {
|
||||
/** Default provider route for created/resumed agents. */
|
||||
provider: string
|
||||
/** Default model id. */
|
||||
model: string
|
||||
/**
|
||||
* Whether this deployment can hand paths to a native desktop opener —
|
||||
* the `hasDocument` capability the agent-preset roster reports. Absent,
|
||||
* the platform is asked (macOS/Windows/WSL yes; Linux only with a display
|
||||
* server); set it explicitly where detection misleads, e.g. `false` in a
|
||||
* container whose DISPLAY points nowhere a user can see.
|
||||
*/
|
||||
nativeOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,13 +52,12 @@ export interface Config {
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = [
|
||||
'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
|
||||
'agentDefaultModel', 'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
|
||||
'tools', 'userInteraction', 'workspace',
|
||||
]
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
nativeOpen: z.boolean(),
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
@@ -59,6 +67,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly goals: ApiProxy['goals']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly agentPresets: ApiProxy['agentPresets']
|
||||
readonly settings: ApiProxy['settings']
|
||||
readonly credentials: ApiProxy['credentials']
|
||||
readonly llm: ApiProxy['llm']
|
||||
@@ -68,9 +77,10 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'apiProxy')
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(),
|
||||
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
|
||||
cwd: process.cwd(),
|
||||
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.subagents = api.subagents
|
||||
@@ -79,12 +89,13 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
this.commands = api.commands
|
||||
this.goals = api.goals
|
||||
this.skills = api.skills
|
||||
this.agentPresets = api.agentPresets
|
||||
this.settings = api.settings
|
||||
this.credentials = api.credentials
|
||||
this.llm = api.llm
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
// the unbound-method lint without changing behavior.
|
||||
// createApiProxy returns closures (no `this` capture), so the bind is
|
||||
// behavior-neutral.
|
||||
this.respond = api.respond.bind(api)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/** Cross-platform native path and text-document openers used by the local GUI carrier. */
|
||||
/**
|
||||
* Cross-platform native path and text-document openers used by the local GUI
|
||||
* carrier.
|
||||
*
|
||||
* The default intent prefers the default browser for documents it renders when
|
||||
* the platform can name one, then falls back to the default application. WSL
|
||||
* translates every path for the Windows desktop instead of assuming a Linux
|
||||
* GUI. The text-editor intent never consults the browser.
|
||||
*/
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { extname } from 'node:path'
|
||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
@@ -11,11 +20,63 @@ export interface PathOpenerInternals {
|
||||
platform?: NodeJS.Platform
|
||||
/** Kernel release override used to distinguish WSL from desktop Linux. */
|
||||
osRelease?: string
|
||||
/** WSL environment marker override used with the kernel release. */
|
||||
env?: Readonly<Partial<Record<'WSL_DISTRO_NAME' | 'WSL_INTEROP', string>>>
|
||||
/** Environment used for WSL markers and the desktop Linux browser convention. */
|
||||
env?: NodeJS.ProcessEnv
|
||||
run?: PathOpenerRunner
|
||||
}
|
||||
|
||||
/** Documents a browser renders, as opposed to ones an editor merely edits. */
|
||||
const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg'])
|
||||
|
||||
/**
|
||||
* The macOS bundle registered for `https` — the default browser, as
|
||||
* LaunchServices records it. The nested version dict is stripped first
|
||||
* because it carries its own `LSHandlerRoleAll`.
|
||||
*/
|
||||
function macBundleForHttps(plist: string): string | undefined {
|
||||
const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '')
|
||||
const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0]
|
||||
if (block === undefined) return undefined
|
||||
return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one browser-renderable document with the default browser.
|
||||
* @returns true when a browser took it; false when this platform cannot name
|
||||
* one, or naming it failed — the caller then uses the default application.
|
||||
*/
|
||||
async function openInBrowser(
|
||||
path: string, signal: AbortSignal, platform: NodeJS.Platform,
|
||||
run: PathOpenerRunner, env: NodeJS.ProcessEnv,
|
||||
): Promise<boolean> {
|
||||
if (platform === 'darwin') {
|
||||
let bundle: string | undefined
|
||||
try {
|
||||
const { stdout } = await run(
|
||||
'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal)
|
||||
bundle = macBundleForHttps(stdout)
|
||||
} catch {
|
||||
// No LaunchServices record (a fresh account never changed a default):
|
||||
// the content-type handler is then the system's own choice anyway.
|
||||
return false
|
||||
}
|
||||
if (bundle === undefined) return false
|
||||
await run('open', ['-b', bundle, path], signal)
|
||||
return true
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
// $BROWSER is the portable convention; desktop-entry resolution through
|
||||
// xdg-settings needs a launcher this package has no business shipping.
|
||||
const browser = env.BROWSER
|
||||
if (browser === undefined || browser === '') return false
|
||||
await run(browser, [path], signal)
|
||||
return true
|
||||
}
|
||||
// Windows names no browser without reading the UserChoice registry, and its
|
||||
// .html association is the browser in the ordinary case.
|
||||
return false
|
||||
}
|
||||
|
||||
/** Native path-open intent; macOS distinguishes text editing from file association. */
|
||||
type PathOpenIntent = 'default' | 'text-editor'
|
||||
|
||||
@@ -63,6 +124,11 @@ async function openNativePathWithIntent(
|
||||
): Promise<void> {
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
const env = internals.env ?? process.env
|
||||
const wsl = platform === 'linux' && isWsl(internals)
|
||||
|
||||
if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
|
||||
&& await openInBrowser(path, signal, platform, run, env)) return
|
||||
|
||||
if (platform === 'darwin') {
|
||||
await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal)
|
||||
@@ -75,7 +141,7 @@ async function openNativePathWithIntent(
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (isWsl(internals)) {
|
||||
if (wsl) {
|
||||
await openWslPath(path, signal, run)
|
||||
return
|
||||
}
|
||||
@@ -87,10 +153,30 @@ async function openNativePathWithIntent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application.
|
||||
* Whether {@link openNativePath} plausibly reaches a desktop on this host.
|
||||
*
|
||||
* macOS and Windows always carry a desktop opener; Linux does when it is WSL
|
||||
* (the Windows desktop takes the path) or a display server is announced.
|
||||
* A headless or containerised Linux host answers false, which is what lets a
|
||||
* surface show a path as text instead of offering a button that would spawn
|
||||
* `xdg-open` into nothing.
|
||||
* @param internals - platform and environment seam for deterministic tests.
|
||||
* @returns true when handing a path to the native opener can work at all.
|
||||
*/
|
||||
export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean {
|
||||
const platform = internals.platform ?? process.platform
|
||||
if (platform === 'darwin' || platform === 'win32') return true
|
||||
if (platform !== 'linux') return false
|
||||
const env = internals.env ?? process.env
|
||||
return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application, or
|
||||
* with the default browser when the path names a document a browser renders.
|
||||
* @param path - absolute or host-resolvable path (caller owns resolution).
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
* @param internals - Platform, environment, and runner hooks for deterministic tests.
|
||||
*/
|
||||
export function openNativePath(
|
||||
path: string,
|
||||
@@ -105,7 +191,7 @@ export function openNativePath(
|
||||
* so a YAML association with a browser cannot consume the gesture.
|
||||
* @param path - absolute or host-resolvable text-document path.
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
* @param internals - Platform and runner hooks for deterministic tests.
|
||||
*/
|
||||
export function openNativeTextFile(
|
||||
path: string,
|
||||
|
||||
683
packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
Normal file
683
packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
/**
|
||||
* A session's agent preset is fixed at creation. The gateway records the
|
||||
* resolved id on the header and refuses to adopt the identity under a different
|
||||
* one, because the session's history was produced under that preset's tools:
|
||||
* rebuilding it differently would replay tool calls the new agent cannot make.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
||||
import type { HostFrame } from '../src/api/events.ts'
|
||||
import {
|
||||
InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
let nextRpc = 0
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
/** Minimal live agent; the gateway only needs identity and its session. */
|
||||
function stubAgent(session: Session): Agent {
|
||||
return { id: session.id, session, status: 'idle' } as unknown as Agent
|
||||
}
|
||||
|
||||
/**
|
||||
* A roster whose `mount` is a no-op: this spec is about the gateway's identity
|
||||
* rules, and the composition itself is covered by the real-composition test in
|
||||
* `apps/cli`. Ids listed in `userIds` present as locally authored; the rest
|
||||
* ship with the deployment.
|
||||
*/
|
||||
function roster(ids: readonly string[], userIds: readonly string[] = []): unknown {
|
||||
const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system')
|
||||
const presetOf = (id: string): object =>
|
||||
({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` })
|
||||
return {
|
||||
defaultId: ids[0],
|
||||
list: () => Promise.resolve(ids.map(presetOf)),
|
||||
resolve: (id?: string) => {
|
||||
const wanted = id ?? ids[0] ?? ''
|
||||
if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
|
||||
return Promise.resolve(presetOf(wanted))
|
||||
},
|
||||
mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
|
||||
// What a real mount leaves behind: a service instance only the agent that
|
||||
// mounted it can be used to address. The doubles are per agent so a test
|
||||
// can tell "this session's" from "some session's".
|
||||
serviceFor: (agent: { id: unknown }, name: string) => {
|
||||
const perAgent = services.get(String(agent.id))
|
||||
return perAgent?.[name]
|
||||
},
|
||||
authorable: true,
|
||||
read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`),
|
||||
copy: (from: string, id: string) => {
|
||||
if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids))
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id))
|
||||
if (ids.includes(id)) return Promise.reject(new PresetExistsError(id))
|
||||
return Promise.resolve()
|
||||
},
|
||||
remove: (id: string) => {
|
||||
if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
|
||||
return Promise.resolve()
|
||||
},
|
||||
recompose: (_ctx: Context, id: string) => {
|
||||
if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
|
||||
return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
|
||||
},
|
||||
// The standing scope key a cold transcript read resolves presenters in.
|
||||
standingKeyFor: (id?: string) => {
|
||||
const wanted = id ?? ids[0] ?? ''
|
||||
standingKeyRequests.push(wanted)
|
||||
if (!ids.includes(wanted) || failingStandingKeys.has(wanted)) {
|
||||
return Promise.reject(new UnknownPresetError(wanted, ids))
|
||||
}
|
||||
let key = standingKeys.get(wanted)
|
||||
if (key === undefined) {
|
||||
key = { agentPreset: wanted }
|
||||
standingKeys.set(wanted, key)
|
||||
}
|
||||
return Promise.resolve(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Standing keys the roster double minted, and the ids readers asked for. */
|
||||
const standingKeys = new Map<string, object>()
|
||||
const standingKeyRequests: string[] = []
|
||||
/** Preset ids whose standing mount the double reports as unusable. */
|
||||
const failingStandingKeys = new Set<string>()
|
||||
|
||||
/** Per-agent service instances a mounted preset would own, keyed by session id. */
|
||||
const services = new Map<string, Record<string, unknown>>()
|
||||
|
||||
async function harness(
|
||||
presets?: readonly string[],
|
||||
persistence?: unknown,
|
||||
options: { userIds?: readonly string[]; defaults?: Record<string, unknown> } = {},
|
||||
) {
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never)
|
||||
if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
|
||||
|
||||
const factory: AgentFactory = {
|
||||
async createAgent(_ownerCtx, options) {
|
||||
const session = ctx.sessions.create(
|
||||
options.sessionId,
|
||||
options.meta === undefined ? {} : { meta: options.meta },
|
||||
)
|
||||
const agent = stubAgent(session)
|
||||
// Setup runs before publication against a context that carries the
|
||||
// agent, and the agent reaches back through `agent.ctx` — the pair the
|
||||
// gateway's own `installTarget` relies on.
|
||||
const agentCtx = ctx.extend({ agent })
|
||||
;(agent as { ctx?: Context }).ctx = agentCtx
|
||||
await options.setup?.(agentCtx)
|
||||
const unregister = ctx.agents.register(agent)
|
||||
return { agent, dispose: () => { unregister(); return Promise.resolve() } }
|
||||
},
|
||||
async resume() {
|
||||
throw new Error('test harness has no persisted sessions')
|
||||
},
|
||||
}
|
||||
ctx.agents.setFactory(factory)
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
|
||||
cwd,
|
||||
...options.defaults,
|
||||
})
|
||||
return { api, ctx, cwd }
|
||||
}
|
||||
|
||||
describe('session.create with an agent preset', () => {
|
||||
it('records the resolved preset on the session header', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
|
||||
const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'minimal' }))
|
||||
|
||||
expect(created.result.ok).toBe(true)
|
||||
expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('records the default when the caller names none', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
|
||||
await api.sessions.create(request({ sessionId: SessionId('s2') }))
|
||||
|
||||
expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard')
|
||||
})
|
||||
|
||||
it('rejects an unknown preset and names the ones that exist', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('refuses to adopt a live session under a different preset', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'minimal' }))
|
||||
|
||||
const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-conflict')
|
||||
expect(response.result.error.details).toEqual({
|
||||
sessionId: 's4',
|
||||
requestedPreset: 'standard',
|
||||
existingPreset: 'minimal',
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts a live session unchanged when the caller names no preset', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' }))
|
||||
|
||||
// Reconnecting and retrying a create must stay ordinary operations.
|
||||
const response = await api.sessions.create(request({ sessionId: SessionId('s5') }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves the header preset-less when no roster is composed', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
|
||||
await api.sessions.create(request({ sessionId: SessionId('s6') }))
|
||||
|
||||
expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
|
||||
})
|
||||
|
||||
it('says why a preset-less session cannot be adopted under one', async () => {
|
||||
// Two callers reach this: a deployment that composes no roster, and a
|
||||
// session created before one existed. Both record no preset, so naming
|
||||
// any is a conflict rather than an adoption — the history was produced
|
||||
// under a composition this roster cannot name. The message has to say
|
||||
// that, because "already runs agent preset undefined" reads as a bug.
|
||||
const { api } = await harness()
|
||||
await api.sessions.create(request({ sessionId: SessionId('s7') }))
|
||||
|
||||
const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-conflict')
|
||||
expect(response.result.error.message).toContain('records no agent preset')
|
||||
expect(response.result.error.details).toEqual({
|
||||
sessionId: 's7',
|
||||
requestedPreset: 'standard',
|
||||
existingPreset: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A capability a preset mounts is reachable from nowhere the host normally
|
||||
* looks: an `isolate` realm is what makes it per session. The gateway serves
|
||||
* requests that are ABOUT a session from OUTSIDE it, so it addresses the
|
||||
* instance through the agent instead of reading a root-realm singleton.
|
||||
*/
|
||||
describe('a capability the session\'s preset mounts', () => {
|
||||
it('serves the goal RPC from the session\'s own goal service', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' }))
|
||||
const ref = { id: GoalId('goal-1'), revision: 1 }
|
||||
const paused: unknown[] = []
|
||||
services.set('g1', {
|
||||
goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } },
|
||||
})
|
||||
|
||||
const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { ref } })
|
||||
// Reached the instance this session mounted, and was handed its own agent.
|
||||
expect(paused).toEqual([['g1', ref]])
|
||||
services.delete('g1')
|
||||
})
|
||||
|
||||
it('serves the skill catalog from the session\'s own registry', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' }))
|
||||
services.set('k1', {
|
||||
skills: {
|
||||
list: () => Promise.resolve([{
|
||||
name: 'preset-owned',
|
||||
description: 'ships inside the preset directory',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
}]),
|
||||
},
|
||||
})
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('k1') }))
|
||||
|
||||
// A preset ships its own skill directory, so the catalog IS the
|
||||
// session's; reading a host singleton would answer for the wrong one.
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } })
|
||||
services.delete('k1')
|
||||
})
|
||||
|
||||
it('says so when no composition mounts the capability at all', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' }))
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('n1') }))
|
||||
|
||||
// Absent means absent — not "this session has none", which is what a
|
||||
// root-realm read used to report for every presetd session.
|
||||
expect(response.result.ok).toBe(false)
|
||||
const failure = response.result as { ok: false; error: { message: string } }
|
||||
expect(failure.error.message).toContain('neither this session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentPreset.list', () => {
|
||||
it('marks the default and carries each preset\'s trust', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
|
||||
const response = await api.agentPresets.list(request({}))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.presets).toEqual([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
])
|
||||
expect(response.result.value.authorable).toBe(true)
|
||||
})
|
||||
|
||||
it('answers with an empty roster when the deployment composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
|
||||
const response = await api.agentPresets.list(request({}))
|
||||
|
||||
// Composing no presets is a valid deployment, not an error: every session
|
||||
// then shares the host composition and the browser offers no choice.
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.presets).toEqual([])
|
||||
// Nothing to write to either, so a surface offering "new preset" knows to
|
||||
// stay hidden rather than offering a button whose save always fails.
|
||||
expect(response.result.value.authorable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentPreset.select', () => {
|
||||
it('recomposes a blank session', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' }))
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('records the switch in the log, and the list reads it back', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'core-web'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' }))
|
||||
|
||||
await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' }))
|
||||
|
||||
// The header is written once at creation, so the switch lives in the log —
|
||||
// this is what a restart replays and what every projection resolves from.
|
||||
// Asserting only the RPC's echo would miss a switch that never persisted.
|
||||
const session = ctx.sessions.get(SessionId('sel-log'))
|
||||
if (session === undefined) throw new Error('unreachable')
|
||||
expect(session.header.agentPreset).toBe('standard')
|
||||
expect(resolveSessionPreset(session)).toBe('core-web')
|
||||
const listed = await api.sessions.list(request({}))
|
||||
if (!listed.result.ok) throw new Error('unreachable')
|
||||
expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset)
|
||||
.toBe('core-web')
|
||||
})
|
||||
|
||||
it('frames the committed switch so clients can drop that session\'s catalogs', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' }))
|
||||
// The host-stream opener reads the committed-workspace baseline; this
|
||||
// spec owns preset identity, so the stub suffices (api-proxy-commands
|
||||
// precedent).
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
const abort = new AbortController()
|
||||
const frames: HostFrame[] = []
|
||||
const stream = api.events.host(request({}), abort.signal)
|
||||
const consume = (async () => {
|
||||
for await (const frame of stream) {
|
||||
if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload)
|
||||
}
|
||||
})()
|
||||
|
||||
await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' }))
|
||||
// The queue push rides the synchronous append, so one turn of the loop is
|
||||
// enough to deliver it; closing the stream bounds the read either way.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
abort.abort()
|
||||
await consume
|
||||
|
||||
// Recomposing registers nothing, so this frame — not the registry-wide
|
||||
// commands one — is what tells a client its cached catalogs are stale.
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' },
|
||||
])
|
||||
})
|
||||
|
||||
it('serializes two concurrent selects on one session', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'core-web'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))
|
||||
|
||||
// Both pass the blank check; unserialized, the second unmount finds no
|
||||
// record because the first already removed it, and two compositions end up
|
||||
// in one agent layer. The client's busy flag is not enforcement.
|
||||
const [first, second] = await Promise.all([
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })),
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
|
||||
])
|
||||
|
||||
expect(first.result.ok).toBe(true)
|
||||
expect(second.result.ok).toBe(true)
|
||||
const session = ctx.sessions.get(SessionId('sel-race'))
|
||||
if (session === undefined) throw new Error('unreachable')
|
||||
// One winner, and the log agrees with it: the last committed switch.
|
||||
expect(resolveSessionPreset(session)).toBe('standard')
|
||||
})
|
||||
|
||||
it('refuses once the conversation has started', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))
|
||||
// One turn is enough: the history from here on was produced under
|
||||
// `standard`'s tools, and a swap would strand those tool calls.
|
||||
ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 })
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-2'), agentPreset: 'minimal' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-locked')
|
||||
})
|
||||
|
||||
it('reports an unknown preset without disturbing the session', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-3') }))
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports a deployment that composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-4') }))
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring over the wire', () => {
|
||||
it('reads a composition with its trust', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.read(request({ agentPreset: 'standard' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
// The shipped set is readable: it is the known-good composition a copy
|
||||
// starts from, and trust is what tells a surface to say so.
|
||||
expect(response.result.value.trust).toBe('system')
|
||||
expect(response.result.value.content).toContain('- id: x')
|
||||
})
|
||||
|
||||
it('copies a preset under a new id', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.copy(
|
||||
request({ from: 'standard', agentPreset: 'mine', name: '我的模式' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.agentPreset).toBe('mine')
|
||||
})
|
||||
|
||||
it('rejects a copy target that could escape the preset root', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: '../escape' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-invalid')
|
||||
})
|
||||
|
||||
it('rejects a copy target the roster already supplies', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
|
||||
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: 'minimal' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-invalid')
|
||||
expect(response.result.error.message).toMatch(/already exists/)
|
||||
})
|
||||
|
||||
it('rejects a copy whose source is unknown', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.copy(request({ from: 'never-existed', agentPreset: 'mine' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports a deployment that composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
|
||||
const response = await api.agentPresets.read(request({ agentPreset: 'anything' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports an unknown id on delete rather than succeeding silently', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opening a preset directory', () => {
|
||||
it('hands the resolved directory to the native opener', async () => {
|
||||
const opened: string[] = []
|
||||
const { api } = await harness(['standard', 'my-preset'], undefined, {
|
||||
userIds: ['my-preset'],
|
||||
defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
|
||||
})
|
||||
|
||||
const response = await api.agentPresets.openDocument(
|
||||
request({ agentPreset: 'my-preset' }), new AbortController().signal)
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value).toEqual({ opened: true })
|
||||
// The id selected the directory; the browser supplied no path.
|
||||
expect(opened).toEqual(['/presets/my-preset'])
|
||||
})
|
||||
|
||||
it('answers the path as text where the deployment has no opener', async () => {
|
||||
const { api } = await harness(['standard', 'my-preset'], undefined, {
|
||||
userIds: ['my-preset'],
|
||||
defaults: { canOpenPath: () => false },
|
||||
})
|
||||
|
||||
const response = await api.agentPresets.openDocument(
|
||||
request({ agentPreset: 'my-preset' }), new AbortController().signal)
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' })
|
||||
})
|
||||
|
||||
it('refuses a preset that ships with the deployment', async () => {
|
||||
const opened: string[] = []
|
||||
const { api } = await harness(['standard'], undefined, {
|
||||
defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
|
||||
})
|
||||
|
||||
const response = await api.agentPresets.openDocument(
|
||||
request({ agentPreset: 'standard' }), new AbortController().signal)
|
||||
|
||||
// Pointing an editor into the install invites edits an upgrade will
|
||||
// silently overwrite; the refusal mirrors copy/remove.
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-read-only')
|
||||
expect(opened).toEqual([])
|
||||
})
|
||||
|
||||
it('reports the roster capability on list', async () => {
|
||||
const openable = await harness(['standard'], undefined, {
|
||||
defaults: { canOpenPath: () => true },
|
||||
})
|
||||
const headless = await harness(['standard'], undefined, {
|
||||
defaults: { canOpenPath: () => false },
|
||||
})
|
||||
|
||||
const yes = await openable.api.agentPresets.list(request({}))
|
||||
const no = await headless.api.agentPresets.list(request({}))
|
||||
|
||||
expect(yes.result.ok && yes.result.value.hasDocument).toBe(true)
|
||||
expect(no.result.ok && no.result.value.hasDocument).toBe(false)
|
||||
})
|
||||
|
||||
it('counts an injected opener as openable', async () => {
|
||||
const { api } = await harness(['standard'], undefined, {
|
||||
defaults: { openPath: () => Promise.resolve() },
|
||||
})
|
||||
|
||||
const response = await api.agentPresets.list(request({}))
|
||||
|
||||
expect(response.result.ok && response.result.value.hasDocument).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills over the layered host registry', () => {
|
||||
it('passes the live agent as the view scope to the host registry', async () => {
|
||||
const { api, ctx } = await harness(['standard'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
seen.push(options.scope)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' }))
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('h1') }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
|
||||
expect(seen).toEqual([ctx.agents.get(SessionId('h1'))])
|
||||
})
|
||||
|
||||
it('resolves a cold session to its recorded preset standing key', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'core-web'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
seen.push(options.scope)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } })
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('h2') }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
|
||||
expect(seen).toEqual([standingKeys.get('core-web')])
|
||||
})
|
||||
|
||||
it('serves the global view when the roster no longer supplies the recorded preset', async () => {
|
||||
const { api, ctx } = await harness(['standard'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
seen.push(options.scope)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } })
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('h3') }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
|
||||
expect(seen).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.history presenter scope', () => {
|
||||
it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
|
||||
const { api } = await harness(['standard', 'core-web'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' }))
|
||||
// Cold: creation registered a live agent in this harness, so simulate the
|
||||
// cold path by asking for a session only persistence knows... the harness
|
||||
// has no persistence, so read the live one and assert no roster query.
|
||||
standingKeyRequests.length = 0
|
||||
const live = await api.sessions.history(request({ sessionId: SessionId('p1') }))
|
||||
expect(live.result.ok).toBe(true)
|
||||
// A live agent IS the presenter scope; the roster is not consulted.
|
||||
expect(standingKeyRequests).toEqual([])
|
||||
})
|
||||
|
||||
it('serves a COLD transcript whose standing mount is no longer usable', async () => {
|
||||
// A genuinely cold session: persistence knows it, no live agent exists.
|
||||
const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' }
|
||||
const { api } = await harness(['standard'], {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events: [] }),
|
||||
})
|
||||
// The preset broke after the session ran: the roster rejects the mount.
|
||||
failingStandingKeys.add('standard')
|
||||
try {
|
||||
standingKeyRequests.length = 0
|
||||
const response = await api.sessions.history(request({ sessionId: SessionId('p3') }))
|
||||
// Degraded, never failed: the roster WAS asked, and the transcript
|
||||
// still serves — with the generic cards a viewless entry renders.
|
||||
expect(standingKeyRequests).toEqual(['standard'])
|
||||
expect(response.result.ok).toBe(true)
|
||||
} finally {
|
||||
failingStandingKeys.delete('standard')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
return { ctx, api }
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('approval pending registry', () => {
|
||||
await ctx.plugin(ApprovalService)
|
||||
let api!: ApiProxy
|
||||
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
|
||||
api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
|
||||
await fiber.await()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return {
|
||||
ctx,
|
||||
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }),
|
||||
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
|
||||
attach: (session) => {
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
},
|
||||
|
||||
@@ -11,6 +11,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -62,7 +64,7 @@ describe('sessions.list cold merge', () => {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const response = await api.sessions.list(request({}))
|
||||
expect(response.result.ok).toBe(true)
|
||||
@@ -90,7 +92,7 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
// Old work, resumed just now: the log tail would report the pickup.
|
||||
const worked = 1_000_000
|
||||
@@ -148,7 +150,7 @@ describe('cold history recovery view', () => {
|
||||
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
|
||||
if (!history.result.ok) throw new Error('history failed')
|
||||
@@ -180,6 +182,100 @@ describe('cold history recovery view', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote Agent and Session lookup policy', () => {
|
||||
it('deduplicates a cold resume across Agent and Session parameters', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('session-remote-cold')
|
||||
const meta = header(sessionId, 1000)
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session
|
||||
const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
|
||||
await release.promise
|
||||
return { agent: resumedAgent, dispose: () => Promise.resolve() }
|
||||
})
|
||||
const defaultAgentLookup = ctx.typert.lookups.get('agent')
|
||||
const defaultSessionLookup = ctx.typert.lookups.get('session')
|
||||
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
|
||||
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
|
||||
})
|
||||
const agentLookup = ctx.typert.lookups.get('agent')
|
||||
const sessionLookup = ctx.typert.lookups.get('session')
|
||||
if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
|
||||
|
||||
const resolvedAgent = Promise.resolve(agentLookup.resolve(sessionId))
|
||||
const resolvedSession = Promise.resolve(sessionLookup.resolve(sessionId))
|
||||
await vi.waitFor(() => { expect(resume).toHaveBeenCalledOnce() })
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(resolvedAgent).resolves.toBe(resumedAgent)
|
||||
await expect(resolvedSession).resolves.toBe(resumedSession)
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves the subagent ownership fence for cold and live Remote lookups', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const coldId = sid('session-remote-cold-child')
|
||||
const coldMeta = header(coldId, 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
origin: 'subagent',
|
||||
})
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] }))
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([coldMeta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const liveSession = ctx.sessions.create(sid('session-remote-live-child'), {
|
||||
meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' },
|
||||
})
|
||||
const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(liveAgent)
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const defaultAgentLookup = ctx.typert.lookups.get('agent')
|
||||
const defaultSessionLookup = ctx.typert.lookups.get('session')
|
||||
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
|
||||
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
|
||||
})
|
||||
const agentLookup = ctx.typert.lookups.get('agent')
|
||||
const sessionLookup = ctx.typert.lookups.get('session')
|
||||
if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
|
||||
const ownershipFailure = {
|
||||
failure: {
|
||||
code: 'agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
},
|
||||
}
|
||||
|
||||
const coldFailure = Promise.resolve(agentLookup.resolve(coldId))
|
||||
const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id))
|
||||
await expect(coldFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(coldFailure).rejects.toMatchObject(ownershipFailure)
|
||||
await expect(liveFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(liveFailure).rejects.toMatchObject(ownershipFailure)
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent ownership fence', () => {
|
||||
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -216,7 +312,7 @@ describe('subagent ownership fence', () => {
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const history = await api.sessions.history(request({ sessionId }))
|
||||
expect(history.result.ok).toBe(true)
|
||||
@@ -269,13 +365,13 @@ describe('subagent ownership fence', () => {
|
||||
inspect: () => Promise.resolve({ meta, events }),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
// Pre-#1569 stores classify a child only through the descriptor event and
|
||||
// carry no header `origin`; the pre-release decision stops recognizing
|
||||
// them, so the ownership fence lets generic resume reach the registry
|
||||
// instead of answering `agent-busy`.
|
||||
// Stores whose headers predate `origin` classify a child only through the
|
||||
// descriptor event; the pre-release decision stops recognizing them, so
|
||||
// the ownership fence lets generic resume reach the registry instead of
|
||||
// answering `agent-busy`.
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
.mockRejectedValue(new Error('registry unavailable in this bench'))
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const prompt = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
@@ -316,7 +412,7 @@ describe('subagent ownership fence', () => {
|
||||
})
|
||||
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.enter(startingChild, parent)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
|
||||
expect(stopped.result.ok).toBe(false)
|
||||
@@ -362,7 +458,7 @@ describe('subagent ownership fence', () => {
|
||||
const followup = vi.fn()
|
||||
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const response = await api.sessions.prompt(request({
|
||||
sessionId: agent.id,
|
||||
@@ -380,7 +476,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const listed = await api.sessions.list(request({}))
|
||||
expect(listed.result.ok).toBe(true)
|
||||
@@ -405,7 +501,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
list: () => Promise.resolve([]),
|
||||
inspect,
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
@@ -422,7 +518,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const session = ctx.sessions.create(sid('session-throwing'))
|
||||
// A live structural stub whose delivery verbs throw synchronously, the
|
||||
// shape a disposed loop presents at this seam.
|
||||
// shape a disposed loop presents at this gateway boundary.
|
||||
ctx.agents.register({
|
||||
id: session.id,
|
||||
session,
|
||||
@@ -431,7 +527,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
||||
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
||||
} as unknown as Agent)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
for (const mode of ['queue', 'steer'] as const) {
|
||||
const response = await api.sessions.prompt(request({
|
||||
@@ -475,7 +571,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
ctx.agents.register(child)
|
||||
throw new Error('session id already published')
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const models = await api.sessions.models(request({ sessionId }))
|
||||
expect(models.result.ok).toBe(false)
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' }
|
||||
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
@@ -228,7 +228,10 @@ describe('skill.list', () => {
|
||||
// touch (or resume through) the Agent registry.
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
|
||||
expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }])
|
||||
expect(value.skills).toEqual([
|
||||
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
|
||||
{ name: 'user-only', description: 'User-only', modelInvocable: false },
|
||||
])
|
||||
expect(seenCwds).toEqual(['/proj'])
|
||||
expect(ctx.agents.get(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -22,9 +22,10 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' }
|
||||
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
@@ -43,7 +44,7 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string
|
||||
return response.result.error
|
||||
}
|
||||
|
||||
/** In-memory settings provider: the seam base class owns all tested behavior. */
|
||||
/** In-memory settings provider: the Service Definition base class owns all tested behavior. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown>
|
||||
|
||||
@@ -355,6 +356,21 @@ describe('settings domain', () => {
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
|
||||
})
|
||||
|
||||
it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(settingsNamespace('agent-presets'), z.object({ default: z.string() }))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
expectOk(await api.settings.update(request({ ns: 'agent-presets', patch: { default: 'minimal' } })))
|
||||
|
||||
// Both browser surfaces that offer the choice — the General row and the
|
||||
// management section — write the default through `settings.update`, so a
|
||||
// namespace outside this boundary makes the picker move and then silently
|
||||
// forget, which is worse than refusing the control.
|
||||
expect(ctx.settings.describe().find(view => String(view.ns) === 'agent-presets')?.value)
|
||||
.toEqual({ default: 'minimal' })
|
||||
})
|
||||
|
||||
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
|
||||
const ctx = await harness({ configurableProviders: false })
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
@@ -366,9 +382,10 @@ describe('settings domain', () => {
|
||||
|
||||
it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => {
|
||||
// Editing `models` changes no route, so llm/adapters-updated never fires
|
||||
// and an open model picker kept serving the old catalog. And storing an
|
||||
// override equal to the resolved value emits nothing on settings/updated,
|
||||
// so another tab never learned the field became overridden.
|
||||
// and an open model picker would keep serving the stale catalog. Storing
|
||||
// an override equal to the resolved value emits nothing on
|
||||
// settings/updated, so another tab would never learn the field became
|
||||
// overridden.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
@@ -398,6 +415,25 @@ describe('settings domain', () => {
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when the Agent default selection changes', async () => {
|
||||
const ctx = await harness()
|
||||
const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
}), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
// The shared section names the selection every blank session resolves to,
|
||||
// so an externally edited default — another tab, a
|
||||
// hand-edited settings.yaml — has to reach an open selector as well.
|
||||
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
|
||||
await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'agent-default-model' },
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
|
||||
@@ -82,8 +82,7 @@ function liveAgent(
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, {
|
||||
provider: 'default-provider',
|
||||
model: 'default-model',
|
||||
defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }),
|
||||
cwd: '/tmp',
|
||||
})
|
||||
|
||||
@@ -254,7 +253,7 @@ describe('sessions.fork', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('installs the latest logged model target before the child can run', async () => {
|
||||
it('installs the latest logged model selection before the child can run', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-routed', 1)
|
||||
source.append('request/header', {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Web session model-directory and selection behavior: dynamic provider grouping,
|
||||
* provider-local catalog failures, logged-target restoration without stale
|
||||
* provider-local catalog failures, logged-selection restoration without stale
|
||||
* catalog injection, advisory pass-through models, and the prompt-assembly
|
||||
* boundary for a running selection change.
|
||||
*/
|
||||
@@ -119,13 +119,13 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
|
||||
}
|
||||
|
||||
describe('Web session model selection', () => {
|
||||
it('groups successful providers and leaves an unlisted current target out of the catalog', async () => {
|
||||
it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => {
|
||||
const { ctx, sessionId } = await harness({
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.current).toEqual({
|
||||
@@ -160,7 +160,7 @@ describe('Web session model selection', () => {
|
||||
|
||||
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
|
||||
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
||||
const signal = new AbortController().signal
|
||||
|
||||
@@ -225,4 +225,127 @@ describe('Web session model selection', () => {
|
||||
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reads the Agent default live for a session whose log names no selection', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => stored,
|
||||
cwd: '/tmp',
|
||||
})
|
||||
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
// The default moving after the session exists still reaches it: New
|
||||
// Session reuses a blank session rather than minting another, so a seed
|
||||
// captured at creation would show the superseded model there.
|
||||
stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
expect(expectValue(await api.host.describe(request({}))))
|
||||
.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps a session on its logged selection when the Agent default differs', async () => {
|
||||
const { ctx, sessionId } = await harness({
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-chat',
|
||||
})
|
||||
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => stored,
|
||||
cwd: '/tmp',
|
||||
})
|
||||
|
||||
stored = { provider: 'duplicate', model: 'same' }
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('saves an accepted selection as the default and survives a storage failure', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const saved: unknown[] = []
|
||||
let reject = false
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
|
||||
saveDefaultModelSelection: (selection) => {
|
||||
saved.push(selection)
|
||||
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
|
||||
},
|
||||
cwd: '/tmp',
|
||||
})
|
||||
|
||||
expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max',
|
||||
})))
|
||||
expect(saved).toEqual([
|
||||
{ provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' },
|
||||
])
|
||||
|
||||
// A refused selection never becomes anyone's default.
|
||||
await api.sessions.selectModel(request({ sessionId, provider: 'missing', model: 'model' }))
|
||||
expect(saved).toHaveLength(1)
|
||||
|
||||
// Storage failing is not the selection failing: the switch already applies
|
||||
// to this session, so the call still succeeds.
|
||||
reject = true
|
||||
const stillAccepted = expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
|
||||
})))
|
||||
expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
|
||||
cwd: '/tmp',
|
||||
})
|
||||
|
||||
// The client disabling its input is an affordance; this method stays
|
||||
// callable, so the refusal has to live here.
|
||||
const refused = await api.sessions.prompt(request({
|
||||
sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
|
||||
}))
|
||||
expect(refused.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
|
||||
})
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false)
|
||||
|
||||
// An advisory-unlisted model on a live route is NOT this: the route
|
||||
// serves it, so the prompt goes through and nothing blocks.
|
||||
expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
|
||||
})))
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.routable).toBe(true)
|
||||
expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
|
||||
.not.toContain('unlisted-but-served')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, {
|
||||
// What a Models-page removal leaves behind: the settings document still
|
||||
// names the route the user last picked, and nothing serves it.
|
||||
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
|
||||
cwd: '/tmp',
|
||||
})
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
// Passed through rather than repaired: matching no group is precisely what
|
||||
// makes the composer seat prompt for a selection instead of naming a model
|
||||
// the deployment cannot reach.
|
||||
expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
|
||||
expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
|
||||
.not.toContain('deleted-gateway/deleted-model')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
describe('session.history projections block', () => {
|
||||
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -10,15 +10,19 @@ import { createApiProxy } from '../src/api-proxy.ts'
|
||||
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
return {
|
||||
ctx,
|
||||
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }),
|
||||
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
|
||||
}
|
||||
}
|
||||
|
||||
function agent(id: string): Agent {
|
||||
return { id } as unknown as Agent
|
||||
function agent(ctx: Context): Agent {
|
||||
const session = ctx.sessions.create()
|
||||
const value = { id: session.id, session, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function openMux(api: ApiProxy, abort: AbortController): {
|
||||
@@ -71,7 +75,7 @@ describe('question response validation', () => {
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const asked = ctx.userInteraction.ask({
|
||||
agent: agent('session-multi'),
|
||||
agent: agent(ctx),
|
||||
questions: [{
|
||||
id: 'targets',
|
||||
question: 'Choose targets and add another',
|
||||
@@ -95,7 +99,7 @@ describe('question response validation', () => {
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const asked = ctx.userInteraction.ask({
|
||||
agent: agent('session-single'),
|
||||
agent: agent(ctx),
|
||||
questions: [{
|
||||
id: 'target',
|
||||
question: 'Choose one target',
|
||||
|
||||
@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
|
||||
return session
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
describe('sessions.rename', () => {
|
||||
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
})
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
const defaults = { provider: 'p', model: 'm', cwd: '/tmp' }
|
||||
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
|
||||
|
||||
function request(query: string): RpcRequest<{ query: string }> {
|
||||
return { rpcId: RpcId(`search-${query}`), payload: { query } }
|
||||
@@ -165,7 +165,7 @@ describe('session.search', () => {
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects snippets whose provider provenance violates the Host filters', async () => {
|
||||
it('rejects snippets whose recorded provider violates the Host filters', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = hit('visible')
|
||||
ctx.sessions.create(visible.header.id, { meta: visible.header })
|
||||
|
||||
@@ -19,6 +19,7 @@ function bench(options: {
|
||||
childStatus?: 'idle' | 'running'
|
||||
entries?: object[]
|
||||
followupError?: Error
|
||||
interruptError?: Error
|
||||
listError?: Error
|
||||
/** Persistence forgets the child entirely (the vanished-mid-read race). */
|
||||
storedChild?: false
|
||||
@@ -53,6 +54,12 @@ function bench(options: {
|
||||
) => options.followupError === undefined
|
||||
? Promise.resolve('message-1')
|
||||
: Promise.reject(options.followupError))
|
||||
const interrupt = vi.fn((
|
||||
_targetSessionId: SessionId,
|
||||
_authority: { kind: 'user'; parentSessionId: SessionId },
|
||||
) => {
|
||||
if (options.interruptError !== undefined) throw options.interruptError
|
||||
})
|
||||
const childHeader = {
|
||||
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
|
||||
} satisfies SessionHeader
|
||||
@@ -72,7 +79,7 @@ function bench(options: {
|
||||
})
|
||||
const ctx = new Context()
|
||||
ctx.provide('agents', { get: getAgent })
|
||||
ctx.provide('subagents', { listChildren, followup })
|
||||
ctx.provide('subagents', { listChildren, followup, interrupt })
|
||||
ctx.provide('sessions', {
|
||||
get: (id: SessionId) => options.liveChild === true && id === CHILD
|
||||
? { id: CHILD, header: childHeader, events: childEvents }
|
||||
@@ -84,13 +91,13 @@ function bench(options: {
|
||||
locate: () => undefined,
|
||||
})
|
||||
// The gateway's own projection push feed subscribes at construction; the
|
||||
// no-op disposer keeps that seam quiet while these tests pin history reads.
|
||||
// no-op disposer keeps that feed quiet while these tests pin history reads.
|
||||
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
|
||||
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'p', model: 'm', cwd: '/tmp',
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
|
||||
})
|
||||
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
|
||||
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
|
||||
}
|
||||
|
||||
describe('subagent gateway', () => {
|
||||
@@ -309,4 +316,48 @@ describe('subagent gateway', () => {
|
||||
error: { code: 'internal', message: 'subagent prompt failed' },
|
||||
})
|
||||
})
|
||||
|
||||
it('interrupts through the core primitive alone while the parent Agent is offline', async () => {
|
||||
const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false })
|
||||
const response = await api.subagents.interrupt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
|
||||
}))
|
||||
expect(response.rpcId).toBe('subagent-rpc')
|
||||
expect(response.result).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT })
|
||||
// No parent-registry, catalog, or history dependency: this is what keeps a
|
||||
// live child interruptible after its parent Agent went offline.
|
||||
expect(getAgent).not.toHaveBeenCalled()
|
||||
expect(listChildren).not.toHaveBeenCalled()
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps interrupt authorization rejection without touching other services', async () => {
|
||||
const { api, listChildren } = bench({
|
||||
interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'),
|
||||
})
|
||||
const response = await api.subagents.interrupt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
|
||||
}))
|
||||
expect(response.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent does not belong to this parent',
|
||||
details: { childSessionId: CHILD },
|
||||
},
|
||||
})
|
||||
expect(listChildren).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides unexpected interrupt failures behind the internal code', async () => {
|
||||
const { api } = bench({ interruptError: new Error('secret activation state') })
|
||||
const response = await api.subagents.interrupt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
|
||||
}))
|
||||
expect(response.result).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'subagent interrupt failed', details: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: num
|
||||
describe('mux live view computation', () => {
|
||||
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 9, abort)
|
||||
@@ -170,7 +170,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
// history resolves the agent first; a live structural stub is enough (only
|
||||
// .session is read on this path).
|
||||
@@ -236,9 +236,9 @@ describe('mux live view computation', () => {
|
||||
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
|
||||
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
@@ -247,7 +247,7 @@ describe('mux live view computation', () => {
|
||||
const third = appendUserText(session, 'second prompt')
|
||||
appendAssistantText(session, 'second reply', 2)
|
||||
const shadowed = [...session.surface.nodes]
|
||||
// A compaction transaction: log-only provenance immediately followed by the
|
||||
// A compaction transaction: a log-only summary record immediately followed by the
|
||||
// replacement that shadows the range.
|
||||
const summary = appendExtension(session, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
@@ -277,7 +277,7 @@ describe('mux live view computation', () => {
|
||||
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
|
||||
expect(page.some(event => event.seq === first.seq)).toBe(false)
|
||||
expect(response.result.value.hasMore).toBe(true)
|
||||
// The range stays contiguous, so the checkpoint's provenance is readable on
|
||||
// The range stays contiguous, so the checkpoint's summary record is readable on
|
||||
// the same page as the checkpoint itself.
|
||||
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
|
||||
expect(summaryIndex).toBeGreaterThan(-1)
|
||||
@@ -287,7 +287,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
|
||||
|
||||
@@ -308,7 +308,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 4, abort)
|
||||
|
||||
@@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent {
|
||||
|
||||
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
|
||||
async function harness(
|
||||
root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
||||
root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
||||
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
|
||||
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
|
||||
) {
|
||||
@@ -100,8 +100,7 @@ async function harness(
|
||||
// object per harness mirrors the seam's stability contract.
|
||||
ctx.provide('directoryPicker', { capability: () => picker } as never)
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
|
||||
cwd: root,
|
||||
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ function scriptedApi(overrides: {
|
||||
host?: Partial<ApiProxy['host']>
|
||||
commands?: Partial<ApiProxy['commands']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
agentPresets?: Partial<ApiProxy['agentPresets']>
|
||||
events?: Partial<ApiProxy['events']>
|
||||
goals?: Partial<ApiProxy['goals']>
|
||||
settings?: Partial<ApiProxy['settings']>
|
||||
@@ -41,10 +42,11 @@ function scriptedApi(overrides: {
|
||||
history: r => ok(r, {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
models: r => ok(r, {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
}),
|
||||
@@ -62,6 +64,7 @@ function scriptedApi(overrides: {
|
||||
list: r => ok(r, { entries: [], parentAvailable: false }),
|
||||
history: r => ok(r, { events: [], hasMore: false }),
|
||||
prompt: r => ok(r, { messageId: 'message-1' as never }),
|
||||
interrupt: r => ok(r, { accepted: true as const }),
|
||||
...overrides.subagents,
|
||||
},
|
||||
host: {
|
||||
@@ -86,6 +89,15 @@ function scriptedApi(overrides: {
|
||||
...overrides.commands,
|
||||
},
|
||||
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
|
||||
agentPresets: {
|
||||
list: r => ok(r, { presets: [], authorable: false, hasDocument: false }),
|
||||
select: r => ok(r, { agentPreset: r.payload.agentPreset }),
|
||||
read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '' }),
|
||||
copy: r => ok(r, { agentPreset: r.payload.agentPreset }),
|
||||
openDocument: r => ok(r, { opened: true as const }),
|
||||
remove: r => ok(r, {}),
|
||||
...overrides.agentPresets,
|
||||
},
|
||||
goals: {
|
||||
create: err,
|
||||
edit: err,
|
||||
@@ -220,6 +232,18 @@ describe('unary round trip', () => {
|
||||
expect(appended.result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('routes the agent-preset roster and switch through the wire', async () => {
|
||||
const c = client(scriptedApi())
|
||||
|
||||
const listed = await c.agentPresets.list({})
|
||||
expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false, hasDocument: false } })
|
||||
|
||||
// The switch carries the session it is about: the host refuses one whose
|
||||
// conversation has started, and it can only know which by id.
|
||||
const selected = await c.agentPresets.select({ sessionId: sid('s1'), agentPreset: 'standard' })
|
||||
expect(selected.result).toEqual({ ok: true, value: { agentPreset: 'standard' } })
|
||||
})
|
||||
|
||||
it('passes business errors through as 200 + err result, not a throw', async () => {
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
@@ -247,6 +271,32 @@ describe('unary round trip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
|
||||
const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
|
||||
const api = scriptedApi({ subagents: { interrupt } })
|
||||
const c = client(api)
|
||||
|
||||
const accepted = await c.subagents.interrupt({
|
||||
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
|
||||
})
|
||||
expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(interrupt).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The wire schema owns the mode fence: a one-shot address never reaches the impl.
|
||||
const oneShot = await c.subagents.interrupt({
|
||||
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
|
||||
} as never)
|
||||
expect(oneShot.result.ok).toBe(false)
|
||||
if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
|
||||
|
||||
const incomplete = await c.subagents.interrupt({
|
||||
parentSessionId: sid('parent'), mode: 'continuable',
|
||||
} as never)
|
||||
expect(incomplete.result.ok).toBe(false)
|
||||
if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
|
||||
expect(interrupt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a method/path mismatch as bad-request', async () => {
|
||||
const handler = toFetchHandler(scriptedApi())
|
||||
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
|
||||
|
||||
@@ -64,6 +64,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
ok: true,
|
||||
value: {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
},
|
||||
@@ -127,6 +128,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
result: { ok: true, value: { messageId: 'message-1' as never } },
|
||||
}
|
||||
},
|
||||
async interrupt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
},
|
||||
host: {
|
||||
async describe(request) {
|
||||
@@ -193,9 +197,35 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
|
||||
},
|
||||
},
|
||||
agentPresets: {
|
||||
list(request: RpcRequest<{}>) {
|
||||
return Promise.resolve({
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true as const, value: { presets: [], authorable: false, hasDocument: false } },
|
||||
})
|
||||
},
|
||||
select(request: RpcRequest<{ agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
read(request: RpcRequest<{ agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '' }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
copy(request: RpcRequest<{ from: string; agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
openDocument(request: RpcRequest<{ agentPreset: string }>) {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { opened: true as const } } })
|
||||
},
|
||||
remove(request: RpcRequest<{ agentPreset: string }>) {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: {} } })
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } }
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
@@ -336,6 +366,28 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect((await c.host.describe({})).result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips every agent-preset method, authoring included', async () => {
|
||||
const c = client()
|
||||
|
||||
// The whole domain crosses the carrier: the roster a picker reads, the
|
||||
// per-session switch, and the authoring calls the settings page makes.
|
||||
// Each has its own request schema, so a registration missing from either
|
||||
// half fails here rather than in the browser.
|
||||
expect((await c.agentPresets.list({})).result).toEqual({
|
||||
ok: true, value: { presets: [], authorable: false, hasDocument: false },
|
||||
})
|
||||
expect((await c.agentPresets.select({ sessionId: 's' as never, agentPreset: 'minimal' })).result)
|
||||
.toEqual({ ok: true, value: { agentPreset: 'minimal' } })
|
||||
expect((await c.agentPresets.read({ agentPreset: 'mine' })).result).toEqual({
|
||||
ok: true, value: { agentPreset: 'mine', trust: 'user', content: '' },
|
||||
})
|
||||
expect((await c.agentPresets.copy({ from: 'standard', agentPreset: 'mine' })).result)
|
||||
.toEqual({ ok: true, value: { agentPreset: 'mine' } })
|
||||
expect((await c.agentPresets.openDocument({ agentPreset: 'mine' })).result)
|
||||
.toEqual({ ok: true, value: { opened: true } })
|
||||
expect((await c.agentPresets.remove({ agentPreset: 'mine' })).result).toEqual({ ok: true, value: {} })
|
||||
})
|
||||
|
||||
it('round-trips the native picker without the default unary timeout', async () => {
|
||||
const api = fakeApi()
|
||||
api.host.pickDirectory = async (request) => {
|
||||
@@ -380,7 +432,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
|
||||
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
|
||||
const skills = await c.skills.list({ sessionId: 's' as never })
|
||||
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
|
||||
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } })
|
||||
})
|
||||
|
||||
it('lets command.execute finish after the 30-second default unary deadline', async () => {
|
||||
@@ -432,6 +484,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
mode: 'continuable',
|
||||
content: [],
|
||||
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
|
||||
expect((await c.subagents.interrupt({
|
||||
parentSessionId: 'parent' as never,
|
||||
childSessionId: 'child' as never,
|
||||
mode: 'continuable',
|
||||
})).result).toEqual({ ok: true, value: { accepted: true } })
|
||||
})
|
||||
|
||||
it('keeps caller and connection aborts on command.execute', async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
@@ -167,3 +167,155 @@ describe('native path opener', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser-renderable documents', () => {
|
||||
const LS_PLIST = `{
|
||||
LSHandlers = (
|
||||
{
|
||||
LSHandlerPreferredVersions = {
|
||||
LSHandlerRoleAll = "-";
|
||||
};
|
||||
LSHandlerRoleAll = "com.google.chrome";
|
||||
LSHandlerURLScheme = https;
|
||||
}
|
||||
);
|
||||
}`
|
||||
|
||||
it('opens a page with the default browser rather than the .html handler on darwin', async () => {
|
||||
const calls: { command: string; args: readonly string[] }[] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push({ command, args })
|
||||
return { stdout: command === 'defaults' ? LS_PLIST : '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run })
|
||||
// A developer who bound .html to an editor still gets a rendered page.
|
||||
expect(calls.map(c => [c.command, ...c.args])).toEqual([
|
||||
['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'],
|
||||
['open', '-b', 'com.google.chrome', '/w/page.html'],
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves every other document to the default application', async () => {
|
||||
const calls: string[][] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push([command, ...args])
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/report.md', new AbortController().signal, { platform: 'darwin', run })
|
||||
// No LaunchServices read at all: markdown is not a browser document.
|
||||
expect(calls).toEqual([['open', '/w/report.md']])
|
||||
})
|
||||
|
||||
it('falls back to the default application when no browser can be named', async () => {
|
||||
// LaunchServices has no https record (a fresh account), so the system's
|
||||
// own content-type choice is the best answer available.
|
||||
const calls: string[][] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push([command, ...args])
|
||||
if (command === 'defaults') throw new Error('domain not found')
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run })
|
||||
expect(calls).toEqual([
|
||||
['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'],
|
||||
['open', '/w/page.html'],
|
||||
])
|
||||
|
||||
// A record without an https handler is the same answer.
|
||||
const bare: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'darwin',
|
||||
run: async (command, args) => {
|
||||
bare.push([command, ...args])
|
||||
return { stdout: '{ LSHandlers = ( ); }', stderr: '' }
|
||||
},
|
||||
})
|
||||
expect(bare[1]).toEqual(['open', '/w/page.html'])
|
||||
})
|
||||
|
||||
it('honors $BROWSER on linux and leaves windows to its association', async () => {
|
||||
const linux: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
osRelease: '6.8.0-generic',
|
||||
env: { BROWSER: 'firefox' },
|
||||
run: async (command, args) => { linux.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(linux).toEqual([['firefox', '/w/page.html']])
|
||||
|
||||
// Unset $BROWSER: xdg-open's association is the fallback.
|
||||
const bare: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
osRelease: '6.8.0-generic',
|
||||
env: {},
|
||||
run: async (command, args) => { bare.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(bare).toEqual([['xdg-open', '/w/page.html']])
|
||||
|
||||
// Windows names no browser without the UserChoice registry.
|
||||
const win: string[][] = []
|
||||
await openNativePath('C:\\w\\page.html', new AbortController().signal, {
|
||||
platform: 'win32',
|
||||
run: async (command, args) => { win.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(win[0]?.[0]).toBe('powershell.exe')
|
||||
})
|
||||
|
||||
it('hands browser-renderable WSL paths to the Windows desktop', async () => {
|
||||
const calls: string[][] = []
|
||||
await openNativePath('/home/test/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
osRelease: '5.15.153.1-microsoft-standard-WSL2',
|
||||
env: { BROWSER: 'firefox' },
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args])
|
||||
return {
|
||||
stdout: command === 'wslpath' ? 'C:\\workspace\\page.html\n' : '',
|
||||
stderr: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
['wslpath', '-w', '/home/test/page.html'],
|
||||
[
|
||||
'powershell.exe',
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
"Invoke-Item -LiteralPath 'C:\\workspace\\page.html'",
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canOpenNativePath', () => {
|
||||
it('always answers yes where the desktop is part of the platform', () => {
|
||||
expect(canOpenNativePath({ platform: 'darwin', env: {} })).toBe(true)
|
||||
expect(canOpenNativePath({ platform: 'win32', env: {} })).toBe(true)
|
||||
})
|
||||
|
||||
it('requires a display server or WSL interop on linux', () => {
|
||||
const linux = { platform: 'linux' as const, osRelease: '6.8.0-generic' }
|
||||
// Headless is the case the capability exists for: `xdg-open` would spawn
|
||||
// into nothing, so a surface should show the path as text instead.
|
||||
expect(canOpenNativePath({ ...linux, env: {} })).toBe(false)
|
||||
expect(canOpenNativePath({ ...linux, env: { DISPLAY: ':0' } })).toBe(true)
|
||||
expect(canOpenNativePath({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-0' } })).toBe(true)
|
||||
expect(canOpenNativePath({
|
||||
platform: 'linux', osRelease: '5.15.153.1-microsoft-standard-WSL2', env: {},
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('answers no on a platform the opener does not support', () => {
|
||||
expect(canOpenNativePath({ platform: 'freebsd', env: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('samples the ambient environment when no override is supplied', () => {
|
||||
const env = process.env
|
||||
const marked = (value: string | undefined): boolean => value !== undefined && value !== ''
|
||||
const expected = marked(env.WSL_DISTRO_NAME) || marked(env.WSL_INTEROP)
|
||||
|| marked(env.DISPLAY) || marked(env.WAYLAND_DISPLAY)
|
||||
|
||||
expect(canOpenNativePath({ platform: 'linux', osRelease: '6.8.0-generic' })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
commandListRequestSchema, commandListValueSchema,
|
||||
} from '../src/api/commands.schema.ts'
|
||||
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
|
||||
} from '../src/api/agent-presets.schema.ts'
|
||||
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
||||
@@ -192,11 +195,12 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionHistoryValueSchema.parse({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}).hasMore).toBe(false)
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
@@ -274,8 +278,10 @@ describe('sessions domain schemas', () => {
|
||||
describe('host domain schemas', () => {
|
||||
it('validates describe request/value', () => {
|
||||
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
||||
const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 })
|
||||
expect(value.attachedSessions).toBe(2)
|
||||
const value = hostDescribeValueSchema.parse({
|
||||
version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2,
|
||||
})
|
||||
expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2 })
|
||||
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -392,12 +398,15 @@ describe('skills domain schemas', () => {
|
||||
expect(() => skillListRequestSchema.parse({})).toThrow()
|
||||
expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([])
|
||||
const value = skillListValueSchema.parse({ skills: [
|
||||
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' },
|
||||
{ name: 'bare', description: 'No guidance' },
|
||||
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
|
||||
{ name: 'bare', description: 'No guidance', modelInvocable: false },
|
||||
] })
|
||||
expect(value.skills[0]?.whenToUse).toBe('when committing')
|
||||
expect(value.skills[1]?.whenToUse).toBeUndefined()
|
||||
expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow()
|
||||
expect(value.skills[1]?.modelInvocable).toBe(false)
|
||||
expect(() => skillEntrySchema.parse({ name: '', description: 'd', modelInvocable: true })).toThrow()
|
||||
// modelInvocable is required wire data: an entry without it fails.
|
||||
expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -484,6 +493,7 @@ describe('events frame schemas', () => {
|
||||
} },
|
||||
{ type: 'host/workspace-removed', workspaceId: 'w' },
|
||||
{ type: 'host/commands-changed' },
|
||||
{ type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
@@ -502,3 +512,27 @@ describe('respond payload schemas', () => {
|
||||
expect(payload.sessionId).toBe('s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent-preset schemas', () => {
|
||||
it('accepts a roster row and rejects an unknown trust', () => {
|
||||
expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true }))
|
||||
.toEqual({ id: 'standard', trust: 'system', isDefault: true })
|
||||
expect(() => agentPresetEntrySchema.parse({ id: 'x', trust: 'root', isDefault: false })).toThrow()
|
||||
expect(() => agentPresetEntrySchema.parse({ id: '', trust: 'user', isDefault: false })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts an empty roster', () => {
|
||||
// A deployment composing no presets still reports its authoring and
|
||||
// native-open capabilities, so a surface knows what to offer.
|
||||
expect(agentPresetListValueSchema.parse({ presets: [], authorable: false, hasDocument: false }))
|
||||
.toEqual({ presets: [], authorable: false, hasDocument: false })
|
||||
})
|
||||
|
||||
it('answers the open-document union by its discriminant', () => {
|
||||
expect(agentPresetOpenDocumentValueSchema.parse({ opened: true })).toEqual({ opened: true })
|
||||
expect(agentPresetOpenDocumentValueSchema.parse({ opened: false, path: '/presets/mine' }))
|
||||
.toEqual({ opened: false, path: '/presets/mine' })
|
||||
// A closed reply must carry the path the surface shows instead.
|
||||
expect(() => agentPresetOpenDocumentValueSchema.parse({ opened: false })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
@@ -32,6 +35,12 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-default-model"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
@@ -39,19 +48,19 @@
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection-cache"
|
||||
"path": "../../session/session-projection-cache"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
"path": "../../session/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
@@ -63,13 +72,13 @@
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
"path": "../../interaction/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../workspace/workspace"
|
||||
|
||||
Reference in New Issue
Block a user