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:
@@ -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': `fixture:goal 已设置(${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 })
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 } } }),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user