diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 331fd66a3f..a06cbd8903 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -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 docs/config-catalog.md
-config-catalog.md: aaadee78a5de4a42fc28566215b9fff70a5caca4
-config-catalog.zh.md: 393857b50cb7e750eb90fc6e3a605559a5ce2cad
+config-catalog.md: 74834994902bd5fe1e75653ab5645aef5bf587be
+config-catalog.zh.md: 77c83f46128a54b1a8f917e82ce3c6750060fcec
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index aaadee78a5..7483499490 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -155,7 +155,7 @@ export interface PresetRoot {
export type PresetTrust = 'system' | 'user'
```
-Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts)
+Source: [`packages/preset/agent-presets/src/preset.ts:54`](../packages/preset/agent-presets/src/preset.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 393857b50c..77c83f4612 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -157,7 +157,7 @@ export interface PresetRoot {
export type PresetTrust = 'system' | 'user'
```
-来源:[`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts)
+来源:[`packages/preset/agent-presets/src/preset.ts:54`](../packages/preset/agent-presets/src/preset.ts)
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index d217fb3d03..0f42ef8ea1 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.i18n.yaml
@@ -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 docs/event-producer-consumer.md
-event-producer-consumer.md: 6353c42953fcdd460749bd19197b3bab0b936b66
-event-producer-consumer.zh.md: 9879e6a3141ed078976beede6f1192867ee3d26b
+event-producer-consumer.md: ff4a530f6071824da89c88a30a9744695f4d4eef
+event-producer-consumer.zh.md: 074597f038b215731e8c005afcd2fed1bc8c4bd4
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 6353c42953..ff4a530f60 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
-| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
+| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 9879e6a314..074597f038 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -10,7 +10,7 @@
| 事件 | 模式 | 声明位置 | 派发方 | 监听方 |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
-| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
+| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index ae3d6422d8..e561402860 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.i18n.yaml
@@ -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 docs/module-graph.md
-module-graph.md: e044939e4b48dee60faa03ff955c713e88ddaefa
-module-graph.zh.md: d707bd63394b94f511ca40484e23e9e629521515
+module-graph.md: f2c8a499fc54ac90bd9b9a8a7f155ee56ad94dc7
+module-graph.zh.md: ff0d0c72020ed8cd23d6701aaac6e0125174e501
diff --git a/docs/module-graph.md b/docs/module-graph.md
index e044939e4b..f2c8a499fc 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -896,6 +896,7 @@ flowchart TD
pkg_agent_presets --> pkg_settings
pkg_agent_presets --> pkg_system_prompt
pkg_agent_presets --> pkg_tools
+ pkg_agent_presets --> pkg_typert_protocol
pkg_schedule --> pkg_agent
pkg_schedule --> pkg_brand
pkg_schedule --> pkg_invariants
@@ -1822,7 +1823,7 @@ flowchart TD
| [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index d707bd6339..ff0d0c7202 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -898,6 +898,7 @@ flowchart TD
pkg_agent_presets --> pkg_settings
pkg_agent_presets --> pkg_system_prompt
pkg_agent_presets --> pkg_tools
+ pkg_agent_presets --> pkg_typert_protocol
pkg_schedule --> pkg_agent
pkg_schedule --> pkg_brand
pkg_schedule --> pkg_invariants
@@ -1824,7 +1825,7 @@ flowchart TD
| [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml
index 1b16dff02b..f5d61a2ed8 100644
--- a/docs/subsystems/core.i18n.yaml
+++ b/docs/subsystems/core.i18n.yaml
@@ -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 docs/subsystems/core.md
-core.md: 15135ad1dcacd762fbf31668c13ea0760cd9bc95
-core.zh.md: 7e0270a49f11ef01e6ffb2ca8e1af5831a738198
+core.md: 6490712bc3c90686ba2cbd787a9f21f59b9f9f3d
+core.zh.md: 79a2b5d1e9090fe56ea3807d4cf42d0f87c6a48b
diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md
index 15135ad1dc..6490712bc3 100644
--- a/docs/subsystems/core.md
+++ b/docs/subsystems/core.md
@@ -400,6 +400,16 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
*/
async list(): Promise
+/**
+ * The roster off the Host: {@link list} projected to path-free rows, with
+ * the default marked and this deployment's authoring capability beside it.
+ *
+ * Whether a client can open a preset's directory is the Host's own opener
+ * capability, not a roster property — a caller needing both joins them.
+ * @returns the rows and the authoring capability.
+ */
+@Remote('list') async remoteExportList(): Promise
+
/**
* Resolve one preset by id.
*
@@ -474,6 +484,15 @@ composedPreset(agentCtx: Context): string | undefined
*/
async read(id: string): Promise
+/**
+ * One preset's composition text with the roster row it belongs to.
+ * @param agentPreset - the preset id.
+ * @returns the composition beside its trust and published metadata.
+ * @throws {TypertRemoteFailure} `bad-request` for an empty id, or
+ * `agent-preset-not-found` when no configured root supplies it.
+ */
+@Remote('read') async readDocument(agentPreset: string): Promise
+
/**
* Create a locally authored preset by copying an existing one whole.
*
@@ -491,13 +510,34 @@ async read(id: string): Promise
*/
async copy(from: string, id: string, name?: string): Promise
+/**
+ * Copy one preset through the Remote API.
+ * @param from - the source preset id.
+ * @param id - the new preset id.
+ * @param name - the copy's optional display name.
+ * @returns once the copy is stored.
+ * @throws {TypertRemoteFailure} with the corresponding stable preset code
+ * and details when the copy is refused.
+ */
+@Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise
+
/**
* Delete a locally authored preset.
+ *
* @param id - the preset id.
* @throws when the preset is unknown or ships with the deployment.
*/
async remove(id: string): Promise
+/**
+ * Delete one preset through the Remote API.
+ * @param id - the preset id.
+ * @returns once the preset is deleted.
+ * @throws {TypertRemoteFailure} with the corresponding stable preset code
+ * and details when deletion is refused.
+ */
+@Remote('deletePreset') async remoteExportDelete(id: string): Promise
+
/**
* One agent's instance of a service its preset mounted.
*
@@ -540,6 +580,16 @@ serviceFor(agent: { ctx: Context }, name: K):
*/
async recompose(agentCtx: Context, id: string): Promise
+/**
+ * Compose a blank session's agent from a different preset and record it.
+ * @param agent - the session's live agent, resolved from the wire identity.
+ * @param agentPreset - the preset to compose the agent from instead.
+ * @returns the preset id that was recorded.
+ * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`,
+ * `agent-preset-not-found`, or `agent-preset-invalid` when refused.
+ */
+@Remote('select') async select(agent: Agent, agentPreset: string): Promise
+
/**
* The standing scope key of one preset, for a host reader with no agent.
*
diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md
index 7e0270a49f..79a2b5d1e9 100644
--- a/docs/subsystems/core.zh.md
+++ b/docs/subsystems/core.zh.md
@@ -410,6 +410,16 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
*/
async list(): Promise
+/**
+ * The roster off the Host: {@link list} projected to path-free rows, with
+ * the default marked and this deployment's authoring capability beside it.
+ *
+ * Whether a client can open a preset's directory is the Host's own opener
+ * capability, not a roster property — a caller needing both joins them.
+ * @returns the rows and the authoring capability.
+ */
+@Remote('list') async remoteExportList(): Promise
+
/**
* Resolve one preset by id.
*
@@ -484,6 +494,15 @@ composedPreset(agentCtx: Context): string | undefined
*/
async read(id: string): Promise
+/**
+ * One preset's composition text with the roster row it belongs to.
+ * @param agentPreset - the preset id.
+ * @returns the composition beside its trust and published metadata.
+ * @throws {TypertRemoteFailure} `bad-request` for an empty id, or
+ * `agent-preset-not-found` when no configured root supplies it.
+ */
+@Remote('read') async readDocument(agentPreset: string): Promise
+
/**
* Create a locally authored preset by copying an existing one whole.
*
@@ -501,13 +520,34 @@ async read(id: string): Promise
*/
async copy(from: string, id: string, name?: string): Promise
+/**
+ * Copy one preset through the Remote API.
+ * @param from - the source preset id.
+ * @param id - the new preset id.
+ * @param name - the copy's optional display name.
+ * @returns once the copy is stored.
+ * @throws {TypertRemoteFailure} with the corresponding stable preset code
+ * and details when the copy is refused.
+ */
+@Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise
+
/**
* Delete a locally authored preset.
+ *
* @param id - the preset id.
* @throws when the preset is unknown or ships with the deployment.
*/
async remove(id: string): Promise
+/**
+ * Delete one preset through the Remote API.
+ * @param id - the preset id.
+ * @returns once the preset is deleted.
+ * @throws {TypertRemoteFailure} with the corresponding stable preset code
+ * and details when deletion is refused.
+ */
+@Remote('deletePreset') async remoteExportDelete(id: string): Promise
+
/**
* One agent's instance of a service its preset mounted.
*
@@ -550,6 +590,16 @@ serviceFor(agent: { ctx: Context }, name: K):
*/
async recompose(agentCtx: Context, id: string): Promise
+/**
+ * Compose a blank session's agent from a different preset and record it.
+ * @param agent - the session's live agent, resolved from the wire identity.
+ * @param agentPreset - the preset to compose the agent from instead.
+ * @returns the preset id that was recorded.
+ * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`,
+ * `agent-preset-not-found`, or `agent-preset-invalid` when refused.
+ */
+@Remote('select') async select(agent: Agent, agentPreset: string): Promise
+
/**
* The standing scope key of one preset, for a host reader with no agent.
*
diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts
index 859230eff7..6956336198 100644
--- a/packages/api/remotes/src/client/index.ts
+++ b/packages/api/remotes/src/client/index.ts
@@ -1,6 +1,7 @@
/** Platform-neutral assembly of generated Host Remote contributions. */
import type { Context } from '@deepseek-ai/cordis'
+import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
@@ -14,6 +15,7 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
+export type {} from '@deepseek-ai/dsh-agent-presets/remote'
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-file-reference/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
@@ -106,6 +108,7 @@ export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-
/** Failure vocabulary exposed by the assembled Client data layer. */
export type ClientFailure =
| import('@deepseek-ai/dsh-client-connection/client').RpcError
+ | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError
| import('@deepseek-ai/dsh-api-session-controller/types').SessionError
| import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError
@@ -133,7 +136,7 @@ export async function apply(ctx: Context): Promise<() => Promise> {
const disposers: Array<() => Promise> = []
try {
for (const contribution of [
- commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
+ agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
sessionRemote, workspaceRemote,
]) {
diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts
index a08af90309..b3aa0fe29d 100644
--- a/packages/api/session-controller/tests/fake-api.client.ts
+++ b/packages/api/session-controller/tests/fake-api.client.ts
@@ -237,19 +237,8 @@ export class FakeApiClient implements IApiClient {
readonly agentPresets: IApiClient['agentPresets'] = {
- list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
- select: (payload: { agentPreset: string }) =>
- this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
- read: (payload: { agentPreset: string }) =>
- this.record('agentPreset.read', payload, Promise.resolve(ok({
- agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
- }))),
- copy: (payload: { agentPreset: string }) =>
- this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
openDocument: (payload: { agentPreset: string }) =>
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
- remove: (payload: { agentPreset: string }) =>
- this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
}
readonly skills: IApiClient['skills'] = {
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index ce2fcdae6a..c02f0e11bc 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -2273,6 +2273,82 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
return { ok: true, value: goalView(projection) }
}
+ /** Canonical fixture implementation of the generated AgentPresets Remote contract. */
+ const presetRemotes = {
+ // Both trusts appear, because a surface must present a locally authored
+ // preset differently from one the deployment vetted.
+ list(): RpcResult<{ presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[]; authorable: boolean }> {
+ return {
+ ok: true,
+ value: {
+ presets: [...fixturePresets].map(([id, preset]) => ({
+ id,
+ trust: preset.trust,
+ isDefault: id === fixtureDefaultPreset,
+ })),
+ authorable: true,
+ },
+ }
+ },
+ select(_id: SessionId, agentPreset: string): RpcResult {
+ fixtureDefaultPreset = agentPreset
+ return { ok: true, value: agentPreset }
+ },
+ read(agentPreset: string): RpcResult<{ agentPreset: string; trust: 'system' | 'user'; content: string }> {
+ const preset = fixturePresets.get(agentPreset)
+ if (preset === undefined) {
+ return {
+ ok: false,
+ error: {
+ code: 'agent-preset-not-found',
+ message: `unknown agent preset "${agentPreset}"`,
+ details: { agentPreset, available: [...fixturePresets.keys()] },
+ },
+ }
+ }
+ return { ok: true, value: { agentPreset, trust: preset.trust, content: preset.content } }
+ },
+ copy(from: string, id: string): RpcResult {
+ const source = fixturePresets.get(from)
+ if (source === undefined) {
+ return {
+ ok: false,
+ error: {
+ code: 'agent-preset-not-found',
+ message: `unknown agent preset "${from}"`,
+ details: { agentPreset: from, available: [...fixturePresets.keys()] },
+ },
+ }
+ }
+ if (fixturePresets.has(id)) {
+ return {
+ ok: false,
+ error: {
+ code: 'agent-preset-invalid',
+ message: `agent preset "${id}" already exists`,
+ details: { agentPreset: id, reason: 'already exists' },
+ },
+ }
+ }
+ fixturePresets.set(id, { trust: 'user', content: source.content })
+ return { ok: true, value: undefined }
+ },
+ deletePreset(id: string): RpcResult {
+ if (fixturePresets.get(id)?.trust === 'system') {
+ return {
+ ok: false,
+ error: {
+ code: 'agent-preset-read-only',
+ message: `agent preset "${id}" ships with the deployment`,
+ details: { agentPreset: id, reason: 'it ships with the deployment' },
+ },
+ }
+ }
+ fixturePresets.delete(id)
+ return { ok: true, value: undefined }
+ },
+ }
+
/** At most one in-flight replay per session; cancel clears it. */
const replays = new Map; finish(aborted: boolean): void }>()
@@ -3234,57 +3310,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
openPath: request => ok(request, { opened: true as const }),
},
agentPresets: {
- // Both trusts appear, because a surface must present a locally authored
- // preset differently from one the deployment vetted.
- list: request => ok(request, {
- presets: [...fixturePresets].map(([id, preset]) => ({
- id,
- trust: preset.trust,
- isDefault: id === fixtureDefaultPreset,
- })),
- authorable: true,
- hasDocument: true,
- }),
- select: (request) => {
- fixtureDefaultPreset = request.payload.agentPreset
- return ok(request, { agentPreset: request.payload.agentPreset })
- },
- read: (request) => {
- const { agentPreset } = request.payload
- const preset = fixturePresets.get(agentPreset)
- if (preset === undefined) {
- return err(request, {
- code: 'agent-preset-not-found',
- message: `unknown agent preset "${agentPreset}"`,
- details: { agentPreset, available: [...fixturePresets.keys()] },
- })
- }
- return ok(request, {
- agentPreset,
- trust: preset.trust,
- content: preset.content,
- })
- },
- copy: (request) => {
- const { from, agentPreset } = request.payload
- const source = fixturePresets.get(from)
- if (source === undefined) {
- return err(request, {
- code: 'agent-preset-not-found',
- message: `unknown agent preset "${from}"`,
- details: { agentPreset: from, available: [...fixturePresets.keys()] },
- })
- }
- if (fixturePresets.has(agentPreset)) {
- return err(request, {
- code: 'agent-preset-invalid',
- message: `agent preset "${agentPreset}" already exists`,
- details: { agentPreset, reason: 'already exists' },
- })
- }
- fixturePresets.set(agentPreset, { trust: 'user', content: source.content })
- return ok(request, { agentPreset })
- },
// Native opens are deterministic no-op successes in this fixture, so the
// open-directory affordance renders and the path-text fallback stays a
// component-test concern.
@@ -3300,19 +3325,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
}
return ok(request, { opened: true as const })
},
- remove: (request) => {
- const { agentPreset } = request.payload
- const existing = fixturePresets.get(agentPreset)
- if (existing?.trust === 'system') {
- return err(request, {
- code: 'agent-preset-read-only',
- message: `agent preset "${agentPreset}" ships with the deployment`,
- details: { agentPreset, reason: 'it ships with the deployment' },
- })
- }
- fixturePresets.delete(agentPreset)
- return ok(request, {})
- },
},
skills: {
@@ -3422,6 +3434,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
query?: string
images?: readonly unknown[]
ref?: { id: string; revision: number }
+ agentPreset?: string
+ from?: string
+ id?: string
request?: unknown
_request?: unknown
}>
@@ -3449,6 +3464,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef))
case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef))
case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef))
+ case 'agentPresets/list': return Promise.resolve(presetRemotes.list())
+ case 'agentPresets/select': return Promise.resolve(presetRemotes.select(sessionId, args.agentPreset as string))
+ case 'agentPresets/read': return Promise.resolve(presetRemotes.read(args.agentPreset as string))
+ case 'agentPresets/copy': return Promise.resolve(presetRemotes.copy(args.from as string, args.id as string))
+ case 'agentPresets/deletePreset': return Promise.resolve(presetRemotes.deletePreset(args.id as string))
case 'session/list': return sessionApi.list(
args._request as Parameters[0],
)
@@ -3580,12 +3600,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'host.createDirectory': return this.api.host.createDirectory(request)
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
case 'skill.list': return this.api.skills.list(request)
- case 'agentPreset.list': return this.api.agentPresets.list(request)
- case 'agentPreset.select': return this.api.agentPresets.select(request)
- case 'agentPreset.read': return this.api.agentPresets.read(request)
- case 'agentPreset.copy': return this.api.agentPresets.copy(request)
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
- case 'agentPreset.remove': return this.api.agentPresets.remove(request)
case 'settings.describe': return this.api.settings.describe(request)
case 'settings.openDocument': return this.api.settings.openDocument(request, signal)
case 'settings.update': return this.api.settings.update(request)
diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts
index 1d0266b8d0..90d042f171 100644
--- a/packages/client/connection/tests/fake-api.client.ts
+++ b/packages/client/connection/tests/fake-api.client.ts
@@ -97,19 +97,8 @@ export class FakeApiClient implements IApiClient {
readonly agentPresets: IApiClient['agentPresets'] = {
- list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
- select: (payload: { agentPreset: string }) =>
- this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
- read: (payload: { agentPreset: string }) =>
- this.record('agentPreset.read', payload, Promise.resolve(ok({
- agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
- }))),
- copy: (payload: { agentPreset: string }) =>
- this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
openDocument: (payload: { agentPreset: string }) =>
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
- remove: (payload: { agentPreset: string }) =>
- this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
}
readonly skills: IApiClient['skills'] = {
diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts
index 75691394ed..e1cb7abe83 100644
--- a/packages/client/connection/tests/node-half.host.spec.ts
+++ b/packages/client/connection/tests/node-half.host.spec.ts
@@ -176,7 +176,7 @@ describe('connection node half', () => {
const methods = [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set',
- 'llm.discoverModels', 'llm.models', 'agentPreset.read', 'agentPreset.list',
+ 'llm.discoverModels', 'llm.models', 'agentPreset.openDocument',
]
for (const method of methods) {
const denied = fakeResponse()
@@ -505,8 +505,8 @@ describe('connection node half over a real HTTP server', () => {
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
'llm.discoverModels',
- 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
- 'llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select',
+ 'agentPreset.openDocument',
+ 'llm.providers', 'llm.models',
]
for (const method of methods) {
expect([method, await call(port, method, 'localhost')]).toEqual([method, 401])
diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts
index 28a50c034f..b8bcecdcce 100644
--- a/packages/client/ui-agent-preset/src/client/index.ts
+++ b/packages/client/ui-agent-preset/src/client/index.ts
@@ -50,7 +50,7 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
/** Required services (cordis fiber inject). */
-export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
+export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope']
/**
* Mount the General-settings row.
@@ -58,11 +58,11 @@ export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope
*/
export function apply(ctx: ClientContext): void {
const { api } = ctx.get('connection') as ConnectionHandle
- const controller = new AgentPresetSettingsController(api, ctx.settingsScope.describe())
+ const controller = new AgentPresetSettingsController(api, ctx.remote, ctx.settingsScope.describe())
// One roster, four surfaces. The chip is registered in a later scope, so it
// subscribes here rather than being reached from this one.
const rosterReaders = new Set<() => void>()
- const section = new AgentPresetSectionController(api, () => {
+ const section = new AgentPresetSectionController(api, ctx.remote, () => {
void controller.load()
for (const read of rosterReaders) read()
})
@@ -104,8 +104,7 @@ export function apply(ctx: ClientContext): void {
// The new-session chip and the header label: one controller, because the
// staged choice belongs to the flow rather than to any one session.
ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => {
- const api = (scope.get('connection') as ConnectionHandle).api
- const seat = new AgentPresetSeatController(api, () => {
+ const seat = new AgentPresetSeatController(scope.remote, () => {
const state = scope.sessions.list.getSnapshot()
return state.current === undefined ? undefined : state.byId[state.current]
})
diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts
index b16e803ac5..8e2714bce1 100644
--- a/packages/client/ui-agent-preset/src/client/seat-store.ts
+++ b/packages/client/ui-agent-preset/src/client/seat-store.ts
@@ -10,11 +10,11 @@
* deployment default again, matching the workspace picker beside it.
*/
-import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type {} from '@deepseek-ai/dsh-agent-presets/types'
-import { messageOf, presetOptions } from './settings-store.ts'
+import { messageOf, presetOptions, readRoster } from './settings-store.ts'
import type { AgentPresetOption } from './settings-store.ts'
/** Hero-chip snapshot. */
@@ -53,7 +53,7 @@ export class AgentPresetSeatController {
private staged: string | undefined
constructor(
- private readonly api: Pick,
+ private readonly remote: Pick,
/** The session the hero is about to hand over to, when there is one. */
private readonly currentSession: () => Pick<
SessionSummary,
@@ -67,32 +67,28 @@ export class AgentPresetSeatController {
/**
* Read the roster and open the chip on the deployment default.
- * @returns once the snapshot reflects the host.
- */
+ * @returns once the snapshot reflects the host.
+ */
async load(): Promise {
- try {
- const response = await this.api.agentPresets.list({})
- if (!response.result.ok) {
- this.set({ error: response.result.error.message })
- return
- }
- const { presets } = response.result.value
- this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
- const session = this.currentSession()
- this.set({
- options: presetOptions(presets),
- // Staged pick first, then the composition the current session
- // already carries, then the deployment default. The middle term is
- // what keeps a late-landing load from regressing the display after
- // an applied stage was consumed — the chip mounts (and loads) only
- // once the flow's session is current, so the reply can arrive after
- // apply() already composed it.
- current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
- error: null,
- })
- } catch (error) {
- this.set({ error: messageOf(error) })
+ const roster = await readRoster(this.remote)
+ if (!roster.ok) {
+ this.set({ error: roster.error })
+ return
}
+ const { presets } = roster.value
+ this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
+ const session = this.currentSession()
+ this.set({
+ options: presetOptions(presets),
+ // Staged pick first, then the composition the current session
+ // already carries, then the deployment default. The middle term is
+ // what keeps a late-landing load from regressing the display after
+ // an applied stage was consumed — the chip mounts (and loads) only
+ // once the flow's session is current, so the reply can arrive after
+ // apply() already composed it.
+ current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
+ error: null,
+ })
}
/**
@@ -153,18 +149,18 @@ export class AgentPresetSeatController {
}
this.set({ busy: true, error: null })
try {
- const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged })
+ const result = await this.remote.agentPresets.select(session.id, staged)
this.staged = undefined
- if (!response.result.ok) {
+ if (!result.ok) {
this.set({
busy: false,
- error: response.result.error.message,
+ error: result.error.message,
current: presetOf(session) ?? '',
})
return
}
// Consumed: the next new session opens on the deployment default again.
- this.set({ busy: false, current: response.result.value.agentPreset })
+ this.set({ busy: false, current: result.value })
} catch (error) {
this.staged = undefined
this.set({
diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts
index a4af289990..0d034cd833 100644
--- a/packages/client/ui-agent-preset/src/client/section-store.ts
+++ b/packages/client/ui-agent-preset/src/client/section-store.ts
@@ -14,7 +14,7 @@
* more than the row it targeted.
*/
-import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'
@@ -133,7 +133,8 @@ export class AgentPresetSectionController {
readonly store: SnapshotStore = createSnapshotStore(INITIAL)
constructor(
- private readonly api: Pick,
+ private readonly api: Pick,
+ private readonly remote: Pick,
/**
* Called after this page changes the roster DIRECTORY, so the other
* surfaces reading the same roster re-read it. A settings field moving is
@@ -162,9 +163,18 @@ export class AgentPresetSectionController {
* @returns once the snapshot reflects the host.
*/
async load(): Promise {
- const roster = await beginRosterRead(this.api, this.store)
+ // Whether a preset's directory can be opened is the Host's opener
+ // capability rather than a roster property, so the page joins the two.
+ // Issued together: one round trip decides the page, and a load that waited
+ // for them in turn would hold the section in `loading` twice as long,
+ // where a concurrent reload silently returns instead of refreshing.
+ const opener = this.api.host.describe({})
+ const roster = await beginRosterRead(this.remote, this.store)
+ // A refused describe leaves the reveal-the-path path, which needs no opener.
+ const described = await opener.catch(() => undefined)
if (roster === undefined) return
- const { presets, authorable, hasDocument } = roster
+ const { presets, authorable } = roster
+ const hasDocument = described?.result.ok === true && described.result.value.canOpenPath
if (presets.length === 0) {
// Nothing to manage leaves nothing to keep a dialog open over.
this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null })
@@ -193,12 +203,12 @@ export class AgentPresetSectionController {
async view(id: string): Promise {
this.set({ error: null })
try {
- const response = await this.api.agentPresets.read({ agentPreset: id })
- if (!response.result.ok) {
- this.set({ error: response.result.error.message })
+ const result = await this.remote.agentPresets.read(id)
+ if (!result.ok) {
+ this.set({ error: result.error.message })
return
}
- const { name, content } = response.result.value
+ const { name, content } = result.value
this.set({ view: { id, title: name ?? id, content } })
} catch (error) {
this.set({ error: messageOf(error) })
@@ -256,13 +266,14 @@ export class AgentPresetSectionController {
this.patchCopy({ saving: true, error: null })
try {
const name = draft.name.trim()
- const response = await this.api.agentPresets.copy({
- from: draft.from,
- agentPreset: draft.id,
- ...name === '' ? {} : { name },
- })
- if (!response.result.ok) {
- this.patchCopy({ saving: false, error: response.result.error.message })
+ // Every declared parameter is passed even when optional: the Remote face
+ // checks arity against the declaration and rejects a short call. An
+ // empty display name goes as `undefined` — absent rather than empty, so
+ // the host falls back to the id instead of labelling the row with ''.
+ const result = await this.remote.agentPresets.copy(
+ draft.from, draft.id, name === '' ? undefined : name)
+ if (!result.ok) {
+ this.patchCopy({ saving: false, error: result.error.message })
return
}
this.set({ copy: null })
@@ -318,9 +329,9 @@ export class AgentPresetSectionController {
if (pendingDelete === null || deleting) return
this.set({ deleting: true, error: null })
try {
- const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete })
- if (!response.result.ok) {
- this.set({ deleting: false, pendingDelete: null, error: response.result.error.message })
+ const result = await this.remote.agentPresets.deletePreset(pendingDelete)
+ if (!result.ok) {
+ this.set({ deleting: false, pendingDelete: null, error: result.error.message })
return
}
this.set({ deleting: false, pendingDelete: null })
diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts
index 8c7e7444f1..cbae273243 100644
--- a/packages/client/ui-agent-preset/src/client/settings-store.ts
+++ b/packages/client/ui-agent-preset/src/client/settings-store.ts
@@ -1,14 +1,15 @@
/**
* Agent-preset default-settings controller.
*
- * Options and the current default both come from one `agentPreset.list` call:
+ * Options and the current default both come from one `agentPresets.list` call:
* the roster already reports which id a session with no explicit choice gets,
* so the row needs no schema introspection. Writes target the settings
* namespace's `default` field, which is what the host resolves at creation.
*/
-import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types'
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
/** The agent-preset settings namespace on the host wire. */
@@ -63,33 +64,12 @@ export interface AgentPresetOption {
}
/** One roster entry exactly as the host reports it. */
-export interface RosterPreset {
- /** Preset id and directory name. */
- id: string
- /** Whether the preset ships with the deployment or was authored locally. */
- trust: 'system' | 'user'
- /** Whether a session that names no preset gets this one. */
- isDefault: boolean
- /** Display name the preset published, absent when it published none. */
- name?: string
- /** One sentence on what the preset is for. */
- description?: string
- /** Why the preset cannot compose a session, absent when it can. */
- broken?: string
-}
-
-/** The roster the host answered with. */
-export interface RosterValue {
- /** Every preset the deployment composes, in the order the host lists them. */
- presets: readonly RosterPreset[]
- /** Whether this browser may author presets at all. */
- authorable: boolean
- /** Whether the host can open a preset directory on a native desktop. */
- hasDocument: boolean
-}
+export type RosterPreset = AgentPresetRoster['presets'][number]
/** The roster, or the message to show in its place. */
-export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error: string }
+export type RosterRead = { ok: true; value: AgentPresetRoster } | { ok: false; error: string }
+
+const EMPTY_ROSTER: AgentPresetRoster = { presets: [], authorable: false }
/**
* Read the roster, folding both refusal shapes into one message.
@@ -98,15 +78,18 @@ export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error:
* `ok: false` envelope — and every surface treats them identically. Folding
* them here keeps each store's `load` about what it does with a roster rather
* than about how the call can fail.
- * @param api - the agent-preset wire face.
+ * @param remote - the agent-preset Remote namespace.
* @returns the roster, or the message to show in its place.
*/
-export async function readRoster(api: Pick): Promise {
+export async function readRoster(remote: Pick): Promise {
try {
- const response = await api.agentPresets.list({})
- return response.result.ok
- ? { ok: true, value: response.result.value }
- : { ok: false, error: response.result.error.message }
+ const result = await remote.agentPresets.list()
+ if (result.ok) return { ok: true, value: result.value }
+ // Agent presets are optional: without that service every session uses the
+ // Host composition, so callers receive the same empty roster as a mounted
+ // service with no configured roots.
+ if (result.error.code === 'invocation-unavailable') return { ok: true, value: EMPTY_ROSTER }
+ return { ok: false, error: result.error.message }
} catch (error) {
return { ok: false, error: messageOf(error) }
}
@@ -119,18 +102,18 @@ export async function readRoster(api: Pick): Promise
* A surface that gets `undefined` returns without touching its snapshot
* further — either another read owns it, or this one already wrote the
* failure. What differs between surfaces starts after this.
- * @param api - the agent-preset wire face.
+ * @param remote - the agent-preset Remote namespace.
* @param store - the surface's own snapshot store.
* @returns the roster, or undefined when the caller should return.
*/
export async function beginRosterRead(
- api: Pick,
+ remote: Pick,
store: SnapshotStore,
-): Promise {
+): Promise {
const before = store.getSnapshot()
if (before.status === 'loading') return undefined
store.set({ ...before, status: 'loading', error: null })
- const roster = await readRoster(api)
+ const roster = await readRoster(remote)
if (roster.ok) return roster.value
store.set({ ...store.getSnapshot(), status: 'error', error: roster.error })
return undefined
@@ -193,11 +176,13 @@ export class AgentPresetSettingsController {
readonly store: SnapshotStore = createSnapshotStore(INITIAL)
/**
- * @param api - the agent-preset and settings wire faces (roster and default write).
+ * @param api - the settings wire face (the default write).
+ * @param remote - the agent-preset Remote namespace (the roster read).
* @param describeFace - the shared mirror's describe face (writability source).
*/
constructor(
- private readonly api: IApiClient,
+ private readonly api: Pick,
+ private readonly remote: Pick,
private readonly describeFace: SettingsDescribeFace,
) {}
@@ -212,7 +197,7 @@ export class AgentPresetSettingsController {
* @returns once the snapshot reflects the host.
*/
async load(): Promise {
- const roster = await beginRosterRead(this.api, this.store)
+ const roster = await beginRosterRead(this.remote, this.store)
if (roster === undefined) return
const { presets } = roster
const [first] = presets
diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts
index f0215a0341..43f76dc2ad 100644
--- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts
+++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts
@@ -29,46 +29,34 @@ import { AgentPresetSeatController } from '../src/client/seat-store.ts'
// FALLBACK_LOCALE (en); each bench stages zh explicitly on the locale instead.
const ROSTER_ONE = {
- rpcId: 'r',
- result: {
- ok: true as const,
- value: {
- presets: [{ id: 'standard', trust: 'system', isDefault: true }],
- authorable: true,
- hasDocument: true,
- },
+ ok: true as const,
+ value: {
+ presets: [{ id: 'standard', trust: 'system', isDefault: true }],
+ authorable: true,
},
}
/** The roster after this browser copied one preset of its own. */
const ROSTER_AUTHORED = {
- rpcId: 'r',
- result: {
- ok: true as const,
- value: {
- presets: [
- { id: 'standard', trust: 'system', isDefault: true },
- { id: 'mine', trust: 'user', isDefault: false },
- ],
- authorable: true,
- hasDocument: true,
- },
+ ok: true as const,
+ value: {
+ presets: [
+ { id: 'standard', trust: 'system', isDefault: true },
+ { id: 'mine', trust: 'user', isDefault: false },
+ ],
+ authorable: true,
},
}
/** The same roster with a second preset carrying the default. */
const ROSTER_MOVED = {
- rpcId: 'r',
- result: {
- ok: true as const,
- value: {
- presets: [
- { id: 'standard', trust: 'system', isDefault: false },
- { id: 'minimal', trust: 'system', isDefault: true },
- ],
- authorable: true,
- hasDocument: true,
- },
+ ok: true as const,
+ value: {
+ presets: [
+ { id: 'standard', trust: 'system', isDefault: false },
+ { id: 'minimal', trust: 'system', isDefault: true },
+ ],
+ authorable: true,
},
}
@@ -84,30 +72,45 @@ async function bench() {
ctx.provide('locale', locale)
const remote = new TestRemote(ctx)
const calls: string[] = []
+ // The roster and the switch are the AgentPresets Remote namespace; the
+ // shared double carries no generated namespaces, so this spec stages its
+ // own. Registered twice on purpose: the nested key satisfies the plugin's
+ // `inject`, and the property is what `ctx.remote.agentPresets` reads,
+ // because the double is a plain provided object rather than a Service.
+ const agentPresets = {
+ list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
+ read: () => Promise.resolve({
+ ok: true as const,
+ value: { agentPreset: 'standard', trust: 'system', content: '' },
+ }),
+ copy: (_from: string, id: string) => {
+ calls.push(`copy:${id}`)
+ // The host's roster now contains it, which is the whole point of the
+ // copy and what every surface must converge on.
+ ROSTER = ROSTER_AUTHORED
+ return Promise.resolve({ ok: true as const, value: undefined })
+ },
+ deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }),
+ select: (_agentId: SessionId, agentPreset: string) => {
+ calls.push(`select:${agentPreset}`)
+ return Promise.resolve({ ok: true as const, value: agentPreset })
+ },
+ }
+ ctx.provide('remote.agentPresets', agentPresets as never)
+ Object.assign(remote, { agentPresets })
ctx.provide('connection', {
api: {
- agentPresets: {
- list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
- read: () => Promise.resolve({
+ host: {
+ describe: () => Promise.resolve({
rpcId: 'r',
- result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } },
+ result: { ok: true as const, value: { canOpenPath: true } },
}),
- copy: (payload: { from: string; agentPreset: string }) => {
- calls.push(`copy:${payload.agentPreset}`)
- // The host's roster now contains it, which is the whole point of the
- // copy and what every surface must converge on.
- ROSTER = ROSTER_AUTHORED
- return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
- },
+ },
+ agentPresets: {
openDocument: (payload: { agentPreset: string }) => {
calls.push(`openDocument:${payload.agentPreset}`)
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } })
},
- remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
- select: (payload: { agentPreset: string }) => {
- calls.push(`select:${payload.agentPreset}`)
- return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
- },
},
settings: {
// The row reads this to learn whether this browser may write at all.
@@ -179,7 +182,7 @@ function sessionsDouble(state: {
describe('ui-agent-preset apply', () => {
it('declares the services it uses', () => {
- expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
+ expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope'])
})
it('registers the General row and the settings section', async () => {
@@ -600,8 +603,7 @@ describe('AgentPresetSeatController reconciliation', () => {
{
name: 'RPC rejection',
select: () => Promise.resolve({
- rpcId: 'r',
- result: { ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} } },
+ ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} },
}),
message: 'selection rejected',
},
diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts
index dc62f74aff..ca253c69fa 100644
--- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts
+++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts
@@ -7,7 +7,7 @@
*/
import { describe, expect, it } from 'vitest'
-import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
@@ -41,6 +41,8 @@ interface FakeOptions {
authorable?: boolean
/** Whether the host can open a preset directory on a desktop. */
hasDocument?: boolean
+ /** Reject `host.describe`, as a dead transport does. */
+ throwDescribe?: boolean
/** Hold `remove` until this resolves, to observe the in-flight state. */
holdRemove?: Promise
}
@@ -49,63 +51,29 @@ const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true
const fail = (message: string) =>
Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } })
+const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value })
+const remoteFail = (message: string) =>
+ Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } })
+
/**
- * A wire face over an in-memory preset store: copies land, so the roster the
- * controller re-reads after a copy is the one the copy produced.
- * @param presets - the starting compositions by id.
+ * The carried wire face: the desktop opener, the default write, and the opener
+ * capability the page joins onto the roster.
* @param defaultId - the preset a session with no choice gets.
* @param options - failure injection and call recording.
* @returns the fake client.
*/
function fakeApi(
- presets: Map,
defaultId: { id: string },
options: FakeOptions = {},
-): Pick {
+): Pick {
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
return {
+ host: {
+ describe: () => (options.throwDescribe === true
+ ? Promise.reject(new Error('socket closed'))
+ : ok({ canOpenPath: options.hasDocument ?? true })),
+ },
agentPresets: {
- list: () => {
- record('list', {})
- if (options.throwList === true) return Promise.reject(new Error('socket closed'))
- if (options.failList !== undefined) return fail(options.failList)
- return ok({
- presets: [...presets].map(([id, preset]) => ({
- id, trust: preset.trust, isDefault: id === defaultId.id,
- ...preset.name === undefined ? {} : { name: preset.name },
- })),
- authorable: options.authorable ?? true,
- hasDocument: options.hasDocument ?? true,
- })
- },
- read: (payload: { agentPreset: string }) => {
- record('read', payload)
- if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
- if (options.failRead !== undefined) return fail(options.failRead)
- const preset = presets.get(payload.agentPreset)
- /* v8 ignore next -- every test reads an id the fake store holds */
- if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`)
- return ok({
- agentPreset: payload.agentPreset,
- trust: preset.trust,
- content: preset.content,
- ...preset.name === undefined ? {} : { name: preset.name },
- })
- },
- copy: (payload: { from: string; agentPreset: string; name?: string }) => {
- record('copy', payload)
- if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
- if (options.failCopy !== undefined) return fail(options.failCopy)
- const source = presets.get(payload.from)
- /* v8 ignore next -- every test copies a source the fake store holds */
- if (source === undefined) return fail(`unknown preset ${payload.from}`)
- presets.set(payload.agentPreset, {
- trust: 'user',
- content: source.content,
- ...payload.name === undefined ? {} : { name: payload.name },
- })
- return ok({ agentPreset: payload.agentPreset })
- },
openDocument: (payload: { agentPreset: string }) => {
record('openDocument', payload)
if (options.throwOpen === true) return Promise.reject(new Error('socket closed'))
@@ -114,13 +82,6 @@ function fakeApi(
? ok({ opened: true })
: ok({ opened: false, path: `/presets/${payload.agentPreset}` })
},
- remove: async (payload: { agentPreset: string }) => {
- record('remove', payload)
- await options.holdRemove
- if (options.failRemove !== undefined) return await fail(options.failRemove)
- presets.delete(payload.agentPreset)
- return await ok({})
- },
},
settings: {
update: (payload: { ns: string; patch: { default?: string } }) => {
@@ -131,7 +92,81 @@ function fakeApi(
return ok({})
},
},
- } as unknown as Pick
+ } as unknown as Pick
+}
+
+/**
+ * The Remote namespace over an in-memory preset store: copies land, so the
+ * roster the controller re-reads after a copy is the one the copy produced.
+ * @param presets - the starting compositions by id.
+ * @param defaultId - the preset a session with no choice gets.
+ * @param options - failure injection and call recording.
+ * @returns the fake Remote namespace.
+ */
+function fakeRemote(
+ presets: Map,
+ defaultId: { id: string },
+ options: FakeOptions = {},
+): Pick {
+ const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
+ return {
+ agentPresets: {
+ list: () => {
+ record('list', {})
+ if (options.throwList === true) return Promise.reject(new Error('socket closed'))
+ if (options.failList !== undefined) return remoteFail(options.failList)
+ return remoteOk({
+ presets: [...presets].map(([id, preset]) => ({
+ id, trust: preset.trust, isDefault: id === defaultId.id,
+ ...preset.name === undefined ? {} : { name: preset.name },
+ })),
+ authorable: options.authorable ?? true,
+ })
+ },
+ read: (agentPreset: string) => {
+ record('read', { agentPreset })
+ if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
+ if (options.failRead !== undefined) return remoteFail(options.failRead)
+ const preset = presets.get(agentPreset)
+ /* v8 ignore next -- every test reads an id the fake store holds */
+ if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`)
+ return remoteOk({
+ agentPreset,
+ trust: preset.trust,
+ content: preset.content,
+ ...preset.name === undefined ? {} : { name: preset.name },
+ })
+ },
+ // Arity is checked against the declaration, not against which arguments
+ // carry a value, so a short call rejects instead of answering. Reject
+ // one here too: the real face would, and a lenient double hid it once.
+ copy: (...args: [from: string, id: string, name?: string]) => {
+ if (args.length !== 3) {
+ return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`))
+ }
+ const [from, id, name] = args
+ record('copy', { from, id, ...name === undefined ? {} : { name } })
+ if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
+ if (options.failCopy !== undefined) return remoteFail(options.failCopy)
+ const source = presets.get(from)
+ /* v8 ignore next -- every test copies a source the fake store holds */
+ if (source === undefined) return remoteFail(`unknown preset ${from}`)
+ presets.set(id, {
+ trust: 'user',
+ content: source.content,
+ ...name === undefined ? {} : { name },
+ })
+ return remoteOk(undefined)
+ },
+ deletePreset: async (id: string) => {
+ record('deletePreset', { id })
+ await options.holdRemove
+ if (options.failRemove !== undefined) return await remoteFail(options.failRemove)
+ presets.delete(id)
+ return await remoteOk(undefined)
+ },
+ },
+ } as unknown as Pick
}
function seed(): Map {
@@ -146,8 +181,10 @@ function harness(options: FakeOptions = {}) {
const defaultId = { id: 'standard' }
const calls: Recorded[] = []
let rosterChanges = 0
+ const wired = { ...options, calls: options.calls ?? calls }
const controller = new AgentPresetSectionController(
- fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
+ fakeApi(defaultId, wired),
+ fakeRemote(presets, defaultId, wired),
() => { rosterChanges += 1 },
)
return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
@@ -160,6 +197,19 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft {
}
describe('loading the roster', () => {
+ it('still lists the roster when the opener capability cannot be read', async () => {
+ const { controller } = harness({ throwDescribe: true })
+
+ await controller.load()
+
+ // The two reads are independent: a refused `host.describe` costs the
+ // open-directory affordance, not the page.
+ const state = controller.store.getSnapshot()
+ expect(state.status).toBe('ready')
+ expect(state.hasDocument).toBe(false)
+ expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
+ })
+
it('maps the roster onto rows with the capability flags', async () => {
const { controller } = harness({ authorable: true, hasDocument: false })
@@ -352,7 +402,7 @@ describe('submitting a copy', () => {
expect(state.rows.map(row => row.id)).toContain('my-copy')
expect(rosterChanges()).toBe(1)
expect(calls.find(call => call.method === 'copy')?.payload)
- .toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' })
+ .toEqual({ from: 'standard', id: 'my-copy', name: '我的模式' })
// A preset is its files from here on, so landing in them completes the
// copy rather than following it.
expect(calls.find(call => call.method === 'openDocument')?.payload)
@@ -369,7 +419,7 @@ describe('submitting a copy', () => {
await controller.confirmCopy()
expect(calls.find(call => call.method === 'copy')?.payload)
- .toEqual({ from: 'standard', agentPreset: 'my-copy' })
+ .toEqual({ from: 'standard', id: 'my-copy' })
})
it('reveals the new directory as text where the host has no desktop', async () => {
@@ -492,7 +542,7 @@ describe('deleting', () => {
await controller.remove()
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine')
- expect(calls.some(call => call.method === 'remove')).toBe(false)
+ expect(calls.some(call => call.method === 'deletePreset')).toBe(false)
})
it('ignores a second confirmation while one delete is in flight', async () => {
@@ -508,7 +558,7 @@ describe('deleting', () => {
release()
await removal
- expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
+ expect(calls.filter(call => call.method === 'deletePreset')).toHaveLength(1)
})
it('surfaces a refusal and clears the confirmation', async () => {
@@ -528,13 +578,15 @@ describe('deleting', () => {
const { controller, presets } = harness()
await controller.load()
presets.clear()
- const broken = new AgentPresetSectionController({
- agentPresets: {
- list: () => Promise.reject(new Error('gone')),
- remove: () => Promise.reject(new Error('socket closed')),
- },
- settings: {},
- } as unknown as Pick)
+ const broken = new AgentPresetSectionController(
+ { agentPresets: {}, settings: {}, host: {} } as unknown as Pick,
+ {
+ agentPresets: {
+ list: () => Promise.reject(new Error('gone')),
+ deletePreset: () => Promise.reject(new Error('socket closed')),
+ },
+ } as unknown as Pick,
+ )
broken.confirmDelete('mine')
await broken.remove()
@@ -548,7 +600,9 @@ describe('a controller with no roster listener', () => {
// The rosterChanged callback is optional wiring, not a requirement: a
// page composed without sibling surfaces still deletes cleanly.
const presets = seed()
- const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' }))
+ const defaultId = { id: 'standard' }
+ const alone = new AgentPresetSectionController(
+ fakeApi(defaultId), fakeRemote(presets, defaultId))
await alone.load()
alone.confirmDelete('mine')
diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts
index 6d66b4ee6d..74ef828a35 100644
--- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts
+++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest'
-import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -14,9 +14,15 @@ import {
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
} from '../src/client/settings-store.ts'
+/** The two faces the row reads: the roster Remote and the settings wire. */
+interface FakeWire {
+ api: IApiClient
+ remote: Pick
+}
+
/** Controller over a real mirror derived from the same fake wire. */
-function derivedController(api: IApiClient) {
- return new AgentPresetSettingsController(api, new SettingsDescribeMirror(api))
+function derivedController(wire: FakeWire) {
+ return new AgentPresetSettingsController(wire.api, wire.remote, new SettingsDescribeMirror(wire.api))
}
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
@@ -24,7 +30,27 @@ type SeatSession = Pick
interface Recorded { ns: string; patch: unknown }
-/** A client whose roster and write outcome the test controls. */
+/** A roster Remote answering a fixed set of rows, or refusing. */
+function fakeRoster(
+ presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
+ options: { failList?: string; failListCode?: string; throwOnList?: boolean } = {},
+): Pick {
+ return {
+ agentPresets: {
+ list: () => {
+ if (options.throwOnList === true) return Promise.reject(new Error('socket closed'))
+ return Promise.resolve(options.failList === undefined
+ ? { ok: true as const, value: { presets, authorable: true } }
+ : {
+ ok: false as const,
+ error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} },
+ })
+ },
+ },
+ } as unknown as Pick
+}
+
+/** A wire whose roster and write outcome the test controls. */
function fakeApi(
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
options: {
@@ -34,13 +60,8 @@ function fakeApi(
failWriteWith?: Error
readOnly?: boolean
} = {},
-): IApiClient {
- return {
- agentPresets: {
- list: () => Promise.resolve(options.failList === undefined
- ? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
- : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }),
- },
+): FakeWire {
+ const api = {
settings: {
// Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false
// and the row disables its control instead of offering a refused write.
@@ -65,6 +86,10 @@ function fakeApi(
},
},
} as unknown as IApiClient
+ return {
+ api,
+ remote: fakeRoster(presets, options.failList === undefined ? {} : { failList: options.failList }),
+ }
}
describe('the agent-preset settings controller', () => {
@@ -138,6 +163,20 @@ describe('the agent-preset settings controller', () => {
expect(controller.store.getSnapshot().error).toBeNull()
})
+ it('treats an unavailable optional namespace as an empty roster', async () => {
+ const controller = derivedController({
+ api: {} as IApiClient,
+ remote: fakeRoster([], {
+ failList: 'no active Remote method exports this endpoint',
+ failListCode: 'invocation-unavailable',
+ }),
+ })
+
+ await controller.load()
+
+ expect(controller.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: null, options: [] })
+ })
+
it('writes only the default field, into the agent-presets namespace', async () => {
const writes: Recorded[] = []
const controller = derivedController(fakeApi([
@@ -221,8 +260,9 @@ describe('the agent-preset settings controller', () => {
it('reports a transport that rejects rather than answering', async () => {
const controller = derivedController({
- agentPresets: { list: () => Promise.reject(new Error('socket closed')) },
- } as unknown as IApiClient)
+ api: {} as IApiClient,
+ remote: fakeRoster([], { throwOnList: true }),
+ })
await controller.load()
@@ -249,27 +289,43 @@ describe('the new-session chip controller', () => {
function chip(
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
current: SeatSession | undefined | (() => SeatSession | undefined),
- options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
+ options: {
+ writes?: Recorded[]
+ failSelect?: string
+ failList?: string
+ failListCode?: string
+ throwOn?: 'list' | 'select'
+ } = {},
): AgentPresetSeatController {
- const api = {
+ const remote = {
agentPresets: {
list: () => {
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
return Promise.resolve(options.failList === undefined
- ? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
- : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } })
+ ? { ok: true as const, value: { presets, authorable: true } }
+ : {
+ ok: false as const,
+ error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} },
+ })
},
- select: (payload: { agentPreset: string }) => {
+ select: (agentId: SessionId, agentPreset: string) => {
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
- options.writes?.push({ ns: 'select', patch: payload.agentPreset })
+ options.writes?.push({ ns: 'select', patch: agentPreset })
return Promise.resolve(options.failSelect === undefined
- ? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
- : { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } })
+ ? { ok: true as const, value: agentPreset }
+ : {
+ ok: false as const,
+ error: {
+ code: 'agent-preset-locked',
+ message: options.failSelect,
+ details: { sessionId: agentId, agentPreset },
+ },
+ })
},
},
- } as unknown as IApiClient
+ } as unknown as Pick
return new AgentPresetSeatController(
- api,
+ remote,
typeof current === 'function' ? current : () => current,
)
}
@@ -325,6 +381,17 @@ describe('the new-session chip controller', () => {
expect(controller.store.getSnapshot().current).toBe('')
})
+ it('opens on nothing when the optional namespace is unavailable', async () => {
+ const controller = chip([], undefined, {
+ failList: 'no active Remote method exports this endpoint',
+ failListCode: 'invocation-unavailable',
+ })
+
+ await controller.load()
+
+ expect(controller.store.getSnapshot()).toMatchObject({ current: '', error: null, options: [] })
+ })
+
it('stages a pick made before any session exists', async () => {
const writes: Recorded[] = []
const controller = chip(ROSTER, undefined, { writes })
@@ -500,18 +567,12 @@ describe('the new-session chip controller', () => {
})
it('degrades to a read-only row while the mirror holds no answer', async () => {
- const api = {
- agentPresets: {
- list: () => Promise.resolve({
- rpcId: 'r',
- result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
- }),
- },
+ const controller = derivedController({
// The roster answered; the mirror's read is what failed, so the row
// shows the current default without offering a write it never confirmed.
- settings: { describe: () => Promise.reject(new Error('socket closed')) },
- } as unknown as IApiClient
- const controller = derivedController(api)
+ api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as IApiClient,
+ remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]),
+ })
await controller.load()
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 3256371016..c18983dfbf 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -141,6 +141,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [],
returns: 'the presets, first-root-wins per id.',
},
+ {
+ signature: '@Remote(\'list\') async remoteExportList(): Promise',
+ description: 'The roster off the Host: list projected to path-free rows, with the default marked and this deployment\'s authoring capability beside it.\n\nWhether a client can open a preset\'s directory is the Host\'s own opener capability, not a roster property — a caller needing both joins them.',
+ parameters: [],
+ returns: 'the rows and the authoring capability.',
+ },
{
signature: 'async resolve(id?: string): Promise',
description: 'Resolve one preset by id.\n\nA broken preset resolves — deleting one, reading one, and reporting one all need the row — and the mounting paths refuse it AFTER resolution through resolveMountable.',
@@ -175,18 +181,39 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the composition exactly as stored.',
throws: ['when no configured root supplies that id.'],
},
+ {
+ signature: '@Remote(\'read\') async readDocument(agentPreset: string): Promise',
+ description: 'One preset\'s composition text with the roster row it belongs to.',
+ parameters: [{ name: 'agentPreset', description: 'the preset id.' }],
+ returns: 'the composition beside its trust and published metadata.',
+ throws: ['{TypertRemoteFailure} `bad-request` for an empty id, or `agent-preset-not-found` when no configured root supplies it.'],
+ },
{
signature: 'async copy(from: string, id: string, name?: string): Promise',
description: 'Create a locally authored preset by copying an existing one whole.\n\nCopy is the only authoring write. Composition text never crosses this seam: the source is named by id and its directory is copied as it stands, so the copy is exactly as loadable as its source and authoring grants no capability the roster did not already carry. The copy is NOT mounted to validate — a source that mounts today yields a copy that mounts today.',
parameters: [{ name: 'from', description: 'the preset the copy starts from; shipped presets are the primary source, so any trust is accepted.' }, { name: 'id', description: 'the new preset\'s id, which becomes its directory name.' }, { name: 'name', description: 'display name for the copy; absent falls back to the id.' }],
throws: ['when the source is unknown, the id is unusable or already taken, or the deployment configures no writable root.'],
},
+ {
+ signature: '@Remote(\'copy\') async remoteExportCopy(from: string, id: string, name?: string): Promise',
+ description: 'Copy one preset through the Remote API.',
+ parameters: [{ name: 'from', description: 'the source preset id.' }, { name: 'id', description: 'the new preset id.' }, { name: 'name', description: 'the copy\'s optional display name.' }],
+ returns: 'once the copy is stored.',
+ throws: ['{TypertRemoteFailure} with the corresponding stable preset code and details when the copy is refused.'],
+ },
{
signature: 'async remove(id: string): Promise',
description: 'Delete a locally authored preset.',
parameters: [{ name: 'id', description: 'the preset id.' }],
throws: ['when the preset is unknown or ships with the deployment.'],
},
+ {
+ signature: '@Remote(\'deletePreset\') async remoteExportDelete(id: string): Promise',
+ description: 'Delete one preset through the Remote API.',
+ parameters: [{ name: 'id', description: 'the preset id.' }],
+ returns: 'once the preset is deleted.',
+ throws: ['{TypertRemoteFailure} with the corresponding stable preset code and details when deletion is refused.'],
+ },
{
signature: 'serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined',
description: 'One agent\'s instance of a service its preset mounted.\n\nA preset publishes services behind `isolate` realms, which are invisible outside the group that declares them — including to the host. This is how a caller holding the agent reads one anyway: a request that is ABOUT a session but arrives from outside it, which is every browser RPC.\n\nRead addressing only. A host row that `inject`s a service cannot use this, because injection resolves before any session exists and has no agent to key by; such a service belongs on the host plane instead.',
@@ -200,6 +227,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the preset now installed.',
throws: ['when the preset is unknown or its composition is unusable.'],
},
+ {
+ signature: '@Remote(\'select\') async select(agent: Agent, agentPreset: string): Promise',
+ description: 'Compose a blank session\'s agent from a different preset and record it.',
+ parameters: [{ name: 'agent', description: 'the session\'s live agent, resolved from the wire identity.' }, { name: 'agentPreset', description: 'the preset to compose the agent from instead.' }],
+ returns: 'the preset id that was recorded.',
+ throws: ['{TypertRemoteFailure} with `bad-request`, `agent-preset-locked`, `agent-preset-not-found`, or `agent-preset-invalid` when refused.'],
+ },
{
signature: 'async standingKeyFor(id?: string): Promise',
description: 'The standing scope key of one preset, for a host reader with no agent.\n\nA cold transcript read resolves tool presenters against the composition the session recorded, and the standing mount makes that possible without resuming anything: ensuring the mount composes plugins but starts no agent, no session, and no turn.',
@@ -3197,6 +3231,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentPreset',
declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly broken?: string;\n}',
},
+ {
+ name: 'AgentPresetDocument',
+ declaration: 'export interface AgentPresetDocument {\n readonly agentPreset: string;\n readonly trust: PresetTrust;\n readonly content: string;\n readonly name?: string;\n readonly description?: string;\n}',
+ },
+ {
+ name: 'AgentPresetRoster',
+ declaration: 'export interface AgentPresetRoster {\n readonly presets: readonly AgentPresetRow[];\n readonly authorable: boolean;\n}',
+ },
+ {
+ name: 'AgentPresetRow',
+ declaration: 'export interface AgentPresetRow {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly isDefault: boolean;\n readonly name?: string;\n readonly description?: string;\n readonly broken?: string;\n}',
+ },
{
name: 'AgentSetup',
declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;',
diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts
index 56b90e64f2..857172bcc2 100644
--- a/packages/host/apiproxy/src/api-proxy.ts
+++ b/packages/host/apiproxy/src/api-proxy.ts
@@ -8,11 +8,11 @@ import { dirname } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import type { ModelSelection } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-presets/types'
-import type { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionId } from '@deepseek-ai/dsh-session'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
import {
- InvalidPresetIdError, PresetExistsError, PresetMountError,
+ InvalidPresetIdError, PresetExistsError,
PresetNotWritableError, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
import type {
@@ -80,42 +80,6 @@ function err(request: RpcRequest, error: RpcError): RpcResponse {
return { rpcId: request.rpcId, result: { ok: false, error } }
}
-/**
- * Map an agent-preset selection failure onto its stable RPC refusal, or leave
- * unrelated failures to the caller.
- * @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, error: unknown): RpcResponse | 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
-}
-
-/**
- * Whether the session's conversation has started: no turn has run yet (a
- * turn is one model-loop execution). Standalone plugin events — command
- * lifecycle records, plan/mode, titles, goals — never open a turn, so
- * running `/plan` or `/goal` on a fresh session keeps it blank
- * (list-hidden, reusable).
- */
-function sessionBlank(session: Session): boolean {
- return !session.events.some(event => event.type === 'turn/start')
-}
-
/** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
function directoryError(error: unknown): RpcError {
if (error instanceof DirectoryPickerError) {
@@ -195,14 +159,6 @@ function projectionsUnavailableError(): RpcError {
}
}
-/**
- * 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 {
@@ -239,17 +195,6 @@ function presetError(agentPreset: string, error: unknown): RpcError {
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
- /**
- * 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>()
- const agentFor = (sessionId: SessionId) =>
- ctx.sessionController.resolveAgent(sessionId)
-
/** Resolve a Session's live or standing preset scope without resuming it. */
async function sessionScopeFor(
sessionId: SessionId,
@@ -580,112 +525,8 @@ 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> => {
- // 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)
- }
- },
-
- // 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. Connection authenticates the complete API.
- 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))
- }
- },
-
+ // Only the desktop opener remains here: the roster, selection, and
+ // authoring calls are the AgentPresets service's own Remote namespace.
async openDocument(request, signal) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
@@ -708,18 +549,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
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: {
diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts
index 36a4a5b9c8..ad15512ae0 100644
--- a/packages/host/apiproxy/src/api/agent-presets.schema.ts
+++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts
@@ -1,71 +1,11 @@
/**
* agent-presets domain zod schemas (names derived from map keys:
- * agentPresetListRequestSchema / agentPresetListValueSchema).
+ * agentPresetOpenDocumentRequestSchema / agentPresetOpenDocumentValueSchema).
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
-import { sessionIdSchema } from './ids.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>
-
-/** agentPreset.list request payload. */
-export const agentPresetListRequestSchema = z.object({
-}) satisfies z.ZodType>>
-
-/** agentPreset.list response value. */
-export const agentPresetListValueSchema = z.object({
- presets: z.array(agentPresetEntrySchema),
- authorable: z.boolean(),
- hasDocument: z.boolean(),
-}) satisfies z.ZodType>>
-
-/** agentPreset.select request payload. */
-export const agentPresetSelectRequestSchema = z.object({
- sessionId: sessionIdSchema,
- agentPreset: z.string().min(1),
-}) satisfies z.ZodType>>
-
-/** agentPreset.select response value. */
-export const agentPresetSelectValueSchema = z.object({
- agentPreset: z.string(),
-}) satisfies z.ZodType>>
-
-/** agentPreset.read request payload. */
-export const agentPresetReadRequestSchema = z.object({
- agentPreset: z.string().min(1),
-}) satisfies z.ZodType>>
-
-/** 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>>
-
-/** 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>>
-
-/** agentPreset.copy response value. */
-export const agentPresetCopyValueSchema = z.object({
- agentPreset: z.string(),
-}) satisfies z.ZodType>>
/** agentPreset.openDocument request payload. */
export const agentPresetOpenDocumentRequestSchema = z.object({
@@ -77,12 +17,3 @@ export const agentPresetOpenDocumentValueSchema = z.union([
z.object({ opened: z.literal(true) }),
z.object({ opened: z.literal(false), path: z.string() }),
]) satisfies z.ZodType>>
-
-/** agentPreset.remove request payload. */
-export const agentPresetRemoveRequestSchema = z.object({
- agentPreset: z.string().min(1),
-}) satisfies z.ZodType>>
-
-/** agentPreset.remove response value. */
-export const agentPresetRemoveValueSchema = z.object({
-}) satisfies z.ZodType>>
diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts
index 3918de5894..a7749ab6aa 100644
--- a/packages/host/apiproxy/src/api/agent-presets.ts
+++ b/packages/host/apiproxy/src/api/agent-presets.ts
@@ -1,116 +1,25 @@
/**
- * agent-presets domain contract: the roster a browser offers when starting a
- * session, plus the authoring calls behind it.
+ * agent-presets domain contract: handing one preset's directory to the
+ * platform opener, which is the only agent-preset call still carried here.
*
- * A composition names the plugins a session runs, so reading one is
- * reconnaissance; although authoring is copy-only (no caller supplies
- * composition text or a path), copying and deleting still rearrange what the
- * deployment offers. Connection authenticates these calls with the complete
- * Host API rather than assigning a separate method tier.
+ * The roster and its authoring calls are the AgentPresets service's own Remote
+ * namespace. This one stays because the opener is a Host desktop integration
+ * rather than a preset operation.
*/
-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>
-
- /**
- * 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>
-
- /**
- * 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>
-
- /**
- * 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>
-
/**
* Hand one locally authored preset's DIRECTORY to the platform opener, for
* editing the files, which are 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.
+ * has no native opener (`canOpenPath: false` on `host.describe`), 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>
-
- /** Delete a locally authored preset. Shipped presets are refused. */
- remove(request: RpcRequest<{ agentPreset: string }>): Promise>
}
diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts
index 5ab6e52043..be660ec53b 100644
--- a/packages/host/apiproxy/src/api/index.ts
+++ b/packages/host/apiproxy/src/api/index.ts
@@ -37,7 +37,7 @@ export type {
SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
-export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'
+export type { AgentPresetsApi } from './agent-presets.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts
index edeb2d10a8..ad37ade1b8 100644
--- a/packages/host/apiproxy/src/api/rpc-map.ts
+++ b/packages/host/apiproxy/src/api/rpc-map.ts
@@ -27,12 +27,7 @@ export interface RpcMethodMap {
'host.createDirectory': HostApi['createDirectory']
'host.openPath': HostApi['openPath']
'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']
'settings.describe': SettingsApi['describe']
'settings.openDocument': SettingsApi['openDocument']
'settings.update': SettingsApi['update']
diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts
index c68ca65410..21a89483dc 100644
--- a/packages/host/apiproxy/src/fetch/client.ts
+++ b/packages/host/apiproxy/src/fetch/client.ts
@@ -18,8 +18,7 @@ import {
} from '../api/host.schema.ts'
import { skillListValueSchema } from '../api/skills.schema.ts'
import {
- agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
- agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema,
+ agentPresetOpenDocumentValueSchema,
} from '../api/agent-presets.schema.ts'
import {
settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema,
@@ -64,12 +63,7 @@ export interface IApiClient {
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>>
}
agentPresets: {
- list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise>>
- select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise>>
- read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise>>
- copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise>>
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise>>
- remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise>>
}
settings: {
describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise>>
@@ -104,12 +98,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType 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 settings: IApiClient['settings'] = {
diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts
index 5ba7197cbb..1451741fc2 100644
--- a/packages/host/apiproxy/src/fetch/handler.ts
+++ b/packages/host/apiproxy/src/fetch/handler.ts
@@ -21,8 +21,7 @@ import {
} from '../api/host.schema.ts'
import { skillListRequestSchema } from '../api/skills.schema.ts'
import {
- agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
- agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema,
+ agentPresetOpenDocumentRequestSchema,
} from '../api/agent-presets.schema.ts'
import {
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema,
@@ -64,12 +63,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(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) },
'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) },
'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) },
'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) },
diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
index 990b658c9d..76643472e0 100644
--- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
@@ -244,205 +244,6 @@ describe('a capability the session\'s preset mounts', () => {
})
})
-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 createSession(api, { 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', async () => {
- const { api, ctx } = await harness(['standard', 'minimal'])
- await createSession(api, { sessionId: SessionId('sel-log'), agentPreset: 'standard' })
-
- await api.agentPresets.select(
- request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' }))
-
- // 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(session.events.findLast(event => event.type === 'agent-preset/selected')?.data)
- .toEqual({ agentPreset: 'minimal' })
- })
-
- it('serializes two concurrent selects on one session', async () => {
- const { api, ctx } = await harness(['standard', 'minimal'])
- await createSession(api, { 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: 'minimal' })),
- 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(session.events.findLast(event => event.type === 'agent-preset/selected')?.data)
- .toEqual({ agentPreset: 'standard' })
- })
-
- it('refuses once the conversation has started', async () => {
- const { api, ctx } = await harness(['standard', 'minimal'])
- await createSession(api, { 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 createSession(api, { 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 createSession(api, { 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[] = []
@@ -492,7 +293,7 @@ describe('opening a preset directory', () => {
expect(opened).toEqual([])
})
- it('reports the roster capability on list', async () => {
+ it('reports the opener capability on host.describe', async () => {
const openable = await harness(['standard'], {
defaults: { canOpenPath: () => true },
})
@@ -500,11 +301,13 @@ describe('opening a preset directory', () => {
defaults: { canOpenPath: () => false },
})
- const yes = await openable.api.agentPresets.list(request({}))
- const no = await headless.api.agentPresets.list(request({}))
+ // The capability a surface joins onto the roster to decide between opening
+ // a preset directory and showing its path as text.
+ const yes = await openable.api.host.describe(request({}))
+ const no = await headless.api.host.describe(request({}))
- expect(yes.result.ok && yes.result.value.hasDocument).toBe(true)
- expect(no.result.ok && no.result.value.hasDocument).toBe(false)
+ expect(yes.result.ok && yes.result.value.canOpenPath).toBe(true)
+ expect(no.result.ok && no.result.value.canOpenPath).toBe(false)
})
it('counts an injected opener as openable', async () => {
@@ -512,9 +315,9 @@ describe('opening a preset directory', () => {
defaults: { openPath: () => Promise.resolve() },
})
- const response = await api.agentPresets.list(request({}))
+ const response = await api.host.describe(request({}))
- expect(response.result.ok && response.result.value.hasDocument).toBe(true)
+ expect(response.result.ok && response.result.value.canOpenPath).toBe(true)
})
})
diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts
index 6f5fd528f0..1911eec99d 100644
--- a/packages/host/apiproxy/tests/client-handler.spec.ts
+++ b/packages/host/apiproxy/tests/client-handler.spec.ts
@@ -47,12 +47,7 @@ function scriptedApi(overrides: {
},
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,
},
settings: {
@@ -115,16 +110,9 @@ describe('unary round trip', () => {
expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } })
})
- 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('routes the agent-preset document opener through the wire', async () => {
+ const opened = await client(scriptedApi()).agentPresets.openDocument({ agentPreset: 'mine' })
+ expect(opened.result).toEqual({ ok: true, value: { opened: true } })
})
it('passes business errors through as 200 + err result, not a throw', async () => {
diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
index 125d2a4171..10614e8caa 100644
--- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts
+++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
@@ -57,30 +57,9 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy {
},
},
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) {
@@ -162,26 +141,12 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
if (!response.result.ok) expect(response.result.error.code).toBe('settings-rejected')
})
- 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)
+ it('round-trips the agent-preset document opener', async () => {
+ // The opener is the domain's whole carried surface: its request schema is
+ // registered in both halves, so a missing registration fails here rather
+ // than in the browser.
+ expect((await client().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 () => {
diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts
index e1904cbcf1..cf4489f864 100644
--- a/packages/host/apiproxy/tests/native-path-opener.spec.ts
+++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts
@@ -318,4 +318,10 @@ describe('canOpenNativePath', () => {
expect(canOpenNativePath({ platform: 'linux', osRelease: '6.8.0-generic' })).toBe(expected)
})
+
+ it('samples the ambient platform when none is named', () => {
+ // The internals are a test seam; a deployment calls this with nothing and
+ // must get the answer for the host it is actually running on.
+ expect(canOpenNativePath()).toBe(canOpenNativePath({ platform: process.platform }))
+ })
})
diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
index 3a821a8e3f..2644262bfa 100644
--- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts
+++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
@@ -11,9 +11,7 @@ import {
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
} from '../src/api/host.schema.ts'
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
-import {
- agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
-} from '../src/api/agent-presets.schema.ts'
+import { agentPresetOpenDocumentValueSchema } from '../src/api/agent-presets.schema.ts'
import { subagentPromptRequestSchema } from '../src/api/subagents.schema.ts'
describe('RpcId', () => {
@@ -178,20 +176,6 @@ describe('skills domain schemas', () => {
})
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' }))
diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml
index 6d552c4e43..0fb8f1ea65 100644
--- a/packages/preset/agent-presets/README.i18n.yaml
+++ b/packages/preset/agent-presets/README.i18n.yaml
@@ -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/preset/agent-presets/README.md
-README.md: 9dff11bd44a92013852021db5de6b23dfd7e634c
-README.zh.md: c63c72b7a1066948eeabb385685306535a03e84d
+README.md: e2e002da72069a0ad0edd5cc8e2692784dcbd8bd
+README.zh.md: 4f0e11413edaa8f461681d2e1a1e5b23b2421b49
diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md
index 9dff11bd44..e2e002da72 100644
--- a/packages/preset/agent-presets/README.md
+++ b/packages/preset/agent-presets/README.md
@@ -110,7 +110,7 @@ This section explains the design behind the roster and the standing mount; obser
| [`src/authoring.ts`](src/authoring.ts) | Copy/delete/read of locally authored presets, permission tightening |
| [`src/metadata.ts`](src/metadata.ts) | `preset.yml` display metadata |
| [`src/session.ts`](src/session.ts) | `agent-preset/selected` event and the `agentPreset` Session projection |
-| [`src/types.ts`](src/types.ts) | Client-safe cordis event declaration |
+| [`src/types.ts`](src/types.ts) | Client-safe wire payloads and cordis event declaration |
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: post-mount service-leak recheck, unjoined-agent failure |
### The standing mount
diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md
index c63c72b7a1..4f0e11413e 100644
--- a/packages/preset/agent-presets/README.zh.md
+++ b/packages/preset/agent-presets/README.zh.md
@@ -110,7 +110,7 @@ agent-presets:
| [`src/authoring.ts`](src/authoring.ts) | 本地创作 preset 的复制/删除/读取、权限收紧 |
| [`src/metadata.ts`](src/metadata.ts) | `preset.yml` 展示元数据 |
| [`src/session.ts`](src/session.ts) | `agent-preset/selected` 事件与 `agentPreset` Session 投影 |
-| [`src/types.ts`](src/types.ts) | client-safe 的 cordis 事件声明 |
+| [`src/types.ts`](src/types.ts) | client-safe 的线上载荷与 cordis 事件声明 |
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:挂载后的服务泄漏复查、未加入 agent 的失败 |
### 常驻挂载
diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json
index 15db5b3afd..ba432078bb 100644
--- a/packages/preset/agent-presets/package.json
+++ b/packages/preset/agent-presets/package.json
@@ -26,6 +26,14 @@
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
+ "./typert": {
+ "types": "./lib/typert.host.d.ts",
+ "default": "./lib/typert.host.js"
+ },
+ "./remote": {
+ "types": "./lib/typert.remote-client.d.ts",
+ "default": "./lib/typert.remote-client.js"
+ },
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -34,7 +42,11 @@
"lib/invariant.js",
"presets",
"lib/types/**/*.js",
- "lib/types/**/*.d.ts"
+ "lib/types/**/*.d.ts",
+ "lib/typert.host.js",
+ "lib/typert.host.d.ts",
+ "lib/typert.remote-client.js",
+ "lib/typert.remote-client.d.ts"
],
"license": "MIT",
"peerDependencies": {
@@ -50,6 +62,7 @@
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
+ "@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
@@ -73,6 +86,7 @@
"@deepseek-ai/dsh-settings-file": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
+ "@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts
index 83516d5c4d..6be67f11b4 100644
--- a/packages/preset/agent-presets/src/index.ts
+++ b/packages/preset/agent-presets/src/index.ts
@@ -22,27 +22,96 @@
*/
import { stat } from 'node:fs/promises'
-import { Context, Service } from '@deepseek-ai/cordis'
+import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
+import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
// Type-only: resolves the `agent/created` lifecycle event this service watches.
import type {} from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { AgentPresetDocument, AgentPresetErrorDetailsMap, AgentPresetRoster } from './types.ts'
import type {} from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves the registry notification emitted after scope reparenting.
import type {} from '@deepseek-ai/dsh-tools'
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts'
-import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
+import {
+ copyComposition, deleteComposition, readComposition,
+ InvalidPresetIdError, PresetExistsError, PresetNotWritableError,
+} from './authoring.ts'
import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
-import { PresetExistsError } from './authoring.ts'
-import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts'
+import {
+ PresetLockedError, PresetMountError, UnknownPresetError,
+ type AgentPreset, type Config, type PresetRoot,
+} from './preset.ts'
import { agentPresetProjectionDefinition } from './session.ts'
export type * from './types.ts'
/** Settings namespace carrying the user's chosen default preset. */
export const SETTINGS_NAMESPACE = 'agent-presets'
+/** Construct one typed preset failure for the Remote carrier. */
+function remotePresetFailure(
+ code: Code,
+ message: string,
+ details: AgentPresetErrorDetailsMap[Code],
+): TypertRemoteFailure {
+ return new TypertRemoteFailure({ code, message, details })
+}
+
+/** Map one preset rejection to its stable Remote code and details. */
+function presetFailure(error: unknown, agentPreset: string): TypertRemoteFailure | undefined {
+ if (error instanceof UnknownPresetError) {
+ return remotePresetFailure(
+ 'agent-preset-not-found',
+ error.message,
+ { agentPreset: error.presetId, available: [...error.available] },
+ )
+ }
+ if (error instanceof PresetMountError) {
+ return remotePresetFailure(
+ 'agent-preset-invalid',
+ error.message,
+ { agentPreset: error.presetId, reason: error.reason },
+ )
+ }
+ if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
+ return remotePresetFailure(
+ 'agent-preset-invalid',
+ error.message,
+ { agentPreset: error.presetId, reason: error.message },
+ )
+ }
+ if (error instanceof PresetNotWritableError) {
+ return remotePresetFailure(
+ 'agent-preset-read-only',
+ error.message,
+ { agentPreset, reason: error.message },
+ )
+ }
+ if (error instanceof PresetLockedError) {
+ return remotePresetFailure(
+ 'agent-preset-locked',
+ `session "${error.sessionId}" has already started; its agent preset is fixed`,
+ { sessionId: error.sessionId, agentPreset: error.presetId },
+ )
+ }
+ return undefined
+}
+
+/** Refuse an empty preset id before invoking a domain operation. */
+function validatePresetId(value: string, field: 'agentPreset' | 'from'): void {
+ if (value.length === 0) {
+ throw remotePresetFailure('bad-request', `${field} must be a non-empty string`, {})
+ }
+}
+
+/** Throw the stable preset failure or the caller's operation-specific fallback. */
+function rejectPreset(error: unknown, agentPreset: string, fallbackMessage: string): never {
+ throw presetFailure(error, agentPreset) ?? remotePresetFailure('internal', fallbackMessage, {})
+}
+
/** The user-writable slice of this plugin's config. */
export interface AgentPresetSettings {
/** Preset mounted when a session names none. */
@@ -67,7 +136,7 @@ export {
PresetNotWritableError, readComposition, writableRoot,
} from './authoring.ts'
export { agentPresetProjectionDefinition } from './session.ts'
-export { PresetMountError, UnknownPresetError } from './preset.ts'
+export { PresetLockedError, PresetMountError, UnknownPresetError } from './preset.ts'
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts'
declare module '@deepseek-ai/cordis' {
@@ -83,7 +152,7 @@ declare module '@deepseek-ai/cordis' {
* call so a preset authored while the process runs is visible immediately,
* and a preset deleted underneath a picker disappears from the next read.
*/
-export class AgentPresets extends Service {
+export class AgentPresets extends TypertRemoteService {
static inject = ['loader']
/** Runtime schema for the preset roster. */
@@ -213,6 +282,30 @@ export class AgentPresets extends Service {
return await discoverPresets(this.resolvedRoots)
}
+ /**
+ * The roster off the Host: {@link list} projected to path-free rows, with
+ * the default marked and this deployment's authoring capability beside it.
+ *
+ * Whether a client can open a preset's directory is the Host's own opener
+ * capability, not a roster property — a caller needing both joins them.
+ * @returns the rows and the authoring capability.
+ */
+ @Remote('list')
+ async remoteExportList(): Promise {
+ const defaultId = this.defaultId
+ return {
+ presets: (await this.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: this.authorable,
+ }
+ }
+
/**
* Resolve one preset by id.
*
@@ -376,6 +469,30 @@ export class AgentPresets extends Service {
return await readComposition(await this.resolve(id))
}
+ /**
+ * One preset's composition text with the roster row it belongs to.
+ * @param agentPreset - the preset id.
+ * @returns the composition beside its trust and published metadata.
+ * @throws {TypertRemoteFailure} `bad-request` for an empty id, or
+ * `agent-preset-not-found` when no configured root supplies it.
+ */
+ @Remote('read')
+ async readDocument(agentPreset: string): Promise {
+ validatePresetId(agentPreset, 'agentPreset')
+ try {
+ const preset = await this.resolve(agentPreset)
+ return {
+ agentPreset: preset.id,
+ trust: preset.trust,
+ content: await this.read(preset.id),
+ ...preset.name === undefined ? {} : { name: preset.name },
+ ...preset.description === undefined ? {} : { description: preset.description },
+ }
+ } catch (error: unknown) {
+ rejectPreset(error, agentPreset, `agent preset "${agentPreset}": ${String(error)}`)
+ }
+ }
+
/**
* Create a locally authored preset by copying an existing one whole.
*
@@ -406,8 +523,29 @@ export class AgentPresets extends Service {
this.standing.delete(id)
}
+ /**
+ * Copy one preset through the Remote API.
+ * @param from - the source preset id.
+ * @param id - the new preset id.
+ * @param name - the copy's optional display name.
+ * @returns once the copy is stored.
+ * @throws {TypertRemoteFailure} with the corresponding stable preset code
+ * and details when the copy is refused.
+ */
+ @Remote('copy')
+ async remoteExportCopy(from: string, id: string, name?: string): Promise {
+ validatePresetId(from, 'from')
+ validatePresetId(id, 'agentPreset')
+ try {
+ await this.copy(from, id, name)
+ } catch (error: unknown) {
+ rejectPreset(error, id, `agent preset "${id}": ${String(error)}`)
+ }
+ }
+
/**
* Delete a locally authored preset.
+ *
* @param id - the preset id.
* @throws when the preset is unknown or ships with the deployment.
*/
@@ -429,6 +567,23 @@ export class AgentPresets extends Service {
)
}
+ /**
+ * Delete one preset through the Remote API.
+ * @param id - the preset id.
+ * @returns once the preset is deleted.
+ * @throws {TypertRemoteFailure} with the corresponding stable preset code
+ * and details when deletion is refused.
+ */
+ @Remote('deletePreset')
+ async remoteExportDelete(id: string): Promise {
+ validatePresetId(id, 'agentPreset')
+ try {
+ await this.remove(id)
+ } catch (error: unknown) {
+ rejectPreset(error, id, `agent preset "${id}": ${String(error)}`)
+ }
+ }
+
/**
* One agent's instance of a service its preset mounted.
*
@@ -495,6 +650,58 @@ export class AgentPresets extends Service {
return preset
}
+ /**
+ * Serializes {@link select} per session. Two concurrent selects would both
+ * pass the blank check, and the second re-link would then find the record
+ * the first already replaced — leaving two compositions registered into one
+ * agent layer. A client's `busy` flag is not enforcement: the wire is
+ * reachable directly.
+ *
+ * Entries hold a failure-swallowing guard rather than the turn itself, so a
+ * refused switch does not reject the next caller's chain.
+ */
+ private readonly switches = new Map>()
+
+ /**
+ * Compose a blank session's agent from a different preset and record it.
+ * @param agent - the session's live agent, resolved from the wire identity.
+ * @param agentPreset - the preset to compose the agent from instead.
+ * @returns the preset id that was recorded.
+ * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`,
+ * `agent-preset-not-found`, or `agent-preset-invalid` when refused.
+ */
+ @Remote('select')
+ async select(agent: Agent, agentPreset: string): Promise {
+ validatePresetId(agentPreset, 'agentPreset')
+ const queued = this.switches.get(agent.id) ?? Promise.resolve()
+ const turn = queued.then(() => this.swap(agent, agentPreset))
+ const guard = turn.catch(() => undefined)
+ this.switches.set(agent.id, guard)
+ try {
+ return await turn
+ } catch (error: unknown) {
+ return rejectPreset(error, agentPreset, `failed to select agent preset "${agentPreset}": ${String(error)}`)
+ } finally {
+ if (this.switches.get(agent.id) === guard) this.switches.delete(agent.id)
+ }
+ }
+
+ /** One queued switch: re-check, recompose, then record what the agent runs. */
+ private async swap(agent: Agent, agentPreset: string): Promise {
+ // Re-read inside the queue: an earlier switch may have run, and a
+ // conversation may have started, since this call was queued. A turn is one
+ // model-loop execution; standalone plugin events never open one, so a
+ // session that has only run commands is still blank.
+ if (agent.session.events.some(event => event.type === 'turn/start')) {
+ throw new PresetLockedError(agent.id, agentPreset)
+ }
+ const preset = await this.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 preset.id
+ }
+
/**
* The standing scope key of one preset, for a host reader with no agent.
*
diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts
index bbb02c5623..bb0dd18496 100644
--- a/packages/preset/agent-presets/src/preset.ts
+++ b/packages/preset/agent-presets/src/preset.ts
@@ -1,5 +1,7 @@
/** Agent-preset vocabulary shared by discovery, mounting, and consumers. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+
/**
* Where a preset's composition came from. A `system` preset ships with the
* deployment; a `user` preset was authored locally, by a person or by an
@@ -87,6 +89,22 @@ export class UnknownPresetError extends Error {
}
}
+/**
+ * The session's composition is fixed: its conversation has started, so its
+ * history was produced under the preset it runs and swapping the composition
+ * would leave logged tool calls the new one cannot make.
+ */
+export class PresetLockedError extends Error {
+ constructor(
+ /** The session whose composition is already fixed. */
+ readonly sessionId: SessionId,
+ /** The preset that was refused. */
+ readonly presetId: string,
+ ) {
+ super(`agent-presets: session "${sessionId}" has already started; its agent preset is fixed`)
+ }
+}
+
/** A preset exists but its composition cannot be installed. */
export class PresetMountError extends Error {
constructor(
diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts
index a1e04c3d2f..fc567f9ad5 100644
--- a/packages/preset/agent-presets/src/types.ts
+++ b/packages/preset/agent-presets/src/types.ts
@@ -1,5 +1,74 @@
-/** Client-safe event declarations owned by the agent-preset domain. */
+/** Client-safe payloads and event declarations owned by the agent-preset domain. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { PresetTrust } from './preset.ts'
+
+export type { PresetTrust } from './preset.ts'
+
+/**
+ * One roster row as a client reads it. Path-free: a preset is addressed by id
+ * everywhere off the Host, and the composition's location is the Host's own.
+ */
+export interface AgentPresetRow {
+ /** Stable identifier; also the label's fallback. */
+ readonly id: string
+ /** Trust of the root this preset was discovered under. */
+ readonly trust: PresetTrust
+ /** Whether a session naming no preset composes this one. */
+ readonly isDefault: boolean
+ /** Display name the preset published. */
+ readonly name?: string
+ /** One sentence on what this preset is for. */
+ readonly description?: string
+ /** Why this preset cannot compose a session; absent when it can. */
+ readonly broken?: string
+}
+
+/** The roster one deployment currently supplies, with its authoring capability. */
+export interface AgentPresetRoster {
+ /** Every preset the configured roots supply, first-root-wins per id. */
+ readonly presets: readonly AgentPresetRow[]
+ /** Whether this deployment has a root locally authored presets go to. */
+ readonly authorable: boolean
+}
+
+/** Stable details for agent-preset failures returned by the Remote namespace. */
+export interface AgentPresetErrorDetailsMap {
+ /** A required preset id is empty. */
+ 'bad-request': Record
+ /** No configured root supplies the requested id. */
+ 'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] }
+ /** The id is unusable, already taken, or its composition cannot be installed. */
+ 'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string }
+ /** The preset ships with the deployment and is not the user's to change. */
+ 'agent-preset-read-only': { readonly agentPreset: string; readonly reason: string }
+ /** The session's conversation has started, so its composition is fixed. */
+ 'agent-preset-locked': { readonly sessionId: SessionId; readonly agentPreset: string }
+ /** The preset operation failed without a caller-actionable classification. */
+ internal: Record
+}
+
+/** One agent-preset refusal as a client reads it. */
+export type AgentPresetError = {
+ [Code in keyof AgentPresetErrorDetailsMap]: {
+ readonly code: Code
+ readonly message: string
+ readonly details: AgentPresetErrorDetailsMap[Code]
+ }
+}[keyof AgentPresetErrorDetailsMap]
+
+/** One preset's composition text beside the row it belongs to. */
+export interface AgentPresetDocument {
+ /** The preset the composition belongs to. */
+ readonly agentPreset: string
+ /** Trust of the root this preset was discovered under. */
+ readonly trust: PresetTrust
+ /** The composition exactly as stored. */
+ readonly content: string
+ /** Display name the preset published. */
+ readonly name?: string
+ /** One sentence on what this preset is for. */
+ readonly description?: string
+}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts
new file mode 100644
index 0000000000..3a8380cb02
--- /dev/null
+++ b/packages/preset/agent-presets/tests/remote.spec.ts
@@ -0,0 +1,460 @@
+/**
+ * The agent-preset Remote namespace: the path-free roster a client reads, the
+ * composition view behind the read-only viewer, and the per-session switch —
+ * which is the only one of the three that mutates an agent.
+ */
+
+import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import LlmRuntime from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { TypertRemoteFailure, type RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import AgentPresets, { COMPOSITION_FILE, METADATA_FILE } from '@deepseek-ai/dsh-agent-presets'
+import type { Config } from '@deepseek-ai/dsh-agent-presets'
+import type {} from '@deepseek-ai/dsh-agent-presets/types'
+
+const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
+const ROOTS = [
+ { path: join(FIXTURES, 'system'), trust: 'system' as const },
+ { path: join(FIXTURES, 'user'), trust: 'user' as const },
+]
+const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
+
+afterEach(() => vi.restoreAllMocks())
+
+async function remoteFailure(operation: Promise): Promise {
+ try {
+ await operation
+ } catch (error: unknown) {
+ expect(error).toBeInstanceOf(TypertRemoteFailure)
+ if (error instanceof TypertRemoteFailure) return error.failure
+ throw error
+ }
+ throw new Error('expected the Remote operation to fail')
+}
+
+function availableOf(failure: RemoteFailure): readonly string[] {
+ if (!('available' in failure.details)) throw new Error('expected available preset ids')
+ const available: unknown = failure.details.available
+ if (!Array.isArray(available)
+ || !available.every((value: unknown): value is string => typeof value === 'string')) {
+ throw new Error('expected available preset ids')
+ }
+ return available
+}
+
+function reasonOf(failure: RemoteFailure): string {
+ if (!('reason' in failure.details)) throw new Error('expected a preset failure reason')
+ const reason: unknown = failure.details.reason
+ if (typeof reason !== 'string') throw new Error('expected a preset failure reason')
+ return reason
+}
+
+async function harness(
+ roster: Config = { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false },
+): Promise {
+ const ctx = new Context()
+ ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
+ await ctx.plugin(Loader)
+ ctx.loader.builtins.include = Include
+ await ctx.plugin(LlmRuntime)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SystemPrompt, { persona: '' })
+ await ctx.plugin(ToolRuntime)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(AgentPresets, roster)
+ return ctx
+}
+
+async function agentOn(ctx: Context, id: string, presetId?: string): Promise {
+ const handle = await ctx.agents.create({
+ sessionId: SessionId(id),
+ setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId),
+ })
+ return handle.agent
+}
+
+/** The recorded preset a restart replays, which is what a switch must move. */
+const recordedPreset = (agent: Agent): unknown =>
+ agent.session.events.findLast(event => event.type === 'agent-preset/selected')?.data
+
+describe('the roster a client reads', () => {
+ it('projects path-free rows, marking the default and carrying published metadata', async () => {
+ const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+ await mkdir(join(userRoot, 'documented'), { recursive: true })
+ await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
+ await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
+ const ctx = await harness({
+ default: 'minimal',
+ roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
+ includeShippedRoot: false,
+ includeUserRoot: false,
+ })
+
+ const roster = await ctx.agentPresets.remoteExportList()
+
+ expect(roster.authorable).toBe(true)
+ expect(roster.presets).toEqual([
+ { id: 'minimal', trust: 'system', isDefault: true },
+ { id: 'standard', trust: 'system', isDefault: false },
+ { id: 'documented', trust: 'user', isDefault: false, name: '我的模式', description: '只做检索。' },
+ ])
+ // No row carries the composition's location: a preset is addressed by id
+ // everywhere off the Host.
+ expect(roster.presets.every(row => !('path' in row))).toBe(true)
+ })
+
+ it('keeps a broken preset on the roster with its reason', async () => {
+ const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+ await mkdir(join(userRoot, 'damaged'), { recursive: true })
+ const ctx = await harness({
+ default: 'standard',
+ roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
+ includeShippedRoot: false,
+ includeUserRoot: false,
+ })
+
+ const roster = await ctx.agentPresets.remoteExportList()
+
+ // The directory still occupies the id, so a surface must be able to show
+ // and delete it; offering it for selection is what the reason prevents.
+ expect(roster.presets.find(row => row.id === 'damaged')?.broken).toEqual(expect.any(String))
+ })
+
+ it('answers an empty roster with nothing authorable', async () => {
+ const ctx = await harness({ default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false })
+
+ const roster = await ctx.agentPresets.remoteExportList()
+
+ // Composing no presets is a valid deployment: every session then shares
+ // the host composition, and nothing can be written either.
+ expect(roster).toEqual({ presets: [], authorable: false })
+ })
+})
+
+describe('reading one composition', () => {
+ it('rejects an empty id before resolving it', async () => {
+ const ctx = await harness()
+ const resolve = vi.spyOn(ctx.agentPresets, 'resolve')
+
+ await expect(ctx.agentPresets.readDocument(''))
+ .rejects.toMatchObject({ failure: { code: 'bad-request' } })
+ expect(resolve).not.toHaveBeenCalled()
+ })
+
+ it('answers the stored text with the row it belongs to', async () => {
+ const ctx = await harness()
+
+ const document = await ctx.agentPresets.readDocument('standard')
+
+ // 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(document).toEqual({
+ agentPreset: 'standard',
+ trust: 'system',
+ content: await ctx.agentPresets.read('standard'),
+ })
+ })
+
+ it('carries the display metadata a preset published', async () => {
+ const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+ await mkdir(join(userRoot, 'documented'), { recursive: true })
+ await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
+ await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
+ const ctx = await harness({
+ default: 'documented',
+ roots: [{ path: userRoot, trust: 'user' }],
+ includeShippedRoot: false,
+ includeUserRoot: false,
+ })
+
+ const document = await ctx.agentPresets.readDocument('documented')
+
+ // The viewer titles the dialog from the published name, so both optional
+ // fields have to survive the projection rather than only the id.
+ expect(document).toEqual({
+ agentPreset: 'documented',
+ trust: 'user',
+ content: VALID,
+ name: '我的模式',
+ description: '只做检索。',
+ })
+ })
+
+ it('refuses an id no root supplies', async () => {
+ const ctx = await harness()
+
+ const failure = await remoteFailure(ctx.agentPresets.readDocument('never-existed'))
+
+ expect(failure).toMatchObject({
+ code: 'agent-preset-not-found',
+ details: {
+ agentPreset: 'never-existed',
+ },
+ })
+ expect(failure.message)
+ .toMatch(/^agent-presets: preset "never-existed" not found \(available: .+\)$/)
+ expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard']))
+ })
+
+ it('keeps the legacy internal diagnostic for an unrelated read failure', async () => {
+ const ctx = await harness()
+ vi.spyOn(ctx.agentPresets, 'read').mockRejectedValueOnce(new Error('disk failed'))
+
+ const failure = await remoteFailure(ctx.agentPresets.readDocument('standard'))
+
+ expect(failure).toEqual({
+ code: 'internal',
+ message: 'agent preset "standard": Error: disk failed',
+ details: {},
+ })
+ })
+})
+
+describe('authoring over Remote', () => {
+ it('rejects empty source, target, and delete ids before authoring', async () => {
+ const ctx = await harness()
+ const copy = vi.spyOn(ctx.agentPresets, 'copy')
+ const remove = vi.spyOn(ctx.agentPresets, 'remove')
+
+ for (const operation of [
+ () => ctx.agentPresets.remoteExportCopy('', 'mine'),
+ () => ctx.agentPresets.remoteExportCopy('standard', ''),
+ () => ctx.agentPresets.remoteExportDelete(''),
+ ]) {
+ await expect(operation()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
+ }
+ expect(copy).not.toHaveBeenCalled()
+ expect(remove).not.toHaveBeenCalled()
+ })
+
+ it('copies and deletes through the Remote adapters', async () => {
+ const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+ const ctx = await harness({
+ default: 'standard',
+ roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
+ includeShippedRoot: false,
+ includeUserRoot: false,
+ })
+
+ await ctx.agentPresets.remoteExportCopy('standard', 'mine', '我的模式')
+ expect((await ctx.agentPresets.resolve('mine')).name).toBe('我的模式')
+
+ await ctx.agentPresets.remoteExportDelete('mine')
+ await expect(ctx.agentPresets.resolve('mine')).rejects.toThrow(/not found/)
+ })
+
+ it('preserves not-found details for an unknown copy source', async () => {
+ const ctx = await harness()
+
+ const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('never-existed', 'mine'))
+
+ expect(failure).toMatchObject({
+ code: 'agent-preset-not-found',
+ details: {
+ agentPreset: 'never-existed',
+ },
+ })
+ expect(failure.message)
+ .toMatch(/^agent-presets: preset "never-existed" not found \(available: .+\)$/)
+ expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard']))
+ })
+
+ it('preserves invalid-id and occupied-id failures', async () => {
+ const ctx = await harness()
+
+ const invalid = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', '../escape'))
+ expect(invalid).toMatchObject({
+ code: 'agent-preset-invalid',
+ details: { agentPreset: '../escape' },
+ })
+ expect(reasonOf(invalid)).toContain('must match')
+
+ const occupied = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'minimal'))
+ expect(occupied).toMatchObject({
+ code: 'agent-preset-invalid',
+ details: { agentPreset: 'minimal' },
+ })
+ expect(reasonOf(occupied)).toContain('already exists')
+ })
+
+ it('keeps the requested id when no writable root exists', async () => {
+ const ctx = await harness({
+ default: 'standard',
+ roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }],
+ includeShippedRoot: false,
+ includeUserRoot: false,
+ })
+
+ const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'mine'))
+
+ expect(failure).toMatchObject({
+ code: 'agent-preset-read-only',
+ details: { agentPreset: 'mine' },
+ })
+ expect(reasonOf(failure)).toContain('no user-writable preset root')
+ })
+
+ it('preserves read-only and not-found delete failures', async () => {
+ const ctx = await harness()
+
+ const readOnly = await remoteFailure(ctx.agentPresets.remoteExportDelete('standard'))
+ expect(readOnly).toMatchObject({
+ code: 'agent-preset-read-only',
+ details: { agentPreset: 'standard' },
+ })
+ expect(reasonOf(readOnly)).toContain('ships with the deployment')
+
+ const missing = await remoteFailure(ctx.agentPresets.remoteExportDelete('never-existed'))
+ expect(missing).toMatchObject({
+ code: 'agent-preset-not-found',
+ details: { agentPreset: 'never-existed' },
+ })
+ expect(availableOf(missing)).toEqual(expect.arrayContaining(['minimal', 'standard']))
+ })
+
+ it('keeps the legacy internal diagnostic for an unrelated authoring failure', async () => {
+ const ctx = await harness()
+ vi.spyOn(ctx.agentPresets, 'copy').mockRejectedValueOnce(new Error('copy failed'))
+
+ const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'mine'))
+
+ expect(failure).toEqual({
+ code: 'internal',
+ message: 'agent preset "mine": Error: copy failed',
+ details: {},
+ })
+ })
+})
+
+describe('switching one session\'s composition', () => {
+ it('rejects an empty preset id before queuing a switch', async () => {
+ const ctx = await harness()
+ const agent = await agentOn(ctx, 'sel-empty', 'standard')
+ const recompose = vi.spyOn(ctx.agentPresets, 'recompose')
+
+ await expect(ctx.agentPresets.select(agent, ''))
+ .rejects.toMatchObject({ failure: { code: 'bad-request' } })
+ expect(recompose).not.toHaveBeenCalled()
+ })
+
+ it('recomposes a blank session and records what it now runs', async () => {
+ const ctx = await harness()
+ const agent = await agentOn(ctx, 'sel-1', 'standard')
+
+ expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal')
+
+ // The header is written once at creation, so the switch lives in the log:
+ // that is what a restart replays and what every projection resolves from.
+ expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('minimal')
+ expect(recordedPreset(agent)).toEqual({ agentPreset: 'minimal' })
+ })
+
+ it('serializes two concurrent switches on one session', async () => {
+ const ctx = await harness()
+ const agent = await agentOn(ctx, 'sel-race', 'standard')
+
+ // Both pass the blank check; unserialized, the second re-link finds the
+ // record the first already replaced and two compositions end up in one
+ // agent layer. A client's busy flag is not enforcement.
+ await Promise.all([
+ ctx.agentPresets.select(agent, 'minimal'),
+ ctx.agentPresets.select(agent, 'standard'),
+ ])
+
+ // One winner, and the log agrees with it: the last committed switch.
+ expect(recordedPreset(agent)).toEqual({ agentPreset: 'standard' })
+ expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard')
+ })
+
+ it('refuses once the conversation has started', async () => {
+ const ctx = await harness()
+ const agent = await agentOn(ctx, 'sel-locked', 'standard')
+ // One turn is enough: the history from here on was produced under
+ // `standard`'s tools, and a swap would strand those tool calls.
+ agent.session.append('turn/start', { turn: 0 })
+
+ const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal'))
+
+ expect(failure).toEqual({
+ code: 'agent-preset-locked',
+ message: 'session "sel-locked" has already started; its agent preset is fixed',
+ details: { sessionId: SessionId('sel-locked'), agentPreset: 'minimal' },
+ })
+ expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard')
+ expect(recordedPreset(agent)).toBeUndefined()
+ })
+
+ it('leaves the session on its composition when the named preset is unknown', async () => {
+ const ctx = await harness()
+ const agent = await agentOn(ctx, 'sel-unknown', 'standard')
+
+ const failure = await remoteFailure(ctx.agentPresets.select(agent, 'nope'))
+
+ expect(failure).toMatchObject({
+ code: 'agent-preset-not-found',
+ details: { agentPreset: 'nope' },
+ })
+ expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard']))
+ // Resolution happens before any re-link, and nothing is recorded until the
+ // swap commits.
+ expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard')
+ expect(recordedPreset(agent)).toBeUndefined()
+ })
+
+ it('serves a later switch after one was refused', async () => {
+ const ctx = await harness()
+ const agent = await agentOn(ctx, 'sel-after-failure', 'standard')
+
+ await expect(ctx.agentPresets.select(agent, 'nope')).rejects.toThrow(/not found/)
+
+ // The queue holds a failure-swallowing guard, so a refused switch does not
+ // reject the next caller's chain.
+ expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal')
+ })
+
+ it('reports an unusable composition with its discovery reason', async () => {
+ const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+ await mkdir(join(userRoot, 'damaged'), { recursive: true })
+ const ctx = await harness({
+ default: 'standard',
+ roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
+ includeShippedRoot: false,
+ includeUserRoot: false,
+ })
+ const agent = await agentOn(ctx, 'sel-broken', 'standard')
+
+ const failure = await remoteFailure(ctx.agentPresets.select(agent, 'damaged'))
+
+ expect(failure).toMatchObject({
+ code: 'agent-preset-invalid',
+ details: { agentPreset: 'damaged' },
+ })
+ expect(reasonOf(failure)).not.toBe('')
+ })
+
+ it('keeps the legacy internal diagnostic for an unrelated switch failure', async () => {
+ const ctx = await harness()
+ const agent = await agentOn(ctx, 'sel-internal', 'standard')
+ vi.spyOn(ctx.agentPresets, 'recompose').mockRejectedValueOnce(new Error('mount failed'))
+
+ const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal'))
+
+ expect(failure).toEqual({
+ code: 'internal',
+ message: 'failed to select agent preset "minimal": Error: mount failed',
+ details: {},
+ })
+ })
+})
diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json
index f42a158941..926ed9ce51 100644
--- a/packages/preset/agent-presets/tsconfig.json
+++ b/packages/preset/agent-presets/tsconfig.json
@@ -36,6 +36,9 @@
{
"path": "../../core/tools"
},
+ {
+ "path": "../../typert/protocol"
+ },
{
"path": "../../settings/settings"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3cbd1c1e18..a2ddd689f7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6673,6 +6673,9 @@ importers:
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
+ '@deepseek-ai/dsh-typert-protocol':
+ specifier: workspace:^
+ version: link:../../typert/protocol
packages/preset/persona:
dependencies:
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 40bda124f7..fc040769e1 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -629,6 +629,8 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = {
InsertTextRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts',
AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md',
+ AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md',
+ AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md',
PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md',
BashEnvContributor: 'service-local extension type is owned by packages/shell/tool-bash/src/index.ts',
BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts',