From 4ddec0ba2f3082588dc00f0647549da9ef06031d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:38:24 +0800 Subject: [PATCH] refactor: command.execute degrades to pure admission; composer notice channel retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 26 +++++++------- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 4 +-- .../connection/tests/fixture-commands.spec.ts | 26 +++++++++++--- .../client/ui-command/src/client/service.ts | 34 +++++++++++------- .../client/ui-command/tests/service.spec.ts | 36 +++++++++---------- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 9 +++-- .../host/apiproxy/src/api/commands.schema.ts | 11 ++---- packages/host/apiproxy/src/api/commands.ts | 19 +++++----- packages/host/apiproxy/src/api/index.ts | 2 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 9 ++++- .../host/apiproxy/tests/fetch-carrier.spec.ts | 4 +-- .../host/apiproxy/tests/rpc-schemas.spec.ts | 8 ++--- 17 files changed, 110 insertions(+), 90 deletions(-) diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index edc5b2e25d..6bc07fd488 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -9,7 +9,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, WorkspaceApi, WorkspaceId, WorkspaceView, - CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f53f7ac22e..c1cd17f741 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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) => { const missing = requireSession(request) if (missing !== undefined) return missing + const id = request.payload.sessionId const line = request.payload.line.trim() const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line) const name = match?.[1] - if (name === 'compact' || name === 'echo') { - return ok(request, { - matched: true as const, - result: { kind: 'success' as const, text: name === 'echo' ? (match?.[2] ?? '') : 'fixture:已压缩(假动作)' }, - }) + const outcomes: Record = { + compact: 'fixture:已压缩(假动作)', + echo: match?.[2] ?? '', + 'goal-fixture': `fixture:goal 已设置(${id})`, } - if (name === 'goal-fixture') { - return ok(request, { - matched: true as const, - result: { kind: 'success' as const, text: `fixture:goal 已设置(${request.payload.sessionId})` }, - }) - } - return ok(request, { matched: false as const }) + const text = name === undefined ? undefined : outcomes[name] + if (name === undefined || text === undefined) return ok(request, { matched: false as const }) + const commandId = `fx-cmd-${logOf(id).length}` + 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 }) }, }, skills: { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index d4505eb659..3b1beb1f83 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -14,7 +14,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, - CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index cfabe476e3..b5982e3729 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -2,7 +2,7 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, + CommandDescriptor, HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId, SkillEntry, } 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. onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> + onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index d3c62e736b..6840ec50b3 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -36,20 +36,36 @@ describe('createFixtureApi commands/skills', () => { 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 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) if (!response.result.ok) throw new Error('execute failed') - expect(response.result.value.matched).toBe(true) - expect(response.result.value.result).toEqual({ kind: 'success', text: 'hello world' }) + expect(response.result.value).toEqual({ matched: true }) + await pump + const events = frames + .filter((f): f is { type: string; event: { type: string; data: Record } } => (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 hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal) if (!hit.result.ok) throw new Error('execute failed') 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) expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index df59ad2dcd..22f4a911b3 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -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( session: ClientSessionContext, line: string, @@ -236,25 +244,25 @@ export class CommandService extends Service implements CommandServiceContract { 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.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` } - const detached = result.value.result - return detached === undefined - ? { kind: 'success' } - : { kind: detached.kind, ...(detached.text !== undefined ? { text: detached.text } : {}) } + return { kind: 'success' } } /** - * Fire-and-forget execute for the internal ('handled') paths. The detached - * result surfaces as a notice routed to the triggering session's composer, - * so a late result lands on its own session after a switch. + * Fire-and-forget execute for the internal ('handled') paths. Outcomes are + * NOT surfaced here: the host executor durably logs the command lifecycle + * (`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 { void this.execute(session, line).then( (outcome) => { - if (outcome.kind === 'error') this.noticeFor(session.sessionId, desc.name, 'error', outcome.text ?? `/${desc.name} failed`) - else if (outcome.text !== undefined) this.noticeFor(session.sessionId, desc.name, 'info', outcome.text) + // matched:false maps to an error outcome with no logged lifecycle. + if (outcome.kind === 'error') this.noticeFor(session.sessionId, 'error', outcome.text ?? `/${desc.name} failed`) }, (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). */ - private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void { + /** Route an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */ + private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void { const actx = this.scopeFor(id) if (actx === undefined) return const conversation = actx.get('conversation') diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index 0cf94e2f82..d3e6b5d34c 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -31,7 +31,7 @@ const S2_CMDS: CommandDescriptor[] = [ { name: 'attach', description: 'scoped shadow', input: { hint: 'path' } }, ] -type ExecuteValue = { matched: boolean; result?: { kind: 'success' | 'error'; text?: string } } +type ExecuteValue = { matched: boolean } interface BenchOptions { /** Scripted catalog per list payload; default serves the fixed catalogs by session. */ @@ -361,16 +361,18 @@ describe('matchEnter (enter column)', () => { }) 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({ - execute: () => Promise.resolve({ matched: true, result: { kind: 'success', text: 'goal set' } }), + execute: () => Promise.resolve({ matched: true }), }) await warm(proj('s1')) const outcome = source.matchSpace!(proj('s1'), '/goal') if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') const settled = await outcome.claim.submit('ship it', new Context()) 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 () => { @@ -389,33 +391,29 @@ describe('execute payload', () => { }) }) -describe('detached result notices', () => { +describe('detached admission notices', () => { const flush = () => new Promise(resolve => setTimeout(resolve, 0)) - it('success text → info; error result → error; rejection → error, all on the triggering session', async () => { - let mode: 'info' | 'error' | 'reject' = 'info' + it('admitted outcomes stay silent; admission miss and transport rejection notice as errors', async () => { + let mode: 'admitted' | 'miss' | 'reject' = 'admitted' const { source, mint, warm, notices } = await bench({ execute: () => { if (mode === 'reject') return Promise.reject(new Error('network down')) - return Promise.resolve({ - matched: true, - result: mode === 'info' - ? { kind: 'success' as const, text: 'compacted 12 messages' } - : { kind: 'error' as const, text: 'plan mode refused' }, - }) + return Promise.resolve({ matched: mode === 'admitted' }) }, }) mint('s1') await warm(proj('s1')) + // Admitted: the durable lifecycle events own the outcome — no notice. menuPick(source, 'plan', proj('s1')) await flush() - expect(notices).toEqual([{ scope: sid('s1'), level: 'info', text: 'compacted 12 messages' }]) + expect(notices).toEqual([]) - notices.length = 0 - mode = 'error' + // Admission miss (matched:false): immediate composer feedback stays. + mode = 'miss' await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal) 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 mode = 'reject' @@ -424,9 +422,9 @@ describe('detached result notices', () => { 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({ - 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 menuPick(source, 'plan', proj('ghost')) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index aee09384de..3e340567a8 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 253c0974cc1427fb7140c332fabdccbfc049ae86 -README.zh.md: d79628ca3e1f1d06ad94a0dada16e2208af3cd2c +README.md: 0e8699e513452030bfa4ffc62737df928c161603 +README.zh.md: 8b19d0357389f616ec8a4120beb2cf8d8d7686d8 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 69e616193b..0e8699e513 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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. -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) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index d79628ca3e..8b19d03573 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -18,7 +18,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `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` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index ae23d9fd47..ad0f8fc8e4 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -919,12 +919,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro 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 only reports whether the line resolved to a handler. const result = await commands.execute(found.agent, line, signal) - if (result === undefined) return ok(request, { matched: false }) - return ok(request, { - matched: true, - result: { kind: result.kind, ...result.text === undefined ? {} : { text: result.text } }, - }) + return ok(request, { matched: result !== undefined }) } 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: {} }) diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts index d748d609c1..ba0c5a8e0e 100644 --- a/packages/host/apiproxy/src/api/commands.schema.ts +++ b/packages/host/apiproxy/src/api/commands.schema.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import { sessionIdSchema } from './sessions.schema.ts' -import type { CommandDescriptor, CommandExecuteResult } from './commands.ts' +import type { CommandDescriptor } from './commands.ts' /** CommandDescriptor row of command.list. */ export const commandDescriptorSchema = z.object({ @@ -32,14 +32,7 @@ export const commandExecuteRequestSchema = z.object({ line: z.string(), }) satisfies z.ZodType>> -/** Detached command outcome (result slot of command.execute's value). */ -export const commandExecuteResultSchema = z.object({ - kind: z.union([z.literal('success'), z.literal('error')]), - text: z.string().optional(), -}) satisfies z.ZodType> - -/** command.execute response value (matched=false carries no result). */ +/** command.execute response value: pure admission — outcomes ride the logged lifecycle events, never this response. */ export const commandExecuteValueSchema = z.object({ matched: z.boolean(), - result: commandExecuteResultSchema.optional(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/commands.ts b/packages/host/apiproxy/src/api/commands.ts index 7520d91804..08d25a4dec 100644 --- a/packages/host/apiproxy/src/api/commands.ts +++ b/packages/host/apiproxy/src/api/commands.ts @@ -22,12 +22,6 @@ export interface CommandDescriptor { 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). */ export interface CommandsApi { /** @@ -38,11 +32,14 @@ export interface CommandsApi { /** * Parses and executes one slash-command line against the addressed agent - * without sending it to the model. matched=false when syntax or name does - * not resolve (the client falls back to its default sink). The signal rides - * beside the request, never on the wire: the fetch carrier's request signal - * cancels the running handler. + * 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. 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): - Promise> + Promise> } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 23b08a2ef0..976f80abbc 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -28,7 +28,7 @@ export interface ApiProxy { export type { HistoryEntry, SessionProjectionsBlock, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.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 { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { ApprovalResponsePayload } from './approvals.ts' diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 480f821b95..f284549335 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -115,8 +115,15 @@ describe('command.execute', () => { 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).toEqual({ matched: true, result: { kind: 'success', text: `goal:${agent.id}` } }) + expect(value).toEqual({ matched: true }) 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 () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index f90fd72e8a..d4d146294b 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -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: {} } } } } 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 } } } }, @@ -163,7 +163,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { 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, 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' }) expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 5002f8b857..669bef3e45 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -215,10 +215,10 @@ describe('commands domain schemas', () => { expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow() expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow() expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false }) - const matched = commandExecuteValueSchema.parse({ matched: true, result: { kind: 'success', text: 'done' } }) - expect(matched.result?.kind).toBe('success') - expect(commandExecuteValueSchema.parse({ matched: true, result: { kind: 'error', text: 'bad' } }).result?.kind).toBe('error') - expect(() => commandExecuteValueSchema.parse({ matched: true, result: { kind: 'other' } })).toThrow() + // Pure admission: the value carries only the matched bit (outcomes ride + // the logged lifecycle events, never this response). + expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true }) + expect(() => commandExecuteValueSchema.parse({})).toThrow() }) })