refactor: structured command/run payload {commandId, name, args, source}

The line field is deleted (pre-release, no shim): name and args are
parseCommand's own split — name plus verbatim rawInput with its separator
whitespace — so a consumer (a projection unit folding its own command
records, a rich command card) never re-parses a line. CommandNode mirrors
the split (name/args, both null on a run-less cross-window node); the
generic card rebuilds its display line as /name + args. The connection
fixture logs the same structured payload.
This commit is contained in:
imccyu
2026-07-27 20:57:23 +08:00
parent f72f06e84a
commit 2ebaa30c6d
16 changed files with 49 additions and 40 deletions

View File

@@ -815,18 +815,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
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)
// Structured split mirroring the host parser: name + verbatim rawInput
// (separator whitespace included) — the run payload carries no line.
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
const name = match?.[1]
const args = match?.[2] ?? ''
const outcomes: Record<string, string> = {
compact: 'fixture已压缩假动作',
echo: match?.[2] ?? '',
echo: args.trim(),
'goal-fixture': `fixturegoal 已设置(${id}`,
}
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/run', data: { commandId, name, args, source: { kind: 'user' } } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
return ok(request, { matched: true as const })
},

View File

@@ -55,7 +55,7 @@ describe('createFixtureApi commands/skills', () => {
.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/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } },
{ type: 'command/done', data: { kind: 'success', text: 'hello world' } },
])
expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)

View File

@@ -126,7 +126,7 @@ export interface UnknownSurfaceNode {
* Log-only events never enter the surface fold, so the FoldAdapter indexes
* them separately and merges the nodes into the flow by seq. A window cut
* between the pair soft-falls like tool pairs: a done with no in-window run
* still builds a node (name/line null), and a run with no done renders as
* still builds a node (name/args null), and a run with no done renders as
* still executing.
*/
export interface CommandNode {
@@ -137,10 +137,10 @@ export interface CommandNode {
time: number
/** Pairing id minted by the host executor. */
commandId: string
/** Command name (run payload); null when the run fell outside the window. */
/** Command name (run payload's structured field); null when the run fell outside the window. */
name: string | null
/** Exact dispatched command line (run payload); null when the run fell outside the window. */
line: string | null
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
args: string | null
/** Settlement outcome (done payload); null while the command is still executing. */
outcome: { kind: 'success' | 'error'; text?: string } | null
}

View File

@@ -229,10 +229,10 @@ export class FoldAdapter {
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: string; name: string; line: string }
const data = event.data as unknown as { commandId: string; name: string; args: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, line: data.line, outcome: null,
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
})
return
}
@@ -245,7 +245,7 @@ export class FoldAdapter {
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: null, line: null, outcome,
commandId: data.commandId, name: null, args: null, outcome,
})
return
}

View File

@@ -42,8 +42,8 @@ export const ev = {
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
at(seq, { type: 'todo/write', data: { todos } }),
commandRun: (seq: number, commandId: string, name: string, line: string): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, line, source: { kind: 'user' } } }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
}

View File

@@ -148,23 +148,23 @@ describe('FoldAdapter', () => {
const adapter = new FoldAdapter()
adapter.reset([
ev.user(0, '先说话'),
ev.commandRun(1, 'cmd-1', 'plan', '/plan'),
ev.commandRun(1, 'cmd-1', 'plan'),
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
ev.assistant(3, 0, '然后回答'),
], 0)
const { nodes } = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
expect(nodes[1]).toMatchObject({
kind: 'command', commandId: 'cmd-1', name: 'plan', line: '/plan',
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
it('renders a run with no done as still executing (outcome null)', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', '/goal ship it')], 0)
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'command', name: 'goal', line: '/goal ship it', outcome: null,
kind: 'command', name: 'goal', args: ' ship it', outcome: null,
})
})
@@ -172,7 +172,7 @@ describe('FoldAdapter', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, line: null,
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
outcome: { kind: 'error', text: '失败了' },
})
})
@@ -180,7 +180,7 @@ describe('FoldAdapter', () => {
it('settles a live-appended done in place, keeping the node at the run seq', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
adapter.append(ev.commandRun(6, 'cmd-4', 'clear', '/clear'))
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
expect(running).toMatchObject({ outcome: null })
adapter.append(ev.commandDone(7, 'cmd-4'))
@@ -192,7 +192,7 @@ describe('FoldAdapter', () => {
it('tails command nodes whose seq is past every surface node', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan', '/plan')], 0)
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
})
@@ -201,7 +201,7 @@ describe('FoldAdapter', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
ev.commandRun(0, 'cmd-5', 'plan', '/plan'),
ev.commandRun(0, 'cmd-5', 'plan'),
ev.commandDone(1, 'cmd-5'),
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
], 0)

View File

@@ -103,9 +103,9 @@ describe('live event path', () => {
// Live path: run mints an executing node, done settles it in the flow.
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(6, 'cmd-live', 'plan', '/plan'))
feed(ev.commandRun(6, 'cmd-live', 'plan'))
let command = session.getSnapshot().nodes.at(-1)
expect(command).toMatchObject({ kind: 'command', name: 'plan', line: '/plan', outcome: null })
expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
command = session.getSnapshot().nodes.at(-1)
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
@@ -113,7 +113,7 @@ describe('live event path', () => {
// Replay path (refresh): the same pair inside the history window folds identically.
const replayed = await opened([
...plainTurn(0, 0, 'a', 'b'),
ev.commandRun(6, 'cmd-live', 'plan', '/plan'),
ev.commandRun(6, 'cmd-live', 'plan'),
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
])
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({

View File

@@ -20,12 +20,15 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
// Display line rebuilt from the structured payload (args carries its own
// separator whitespace verbatim); a cross-window node whose run page fell
// out of the window has neither.
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
return (
<ToolRow
variant="others"
icon={<IconApiOutline14 size={16} />}
// A cross-window node whose run page fell out of the window has no line.
title={node.line ?? '命令'}
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.
body={text !== undefined && text.includes('\n') ? text : null}

View File

@@ -168,7 +168,8 @@ export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
/**
* Owner share of the per-command row slot: the frozen {@link CommandNode}
* slice off the snapshot (cache-stable reference — memo premise). The node
* carries the whole lifecycle (line, pairing id, outcome-or-executing), so a
* carries the whole lifecycle (structured name/args, pairing id,
* outcome-or-executing), so a
* registrant needs no second data channel; domain state arrives through its
* own projection cell.
*/

View File

@@ -367,7 +367,7 @@ describe('ChatView', () => {
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
const command = (over: Partial<CommandNode>): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1',
name: 'plan', line: '/plan', outcome: { kind: 'success', text: '已进入 plan mode' },
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
// Settled success: the command line is the title, the outcome text the summary.
@@ -394,7 +394,7 @@ describe('ChatView', () => {
// Cross-window soft-fall (run page truncated): generic title, outcome preserved.
const orphan = makeHarness({
nodes: [command({ seq: 8, commandId: 'cmd-4', name: null, line: null, outcome: { kind: 'success' } })],
nodes: [command({ seq: 8, commandId: 'cmd-4', name: null, args: null, outcome: { kind: 'success' } })],
})
const ov = render(<orphan.ChatView {...orphan.props} />)
expect(ov.getByText('命令')).toBeTruthy()