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,
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 {

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

View File

@@ -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,

View File

@@ -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: [] }))

View File

@@ -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' } })

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(
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')

View File

@@ -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'))