refactor(events): remove the turn boundary mirror events
Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
This commit is contained in:
@@ -36,69 +36,6 @@ function send(agent: ReactLoopAgent, text: string) {
|
||||
}
|
||||
|
||||
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
|
||||
it('a throwing agent/turn-start listener surfaces via agent/error and the loop survives', async () => {
|
||||
// The agent/turn-start emit happens AFTER turn/start is appended to the log,
|
||||
// so a throwing listener is handled inside runTurn (the turn is balanced and
|
||||
// closed via failTurn → agent/error), NOT rethrown to the runLoop backstop.
|
||||
// The second turn should proceed normally and consume the first script entry.
|
||||
const adapter = new MockAdapter([textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-start', () => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new Error('broken turn-start listener')
|
||||
}
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['broken turn-start listener'])
|
||||
// The turn is balanced: its turn/start was logged, so a turn/end was owed
|
||||
// and appended (decided from the log, not a flag).
|
||||
expect(agent.session.events.at(-1)?.type).toBe('turn/end')
|
||||
|
||||
// loop survives: second turn works fine and makes the model call
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests[0]!.messages.some(m => m.content.some(b => 'text' in b && b.text === 'second'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a throwing agent/turn-end listener surfaces via agent/error and the loop survives', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-end', () => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new Error('broken turn-end listener')
|
||||
}
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
// The turn-end throw happens after the model call is complete, so turn 1's
|
||||
// request is consumed. turn/end is already in the log (append pushes before
|
||||
// notifying), so the turn is balanced; the error is surfaced via agent/error.
|
||||
expect(errors.map(e => e.message)).toEqual(['broken turn-end listener'])
|
||||
|
||||
// loop survives: second turn works fine
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => {
|
||||
// A non-serializable message source makes the turn/start append throw BEFORE
|
||||
// the event is pushed (Session.append validates before push), so turn/start
|
||||
@@ -192,14 +129,14 @@ describe('tool JSON parse', () => {
|
||||
})
|
||||
|
||||
describe('toError normalization', () => {
|
||||
it('normalizes non-Error throws from turn-start listeners via toError', async () => {
|
||||
it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-start', () => {
|
||||
if (!threwOnce) {
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'turn/start' && !threwOnce) {
|
||||
threwOnce = true
|
||||
throw 'naked string error' // non-Error throw, normalized via toError
|
||||
}
|
||||
@@ -287,7 +224,7 @@ describe('disposed vs aborted branching', () => {
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
Reference in New Issue
Block a user