refactor(events): document event-domain semantics, drop step-boundary mirror emits
Pin the three-domain rule (session = durable fact log, agent = live runtime surface, tools = registry/exec): a durable replayable fact is a SessionEvent; a live interception or transient/live-object signal is an agent/tools Cordis event. A boundary that is both is mirrored as an agent/* emit ONLY where a live consumer needs the Agent handle. Apply it to the boundary twins: drop agent/step-start and agent/step-end (no production consumer needs the live Agent at a step boundary — consumers read the durable step/start/step/end session events). Keep agent/turn-start/turn-end (the stdio UI labels output by agent.id). Tests that observed step boundaries via the removed emits now observe the durable session events; the pinned behavior is unchanged. Conservative subset of the proposed "remove boundary mirror events" simplification; foundation for the Hooks subsystem's canonical event surface.
This commit is contained in:
@@ -204,7 +204,7 @@ describe('Agent.cancel()', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-start', () => { steps += 1 })
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/start') steps += 1 })
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
|
||||
@@ -46,15 +46,21 @@ describe('agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// Turn boundaries are live agent/* emits; step boundaries are durable
|
||||
// session events only (no agent/* mirror). Interleave both feeds in fire
|
||||
// order to assert the full boundary nesting.
|
||||
const order: string[] = []
|
||||
for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) {
|
||||
for (const name of ['agent/turn-start', 'agent/turn-end'] as const) {
|
||||
ctx.on(name, () => void order.push(name))
|
||||
}
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'step/start' || event.type === 'step/end') order.push(event.type)
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(order).toEqual(['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'])
|
||||
expect(order).toEqual(['agent/turn-start', 'step/start', 'step/end', 'agent/turn-end'])
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
// turn/start opens the turn, THEN the queued user message is recorded inside
|
||||
@@ -269,7 +275,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-end', () => void steps++)
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||
if (steps < 3) return true
|
||||
return next()
|
||||
@@ -371,7 +377,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-end', () => void steps++)
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
||||
// Force exactly one continuation (step 1 → step 2), then defer to default
|
||||
// (step 2 is a plain stop with no tool calls → stops).
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||
@@ -536,7 +542,7 @@ describe('agent loop', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('stops the turn when agent/step-end listener failure has recorded an error', async () => {
|
||||
it('stops the turn when a step/end session-event listener failure has recorded an error', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
textResponse('should not run'),
|
||||
@@ -552,8 +558,11 @@ describe('agent loop', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
let threw = false
|
||||
ctx.on('agent/step-end', () => {
|
||||
if (!threw) { threw = true; throw new Error('bad step-end listener') }
|
||||
// A throwing step/end session-event listener is the surviving boundary-listener
|
||||
// failure path (step boundaries have no agent/* mirror): closeStep contains it
|
||||
// and surfaces it as a turn error rather than stranding the turn open.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') }
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
|
||||
@@ -144,36 +144,6 @@ describe('HIGH: abort during tool execution ends the turn', () => {
|
||||
})
|
||||
|
||||
describe('HIGH: steering from late extension points is never stranded', () => {
|
||||
it('steer() from an agent/step-end listener reaches the next request (/goal pattern)', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
textResponse('after steering'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let steeredOnce = false
|
||||
ctx.on('agent/step-end', () => {
|
||||
if (steeredOnce) return
|
||||
steeredOnce = true
|
||||
agent.steer([{ type: 'text', text: 'goal reminder from step-end' }])
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step-end')
|
||||
})
|
||||
|
||||
it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('no tools, would stop here'),
|
||||
@@ -539,24 +509,26 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
|
||||
})
|
||||
})
|
||||
|
||||
describe('P1-6: step/start is appended before agent/step-start is emitted', () => {
|
||||
it('a step-start listener sees the step/start event already in session.events', async () => {
|
||||
describe('P1-6: a step/start session-event listener sees the event already in the log', () => {
|
||||
it('the step/start event is in session.events when its session/event listener fires', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
|
||||
|
||||
// Capture, at the moment agent/step-start fires, whether the matching
|
||||
// step/start event is already in the log (append-before-emit, the event-sourcing RFC).
|
||||
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||
// so a step/start listener always finds the matching event already in the
|
||||
// log. (Step boundaries have no agent/* mirror — the session log is the live
|
||||
// feed.)
|
||||
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
|
||||
ctx.on('agent/step-start', (subject, turn, step) => {
|
||||
if (subject !== agent) return
|
||||
const events = [...subject.session.events]
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject !== agent.session || event.type !== 'step/start') return
|
||||
const events = [...subject.events]
|
||||
const last = events.at(-1)
|
||||
observed.push({
|
||||
turn,
|
||||
step,
|
||||
turn: event.data.turn,
|
||||
step: event.data.step,
|
||||
lastEventType: last?.type,
|
||||
sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === turn && e.data.step === step),
|
||||
sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === event.data.turn && e.data.step === event.data.step),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -621,29 +593,35 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a throwing agent/step-start listener closes the open step then the turn (step/end before turn/end)', async () => {
|
||||
it('a throwing step/start session-event listener fails the turn balanced (no step stranded open)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
|
||||
|
||||
// Step boundaries have no agent/* mirror; a throwing step/start session-event
|
||||
// listener is the surviving step-boundary-listener failure. The throw fires
|
||||
// INSIDE session.append('step/start') — before the loop marks the step open —
|
||||
// so the loop never had an open step to close (no step/end is owed). The
|
||||
// throw drives the outer catch, which fails the turn balanced. The invariants
|
||||
// oracle (balancedHarness) rejects any imbalance, so a green run proves the
|
||||
// turn/start..turn/end nesting holds with a lone step/start and no step/end.
|
||||
let threw = false
|
||||
ctx.on('agent/step-start', () => { if (!threw) { threw = true; throw new Error('boom step-start') } })
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
const c = boundaryCounts(agent)
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
|
||||
// step/start was appended (Session.append pushes before notifying), but the
|
||||
// listener throw pre-empted the loop marking the step open, so no step/end is
|
||||
// owed; the turn still closes exactly once with an error, balanced.
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 0, errors: 1 })
|
||||
expect(errors.map(x => x.message)).toEqual(['boom step-start'])
|
||||
// step/end must precede turn/end (the invariants oracle would reject
|
||||
// turn/end-while-step-open, but assert the order explicitly too).
|
||||
const stepEndIdx = e.findIndex(x => x.type === 'step/end')
|
||||
const turnEndIdx = e.findIndex(x => x.type === 'turn/end')
|
||||
expect(stepEndIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(stepEndIdx).toBeLessThan(turnEndIdx)
|
||||
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason.kind).toBe('error')
|
||||
})
|
||||
|
||||
it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
|
||||
@@ -829,17 +807,20 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(boundaryCounts(agent).turnEnd).toBe(2)
|
||||
})
|
||||
|
||||
it('a throwing agent/step-end listener during a successful step ends the turn as error, not completed', async () => {
|
||||
// closeStep() must surface a throwing step-end listener via failTurn so the
|
||||
it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
|
||||
// closeStep() must surface a throwing step/end listener via failTurn so the
|
||||
// turn ends with reason error, not a silent "completed" with the throw
|
||||
// swallowed. Regression test for the closeStep() catch that previously
|
||||
// swallowed the throw in the normal (no-tool, no-steering) path.
|
||||
// swallowed the throw in the normal (no-tool, no-steering) path. (Step
|
||||
// boundaries have no agent/* mirror; the session-event listener is the path.)
|
||||
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('boom step-end') } })
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
|
||||
@@ -903,18 +884,18 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
})
|
||||
|
||||
it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
|
||||
// A throwing agent/step-start listener drives the outer catch, which calls
|
||||
// closeStep() during finalization. closeStep appends step/end; a
|
||||
// A finish-error stream opens a step then fails it, driving finalization
|
||||
// through closeStep() with the step open. closeStep appends step/end; a
|
||||
// session/event listener throwing on THAT must not abort the catch before
|
||||
// closeTurn(false) — step/end is already logged (balance holds) and the
|
||||
// throw is contained + surfaced via failTurn, so turn/end is still appended.
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
// closeTurn — step/end is already logged (balance holds) and the throw is
|
||||
// contained + surfaced via failTurn, so turn/end is still appended. (The
|
||||
// failed step itself also routes through failTurn; the step/end-listener
|
||||
// throw is the second, contained, failure.)
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' })
|
||||
|
||||
// Open a step, then make the agent/step-start emit throw (boundary throw →
|
||||
// outer catch → closeStep during finalization).
|
||||
ctx.on('agent/step-start', () => { throw new Error('boom step-start') })
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
|
||||
|
||||
Reference in New Issue
Block a user