feat: durable command lifecycle logging in the executor (command/run + command/done)
CommandService.execute appends the log-only pair around every resolved handler — run before invocation, done at settlement, including thrown and aborted handlers (kind:'error'); admission misses log nothing. commandId is minted monotonically per instance; per-session appends serialize through a tail queue over SessionStore.appendOutOfBand (zero-step wrap on an idle log, direct join inside an open turn). The invariant companion now asserts the pairing relation (unique run ids; a done requires a prior in-log run). CommandSource is a minimal merge-extensible map (user variant only). Dependent benches mount SessionStore; TUI/e2e snapshots re-recorded for the executor's durable-append timing and the /status event counts.
This commit is contained in:
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
function command(name: string, text = `ran:${name}`): CommandDefinition {
|
||||
@@ -16,18 +16,27 @@ function command(name: string, text = `ran:${name}`): CommandDefinition {
|
||||
|
||||
async function mount(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(CommandService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Mint a scope whose key is sufficient for registry lookup and invocation. */
|
||||
/** Mint a scope whose key is a live agent (real session: the executor logs lifecycle events on it). */
|
||||
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
|
||||
const agent = { id: name as SessionId } as Agent
|
||||
const session = ctx.sessions.create(SessionId(name))
|
||||
const agent = { id: session.id, session } as Agent
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
|
||||
return { scope, agent }
|
||||
}
|
||||
|
||||
/** The lifecycle slice of one agent's log (boundary markers stripped). */
|
||||
function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> {
|
||||
return agent.session.events
|
||||
.filter(event => event.type === 'command/run' || event.type === 'command/done')
|
||||
.map(event => ({ type: event.type, data: event.data }))
|
||||
}
|
||||
|
||||
describe('parseCommand()', () => {
|
||||
it.each([
|
||||
['/goal', { name: 'goal', rawInput: '' }],
|
||||
@@ -286,6 +295,110 @@ describe('CommandService', () => {
|
||||
expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
|
||||
})
|
||||
|
||||
it('logs a paired command/run + command/done around a successful handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('deploy', 'deployed'))
|
||||
|
||||
await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
|
||||
|
||||
const lifecycle = lifecycleOf(agent)
|
||||
expect(lifecycle).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'deploy', line: '/deploy now', source: { kind: 'user' } } },
|
||||
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
|
||||
])
|
||||
const [run, done] = lifecycle as [{ data: { commandId: string } }, { data: { commandId: string } }]
|
||||
expect(run.data.commandId).toBe(done.data.commandId)
|
||||
// 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',
|
||||
'turn/start', 'command/done', 'turn/end',
|
||||
])
|
||||
})
|
||||
|
||||
it('mints distinct monotonic commandIds across executions', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('first'))
|
||||
ctx.commands.register(command('second'))
|
||||
await ctx.commands.execute(agent, '/first', new AbortController().signal)
|
||||
await ctx.commands.execute(agent, '/second', new AbortController().signal)
|
||||
const ids = lifecycleOf(agent)
|
||||
.filter(event => event.type === 'command/run')
|
||||
.map(event => (event.data as { commandId: string }).commandId)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
})
|
||||
|
||||
it('logs command/done kind error for an expected error result', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) })
|
||||
await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'denied' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'not now' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('logs command/done kind error when the handler throws, and preserves the throw', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'boom',
|
||||
description: 'Throw',
|
||||
handler: () => { throw new Error('handler exploded') },
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/boom', new AbortController().signal))
|
||||
.rejects.toThrow('handler exploded')
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'boom' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'handler exploded' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('logs command/done kind error when the signal aborts a hanging handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'hang',
|
||||
description: 'Hang',
|
||||
handler: () => new Promise(() => undefined),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.commands.execute(agent, '/hang', controller.signal)
|
||||
// The run append must land before the abort so the pair stays complete.
|
||||
await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) })
|
||||
controller.abort('operator cancelled command')
|
||||
await expect(pending).rejects.toThrow('operator cancelled command')
|
||||
await vi.waitFor(() => {
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'hang' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'operator cancelled command' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('logs nothing for admission misses (syntax or unknown name)', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('real'))
|
||||
const signal = new AbortController().signal
|
||||
await ctx.commands.execute(agent, 'not a command', signal)
|
||||
await ctx.commands.execute(agent, '/missing', signal)
|
||||
expect(agent.session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('mid'))
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await ctx.commands.execute(agent, '/mid', new AbortController().signal)
|
||||
expect(agent.session.events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'command/run', 'command/done',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, /CommandResult/],
|
||||
[null, /CommandResult/],
|
||||
|
||||
Reference in New Issue
Block a user