Browse Source

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.
imccyu 1 tháng trước cách đây
mục cha
commit
4ddec0ba2f

+ 1 - 1
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 {

+ 14 - 12
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:已压缩(假动作)' },
-          })
-        }
-        if (name === 'goal-fixture') {
-          return ok(request, {
-            matched: true as const,
-            result: { kind: 'success' as const, text: `fixture:goal 已设置(${request.payload.sessionId})` },
-          })
+        const outcomes: Record<string, string> = {
+          compact: 'fixture:已压缩(假动作)',
+          echo: match?.[2] ?? '',
+          'goal-fixture': `fixture:goal 已设置(${id})`,
         }
-        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: {

+ 1 - 1
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,

+ 2 - 2
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<RpcResponse<{ commands: CommandDescriptor[] }>>
     = () => 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 }))
   onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
     = () => Promise.resolve(ok({ skills: [] }))

+ 21 - 5
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<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 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' } })

+ 21 - 13
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')

+ 17 - 19
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'))

+ 2 - 2
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

+ 1 - 1
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)
 

+ 1 - 1
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` + 根路径)
 

+ 4 - 5
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: {} })

+ 2 - 9
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<Wire<RequestPayload<'command.execute'>>>
 
-/** 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<Wire<CommandExecuteResult>>
-
-/** 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<Wire<ResponseValue<'command.execute'>>>

+ 8 - 11
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<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
+  Promise<RpcResponse<{ matched: boolean }>>
 }

+ 1 - 1
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'

+ 8 - 1
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 () => {

+ 2 - 2
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 })

+ 4 - 4
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()
   })
 })