refactor: command.execute degrades to pure admission; composer notice channel retired

The wire response now carries only the matched bit — CommandExecuteResult
is deleted from the api, schema, and client mirrors (pre-release, no shim);
outcomes ride the durably logged command/run/command/done pair broadcast on
the mux stream and render as flow nodes. ui-command's runDetached→noticeFor
outcome routing is retired: admitted commands surface nothing through the
composer, while admission misses (matched:false, syntax feedback) and
transport failures keep their immediate notice. The connection fixture
mirrors the host: an admitted command appends the lifecycle pair to the
session log instead of returning result text.
This commit is contained in:
imccyu
2026-07-27 17:38:24 +08:00
parent ba928c5517
commit 4ddec0ba2f
17 changed files with 110 additions and 90 deletions

View File

@@ -9,7 +9,7 @@ export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
WorkspaceApi, WorkspaceId, WorkspaceView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
} from '@deepseek-ai/dsh-host-apiproxy/api' } from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type { export type {

View File

@@ -808,25 +808,27 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
], ],
}) })
}, },
// Pure admission, mirroring the host: an admitted command logs the
// command/run + command/done lifecycle pair (mux-broadcast by append),
// and the response only reports resolution.
execute: (request) => { execute: (request) => {
const missing = requireSession(request) const missing = requireSession(request)
if (missing !== undefined) return missing if (missing !== undefined) return missing
const id = request.payload.sessionId
const line = request.payload.line.trim() const line = request.payload.line.trim()
const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line) const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
const name = match?.[1] const name = match?.[1]
if (name === 'compact' || name === 'echo') { const outcomes: Record<string, string> = {
return ok(request, { compact: 'fixture:已压缩(假动作)',
matched: true as const, echo: match?.[2] ?? '',
result: { kind: 'success' as const, text: name === 'echo' ? (match?.[2] ?? '') : 'fixture:已压缩(假动作)' }, 'goal-fixture': `fixture:goal 已设置(${id})`,
})
} }
if (name === 'goal-fixture') { const text = name === undefined ? undefined : outcomes[name]
return ok(request, { if (name === undefined || text === undefined) return ok(request, { matched: false as const })
matched: true as const, const commandId = `fx-cmd-${logOf(id).length}`
result: { kind: 'success' as const, text: `fixture:goal 已设置(${request.payload.sessionId})` }, append(id, { type: 'command/run', data: { commandId, name, line, source: { kind: 'user' } } })
}) append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
} return ok(request, { matched: true as const })
return ok(request, { matched: false as const })
}, },
}, },
skills: { skills: {

View File

@@ -14,7 +14,7 @@ export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -2,7 +2,7 @@
// data source on a real clock; behavior tests need per-case responses and // data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { import type {
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, CommandDescriptor, HostFrame, IApiClient, MuxFrame,
RpcRequest, RpcResponse, SessionId, SkillEntry, RpcRequest, RpcResponse, SessionId, SkillEntry,
} from '../src/client/api.ts' } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts'
@@ -94,7 +94,7 @@ export class FakeApiClient implements IApiClient {
// wire shapes so cases can program catalogs and skill lists without casts. // wire shapes so cases can program catalogs and skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] })) = () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean }>>
= () => Promise.resolve(ok({ matched: false })) = () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] })) = () => Promise.resolve(ok({ skills: [] }))

View File

@@ -36,20 +36,36 @@ describe('createFixtureApi commands/skills', () => {
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
}) })
it('executes a known command line and reports matched with a result', async () => { it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
const api = createFixtureApi() const api = createFixtureApi()
const frames: unknown[] = []
const abort = new AbortController()
const stream = api.events.mux(req({}), abort.signal)
const pump = (async () => {
for await (const frame of stream) {
frames.push(frame.payload)
if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
}
})()
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal) const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
if (!response.result.ok) throw new Error('execute failed') if (!response.result.ok) throw new Error('execute failed')
expect(response.result.value.matched).toBe(true) expect(response.result.value).toEqual({ matched: true })
expect(response.result.value.result).toEqual({ kind: 'success', text: 'hello world' }) await pump
const events = frames
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
.map(f => f.event)
expect(events).toMatchObject([
{ type: 'command/run', data: { name: 'echo', line: '/echo hello world', source: { kind: 'user' } } },
{ type: 'command/done', data: { kind: 'success', text: 'hello world' } },
])
expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)
}) })
it('addresses execute to the session (result text carries the id)', async () => { it('addresses execute to the session; an unknown session errs', async () => {
const api = createFixtureApi() const api = createFixtureApi()
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal) const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
if (!hit.result.ok) throw new Error('execute failed') if (!hit.result.ok) throw new Error('execute failed')
expect(hit.result.value.matched).toBe(true) expect(hit.result.value.matched).toBe(true)
expect(hit.result.value.result?.text).toContain('fx-alpha')
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal) const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })

View File

@@ -227,7 +227,15 @@ export class CommandService extends Service implements CommandServiceContract {
} }
} }
/** The command.execute transaction, addressed to the session's agent. */ /**
* The command.execute transaction, addressed to the session's agent — pure
* admission semantics. An unmatched line reports an error outcome (the
* composer's immediate admission feedback); an admitted command reports
* plain success regardless of its handler outcome, because the host
* executor durably logged the lifecycle (`command/run`/`command/done`) and
* the outcome renders as a persistent flow node — the composer never
* echoes it. Transport failures throw.
*/
private async execute( private async execute(
session: ClientSessionContext, session: ClientSessionContext,
line: string, line: string,
@@ -236,25 +244,25 @@ export class CommandService extends Service implements CommandServiceContract {
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line }) const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` } if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
const detached = result.value.result return { kind: 'success' }
return detached === undefined
? { kind: 'success' }
: { kind: detached.kind, ...(detached.text !== undefined ? { text: detached.text } : {}) }
} }
/** /**
* Fire-and-forget execute for the internal ('handled') paths. The detached * Fire-and-forget execute for the internal ('handled') paths. Outcomes are
* result surfaces as a notice routed to the triggering session's composer, * NOT surfaced here: the host executor durably logs the command lifecycle
* so a late result lands on its own session after a switch. * (`command/run`/`command/done`), and the mux-broadcast events render as a
* persistent flow node on every tab. Only a transport/admission failure —
* which never entered a handler and therefore never logged — falls back to
* the composer notice as immediate feedback.
*/ */
private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void { private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
void this.execute(session, line).then( void this.execute(session, line).then(
(outcome) => { (outcome) => {
if (outcome.kind === 'error') this.noticeFor(session.sessionId, desc.name, 'error', outcome.text ?? `/${desc.name} failed`) // matched:false maps to an error outcome with no logged lifecycle.
else if (outcome.text !== undefined) this.noticeFor(session.sessionId, desc.name, 'info', outcome.text) if (outcome.kind === 'error') this.noticeFor(session.sessionId, 'error', outcome.text ?? `/${desc.name} failed`)
}, },
(error: unknown) => { (error: unknown) => {
this.noticeFor(session.sessionId, desc.name, 'error', error instanceof Error ? error.message : String(error)) this.noticeFor(session.sessionId, 'error', error instanceof Error ? error.message : String(error))
}, },
) )
} }
@@ -270,8 +278,8 @@ export class CommandService extends Service implements CommandServiceContract {
}) })
} }
/** Route a detached result to the session's composer notice channel (scope gone = attempt died with it). */ /** Route an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */
private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void { private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void {
const actx = this.scopeFor(id) const actx = this.scopeFor(id)
if (actx === undefined) return if (actx === undefined) return
const conversation = actx.get('conversation') const conversation = actx.get('conversation')

View File

@@ -31,7 +31,7 @@ const S2_CMDS: CommandDescriptor[] = [
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } }, { name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
] ]
type ExecuteValue = { matched: boolean; result?: { kind: 'success' | 'error'; text?: string } } type ExecuteValue = { matched: boolean }
interface BenchOptions { interface BenchOptions {
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */ /** Scripted catalog per list payload; default serves the fixed catalogs by session. */
@@ -361,16 +361,18 @@ describe('matchEnter (enter column)', () => {
}) })
describe('execute payload', () => { describe('execute payload', () => {
it('claim.submit addresses the session and maps the detached result', async () => { it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
const { source, warm, executeCalls } = await bench({ const { source, warm, executeCalls } = await bench({
execute: () => Promise.resolve({ matched: true, result: { kind: 'success', text: 'goal set' } }), execute: () => Promise.resolve({ matched: true }),
}) })
await warm(proj('s1')) await warm(proj('s1'))
const outcome = source.matchSpace!(proj('s1'), '/goal') const outcome = source.matchSpace!(proj('s1'), '/goal')
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
const settled = await outcome.claim.submit('ship it', new Context()) const settled = await outcome.claim.submit('ship it', new Context())
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }]) expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
expect(settled).toEqual({ kind: 'success', text: 'goal set' }) // Pure admission: no outcome text ever rides the submit result — the
// durable command lifecycle events render the outcome in the flow.
expect(settled).toEqual({ kind: 'success' })
}) })
it('maps matched:false to an error outcome and a matched bare result to success', async () => { it('maps matched:false to an error outcome and a matched bare result to success', async () => {
@@ -389,33 +391,29 @@ describe('execute payload', () => {
}) })
}) })
describe('detached result notices', () => { describe('detached admission notices', () => {
const flush = () => new Promise(resolve => setTimeout(resolve, 0)) const flush = () => new Promise(resolve => setTimeout(resolve, 0))
it('success text → info; error result → error; rejection → error, all on the triggering session', async () => { it('admitted outcomes stay silent; admission miss and transport rejection notice as errors', async () => {
let mode: 'info' | 'error' | 'reject' = 'info' let mode: 'admitted' | 'miss' | 'reject' = 'admitted'
const { source, mint, warm, notices } = await bench({ const { source, mint, warm, notices } = await bench({
execute: () => { execute: () => {
if (mode === 'reject') return Promise.reject(new Error('network down')) if (mode === 'reject') return Promise.reject(new Error('network down'))
return Promise.resolve({ return Promise.resolve({ matched: mode === 'admitted' })
matched: true,
result: mode === 'info'
? { kind: 'success' as const, text: 'compacted 12 messages' }
: { kind: 'error' as const, text: 'plan mode refused' },
})
}, },
}) })
mint('s1') mint('s1')
await warm(proj('s1')) await warm(proj('s1'))
// Admitted: the durable lifecycle events own the outcome — no notice.
menuPick(source, 'plan', proj('s1')) menuPick(source, 'plan', proj('s1'))
await flush() await flush()
expect(notices).toEqual([{ scope: sid('s1'), level: 'info', text: 'compacted 12 messages' }]) expect(notices).toEqual([])
notices.length = 0 // Admission miss (matched:false): immediate composer feedback stays.
mode = 'error' mode = 'miss'
await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal) await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
await flush() await flush()
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'plan mode refused' }]) expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
notices.length = 0 notices.length = 0
mode = 'reject' mode = 'reject'
@@ -424,9 +422,9 @@ describe('detached result notices', () => {
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }]) expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }])
}) })
it('success without text stays silent; a torn-down scope drops the notice', async () => { it('a torn-down scope drops the failure notice', async () => {
const { source, warm, notices } = await bench({ const { source, warm, notices } = await bench({
execute: () => Promise.resolve({ matched: true, result: { kind: 'success' as const, text: 'orphan' } }), execute: () => Promise.reject(new Error('orphan failure')),
}) })
await warm(proj('ghost')) // never minted: scopeFor misses await warm(proj('ghost')) // never minted: scopeFor misses
menuPick(source, 'plan', proj('ghost')) menuPick(source, 'plan', proj('ghost'))

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 253c0974cc1427fb7140c332fabdccbfc049ae86 README.md: 0e8699e513452030bfa4ffc62737df928c161603
README.zh.md: d79628ca3e1f1d06ad94a0dada16e2208af3cd2c README.zh.md: 8b19d0357389f616ec8a4120beb2cf8d8d7686d8

View File

@@ -20,7 +20,7 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state. `session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports only whether the line resolved to a handler, while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
## Carrier layer (`/client` + root) ## Carrier layer (`/client` + root)

View File

@@ -18,7 +18,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。 `session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应只报告该行是否解析到处理器,结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
## 载体层(`/client` + 根路径) ## 载体层(`/client` + 根路径)

View File

@@ -919,12 +919,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const found = await agentFor(sessionId) const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error) if ('error' in found) return err(request, found.error)
try { try {
// Pure admission: the executor's durable command/run + command/done
// pair (broadcast on the mux stream) carries the outcome; the
// response only reports whether the line resolved to a handler.
const result = await commands.execute(found.agent, line, signal) const result = await commands.execute(found.agent, line, signal)
if (result === undefined) return ok(request, { matched: false }) return ok(request, { matched: result !== undefined })
return ok(request, {
matched: true,
result: { kind: result.kind, ...result.text === undefined ? {} : { text: result.text } },
})
} catch (error: unknown) { } catch (error: unknown) {
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} }) 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: {} }) return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })

View File

@@ -7,7 +7,7 @@ import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts' import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts' import { sessionIdSchema } from './sessions.schema.ts'
import type { CommandDescriptor, CommandExecuteResult } from './commands.ts' import type { CommandDescriptor } from './commands.ts'
/** CommandDescriptor row of command.list. */ /** CommandDescriptor row of command.list. */
export const commandDescriptorSchema = z.object({ export const commandDescriptorSchema = z.object({
@@ -32,14 +32,7 @@ export const commandExecuteRequestSchema = z.object({
line: z.string(), line: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>> }) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
/** Detached command outcome (result slot of command.execute's value). */ /** command.execute response value: pure admission — outcomes ride the logged lifecycle events, never this response. */
export const commandExecuteResultSchema = z.object({
kind: z.union([z.literal('success'), z.literal('error')]),
text: z.string().optional(),
}) satisfies z.ZodType<Wire<CommandExecuteResult>>
/** command.execute response value (matched=false carries no result). */
export const commandExecuteValueSchema = z.object({ export const commandExecuteValueSchema = z.object({
matched: z.boolean(), matched: z.boolean(),
result: commandExecuteResultSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>> }) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>

View File

@@ -22,12 +22,6 @@ export interface CommandDescriptor {
readonly input?: { readonly hint: string } readonly input?: { readonly hint: string }
} }
/** Detached command outcome rendered directly by the requesting client. */
export interface CommandExecuteResult {
readonly kind: 'success' | 'error'
readonly text?: string
}
/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */ /** Command-domain unary methods (the map keys command.* of RpcMethodMap). */
export interface CommandsApi { export interface CommandsApi {
/** /**
@@ -38,11 +32,14 @@ export interface CommandsApi {
/** /**
* Parses and executes one slash-command line against the addressed agent * Parses and executes one slash-command line against the addressed agent
* without sending it to the model. matched=false when syntax or name does * without sending it to the model — pure admission semantics. matched=false
* not resolve (the client falls back to its default sink). The signal rides * when syntax or name does not resolve (the client falls back to its
* beside the request, never on the wire: the fetch carrier's request signal * default sink). The handler's outcome does NOT ride the response: the host
* cancels the running handler. * executor durably logs the lifecycle (`command/run`/`command/done`), which
* broadcasts on the mux stream and renders as a persistent flow node. The
* signal rides beside the request, never on the wire: the fetch carrier's
* request signal cancels the running handler.
*/ */
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal): execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> Promise<RpcResponse<{ matched: boolean }>>
} }

View File

@@ -28,7 +28,7 @@ export interface ApiProxy {
export type { HistoryEntry, SessionProjectionsBlock, SessionsApi, SessionSummary } from './sessions.ts' export type { HistoryEntry, SessionProjectionsBlock, SessionsApi, SessionSummary } from './sessions.ts'
export type { HostApi } from './host.ts' export type { HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts' export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { ApprovalResponsePayload } from './approvals.ts' export type { ApprovalResponsePayload } from './approvals.ts'

View File

@@ -115,8 +115,15 @@ describe('command.execute', () => {
const api = createApiProxy(ctx, DEFAULTS) const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx) const agent = stubAgent(ctx)
const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal)) const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
expect(value).toEqual({ matched: true, result: { kind: 'success', text: `goal:${agent.id}` } }) expect(value).toEqual({ matched: true })
expect(received).toBe(' ship it') 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: { name: 'goal', line: '/goal ship it' } },
{ type: 'command/done', data: { kind: 'success', text: `goal:${agent.id}` } },
])
}) })
it('returns matched:false when syntax or name does not resolve', async () => { it('returns matched:false when syntax or name does not resolve', async () => {

View File

@@ -91,7 +91,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } } return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } }
} }
if (request.payload.line.startsWith('/plan')) { if (request.payload.line.startsWith('/plan')) {
return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, result: { kind: 'success' as const, text: 'plan set' } } } } return { rpcId: request.rpcId, result: { ok: true, value: { matched: true } } }
} }
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } } return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
}, },
@@ -163,7 +163,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const list = await c.commands.list({ sessionId: 's' as never }) 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' } }] } }) 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' }) const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' })
expect(hit.result).toEqual({ ok: true, value: { matched: true, result: { kind: 'success', text: 'plan set' } } }) expect(hit.result).toEqual({ ok: true, value: { matched: true } })
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' }) const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
expect(miss.result).toEqual({ ok: true, value: { matched: false } }) expect(miss.result).toEqual({ ok: true, value: { matched: false } })
const skills = await c.skills.list({ sessionId: 's' as never }) const skills = await c.skills.list({ sessionId: 's' as never })

View File

@@ -215,10 +215,10 @@ describe('commands domain schemas', () => {
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow() expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow() expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false }) expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
const matched = commandExecuteValueSchema.parse({ matched: true, result: { kind: 'success', text: 'done' } }) // Pure admission: the value carries only the matched bit (outcomes ride
expect(matched.result?.kind).toBe('success') // the logged lifecycle events, never this response).
expect(commandExecuteValueSchema.parse({ matched: true, result: { kind: 'error', text: 'bad' } }).result?.kind).toBe('error') expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true })
expect(() => commandExecuteValueSchema.parse({ matched: true, result: { kind: 'other' } })).toThrow() expect(() => commandExecuteValueSchema.parse({})).toThrow()
}) })
}) })