Merge pull request #1543 from deepseek-harness/stack/agent-profiles-8-authoring

feat(web): author agent presets from a settings page
This commit is contained in:
Yichen Jiang
2026-08-10 11:55:58 +08:00
committed by GitHub
331 changed files with 16605 additions and 558 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 44b8ee76b0da67ef1aaf24fb3d54e91d20fc064a
README.zh.md: 06b05aa64727c8bce08c7935040a1766f30b98ed
README.md: 98fcdda155286feb23aaf294cab76529ce31cfc1
README.zh.md: 8cfa7e527a327d9c342a5cf6cf28163f5b45df1c

View File

@@ -46,6 +46,10 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
`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 `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 entry point the slash gesture is. 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 front end (web, TUI, ACP, hand-typed text) shares one deterministic path 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 catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, 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.

View File

@@ -46,6 +46,10 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`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` 相同的回环、同源限制。
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust``user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id界面必须能展示并删除它而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent且仅在会话空白时允许一旦跑过任何轮次那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
`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、ACPAgent Client Protocol、手动键入的文本共享同一条确定性路径没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `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` 错误应答这些领域。

View File

@@ -65,15 +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:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "workspace:^"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -5,7 +5,7 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -26,6 +26,11 @@ import {
WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import {
InvalidPresetIdError, PresetExistsError, PresetMountError,
PresetNotWritableError, resolveSessionPreset,
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
@@ -58,6 +63,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
// Side-effect type import: resolves the `approval/request` waterfall and
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
@@ -79,7 +85,7 @@ import {
hasApiRemoteSubagentOwner,
inspectApiRemoteSession,
} from '@deepseek-ai/dsh-api-remotes'
import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -96,8 +102,15 @@ const COLD_SUMMARY_BATCH_SIZE = 16
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
/** Product settings intentionally exposed beside model-provider namespaces. */
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding'])
/**
* Product settings intentionally exposed beside model-provider namespaces.
*
* The agent-preset namespace carries one field — which preset a session with
* no explicit choice is composed from — and both browser surfaces that offer
* that choice write it through `settings.update`, so it has to cross the
* configuration boundary or the pickers silently fail to persist.
*/
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE])
/** Read live abort state across awaits without treating it as synchronously immutable. */
function isAborted(signal: AbortSignal): boolean {
@@ -206,6 +219,35 @@ function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: false, error } }
}
/**
* The RPC refusal a preset failure becomes, or undefined when the failure is
* about something else.
*
* Both the session-create path and the switch path can be handed the same two
* failures, and a client that has to branch on the code needs them worded the
* same from either.
* @param request - the request being answered.
* @param error - the thrown value.
* @returns the refusal, or undefined when the caller should keep handling.
*/
function presetFailure(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> | undefined {
if (error instanceof UnknownPresetError) {
return err(request, {
code: 'agent-preset-not-found',
message: error.message,
details: { agentPreset: error.presetId, available: [...error.available] },
})
}
if (error instanceof PresetMountError) {
return err(request, {
code: 'agent-preset-invalid',
message: error.message,
details: { agentPreset: error.presetId, reason: error.reason },
})
}
return undefined
}
/** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
class FrameQueue<F> {
private buffer: F[] = []
@@ -266,15 +308,21 @@ function sessionBlank(session: Session): boolean {
}
/** Shared Session-header projection for list baselines and creation frames. */
function sessionListFields(header: SessionHeader): {
function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
parentSessionId?: SessionId
origin?: 'subagent'
cwd?: string
agentPreset?: string
} {
// The preset comes from the log, not the header: a session that switched
// while blank ran its turns under the newer composition, and a picker
// showing the creation-time value would contradict what the model saw.
const agentPreset = resolveSessionPreset({ header, events })
return {
...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
...header.origin === undefined ? {} : { origin: header.origin },
...header.cwd === undefined ? {} : { cwd: header.cwd },
...agentPreset === undefined ? {} : { agentPreset },
}
}
@@ -287,7 +335,7 @@ function summarize(session: Session, running: boolean): SessionSummary {
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
running,
blank: sessionBlank(session),
...sessionListFields(session.header),
...sessionListFields(session.header, session.events),
}
}
@@ -321,12 +369,10 @@ async function summarizeCold(
// a cold log to check for turns would defeat the index read, so a listed
// cold session is served as not-blank (its log holds its conversation).
blank: false,
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
...meta.origin === undefined ? {} : { origin: meta.origin },
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
filters those out (legacy logs are not served); the conditional mirrors
summarize() shape. */
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
// Header-only: reading the log for a blank-window preset switch would
// defeat the same index read, and attaching the session replaces this row
// with `summarize()`, which resolves the switch from the events.
...sessionListFields(meta),
}
}
@@ -363,6 +409,14 @@ export interface ApiProxyDefaults {
openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Native text-editor handoff; injectable for settings-document tests. */
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
/**
* Whether handing a path to the native opener can work at all — the
* `hasDocument` capability the preset roster reports, and the switch
* between opening a preset directory and answering its path as text.
* Absent, an injected `openPath` counts as openable and everything else
* falls back to platform detection ({@link canOpenNativePath}).
*/
canOpenPath?: () => boolean
}
/** The tool/call payload fields the presenter path reads. */
@@ -437,11 +491,21 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
* the client's documented default (generic JSON card) covers every miss.
*/
function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
function viewFor(
ctx: Context,
event: SessionEvent,
argsFor: (callId: string) => unknown,
// Presenters live with the definitions, and definitions live in the scope
// chain: a preset registers its tools into its standing layer. A live agent
// is a scope whose chain passes through its preset; a cold read passes the
// preset's standing key directly — no agent, no resume. An undefined scope
// sees only the global layer, which is the pre-preset deployment shape.
scope?: ScopeKey,
): ToolEventView | undefined {
try {
if (event.type === 'tool/call') {
const { name, arguments: raw } = event.data as ToolCallData
const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw))
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
@@ -450,7 +514,7 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
const callId = message.source.callId
const call = argsFor(callId) as { name: string; args: unknown } | undefined
if (call === undefined) return undefined
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, {
const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, {
content: result.content,
isError: result.isError === true,
...meta === undefined ? {} : { meta },
@@ -493,11 +557,12 @@ function historyPage(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number | undefined,
scope?: ScopeKey,
): { events: HistoryEntry[]; hasMore: boolean } {
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
return {
events: page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope)
return { event, ...view === undefined ? {} : { view } }
}),
hasMore: page.hasMore,
@@ -665,6 +730,57 @@ async function catalogChild(
}
}
/**
* The requested preset differs from the one this session already runs.
*
* A session's composition is fixed at creation: its history was produced under
* that preset's tools, so adopting the identity under a different one would
* replay tool calls the rebuilt agent cannot make. Naming a different preset
* is therefore a caller error rather than a switch.
*/
/** The roster is absent: this deployment composes no agent presets at all. */
function noRoster(agentPreset: string): RpcError {
return {
code: 'agent-preset-not-found',
message: 'this deployment composes no agent presets',
details: { agentPreset, available: [] },
}
}
/** Map one authoring/roster failure onto its wire code. */
function presetError(agentPreset: string, error: unknown): RpcError {
if (error instanceof UnknownPresetError) {
return {
code: 'agent-preset-not-found',
message: error.message,
details: { agentPreset: error.presetId, available: [...error.available] },
}
}
if (error instanceof PresetNotWritableError) {
return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } }
}
if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
}
return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
}
class AgentPresetConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedPreset: string,
readonly existingPreset: string | undefined,
) {
super(
existingPreset === undefined
? `session "${sessionId}" records no agent preset, so it cannot be adopted under one; `
+ 'a deployment composing no roster records none on any session — '
: `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; `
+ `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`,
)
}
}
/** Requested identity already belongs to a session with another project cwd. */
class SessionCwdConflict extends Error {
constructor(
@@ -738,6 +854,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection }
const selections = new WeakMap<Agent, WebModelSelectionRef>()
/**
* Serializes `agentPreset.select` per session. Two concurrent selects both
* pass the blank check, and the second `unmountPresetFor` then finds nothing
* to unmount because the first already removed the record — leaving two
* compositions registered into one agent layer. The client's `busy` flag is
* not enforcement: the wire is reachable directly.
*/
const presetSwitches = new Map<SessionId, Promise<unknown>>()
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
const sessionCreations = new Map<SessionId, Promise<Agent>>()
/** Serializes path ownership and explicit title checks with Workspace mutations. */
@@ -794,6 +918,64 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
selectionFor(agent)
}
/**
* Reject an attempt to run an existing session under a different preset.
*
* A caller that names no preset always adopts the session as it is, so the
* common paths — reconnecting, resuming, retrying a create — are unaffected.
* @param sessionId - the identity being adopted.
* @param requested - the preset the request named, if any.
* @param existing - the preset the session was created under, if any.
* @throws when both are present and differ.
*/
function assertPresetUnchanged(
sessionId: SessionId,
requested: string | undefined,
existing: string | undefined,
): void {
if (requested === undefined || requested === existing) return
throw new AgentPresetConflict(sessionId, requested, existing)
}
/**
* Resolve the preset an agent will be composed from, and the setup that
* installs it.
*
* The id is resolved BEFORE the session exists because the session boundary
* snapshots `meta` before asynchronous setup begins — a preset discovered
* during setup could never reach the header. Mounting still happens in
* setup, where a failure rolls the whole creation back rather than leaving a
* published session whose capabilities are half-installed.
*
* A deployment with no preset roster composes nothing and every session
* shares the host composition, which is the behavior before presets existed.
* @param presetId - the requested preset, or `undefined` for the default.
* @returns the id to record on the header (absent without a roster) and the setup callback.
* @throws when the roster supplies no such preset.
*/
async function composeAgent(presetId: string | undefined): Promise<{
agentPreset?: string
setup: (agentCtx: Context) => Promise<void>
}> {
const presets = ctx.get('agentPresets')
if (presets === undefined) {
return {
setup: (agentCtx: Context) => {
installSelection(agentCtx)
return Promise.resolve()
},
}
}
const resolvedId = (await presets.resolve(presetId)).id
return {
agentPreset: resolvedId,
setup: async (agentCtx: Context) => {
installSelection(agentCtx)
await presets.mount(agentCtx, resolvedId)
},
}
}
const hasSubagentOwner = (
session: Pick<Session, 'header'>,
agent: Agent | undefined,
@@ -802,7 +984,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
apiRemoteSubagentOwnershipError(sessionId)
const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> =>
inspectApiRemoteSession(ctx, sessionId)
const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installSelection })
// Cold resume composes the preset the session recorded, for the same reason
// `session.create` does: its history was produced under that composition.
// Every generic entry point — prompt, models, commands — arrives here, so
// leaving it out meant a session opened after a restart ran on host tools
// and the deployment persona. Resolved from the LOG, not the header: a
// session that switched while blank ran its turns under the newer
// composition, and the header is written once at creation. Reading the
// header here would silently undo the switch on the next restart and
// restore that history under the old tool set.
const agentFor = createApiRemoteAgentResolver(ctx, {
agentOptions,
setup: async ({ meta, events }) =>
(await composeAgent(resolveSessionPreset({ header: meta, events }))).setup,
})
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
@@ -1023,23 +1218,61 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async function historyStateFor(
sessionId: SessionId,
includeProjections: boolean,
): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined) {
const events = [...attached.events]
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
return { events, ...projections === undefined ? {} : { projections } }
return { header: attached.header, events, ...projections === undefined ? {} : { projections } }
}
const inspected = await inspectServable(sessionId)
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
return {
header: inspected.meta,
events: inspected.events,
...projections === undefined ? {} : { projections },
}
}
/**
* The registry view scope a transcript's presenters resolve in.
*
* A live agent is that scope itself (its chain passes through its preset's
* standing layer). A cold session names its preset on the header, and the
* preset's STANDING key serves without resuming anything — ensuring the
* mount composes plugins but starts no agent, session, or turn. No roster,
* no recorded preset, or a preset the roster no longer supplies all fall
* back to the global layer: the transcript still serves, with the generic
* cards a viewless entry renders.
* @param sessionId - the transcript being read.
* @param header - that session's header (attached or inspected).
* @returns the scope to pass to presenter lookups, or undefined for global.
*/
async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise<ScopeKey | undefined> {
const live = ctx.get('agents')?.get(sessionId)
if (live !== undefined) return live
const presets = ctx.get('agentPresets')
if (presets === undefined) return undefined
try {
// An unrecorded preset (a log from before the roster existed) renders
// through the DEFAULT preset's standing layer: that is the composition
// an unnamed session composes today, and presenters are pure display,
// so the worst a mismatch produces is the generic card it had anyway.
return await presets.standingKeyFor(header.agentPreset)
} catch {
// Swallows only the unknown/unusable-preset rejection from the roster:
// a deleted or broken preset must degrade this read, never fail it.
return undefined
}
}
/** Resolve one requested identity to a live agent, creating or resuming it once. */
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
async function ensureSession(
sessionId: SessionId,
cwd: string,
checkPersistedIdentity: boolean,
presetId?: string,
): Promise<Agent> {
let creation = sessionCreations.get(sessionId)
if (creation === undefined) {
creation = (async () => {
@@ -1065,10 +1298,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (inspected.meta.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
}
// Resolved from the log, not the header: a session that switched
// while blank ran every turn under the newer composition.
const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
assertPresetUnchanged(sessionId, presetId, storedPreset)
// The stored preset wins over anything the request names: a resumed
// session's history was produced under that composition, and
// rebuilding it differently would replay tool calls the model can no
// longer make.
return (await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: agentOptions(),
setup: installSelection,
setup: (await composeAgent(storedPreset)).setup,
})).agent
}
@@ -1077,11 +1318,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
} catch (error: unknown) {
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
}
const composition = await composeAgent(presetId)
return (await ctx.agents.create({
sessionId,
agentOptions: agentOptions(),
meta: { cwd },
setup: installSelection,
meta: {
cwd,
...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
},
setup: composition.setup,
})).agent
})().catch((error: unknown) => {
// Another Host entry path may have published the same identity while
@@ -1103,6 +1348,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
const agent = await creation
if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
// Beside the cwd check for the same reason, and after the await so it
// covers every path that yields a live agent — freshly created, adopted
// live, resumed from disk, or recovered by the concurrent-creation catch.
assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset)
if (agent.session.header.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
@@ -1194,11 +1443,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return items
}
/** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
const goals = ctx.get('goals')
/**
* Resolve the goal service THIS agent runs.
*
* The service is per session: an agent preset mounts it behind an `isolate`
* realm, which no host context resolves. Reading it from the root would
* answer "absent" for a session whose composition mounts it — so the lookup
* is keyed by the agent, and only a deployment composing it nowhere is
* genuinely absent.
*/
function goalServiceFor(agent: Agent): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
const presets = ctx.get('agentPresets')
const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals')
if (goals === undefined) {
return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } }
return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } }
}
return goals
}
@@ -1214,10 +1472,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
request: RpcRequest<{ sessionId: SessionId }>,
mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
): Promise<RpcResponse<{ ref: GoalRef }>> {
const goals = goalService()
if ('error' in goals) return err(request, goals.error)
const found = await agentFor(request.payload.sessionId)
if ('error' in found) return err(request, found.error)
const goals = goalServiceFor(found.agent)
if ('error' in goals) return err(request, goals.error)
try {
const ref = mutation(goals, found.agent)
return ok(request, { ref: { id: ref.id, revision: ref.revision } })
@@ -1314,6 +1572,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return openTarget(request, path, signal, open)
}
/** Whether this deployment can hand a path to a native opener at all. */
function canOpenPaths(): boolean {
if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
// An injected opener is by definition usable; otherwise ask the platform.
return defaults.openPath !== undefined || canOpenNativePath()
}
/** Missing-service report shared by the credentials domain. */
function credentialsAbsent(): RpcError {
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
@@ -1572,9 +1837,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}
const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
const requestedPreset = request.payload.agentPreset
try {
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
} catch (error: unknown) {
if (error instanceof AgentPresetConflict) {
return err(request, {
code: 'agent-preset-conflict',
message: error.message,
details: {
sessionId: error.sessionId,
requestedPreset: error.requestedPreset,
...error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset },
},
})
}
const refused = presetFailure(request, error)
if (refused !== undefined) return refused
if (error instanceof SessionCwdConflict) {
return err(request, {
code: 'session-conflict',
@@ -1606,12 +1885,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
}
return ok(request, { sessionId })
// Echo the RESOLVED composition so a client can label the session it
// just created without waiting for the next list refresh — the create
// is the commit point that knows it (a caller that named none gets
// the default the header recorded).
const created = ctx.agents.get(sessionId)
const createdPreset = created?.session.header.agentPreset
return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
},
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }
try {
state = await historyStateFor(sessionId, beforeSeq === undefined)
} catch (error: unknown) {
@@ -1624,7 +1909,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header))
return ok(request, {
events: page.events,
hasMore: page.hasMore,
@@ -1766,6 +2051,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
const childId = `session-${randomUUID()}` as SessionId
// The child inherits the parent's composition for the same reason a
// resumed session keeps its own: the seeded history was produced under
// those tools, and composing anything else would strand the tool calls
// it already carries. Now that no model-facing row sits in the host
// plane, composing nothing would leave the child with no tools at all.
const forkComposition = await composeAgent(resolveSessionPreset(source))
try {
await ctx.agents.create({
sessionId: childId,
@@ -1774,9 +2065,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
parentSession: source.id,
seedLength: cut,
...forkComposition.agentPreset === undefined
? {}
: { agentPreset: forkComposition.agentPreset },
},
agentOptions: agentOptions(),
setup: installSelection,
setup: forkComposition.setup,
})
} catch (error: unknown) {
return err(request, {
@@ -2349,10 +2643,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
async clear(request) {
const goals = goalService()
if ('error' in goals) return err(request, goals.error)
const found = await agentFor(request.payload.sessionId)
if ('error' in found) return err(request, found.error)
const goals = goalServiceFor(found.agent)
if ('error' in goals) return err(request, goals.error)
try {
goals.clear(found.agent, request.payload.ref)
return ok(request, { cleared: true as const })
@@ -2362,10 +2656,154 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
agentPresets: {
// A deployment with no roster answers with an empty list rather than an
// error: composing no presets is a valid deployment, and the browser
// simply offers no choice.
async list(request) {
const presets = ctx.get('agentPresets')
if (presets === undefined) return ok(request, { presets: [], authorable: false, hasDocument: false })
const defaultId = presets.defaultId
return ok(request, {
presets: (await presets.list()).map(preset => ({
id: preset.id,
trust: preset.trust,
isDefault: preset.id === defaultId,
...preset.name === undefined ? {} : { name: preset.name },
...preset.description === undefined ? {} : { description: preset.description },
...preset.broken === undefined ? {} : { broken: preset.broken },
})),
authorable: presets.authorable,
hasDocument: canOpenPaths(),
})
},
// Recomposing is limited to a blank session because a started
// conversation's history was produced under its preset's tools; the
// agent and the session survive, only the composition is swapped.
async select(request) {
const { sessionId, agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) {
return err(request, {
code: 'agent-preset-not-found',
message: 'this deployment composes no agent presets',
details: { agentPreset, available: [] },
})
}
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const { agent } = found
const swap = async (): Promise<RpcResponse<{ agentPreset: string }>> => {
// Re-read inside the queue: an earlier switch may have run, and a
// conversation may have started, since this request arrived.
if (!sessionBlank(agent.session)) {
return err(request, {
code: 'agent-preset-locked',
message: `session "${sessionId}" has already started; its agent preset is fixed`,
details: { sessionId, agentPreset },
})
}
try {
const preset = await presets.recompose(agent.ctx, agentPreset)
// Recorded only after the swap committed: the log states what the
// agent runs, and a rejected mount leaves the previous composition.
agent.session.append('agent-preset/selected', { agentPreset: preset.id })
return ok(request, { agentPreset: preset.id })
} catch (error: unknown) {
const refused = presetFailure(request, error)
if (refused !== undefined) return refused
return err(request, {
code: 'internal',
message: `failed to select agent preset "${agentPreset}": ${String(error)}`,
details: {},
})
}
}
const queued = presetSwitches.get(sessionId) ?? Promise.resolve()
const turn = queued.then(swap)
presetSwitches.set(sessionId, turn.catch(() => undefined))
try {
return await turn
} finally {
if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId)
}
},
// Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection):
// 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.
async read(request) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
const preset = await presets.resolve(agentPreset)
return ok(request, {
agentPreset: preset.id,
trust: preset.trust,
content: await presets.read(preset.id),
...preset.name === undefined ? {} : { name: preset.name },
...preset.description === undefined ? {} : { description: preset.description },
})
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async copy(request) {
const { from, agentPreset, name } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
await presets.copy(from, agentPreset, name)
return ok(request, { agentPreset })
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async openDocument(request, signal) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
const preset = await presets.resolve(agentPreset)
// Same line as copy/remove draw: the shipped install is not the
// user's to manage, and pointing an editor into it invites edits an
// upgrade will silently overwrite.
if (preset.trust !== 'user') {
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
}
// The id resolved against the Host's own roots is what selects the
// directory — no browser payload carries a path in either direction
// unless the deployment has no opener to hand it to.
const directory = dirname(preset.path)
if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory })
return await openPath(request, directory, signal)
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async remove(request) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
await presets.remove(agentPreset)
return ok(request, {})
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
},
skills: {
// Skill lookup never touches the Agent registry: the session address
// resolves to a canonical cwd from the host-resident session header, so
// listing skills cannot create or resume an agent as a side effect.
// Skill lookup never creates or resumes an agent: the session address
// resolves to a canonical cwd from the host-resident session header, and
// the view scope is the live agent or the preset's standing key.
async list(request) {
const { sessionId } = request.payload
const session = ctx.sessions.get(sessionId)
@@ -2382,17 +2820,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
}
const cwd = session.header.cwd
// Same stance as the commands domain: a missing service means the
// deployment omitted dsh-skill from its composition, not an empty
// catalog. ctx.get also keeps this handler independent of the gateway
// plugin's inject list (an undeclared `ctx.skills` property read
// fails the reflect proxy).
const skillRegistry = ctx.get('skills')
// The host registry is layered per scope and serves every session. A
// composition may still realm-mount its own registry instead; that
// instance is invisible to host contexts, so address it through the
// live agent (`agents.get` keeps the no-side-effect stance above).
const live = ctx.agents.get(sessionId)
const presets = ctx.get('agentPresets')
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
// Same stance as the commands domain: a missing service means no
// composition mounts dsh-skill, not an empty catalog. `ctx.get` also
// keeps this handler independent of the gateway plugin's inject list
// (an undeclared `ctx.skills` property read fails the reflect proxy).
const skillRegistry = scoped ?? ctx.get('skills')
if (skillRegistry === undefined) {
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
}
// The scope presenters resolve in — the live agent, else the recorded
// preset's standing key, else the global layer — so a cold session's
// '/' popup lists the catalog its composition actually serves.
const scope = await presenterScopeFor(sessionId, session.header)
try {
const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable)
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
return ok(request, {
skills: skills.map(skill => ({
name: skill.name,
@@ -2622,8 +3070,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
} else if (event.type === 'turn/end') {
openCalls.delete(session.id)
}
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
const view = viewFor(
ctx, event,
callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId),
ctx.agents.get(session.id),
)
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
}),
ctx.on('session/created', (session: Session) => {
@@ -2657,7 +3108,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// has run no turn yet, so this is constantly true in practice.
blank: sessionBlank(session),
// Including cwd lets the client group the new session without refreshing the list.
...sessionListFields(session.header),
...sessionListFields(session.header, session.events),
}))
}),
ctx.on('session/disposed', (session: Session) => {

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

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

View File

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

View File

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

View File

@@ -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
@@ -48,6 +50,7 @@ export type {
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'

View File

@@ -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'
@@ -51,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']

View File

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

View File

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

View File

@@ -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). */

View File

@@ -165,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
@@ -208,9 +215,16 @@ 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

View File

@@ -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,
@@ -121,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>>
@@ -188,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,
@@ -447,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),

View File

@@ -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,
@@ -111,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) },

View File

@@ -38,6 +38,14 @@ declare module 'cordis' {
export interface Config {
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: 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
}
/**
@@ -53,6 +61,7 @@ export class ApiProxyService extends Service implements ApiProxy {
static Config: z<Config> = z.object({
workspaceRoot: z.string(),
nativeOpen: z.boolean(),
})
readonly sessions: ApiProxy['sessions']
@@ -62,6 +71,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']
@@ -76,6 +86,7 @@ export class ApiProxyService extends Service implements ApiProxy {
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
cwd,
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
})
this.sessions = api.sessions
this.subagents = api.subagents
@@ -84,6 +95,7 @@ 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

View File

@@ -152,6 +152,25 @@ async function openNativePathWithIntent(
throw new Error(`native path opener is unsupported on ${platform}`)
}
/**
* 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.

View File

@@ -0,0 +1,652 @@
/**
* 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 {
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,
workspaceRoot: 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('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')
}
})
})

View File

@@ -356,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)

View File

@@ -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']>
@@ -88,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,
@@ -222,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: {

View File

@@ -197,6 +197,32 @@ 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', modelInvocable: true }] } } }
@@ -340,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) => {

View File

@@ -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
@@ -287,3 +287,35 @@ describe('browser-renderable documents', () => {
])
})
})
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)
})
})

View File

@@ -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'
@@ -508,3 +511,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()
})
})

View File

@@ -38,6 +38,9 @@
{
"path": "../../core/agent-default-model"
},
{
"path": "../../preset/agent-presets"
},
{
"path": "../../core/session"
},