refactor(commands): move the command service to Remote

`CommandService.list` and `execute` carry the wire contract directly through
`@Remote`, and the Client assembly mounts the generated commands
contribution. The legacy API Proxy route, its schemas, the map rows, the
generated client methods and the fixture's command domain are removed, so the
catalog and the admission call have one owner again.

`Session.command()` keeps a result-shaped public face for parity with the
prompt, cancel and attachment neighbours it sits beside, and reads the
generated namespace through one `SessionRemotes` parameter. The Session
cluster declares that face against the owning business package rather than the
generated contribution: the Host compiler aggregate builds this package, and
it runs before any contribution is emitted.

Migrated calls lose the `title-invalid` class of protocol-only error codes and
report `internal`; no production caller branched on them.
This commit is contained in:
imccyu
2026-08-11 19:10:31 +08:00
parent a2981207b0
commit 070a2a7f1e
71 changed files with 648 additions and 1129 deletions

View File

@@ -67,7 +67,7 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache'
// GoalError narrows domain rejections to their stable codes at the wire boundary.
import { GoalError } from '@deepseek-ai/dsh-goal'
import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
// Type-only edges: resolve the command-change stream and `ctx.get('skills')`.
import type {} from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-skill'
// The settings/credentials seams: brand guards run at this wire boundary; the
@@ -2889,49 +2889,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
commands: {
// Both methods address one session's agent. agentFor resumes on miss
// and fences every subagent-owned identity with `agent-busy`; the
// api/commands.ts module contract owns that fence's wording, so this
// comment only notes the routing shape: clients send a sessionId for a
// published session, and resume restores an existing entity.
async list(request) {
// Missing service = the deployment omitted dsh-commands from its
// composition, not an empty catalog: fail loud instead of serving [].
const commands = ctx.get('commands')
if (commands === undefined) {
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
}
const found = await agentFor(request.payload.sessionId)
if ('error' in found) return err(request, found.error)
return ok(request, { commands: commands.list(found.agent) })
},
async execute(request, signal) {
const commands = ctx.get('commands')
if (commands === undefined) {
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
}
const { sessionId, line } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
try {
// Pure admission: the executor's durable command/run + command/done
// pair (broadcast on the mux stream) carries the outcome; the
// response reports whether the line resolved to a handler, plus the
// minted pairing id so the issuing client can correlate its request
// with the flow node the lifecycle events produce.
const execution = await commands.execute(found.agent, line, signal)
return ok(request, execution === undefined
? { matched: false }
: { matched: true, commandId: execution.commandId })
} catch (error: unknown) {
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
}
},
},
goals: {
// Mutations only — the read side is the 'goal' session projection.
// Every verb resolves the session's agent (agentFor: implicit cold

View File

@@ -1,44 +0,0 @@
/**
* commands domain zod schemas (names derived from map keys: commandListRequestSchema /
* commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema).
*/
import { z } from 'zod'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts'
import type { CommandDescriptor } from './commands.ts'
/** CommandDescriptor row of command.list. */
export const commandDescriptorSchema = z.object({
name: z.string().min(1),
description: z.string(),
input: z.object({ hint: z.string() }).optional(),
}) satisfies z.ZodType<Wire<CommandDescriptor>>
/** command.list request payload. */
export const commandListRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'command.list'>>>
/** command.list response value. */
export const commandListValueSchema = z.object({
commands: z.array(commandDescriptorSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'command.list'>>>
/** command.execute request payload. */
export const commandExecuteRequestSchema = z.object({
sessionId: sessionIdSchema,
line: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
/** CommandId: one brand cast after schema validation (the only cast point in this domain). */
export const commandIdSchema = z.string().min(1) as unknown as z.ZodType<CommandId>
/** command.execute response value: pure admission — outcomes ride the logged
* lifecycle events; commandId (present exactly when matched) correlates with them. */
export const commandExecuteValueSchema = z.object({
matched: z.boolean(),
commandId: commandIdSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>

View File

@@ -1,50 +0,0 @@
/**
* commands domain contract: the web catalog/dispatch face of the host command
* registry (`ctx.commands`). Both methods address an ordinary session's Agent
* via `sessionId`, resuming it when cold. Session-backed subagents reject with
* `agent-busy` and retain their dedicated continuation owner.
*/
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcRequest, RpcResponse } from './rpc.ts'
/**
* Handler-free command view served to clients. Wire mirror of the host
* registry descriptor (which stays host-side with its cordis dependencies);
* no source field — the host descriptor has none.
*/
export interface CommandDescriptor {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Human-readable summary used in discovery UI. */
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: { readonly hint: string }
}
/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */
export interface CommandsApi {
/**
* Lists the addressed agent's effective command catalog (name-sorted,
* globals plus its scoped shadows). Session-backed subagents reject with
* `agent-busy`.
*/
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
/**
* Parses and executes one slash-command line against the addressed agent
* without sending it to the model — pure admission semantics. matched=false
* when syntax or name does not resolve (the client falls back to its
* default sink). The handler's outcome does NOT ride the response: the host
* executor durably logs the lifecycle (`command/run`/`command/done`), which
* broadcasts on the mux stream and renders as a persistent flow node.
* `commandId` is present exactly when matched — the minted lifecycle
* pairing id, letting the issuing client correlate this acknowledgment
* with that flow node. The signal rides beside the request, never on the
* wire: the fetch carrier's request signal cancels the running handler.
* Session-backed subagents reject with `agent-busy` before dispatch.
*/
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
}

View File

@@ -7,7 +7,6 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts'
import type { SubagentsApi } from './subagents.ts'
@@ -25,7 +24,6 @@ export interface ApiProxy {
subagents: SubagentsApi
host: HostApi
workspace: WorkspaceApi
commands: CommandsApi
skills: SkillsApi
agentPresets: AgentPresetsApi
events: EventsApi
@@ -52,7 +50,6 @@ export type {
} from './subagents.ts'
export type { TaskView } from './tasks.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'

View File

@@ -7,7 +7,6 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts'
import type { GoalsApi } from './goals.ts'
@@ -50,8 +49,6 @@ export interface RpcMethodMap {
'workspace.delete': WorkspaceApi['delete']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
'workspace.archiveSession': WorkspaceApi['archiveSession']
'command.list': CommandsApi['list']
'command.execute': CommandsApi['execute']
'skill.list': SkillsApi['list']
'agentPreset.list': AgentPresetsApi['list']
'agentPreset.select': AgentPresetsApi['select']

View File

@@ -39,7 +39,6 @@ import {
workspaceListValueSchema,
workspaceRenameValueSchema,
} from '../api/workspace.schema.ts'
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
import { skillListValueSchema } from '../api/skills.schema.ts'
import {
agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
@@ -120,10 +119,6 @@ export interface IApiClient {
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>>
}
commands: {
list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.list'>>>
execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.execute'>>>
}
skills: {
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
}
@@ -200,8 +195,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'workspace.delete': workspaceDeleteValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
'workspace.archiveSession': workspaceArchiveSessionValueSchema,
'command.list': commandListValueSchema,
'command.execute': commandExecuteValueSchema,
'skill.list': skillListValueSchema,
'agentPreset.list': agentPresetListValueSchema,
'agentPreset.select': agentPresetSelectValueSchema,
@@ -456,15 +449,6 @@ export abstract class AbstractApiClient implements IApiClient {
archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal),
}
readonly commands: IApiClient['commands'] = {
list: (payload, signal) => this.callUnary('command.list', payload, signal),
// Command handlers are user-driven operations and may legitimately exceed
// the transport health deadline. Caller/connection aborts remain.
execute: (payload, signal) => this.callUnary(
'command.execute', payload, signal, 'caller-signal-only',
),
}
readonly skills: IApiClient['skills'] = {
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
}

View File

@@ -42,7 +42,6 @@ import {
workspaceListRequestSchema,
workspaceRenameRequestSchema,
} from '../api/workspace.schema.ts'
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
import { skillListRequestSchema } from '../api/skills.schema.ts'
import {
agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
@@ -115,8 +114,6 @@ const UNARY_ROUTES: UnaryRoutes = {
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) },
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },

View File

@@ -76,7 +76,6 @@ export class ApiProxyService extends Service implements ApiProxy {
readonly subagents: ApiProxy['subagents']
readonly workspace: ApiProxy['workspace']
readonly host: ApiProxy['host']
readonly commands: ApiProxy['commands']
readonly goals: ApiProxy['goals']
readonly skills: ApiProxy['skills']
readonly agentPresets: ApiProxy['agentPresets']
@@ -102,7 +101,6 @@ export class ApiProxyService extends Service implements ApiProxy {
this.subagents = api.subagents
this.workspace = api.workspace
this.host = api.host
this.commands = api.commands
this.goals = api.goals
this.skills = api.skills
this.agentPresets = api.agentPresets

View File

@@ -1,427 +0,0 @@
import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
/**
* Command/skill RPC handlers and the two new frames over createApiProxy:
* command.list serves the addressed agent's effective catalog (missing
* registry = loud internal error), command.execute dispatches through the
* registry with the carrier signal, skill.list resolves cwd from the session
* header (never via the Agent registry), the host stream broadcasts
* commands-changed, and the mux stream carries live queued frames plus the
* open-time queue snapshot.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import CommandService from '@deepseek-ai/dsh-commands'
import SkillService from '@deepseek-ai/dsh-skill'
import type { HostFrame } from '../src/api/index.ts'
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { assertJsonArgs, createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
}
let nextRpc = 1
function expectOk<T>(response: RpcResponse<T>): T {
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
return response.result.value
}
function expectErr<T>(response: RpcResponse<T>): { code: string; message: string } {
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
return response.result.error
}
/** Composition floor for the command/skill paths (no LLM, no persistence). */
async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
if (options.skills !== false) await ctx.plugin(SkillService, {})
if (options.commands !== false) await ctx.plugin(CommandService)
// Host-stream opener reads the committed-workspace baseline; the stub
// suffices here — the real workspace composition is api-proxy-workspace.spec's.
ctx.provide('workspace', { list: () => [] } as never)
return ctx
}
/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
const session = ctx.sessions.create(sessionId)
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent = {
id: session.id,
session,
inbox,
status: 'idle',
ctx,
} as Agent
ctx.agents.register(agent)
return agent
}
/** Drain `count` frames from a stream, then abort it. */
async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number, abort: AbortController): Promise<F[]> {
const frames: F[] = []
for await (const frame of iterable) {
frames.push(frame.payload)
if (frames.length >= count) abort.abort()
}
return frames
}
/** Read the next payload from an open stream. */
async function nextFrame<F>(iterator: AsyncIterator<RpcRequest<F>>): Promise<F> {
const result = await iterator.next()
if (result.done) throw new Error('stream ended')
return result.value.payload
}
describe('command.list', () => {
it('serves the addressed agent\'s name-sorted catalog', async () => {
const ctx = await harness()
ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) })
ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '<x>' }, handler: () => ({ kind: 'success' }) })
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const value = expectOk(await api.commands.list(request({ sessionId: agent.id })))
expect(value.commands).toEqual([
{ name: 'alpha', description: 'a', input: { hint: '<x>' } },
{ name: 'zeta', description: 'z' },
])
})
it('fails loud with internal when the command registry is not mounted', async () => {
const ctx = await harness({ commands: false })
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId })))
expect(error.code).toBe('internal')
expect(error.message).toContain('command registry')
})
})
describe('command.execute', () => {
it('executes a known command against the addressed agent and detaches the result', async () => {
const ctx = await harness()
let received: string | undefined
ctx.commands.register({
name: 'goal',
description: 'set goal',
handler: (invocation) => {
received = invocation.rawInput
return { kind: 'success', text: `goal:${invocation.agent.id}` }
},
})
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
expect(value).toMatchObject({ matched: true })
expect(value.commandId).toBeTruthy()
expect(received).toBe(' ship it')
// Pure admission on the wire: the outcome rides the durably logged
// lifecycle pair instead of the response.
const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
expect(lifecycle).toMatchObject([
{ type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } },
{ type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } },
])
})
it('returns matched:false when syntax or name does not resolve', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const signal = new AbortController().signal
expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false })
expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false })
})
it('maps a session miss to session-not-found and a registry gap to internal', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const missing = expectErr(await api.commands.execute(
request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal))
expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
const bare = await harness({ commands: false })
const bareApi = createApiProxy(bare, DEFAULTS)
expect(expectErr(await bareApi.commands.execute(
request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal')
})
it('reports an aborted handler as cancelled and a throwing handler as internal', async () => {
const ctx = await harness()
ctx.commands.register({
name: 'hang',
description: 'never settles on its own',
handler: () => new Promise(() => { /* settled only by abort */ }),
})
ctx.commands.register({
name: 'boom',
description: 'throws',
handler: () => { throw new Error('kaboom') },
})
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const controller = new AbortController()
const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal)
controller.abort()
expect(expectErr(await pending).code).toBe('cancelled')
const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal))
expect(thrown.code).toBe('internal')
expect(thrown.message).toContain('kaboom')
})
})
describe('skill.list', () => {
it('lists skills for the session cwd taken from the header', async () => {
const ctx = await harness()
const seenCwds: (string | undefined)[] = []
ctx.skills.registerProvider(() => ({
name: 'probe',
list: (options) => {
seenCwds.push(options.cwd)
return Promise.resolve([
{
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
invocation: { modelInvocable: true, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'user-only', description: 'User-only',
invocation: { modelInvocable: false, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'model-only', description: 'Model-only',
invocation: { modelInvocable: true, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'trusted-only', description: 'Trusted-only',
invocation: { modelInvocable: false, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
])
},
get: () => Promise.resolve(undefined),
}))
const api = createApiProxy(ctx, DEFAULTS)
// No agent is registered for this session: header resolution must not
// touch (or resume through) the Agent registry.
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
expect(value.skills).toEqual([
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
{ name: 'user-only', description: 'User-only', modelInvocable: false },
])
expect(seenCwds).toEqual(['/proj'])
expect(ctx.agents.get(session.id)).toBeUndefined()
})
it('fails loud on an unattached session id (business error, no resume attempt)', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId })))
expect(error.code).toBe('session-not-found')
})
it('fails loud with internal when the skill registry is not mounted', async () => {
const ctx = await harness({ skills: false })
const api = createApiProxy(ctx, DEFAULTS)
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
const error = expectErr(await api.skills.list(request({ sessionId: session.id })))
expect(error.code).toBe('internal')
expect(error.message).toContain('skill registry is absent')
})
it('folds a provider failure into internal', async () => {
const ctx = await harness()
ctx.skills.registerProvider(() => ({
name: 'broken',
list: () => Promise.reject(new Error('directory exploded')),
get: () => Promise.resolve(undefined),
}))
const api = createApiProxy(ctx, DEFAULTS)
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
const response = await api.skills.list(request({ sessionId: session.id }))
// dsh-skill contains one provider's failure (logs and serves the rest), so
// this surfaces as an empty ok catalog rather than an error.
const value = expectOk(response)
expect(value.skills).toEqual([])
})
})
describe('forwarded commands/change frame', () => {
it('broadcasts on registry change', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const abort = new AbortController()
const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
const collected = collect<HostFrame>(stream, 1, abort)
ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
// Verbatim forwarding: the wire name is the host's own event name and
// `args` is its argument list (empty for this pure invalidation).
expect(await collected).toEqual([{ type: 'host/remote-event', event: 'commands/change', args: [] }])
})
// The guard belongs to the forwarding boundary, so it is tested there rather
// than through a malformed `ctx.emit`: every currently allowlisted event has a
// statically JSON-safe payload, so no type-legal emit can reach the rejection
// branch. These cases stand in for a future allowlist entry whose payload the
// wire cannot carry — a composition mistake that must fail loud.
describe('assertJsonArgs', () => {
it('passes a JSON-safe argument list through unchanged', () => {
const args = ['llm-deepseek', 7, null, { nested: ['ok'] }]
expect(assertJsonArgs('settings/document-updated', args)).toEqual(args)
expect(assertJsonArgs('commands/change', [])).toEqual([])
})
it('names the offending event and argument position when a payload is not lossless JSON', () => {
expect(() => assertJsonArgs('credentials/updated', [1n]))
.toThrow('forwarded host event "credentials/updated" argument 0 is not lossless JSON data')
expect(() => assertJsonArgs('settings/document-updated', ['ns', () => {}]))
.toThrow('forwarded host event "settings/document-updated" argument 1 is not lossless JSON data')
})
})
})
/** Build one frozen inbox message. */
function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
return freezeMessage({
id: MessageId(id),
role: 'user',
content: [{ type: 'text' as const, text }],
source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
})
}
describe('session.updateQueue', () => {
it('splices a queued message and reports a lost claim race', async () => {
const ctx = await harness()
const agent = stubAgent(ctx)
const present = inboxMessage('present', 'before')
agent.inbox.splice('next-turn', 0, 0, [present])
const api = createApiProxy(ctx, DEFAULTS)
const applied = await api.sessions.updateQueue({
rpcId: RpcId('q-apply'),
payload: {
sessionId: agent.id,
itemId: MessageId('present'),
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
},
})
expect(expectOk(applied)).toEqual({ accepted: true })
const missing = await api.sessions.updateQueue({
rpcId: RpcId('q-missing'),
payload: {
sessionId: agent.id,
itemId: MessageId('claimed'),
action: { kind: 'remove' },
},
})
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
expect(agent.inbox.nextTurn[0]).toMatchObject({
id: 'present',
content: [{ type: 'text', text: 'edited' }],
})
})
it('rejects a stale occurrence without resuming a cold agent', async () => {
const ctx = await harness()
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, DEFAULTS)
const response = await api.sessions.updateQueue({
rpcId: RpcId('q-cold'),
payload: {
sessionId: 'cold-session' as SessionId,
itemId: MessageId('stale-item'),
action: { kind: 'remove' },
},
})
expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' })
expect(resume).not.toHaveBeenCalled()
})
})
describe('session/queue frames', () => {
it('publishes authoritative inbox snapshots without duplicating message identity', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const queued = inboxMessage('m-1', 'queued prompt')
const edited = inboxMessage('m-1', 'edited prompt')
const steering = inboxMessage('m-2', 'steering prompt')
agent.inbox.splice('next-turn', 0, 0, [queued])
agent.inbox.splice('next-step', 0, 0, [steering])
const abort = new AbortController()
const iterator = api.events.mux({
rpcId: RpcId('t-mux-baseline'),
payload: {},
}, abort.signal)[Symbol.asyncIterator]()
const frames = [
await nextFrame(iterator),
await nextFrame(iterator),
]
agent.inbox.splice('next-turn', 0, 1, [edited])
frames.push(await nextFrame(iterator), await nextFrame(iterator))
const injected = freezeMessage({
id: MessageId('m-3'),
role: 'user',
content: [{ type: 'text' as const, text: 'injected context' }],
source: { kind: 'plugin' as const, plugin: 'approval' },
})
agent.inbox.splice('next-step', 0, 0, [injected])
frames.push(await nextFrame(iterator), await nextFrame(iterator))
abort.abort()
await iterator.return?.()
expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([
{
type: 'session/queue',
sessionId: agent.id,
items: [
{ id: queued.id, placement: 'queued', message: queued },
{ id: steering.id, placement: 'steering', message: steering },
],
},
{
type: 'session/queue',
sessionId: agent.id,
items: [
{ id: edited.id, placement: 'queued', message: edited },
{ id: steering.id, placement: 'steering', message: steering },
],
},
{
type: 'session/queue',
sessionId: agent.id,
items: [
{ id: edited.id, placement: 'queued', message: edited },
{ id: injected.id, placement: 'context', message: injected },
{ id: steering.id, placement: 'steering', message: steering },
],
},
])
})
})

View File

@@ -21,7 +21,6 @@ function scriptedApi(overrides: {
sessions?: Partial<ApiProxy['sessions']>
subagents?: Partial<ApiProxy['subagents']>
host?: Partial<ApiProxy['host']>
commands?: Partial<ApiProxy['commands']>
skills?: Partial<ApiProxy['skills']>
agentPresets?: Partial<ApiProxy['agentPresets']>
events?: Partial<ApiProxy['events']>
@@ -89,11 +88,6 @@ function scriptedApi(overrides: {
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }),
},
commands: {
list: r => ok(r, { commands: [] }),
execute: r => ok(r, { matched: false }),
...overrides.commands,
},
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
agentPresets: {
list: r => ok(r, { presets: [], authorable: false, hasDocument: false }),

View File

@@ -1,4 +1,3 @@
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { describe, expect, it, vi } from 'vitest'
import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts'
import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts'
@@ -190,25 +189,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } }
},
},
commands: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } } }
},
async execute(request, signal) {
if (request.payload.line === '/hang') {
// Cooperative hang: settles only through the carrier signal (sticky
// abort checked first — listeners never fire retroactively).
if (!signal.aborted) {
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
}
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } }
}
if (request.payload.line.startsWith('/plan')) {
return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, commandId: CommandId('cmd-x') } } }
}
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
},
},
agentPresets: {
list(request: RpcRequest<{}>) {
return Promise.resolve({
@@ -441,19 +421,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(response.result).toEqual({ ok: true, value: { opened: true } })
})
it('round-trips command.list / command.execute / skill.list through the wire form', async () => {
it('round-trips skill.list through the wire form', async () => {
const c = client()
const list = await c.commands.list({ sessionId: 's' as never })
expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } })
const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' })
expect(hit.result).toEqual({ ok: true, value: { matched: true, commandId: 'cmd-x' } })
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
const skills = await c.skills.list({ sessionId: 's' as never })
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } })
})
it('lets command.execute finish after the 30-second default unary deadline', async () => {
it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => {
vi.useFakeTimers()
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => {
const controller = new AbortController()
@@ -464,16 +438,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
})
try {
const api = fakeApi()
api.commands.execute = async (request) => {
api.host.pickDirectory = async (request) => {
await new Promise(resolve => setTimeout(resolve, 30_001))
return {
rpcId: request.rpcId,
result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } },
}
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } }
}
const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' })
const execution = client(api).host.pickDirectory({})
const assertion = expect(execution).resolves.toMatchObject({
result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } },
result: { ok: true, value: { path: '/tmp/slow' } },
})
await Promise.all([
@@ -509,10 +480,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
})).result).toEqual({ ok: true, value: { accepted: true } })
})
it('keeps caller and connection aborts on command.execute', async () => {
it('keeps caller and connection aborts on a deadline-exempt unary', async () => {
const api = fakeApi()
const started = Promise.withResolvers<AbortSignal>()
api.commands.execute = async (request, signal) => {
api.host.pickDirectory = async (request, signal) => {
started.resolve(signal)
if (!signal.aborted) {
await new Promise<void>((resolve) => {
@@ -525,10 +496,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
}
}
const controller = new AbortController()
const execution = client(api).commands.execute(
{ sessionId: 's' as never, line: '/hang' },
controller.signal,
)
const execution = client(api).host.pickDirectory({}, controller.signal)
const handlerSignal = await started.promise
controller.abort(new Error('connection closed'))