bf4356cf35
`agentPreset.select` recomposes one session's agent from a different preset. It is allowed only while the session is blank — once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive; only the preset subtree is swapped. That was forced by what the host actually owns: api-proxy discards the `AgentHandle` it creates, and there is no delete RPC, so neither disposing nor recreating the session was available. Swapping the subtree is also the better answer — the session id, its workspace attachment, and its projections all stay put. `recompose` is unmount-then-mount because two compositions cannot coexist: both would register the same tool names into one layer. So it resolves the new preset BEFORE tearing anything down (an unknown id is a no-op) and restores the previous composition when the new one fails to mount, rather than leaving the agent with no tools at all. Both paths are pinned by test. Also restores the English half of the `agentPreset.list` README paragraph, which was lost before the previous commit — and `verify-translation-pairing --write` recorded the pair as consistent anyway, because it records whatever state it finds rather than checking the two sides say the same thing.
191 lines
8.1 KiB
TypeScript
191 lines
8.1 KiB
TypeScript
/**
|
|
* Four-quadrant RPC message model. Channels and messages are decoupled: HTTP,
|
|
* WebSocket, and in-process SSE are physical carriers, while logical messages
|
|
* are channel-independent and form a four-member discriminated union.
|
|
* api/ contract layer: zero Node dependencies, importable from the browser.
|
|
*/
|
|
|
|
import type { z as zCore } from 'zod'
|
|
type ZodIssue = zCore.core.$ZodIssue
|
|
import type { Branded } from '@deepseek-ai/dsh-brand'
|
|
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
|
|
/**
|
|
* Message correlation id: the initiator mints it on a request; a response
|
|
* echoes the matching request's rpcId and never mints a new one.
|
|
*/
|
|
export type RpcId = Branded<'rpc-id'>
|
|
|
|
/**
|
|
* Brands a string as RpcId (same precedent as core `SessionId()`). Minted by the initiator:
|
|
* client-request → client mints; server-request → host mints (answerable frames get a stable
|
|
* logical id, pure pushes mint a fresh one each time).
|
|
* @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures).
|
|
* @returns The same string, branded (compile-time cast, zero runtime cost).
|
|
*/
|
|
export function RpcId(id: string): RpcId {
|
|
return id as RpcId
|
|
}
|
|
|
|
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
|
|
export interface RpcErrorDetailsMap {
|
|
'bad-request': { issues: ZodIssue[] }
|
|
'cancelled': {}
|
|
'session-not-found': { sessionId: SessionId }
|
|
'model-unavailable': { provider: string; model: string }
|
|
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
|
|
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
|
|
'workspace-not-found': { workspaceId: string }
|
|
'workspace-invalid-path': { path: string }
|
|
'workspace-name-conflict': { name: string }
|
|
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
|
|
'directory-unreadable': { path: string }
|
|
'directory-exists': { path: string }
|
|
'directory-create-failed': { path: string }
|
|
'directory-picker-unavailable': { capability: string }
|
|
'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
|
|
'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string }
|
|
'agent-preset-not-found': { agentPreset: string; available: string[] }
|
|
'agent-preset-invalid': { agentPreset: string; reason: string }
|
|
'agent-busy': { reason: string }
|
|
'queue-item-not-found': { itemId: MessageId }
|
|
'steer-unavailable': { itemId: MessageId }
|
|
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
|
'command-error': {}
|
|
/** A leading-/ prompt named no registered command; the message names the token. */
|
|
'unknown-command': {}
|
|
/**
|
|
* A settings write was refused (schema validation, unknown namespace,
|
|
* read-only provider, or storage failure); the message is the seam's text.
|
|
*/
|
|
'settings-rejected': { ns: string }
|
|
/**
|
|
* A settings namespace exists in the seam but is outside the configuration
|
|
* plane's model-provider boundary, so this proxy neither reads nor writes
|
|
* it; the message names the namespace.
|
|
*/
|
|
'settings-not-exposed': { ns: string }
|
|
/**
|
|
* A settings write carried an `expectedRevision` the namespace has already
|
|
* moved past: another writer (tab, editor, or an external file edit) landed
|
|
* first. The details carry both revisions so a client can re-read and retry.
|
|
*/
|
|
'settings-conflict': { ns: string; expected: number; actual: number }
|
|
/** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */
|
|
'credential-rejected': { ref: string }
|
|
/**
|
|
* Interrogating a draft provider endpoint did not produce a model listing:
|
|
* no adapter family serves the namespace, the protocol has no listing this
|
|
* build can read, or the endpoint was unreachable, refused the credential,
|
|
* or answered with something else. The message is the adapter's own text —
|
|
* it is what the form shows before falling back to hand-entry — and the
|
|
* details name the endpoint asked, never the credential offered.
|
|
*/
|
|
'model-discovery-failed': { settingsNs: string; baseURL?: string }
|
|
'title-invalid': { sessionId: SessionId }
|
|
'fork-unavailable': { sessionId: SessionId }
|
|
'subagent-parent-unavailable': { parentSessionId: SessionId }
|
|
'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
|
|
'subagent-catalog-diagnostic': {
|
|
parentSessionId: SessionId
|
|
childSessionId: SessionId
|
|
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
|
}
|
|
'subagent-not-resumable': { childSessionId: SessionId }
|
|
'subagent-unauthorized': { childSessionId: SessionId }
|
|
'subagent-delivery-unavailable': { childSessionId: SessionId }
|
|
'internal': {}
|
|
}
|
|
|
|
/** Closed error-code union (the keys of RpcErrorDetailsMap). */
|
|
export type RpcErrorCode = keyof RpcErrorDetailsMap
|
|
|
|
/**
|
|
* Distributive union expanded from the map: code is the discriminant, so
|
|
* `switch (error.code)` narrows details. details is required (internal uses an explicit {}).
|
|
*/
|
|
export type RpcError = {
|
|
[C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] }
|
|
}[RpcErrorCode]
|
|
|
|
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
|
|
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
|
|
|
|
/**
|
|
* Fold a transport exception into the RpcResult error branch (unified error
|
|
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
|
|
* carrier consumer folds the same way.
|
|
* @param error - the thrown value from the carrier.
|
|
* @returns the error branch of an RpcResult.
|
|
*/
|
|
export function transportError<T>(error: unknown): RpcResult<T> {
|
|
return {
|
|
ok: false,
|
|
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signature-layer narrow form, request side (domain-interface view, shared by
|
|
* both directions): rpcId is explicit in the signature, never mixed into the
|
|
* business payload; the type tag and method are filled in by the carrier layer.
|
|
*/
|
|
export interface RpcRequest<P> {
|
|
rpcId: RpcId
|
|
payload: P
|
|
}
|
|
|
|
/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */
|
|
export interface RpcResponse<T> {
|
|
rpcId: RpcId
|
|
result: RpcResult<T>
|
|
}
|
|
|
|
// ---- Wire full forms: four named members of a discriminated union (discriminant = the four `type` literals) ----
|
|
|
|
/** Call initiated by the client (wire carrier: POST /api/<method> body). */
|
|
export interface ClientRequest {
|
|
type: 'client-request'
|
|
rpcId: RpcId
|
|
method: string
|
|
payload: unknown
|
|
}
|
|
|
|
/** Response to a ClientRequest (wire carrier: the HTTP response body of that POST); rpcId echoed. */
|
|
export interface ServerResponse {
|
|
type: 'server-response'
|
|
rpcId: RpcId
|
|
result: RpcResult<unknown>
|
|
}
|
|
|
|
/**
|
|
* Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions
|
|
* (approval/question requested — stable rpcId, reused on replay) and pure pushes
|
|
* (session/event etc. — rpcId identifies that one push) share this shape; whether a
|
|
* response is expected is determined statically by method (a strict dichotomy, no third kind).
|
|
*/
|
|
export interface ServerRequest {
|
|
type: 'server-request'
|
|
rpcId: RpcId
|
|
method: string
|
|
payload: unknown
|
|
}
|
|
|
|
/** Response to a ServerRequest (wire carrier: POST /api/respond body); rpcId echoed, never minted anew. */
|
|
export interface ClientResponse {
|
|
type: 'client-response'
|
|
rpcId: RpcId
|
|
result: RpcResult<unknown>
|
|
}
|
|
|
|
/** Authoritative wire full-form union; narrow via `switch (message.type)`. */
|
|
export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse
|
|
|
|
/**
|
|
* Carrier receipt (not an RpcMessage — it belongs to the carrier layer, same
|
|
* discipline as "HTTP status describes only the carrier"): the HTTP response
|
|
* body of the POST carrying a client-response. Late/duplicate responses yield not-pending.
|
|
*/
|
|
export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }
|