feat: command.execute returns the lifecycle pairing id ({matched, commandId?})
CommandService.execute now returns a CommandExecution — the normalized result plus the commandId minted for its command/run/command/done records — and the wire admission value carries commandId exactly when matched, so the issuing client can correlate its RPC acknowledgment with the flow node the lifecycle events produce. apiproxy api/schema/handler, the connection fixture, and the TUI/plan/goal consumers follow the new shape.
This commit is contained in:
@@ -830,7 +830,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const commandId = `fx-cmd-${logOf(id).length}`
|
||||
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 })
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
|
||||
@@ -94,7 +94,7 @@ export class FakeApiClient implements IApiClient {
|
||||
// wire shapes so cases can program catalogs and skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean }>>
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: string }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
@@ -49,7 +49,8 @@ describe('createFixtureApi commands/skills', () => {
|
||||
})()
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
expect(response.result.value).toEqual({ matched: true })
|
||||
expect(response.result.value).toMatchObject({ matched: true })
|
||||
expect(response.result.value.commandId).toBeTruthy()
|
||||
await pump
|
||||
const events = frames
|
||||
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
|
||||
|
||||
@@ -119,7 +119,7 @@ export class FakeApiClient implements IApiClient {
|
||||
// skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean }>>
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: string }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
@@ -72,14 +72,14 @@ function domainEvents(session: Session): readonly Session['events'][number][] {
|
||||
}
|
||||
|
||||
/** Execute `/goal` through the same registry boundary as a UI adapter. */
|
||||
async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
|
||||
const result = await test.ctx.commands.execute(
|
||||
async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>['result']> {
|
||||
const execution = await test.ctx.commands.execute(
|
||||
test.agent,
|
||||
`/goal${suffix}`,
|
||||
new AbortController().signal,
|
||||
)
|
||||
if (result === undefined) throw new Error('goal command was not registered')
|
||||
return result
|
||||
if (execution === undefined) throw new Error('goal command was not registered')
|
||||
return execution.result
|
||||
}
|
||||
|
||||
/** Current exact compare-and-set ref. */
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 0e8699e513452030bfa4ffc62737df928c161603
|
||||
README.zh.md: 8b19d0357389f616ec8a4120beb2cf8d8d7686d8
|
||||
README.md: e450f7081998ce0810fc06ac688fd7214c362363
|
||||
README.zh.md: 6658f88ee3b37d1c487abb38456579ddaaba4b61
|
||||
@@ -20,7 +20,7 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
|
||||
|
||||
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports only whether the line resolved to a handler, while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应只报告该行是否解析到处理器,结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
|
||||
@@ -921,9 +921,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
try {
|
||||
// Pure admission: the executor's durable command/run + command/done
|
||||
// pair (broadcast on the mux stream) carries the outcome; the
|
||||
// response only reports whether the line resolved to a handler.
|
||||
const result = await commands.execute(found.agent, line, signal)
|
||||
return ok(request, { matched: result !== undefined })
|
||||
// response reports whether the line resolved to a handler, plus the
|
||||
// minted pairing id so the issuing client can correlate its request
|
||||
// with the flow node the lifecycle events produce.
|
||||
const execution = await commands.execute(found.agent, line, signal)
|
||||
return ok(request, execution === undefined
|
||||
? { matched: false }
|
||||
: { matched: true, commandId: execution.commandId })
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
|
||||
return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
|
||||
|
||||
@@ -32,7 +32,8 @@ export const commandExecuteRequestSchema = z.object({
|
||||
line: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
|
||||
|
||||
/** command.execute response value: pure admission — outcomes ride the logged lifecycle events, never this response. */
|
||||
/** command.execute response value: pure admission — outcomes ride the logged lifecycle events; commandId (present exactly when matched) correlates with them. */
|
||||
export const commandExecuteValueSchema = z.object({
|
||||
matched: z.boolean(),
|
||||
commandId: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>
|
||||
@@ -36,10 +36,12 @@ export interface CommandsApi {
|
||||
* when syntax or name does not resolve (the client falls back to its
|
||||
* default sink). The handler's outcome does NOT ride the response: the host
|
||||
* executor durably logs the lifecycle (`command/run`/`command/done`), which
|
||||
* broadcasts on the mux stream and renders as a persistent flow node. The
|
||||
* signal rides beside the request, never on the wire: the fetch carrier's
|
||||
* request signal cancels the running handler.
|
||||
* broadcasts on the mux stream and renders as a persistent flow node.
|
||||
* `commandId` is present exactly when matched — the minted lifecycle
|
||||
* pairing id, letting the issuing client correlate this acknowledgment
|
||||
* with that flow node. The signal rides beside the request, never on the
|
||||
* wire: the fetch carrier's request signal cancels the running handler.
|
||||
*/
|
||||
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ matched: boolean }>>
|
||||
Promise<RpcResponse<{ matched: boolean; commandId?: string }>>
|
||||
}
|
||||
@@ -115,14 +115,15 @@ describe('command.execute', () => {
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
|
||||
expect(value).toEqual({ matched: true })
|
||||
expect(value).toMatchObject({ matched: true })
|
||||
expect(value.commandId).toBeTruthy()
|
||||
expect(received).toBe(' ship it')
|
||||
// Pure admission on the wire: the outcome rides the durably logged
|
||||
// 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', args: ' ship it' } },
|
||||
{ type: 'command/done', data: { kind: 'success', text: `goal:${agent.id}` } },
|
||||
{ type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } },
|
||||
{ type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } }
|
||||
}
|
||||
if (request.payload.line.startsWith('/plan')) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: true } } }
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, commandId: 'cmd-x' } } }
|
||||
}
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
|
||||
},
|
||||
@@ -163,7 +163,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
const list = await c.commands.list({ sessionId: 's' as never })
|
||||
expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } })
|
||||
const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' })
|
||||
expect(hit.result).toEqual({ ok: true, value: { matched: true } })
|
||||
expect(hit.result).toEqual({ ok: true, value: { matched: true, commandId: 'cmd-x' } })
|
||||
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
|
||||
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
|
||||
const skills = await c.skills.list({ sessionId: 's' as never })
|
||||
|
||||
@@ -215,9 +215,12 @@ describe('commands domain schemas', () => {
|
||||
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
|
||||
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
|
||||
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
|
||||
// Pure admission: the value carries only the matched bit (outcomes ride
|
||||
// the logged lifecycle events, never this response).
|
||||
// Pure admission: matched plus the optional lifecycle pairing id
|
||||
// (outcomes ride the logged lifecycle events, never this response).
|
||||
expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' }))
|
||||
.toEqual({ matched: true, commandId: 'cmd-1' })
|
||||
expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true })
|
||||
expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow()
|
||||
expect(() => commandExecuteValueSchema.parse({})).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -505,7 +505,7 @@ describe('/plan', () => {
|
||||
expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
|
||||
expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
|
||||
const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
|
||||
expect(plain).toEqual({
|
||||
expect(plain?.result).toEqual({
|
||||
kind: 'success',
|
||||
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
|
||||
})
|
||||
@@ -516,7 +516,7 @@ describe('/plan', () => {
|
||||
const messageSteer = vi.fn()
|
||||
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
|
||||
const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
|
||||
expect(plan).toEqual({
|
||||
expect(plan?.result).toEqual({
|
||||
kind: 'success',
|
||||
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
|
||||
})
|
||||
@@ -535,7 +535,7 @@ describe('/plan', () => {
|
||||
const signal = new AbortController().signal
|
||||
|
||||
const inactive = await agentWithSession(ctx, 'inactive-plan-command')
|
||||
expect(await ctx.commands.execute(inactive, '/plan off', signal))
|
||||
expect((await ctx.commands.execute(inactive, '/plan off', signal))?.result)
|
||||
.toEqual({ kind: 'success', text: 'Plan mode is already inactive.' })
|
||||
expect(ctx.planMode.get(inactive)).toEqual({ active: false })
|
||||
|
||||
@@ -543,7 +543,7 @@ describe('/plan', () => {
|
||||
const enteringSteer = vi.fn()
|
||||
;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
|
||||
await ctx.commands.execute(entering, '/plan', signal)
|
||||
expect(await ctx.commands.execute(entering, '/plan off', signal))
|
||||
expect((await ctx.commands.execute(entering, '/plan off', signal))?.result)
|
||||
.toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' })
|
||||
expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false })
|
||||
expect(enteringSteer).not.toHaveBeenCalled()
|
||||
@@ -554,10 +554,10 @@ describe('/plan', () => {
|
||||
const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
|
||||
const activeSteer = vi.fn()
|
||||
;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
|
||||
expect(await ctx.commands.execute(active, '/plan off', signal))
|
||||
expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
|
||||
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
|
||||
expect(ctx.planMode.get(active)).toEqual({ active: true, pending: false })
|
||||
expect(await ctx.commands.execute(active, '/plan off', signal))
|
||||
expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
|
||||
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
|
||||
expect(activeSteer).not.toHaveBeenCalled()
|
||||
await boundary(ctx, active, 'step/end')
|
||||
|
||||
@@ -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: 0a48516cf10902b0a83a8ea12299cc29342ea66d
|
||||
README.zh.md: 33ee0e0b3275350008f7bf612471eafa804fb1d0
|
||||
README.md: 139a21857b41c7e352ee6a0746e4959b218881e8
|
||||
README.zh.md: 466c02ab3699b26e5c946b3442c28e6f0fc93d89
|
||||
@@ -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 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.
|
||||
`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 the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `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`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
|
||||
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式持久记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
|
||||
|
||||
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
|
||||
|
||||
|
||||
@@ -47,6 +47,19 @@ export type CommandResult =
|
||||
| { readonly kind: 'success'; readonly text?: string }
|
||||
| { readonly kind: 'error'; readonly text: string }
|
||||
|
||||
/**
|
||||
* One settled command execution: the handler's normalized result plus the
|
||||
* lifecycle pairing id minted for its `command/run`/`command/done` records,
|
||||
* so a dispatching surface can correlate the RPC-level acknowledgment with
|
||||
* the flow node those events produce.
|
||||
*/
|
||||
export interface CommandExecution {
|
||||
/** Pairing id carried by this execution's lifecycle events. */
|
||||
readonly commandId: string
|
||||
/** The handler's normalized outcome. */
|
||||
readonly result: CommandResult
|
||||
}
|
||||
|
||||
/** Plugin-owned command registration. */
|
||||
export interface CommandDefinition {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
@@ -343,13 +356,14 @@ export class CommandService extends Service {
|
||||
* @param agent - exact receiving agent.
|
||||
* @param line - complete slash-command line.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns a detached result, or `undefined` when syntax or name does not resolve.
|
||||
* @returns the settled execution (result + lifecycle pairing id), or
|
||||
* `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
async execute(
|
||||
agent: Agent,
|
||||
line: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandResult | undefined> {
|
||||
): Promise<CommandExecution | undefined> {
|
||||
const parsed = parseCommand(line)
|
||||
if (parsed === undefined) return undefined
|
||||
const command = this.view(agent).get(parsed.name)
|
||||
@@ -379,7 +393,7 @@ export class CommandService extends Service {
|
||||
commandId, kind: result.kind,
|
||||
...result.text === undefined ? {} : { text: result.text },
|
||||
})
|
||||
return result
|
||||
return Object.freeze({ commandId, result })
|
||||
}
|
||||
|
||||
/** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */
|
||||
|
||||
@@ -96,11 +96,11 @@ describe('CommandService', () => {
|
||||
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
|
||||
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
|
||||
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
|
||||
expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result)
|
||||
.toEqual({ kind: 'success', text: 'scoped' })
|
||||
|
||||
await scope.dispose()
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result.text).toBe('global')
|
||||
})
|
||||
|
||||
it('removes a registration when its contributing plugin fiber is disposed', async () => {
|
||||
@@ -176,10 +176,12 @@ describe('CommandService', () => {
|
||||
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
const execution = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
|
||||
expect(result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
expect(execution?.result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(execution?.commandId).toBeTruthy()
|
||||
expect(Object.isFrozen(execution)).toBe(true)
|
||||
expect(Object.isFrozen(execution?.result)).toBe(true)
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent,
|
||||
rawInput: ' untouched ',
|
||||
@@ -271,9 +273,9 @@ describe('CommandService', () => {
|
||||
description: 'Denied',
|
||||
handler: () => ({ kind: 'error', text: 'not now' }),
|
||||
})
|
||||
const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
const execution = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(execution?.result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(execution?.result)).toBe(true)
|
||||
|
||||
ctx.commands.register({
|
||||
name: 'silent',
|
||||
@@ -281,8 +283,8 @@ describe('CommandService', () => {
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
|
||||
expect(silent).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent)).toBe(true)
|
||||
expect(silent?.result).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent?.result)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -300,7 +302,7 @@ describe('CommandService', () => {
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('deploy', 'deployed'))
|
||||
|
||||
await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
|
||||
const execution = await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
|
||||
|
||||
const lifecycle = lifecycleOf(agent)
|
||||
expect(lifecycle).toMatchObject([
|
||||
@@ -310,6 +312,8 @@ describe('CommandService', () => {
|
||||
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
|
||||
expect(ids[0]).toBeTruthy()
|
||||
expect(ids[0]).toBe(ids[1])
|
||||
// The execution's pairing id is the logged one (RPC-level correlation).
|
||||
expect(execution?.commandId).toBe(ids[0])
|
||||
// Zero-step wrap: the pair stays turn-enclosed on an idle log.
|
||||
expect(agent.session.events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'command/run', 'turn/end',
|
||||
|
||||
@@ -2872,12 +2872,12 @@ export function createTuiChat(
|
||||
const controller = new AbortController()
|
||||
commandControllers.add(controller)
|
||||
void ctx.commands.execute(agent, text, controller.signal).then(
|
||||
(result) => {
|
||||
(execution) => {
|
||||
if (disposed) return
|
||||
if (result === undefined) {
|
||||
if (execution === undefined) {
|
||||
appendNotice(`Unknown command: ${text}`, 'warning')
|
||||
} else if (result.text !== undefined && result.text !== '') {
|
||||
appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
|
||||
} else if (execution.result.text !== undefined && execution.result.text !== '') {
|
||||
appendNotice(execution.result.text, execution.result.kind === 'error' ? 'error' : 'info')
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
|
||||
Reference in New Issue
Block a user