mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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()
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('command.execute', () => {
|
||||
// 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/run', data: { name: 'goal', args: ' ship it' } },
|
||||
{ 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;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
|
||||
README.md: db3d06f395fc50c8a6cf5901e42f0b09e083a07e
|
||||
README.zh.md: bb9b9d52c2fd0845b0795c37ba0155de319bae28
|
||||
README.md: 0a48516cf10902b0a83a8ea12299cc29342ea66d
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
`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` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
|
||||
|
||||
|
||||
@@ -112,10 +112,13 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
/**
|
||||
* A resolved slash command entered its handler. Log-only (never model
|
||||
* surface); paired with `command/done` by `commandId`, mirroring the
|
||||
* `tool/call`↔`tool/result` pairing. `line` is the exact command line as
|
||||
* dispatched.
|
||||
* `tool/call`↔`tool/result` pairing. The payload is structured — `name`
|
||||
* 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
|
||||
* 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)
|
||||
const commandId = this.mintCommandId()
|
||||
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 })
|
||||
let result: CommandResult
|
||||
|
||||
@@ -304,7 +304,7 @@ describe('CommandService', () => {
|
||||
|
||||
const lifecycle = lifecycleOf(agent)
|
||||
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' } },
|
||||
])
|
||||
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
|
||||
|
||||
Reference in New Issue
Block a user