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)
|
const missing = requireSession(request)
|
||||||
if (missing !== undefined) return missing
|
if (missing !== undefined) return missing
|
||||||
const id = request.payload.sessionId
|
const id = request.payload.sessionId
|
||||||
const line = request.payload.line.trim()
|
// Structured split mirroring the host parser: name + verbatim rawInput
|
||||||
const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
|
// (separator whitespace included) — the run payload carries no line.
|
||||||
|
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
|
||||||
const name = match?.[1]
|
const name = match?.[1]
|
||||||
|
const args = match?.[2] ?? ''
|
||||||
const outcomes: Record<string, string> = {
|
const outcomes: Record<string, string> = {
|
||||||
compact: 'fixture:已压缩(假动作)',
|
compact: 'fixture:已压缩(假动作)',
|
||||||
echo: match?.[2] ?? '',
|
echo: args.trim(),
|
||||||
'goal-fixture': `fixture:goal 已设置(${id})`,
|
'goal-fixture': `fixture:goal 已设置(${id})`,
|
||||||
}
|
}
|
||||||
const text = name === undefined ? undefined : outcomes[name]
|
const text = name === undefined ? undefined : outcomes[name]
|
||||||
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
|
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
|
||||||
const commandId = `fx-cmd-${logOf(id).length}`
|
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 } } })
|
append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
|
||||||
return ok(request, { matched: true as const })
|
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')
|
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
|
||||||
.map(f => f.event)
|
.map(f => f.event)
|
||||||
expect(events).toMatchObject([
|
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' } },
|
{ type: 'command/done', data: { kind: 'success', text: 'hello world' } },
|
||||||
])
|
])
|
||||||
expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)
|
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
|
* 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
|
* 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
|
* 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.
|
* still executing.
|
||||||
*/
|
*/
|
||||||
export interface CommandNode {
|
export interface CommandNode {
|
||||||
@@ -137,10 +137,10 @@ export interface CommandNode {
|
|||||||
time: number
|
time: number
|
||||||
/** Pairing id minted by the host executor. */
|
/** Pairing id minted by the host executor. */
|
||||||
commandId: string
|
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
|
name: string | null
|
||||||
/** Exact dispatched command line (run payload); null when the run fell outside the window. */
|
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
|
||||||
line: string | null
|
args: string | null
|
||||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,10 +229,10 @@ export class FoldAdapter {
|
|||||||
// enter the client program, so this wire consumer narrows structurally
|
// enter the client program, so this wire consumer narrows structurally
|
||||||
// (the same posture as tool/code-dispatch in session.ts).
|
// (the same posture as tool/code-dispatch in session.ts).
|
||||||
if ((event.type as string) === 'command/run') {
|
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, {
|
this.commandIdx.set(data.commandId, {
|
||||||
kind: 'command', seq: event.seq, time: event.time,
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,7 @@ export class FoldAdapter {
|
|||||||
// node from the done alone (same soft-fall as a call-less tool result).
|
// node from the done alone (same soft-fall as a call-less tool result).
|
||||||
this.commandIdx.set(data.commandId, {
|
this.commandIdx.set(data.commandId, {
|
||||||
kind: 'command', seq: event.seq, time: event.time,
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export const ev = {
|
|||||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||||
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
|
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
|
||||||
at(seq, { type: 'todo/write', data: { todos } }),
|
at(seq, { type: 'todo/write', data: { todos } }),
|
||||||
commandRun: (seq: number, commandId: string, name: string, line: string): SessionEvent =>
|
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||||
at(seq, { type: 'command/run', data: { commandId, name, line, source: { kind: 'user' } } }),
|
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||||
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,23 +148,23 @@ describe('FoldAdapter', () => {
|
|||||||
const adapter = new FoldAdapter()
|
const adapter = new FoldAdapter()
|
||||||
adapter.reset([
|
adapter.reset([
|
||||||
ev.user(0, '先说话'),
|
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.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
|
||||||
ev.assistant(3, 0, '然后回答'),
|
ev.assistant(3, 0, '然后回答'),
|
||||||
], 0)
|
], 0)
|
||||||
const { nodes } = adapter.nodes()
|
const { nodes } = adapter.nodes()
|
||||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
|
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
|
||||||
expect(nodes[1]).toMatchObject({
|
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' },
|
outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a run with no done as still executing (outcome null)', () => {
|
it('renders a run with no done as still executing (outcome null)', () => {
|
||||||
const adapter = new FoldAdapter()
|
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({
|
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()
|
const adapter = new FoldAdapter()
|
||||||
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
|
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
|
||||||
expect(adapter.nodes().nodes[0]).toMatchObject({
|
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: '失败了' },
|
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', () => {
|
it('settles a live-appended done in place, keeping the node at the run seq', () => {
|
||||||
const adapter = new FoldAdapter()
|
const adapter = new FoldAdapter()
|
||||||
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
|
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')
|
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
|
||||||
expect(running).toMatchObject({ outcome: null })
|
expect(running).toMatchObject({ outcome: null })
|
||||||
adapter.append(ev.commandDone(7, 'cmd-4'))
|
adapter.append(ev.commandDone(7, 'cmd-4'))
|
||||||
@@ -192,7 +192,7 @@ describe('FoldAdapter', () => {
|
|||||||
|
|
||||||
it('tails command nodes whose seq is past every surface node', () => {
|
it('tails command nodes whose seq is past every surface node', () => {
|
||||||
const adapter = new FoldAdapter()
|
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'])
|
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)
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
try {
|
try {
|
||||||
adapter.reset([
|
adapter.reset([
|
||||||
ev.commandRun(0, 'cmd-5', 'plan', '/plan'),
|
ev.commandRun(0, 'cmd-5', 'plan'),
|
||||||
ev.commandDone(1, 'cmd-5'),
|
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' } } }),
|
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
|
||||||
], 0)
|
], 0)
|
||||||
|
|||||||
@@ -103,9 +103,9 @@ describe('live event path', () => {
|
|||||||
// Live path: run mints an executing node, done settles it in the flow.
|
// Live path: run mints an executing node, done settles it in the flow.
|
||||||
const { session } = await opened()
|
const { session } = await opened()
|
||||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
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)
|
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'))
|
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
|
||||||
command = session.getSnapshot().nodes.at(-1)
|
command = session.getSnapshot().nodes.at(-1)
|
||||||
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
|
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.
|
// Replay path (refresh): the same pair inside the history window folds identically.
|
||||||
const replayed = await opened([
|
const replayed = await opened([
|
||||||
...plainTurn(0, 0, 'a', 'b'),
|
...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'),
|
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
|
||||||
])
|
])
|
||||||
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
|
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
|
|||||||
const summary = node.outcome === null
|
const summary = node.outcome === null
|
||||||
? '执行中…'
|
? '执行中…'
|
||||||
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
|
: 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 (
|
return (
|
||||||
<ToolRow
|
<ToolRow
|
||||||
variant="others"
|
variant="others"
|
||||||
icon={<IconApiOutline14 size={16} />}
|
icon={<IconApiOutline14 size={16} />}
|
||||||
// A cross-window node whose run page fell out of the window has no line.
|
title={title}
|
||||||
title={node.line ?? '命令'}
|
|
||||||
summary={summary}
|
summary={summary}
|
||||||
// Expandable only when the outcome text overflows a one-line summary.
|
// Expandable only when the outcome text overflows a one-line summary.
|
||||||
body={text !== undefined && text.includes('\n') ? text : null}
|
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}
|
* Owner share of the per-command row slot: the frozen {@link CommandNode}
|
||||||
* slice off the snapshot (cache-stable reference — memo premise). The node
|
* 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
|
* registrant needs no second data channel; domain state arrives through its
|
||||||
* own projection cell.
|
* 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', () => {
|
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
|
||||||
const command = (over: Partial<CommandNode>): CommandNode => ({
|
const command = (over: Partial<CommandNode>): CommandNode => ({
|
||||||
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1',
|
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,
|
...over,
|
||||||
})
|
})
|
||||||
// Settled success: the command line is the title, the outcome text the summary.
|
// 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.
|
// Cross-window soft-fall (run page truncated): generic title, outcome preserved.
|
||||||
const orphan = makeHarness({
|
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} />)
|
const ov = render(<orphan.ChatView {...orphan.props} />)
|
||||||
expect(ov.getByText('命令')).toBeTruthy()
|
expect(ov.getByText('命令')).toBeTruthy()
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ describe('command.execute', () => {
|
|||||||
// lifecycle pair instead of the response.
|
// lifecycle pair instead of the response.
|
||||||
const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
|
const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
|
||||||
expect(lifecycle).toMatchObject([
|
expect(lifecycle).toMatchObject([
|
||||||
{ type: 'command/run', data: { name: 'goal', line: '/goal ship it' } },
|
{ type: 'command/run', data: { name: 'goal', args: ' ship it' } },
|
||||||
{ type: 'command/done', data: { kind: 'success', text: `goal:${agent.id}` } },
|
{ type: 'command/done', data: { kind: 'success', text: `goal:${agent.id}` } },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
|
# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
|
||||||
README.md: db3d06f395fc50c8a6cf5901e42f0b09e083a07e
|
README.md: 0a48516cf10902b0a83a8ea12299cc29342ea66d
|
||||||
README.zh.md: bb9b9d52c2fd0845b0795c37ba0155de319bae28
|
README.zh.md: 33ee0e0b3275350008f7bf612471eafa804fb1d0
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl
|
|||||||
|
|
||||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
||||||
|
|
||||||
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is durably logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the exact line, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Lifecycle appends are serialized per session through `SessionStore.appendOutOfBand`, so the service requires a composed `sessions` service.
|
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is durably logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Lifecycle appends are serialized per session through `SessionStore.appendOutOfBand`, so the service requires a composed `sessions` service.
|
||||||
|
|
||||||
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
|
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
|
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
|
||||||
|
|
||||||
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式持久记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、精确命令行和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
|
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式持久记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
|
||||||
|
|
||||||
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
|
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
|
||||||
|
|
||||||
|
|||||||
@@ -112,10 +112,13 @@ declare module '@deepseek-ai/dsh-session' {
|
|||||||
/**
|
/**
|
||||||
* A resolved slash command entered its handler. Log-only (never model
|
* A resolved slash command entered its handler. Log-only (never model
|
||||||
* surface); paired with `command/done` by `commandId`, mirroring the
|
* surface); paired with `command/done` by `commandId`, mirroring the
|
||||||
* `tool/call`↔`tool/result` pairing. `line` is the exact command line as
|
* `tool/call`↔`tool/result` pairing. The payload is structured — `name`
|
||||||
* dispatched.
|
* and `args` are `parseCommand`'s own split (name and verbatim rawInput,
|
||||||
|
* separator whitespace included), so a consumer (a projection unit
|
||||||
|
* folding its own command records, a rich command card) never re-parses
|
||||||
|
* a line.
|
||||||
*/
|
*/
|
||||||
'command/run': { commandId: string; name: string; line: string; source: CommandSource }
|
'command/run': { commandId: string; name: string; args: string; source: CommandSource }
|
||||||
/**
|
/**
|
||||||
* The paired command settled. `kind`/`text` carry the handler's verbatim
|
* The paired command settled. `kind`/`text` carry the handler's verbatim
|
||||||
* outcome (a thrown/aborted handler settles as `kind: 'error'` with the
|
* outcome (a thrown/aborted handler settles as `kind: 'error'` with the
|
||||||
@@ -354,7 +357,7 @@ export class CommandService extends Service {
|
|||||||
if (signal.aborted) throw abortError(signal)
|
if (signal.aborted) throw abortError(signal)
|
||||||
const commandId = this.mintCommandId()
|
const commandId = this.mintCommandId()
|
||||||
await this.appendLifecycle(agent.session, 'command/run', {
|
await this.appendLifecycle(agent.session, 'command/run', {
|
||||||
commandId, name: parsed.name, line, source: { kind: 'user' },
|
commandId, name: parsed.name, args: parsed.rawInput, source: { kind: 'user' },
|
||||||
})
|
})
|
||||||
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
|
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
|
||||||
let result: CommandResult
|
let result: CommandResult
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ describe('CommandService', () => {
|
|||||||
|
|
||||||
const lifecycle = lifecycleOf(agent)
|
const lifecycle = lifecycleOf(agent)
|
||||||
expect(lifecycle).toMatchObject([
|
expect(lifecycle).toMatchObject([
|
||||||
{ type: 'command/run', data: { name: 'deploy', line: '/deploy now', source: { kind: 'user' } } },
|
{ type: 'command/run', data: { name: 'deploy', args: ' now', source: { kind: 'user' } } },
|
||||||
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
|
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
|
||||||
])
|
])
|
||||||
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
|
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
|
||||||
|
|||||||
Reference in New Issue
Block a user