From 9d5b3ab8321560d31a12baec7f735e0ea9d1a114 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:18:41 +0800 Subject: [PATCH] fix(agent-loop): append step/start before emitting agent/step-start (P1-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every loop boundary appends the session event before emitting the Cordis event (ADR 0003's append-before-emit rule) — except step/start, which was inverted. A listener on agent/step-start that inspected session.events could not see the step it was just told had started. - Swap the two lines so session.append('step/start') precedes the emit. - Fix the two stale pseudo-code copies (the runLoop JSDoc STEP-loop block and docs/architecture.md) so neither shows step-start emitted before the append. - Regression test: a step-start listener observes the matching step/start event already at the tail of session.events. Verified the test fails on the pre-fix (emit-first) order. --- docs/architecture.md | 2 +- packages/agent-loop/src/loop.ts | 4 +-- .../agent-loop/tests/review-fixes.spec.ts | 29 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 1b7e2a7be1..6cab7201b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,7 +134,7 @@ forever: drain queued → session('user/message'…) → 'turn/start' → emit agent/turn-start STEP loop: drain steering (late steering from previous step's listeners) - emit agent/step-start + session('step/start'); emit agent/step-start assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble req = {model, system, tools, messages: session.deriveMessages(), signal} req = waterfall agent/request ⟵ hooks, compaction, model switch diff --git a/packages/agent-loop/src/loop.ts b/packages/agent-loop/src/loop.ts index 799a1c164d..f6694c7aae 100644 --- a/packages/agent-loop/src/loop.ts +++ b/packages/agent-loop/src/loop.ts @@ -94,7 +94,7 @@ export interface LoopHandle { * drain queued → session('user/message'…) → 'turn/start' → emit agent/turn-start * STEP loop: * drain steering → session('steering/message') ⟵ catches late steering - * emit agent/step-start + * session('step/start'); emit agent/step-start ⟵ append before emit (ADR 0003) * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble * req = {model, system, tools, messages: session.deriveMessages(), signal} * req = waterfall agent/request ⟵ hooks/compaction/model-switch @@ -174,8 +174,8 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn: // (or turn-start listeners on the first step) joins before the request. drainSteering(ctx, agent, turn) - ctx.emit('agent/step-start', agent, turn, step) session.append('step/start', { turn, step }) + ctx.emit('agent/step-start', agent, turn, step) const abort = new AbortController() handle.setAbort(abort) diff --git a/packages/agent-loop/tests/review-fixes.spec.ts b/packages/agent-loop/tests/review-fixes.spec.ts index 8b473de2c9..ee45be1f5b 100644 --- a/packages/agent-loop/tests/review-fixes.spec.ts +++ b/packages/agent-loop/tests/review-fixes.spec.ts @@ -610,3 +610,32 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete expect(reasons).toEqual([{ kind: 'error', message: 'codeless failure' }]) }) }) + +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 () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('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, ADR 0003). + 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] + const last = events.at(-1) + observed.push({ + turn, + step, + lastEventType: last?.type, + sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === turn && e.data.step === step), + }) + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(observed).toHaveLength(1) + expect(observed[0]).toMatchObject({ turn: 1, step: 1, lastEventType: 'step/start', sawStepStart: true }) + }) +})