diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index c1cd17f741..a2add63824 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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 = { 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 }) }, diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index a71a371973..cd29147b62 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -55,7 +55,7 @@ describe('createFixtureApi commands/skills', () => { .filter((f): f is { type: string; event: { type: string; data: Record } } => (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) diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 16ba778009..8644f6ed40 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -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 } diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 8f4b09d72a..635d043525 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -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 } diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 8611a94f8e..1cb43bd208 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -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 } } }), } diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index 4a9bc4c4d1..88a597063e 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -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) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 8a7cf0b1c1..383ce0010a 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -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({ diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index c177742975..1dfea5488b 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -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 ( } - // 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} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 27f8c982f5..cf69f22003 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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. */ diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 4a9c27d9e5..86e13cd45e 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -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 => ({ 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() expect(ov.getByText('命令')).toBeTruthy() diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index f284549335..6cf8799791 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -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}` } }, ]) }) diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml index 57c17b5823..d8deb56576 100644 --- a/packages/ui/commands/README.i18n.yaml +++ b/packages/ui/commands/README.i18n.yaml @@ -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 diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index db3d06f395..0a48516cf1 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -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. diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md index bb9b9d52c2..33ee0e0b32 100644 --- a/packages/ui/commands/README.zh.md +++ b/packages/ui/commands/README.zh.md @@ -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` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。 diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index 99f21ef334..645af6a61f 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -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 diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 941db73522..533b2a1c21 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -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)