fix(events): address Codex confirmation review — strengthen /goal guard, fix doc drift

Second-round Codex review of the PR-A taxonomy change found four issues, all
verified against the code:

- The /goal regression guard asserted only that the steered content reached
  requests[1], which passes even with the hasSteering override (loop.ts) disabled:
  leftover steering is re-enqueued as a next-turn queued message and also lands in
  requests[1], one turn later. The guard now asserts the same-turn shape — ONE
  turn, TWO steps, a steering/message recorded before step 2 — which is the
  mechanism the override drives. Proven to fail red with the override disabled.
- The event-domain-semantics RFC's consequence list still described the pre-fix
  behavior (step marked open AFTER step/start, so no step/end owed). It now states
  the shipped behavior: the loop marks the step open BEFORE the append, so a
  throwing step/start listener gets a balancing step/end via closeStep().
- architecture.md's loop pseudocode said only continuation listeners force
  continuation; step/end session-event listeners (the /goal pattern) do too.
- The agent/turn-end JSDoc listed a `rejected` TurnEndReason that does not exist on
  this branch (it belongs to the later interception work). Removed it and
  regenerated the cordis catalog; `interrupted` (a real variant) stays.
This commit is contained in:
Tianyi Cui
2026-06-30 12:46:59 +08:00
parent 8df89d8e33
commit a821dcbe0d
5 files changed
+37 -16

No files matched your search

+2 -1
View File
@@ -151,7 +151,8 @@ forever:
drain steering → session('steering/message'); emit agent/steering
session('step/end') ⟵ durable step boundary (no agent/* mirror)
cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
steering pending from continuation listeners forces cont = true
steering pending forces cont = true (from continuation listeners OR from
step/end session-event listeners — the /goal pattern; hasSteering override)
if !cont: break
session('turn/end'); emit agent/turn-end
await ctx.parallel('session/flush', session) ⟵ durability checkpoint (failure
+8 -8
View File
@@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts)
#### `agent/queued` — emit
@@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo
Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts)
#### `agent/status` — emit
@@ -97,7 +97,7 @@ Steering content was injected into a running turn.
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
#### `agent/step-result` — waterfall
@@ -109,7 +109,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts)
#### `agent/stream-chunk` — emit
@@ -121,7 +121,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed).
Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:234`](../../packages/core/agent/src/types.ts)
#### `agent/turn-continuation` — waterfall
@@ -133,11 +133,11 @@ Waterfall: override the turn-continuation decision. The default (computed by the
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts)
#### `agent/turn-end` — emit
A turn ended. `reason` distinguishes a clean stop from a truncated, aborted, or hook-rejected one (`completed` | `aborted` | `error` | `disposed` | `max-tokens` | `rejected` | `interrupted`).
A turn ended. `reason` distinguishes a clean stop from a truncated, aborted, failed, disposed, or crash-interrupted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens` | `interrupted`); the reason union is merge-extensible, so a plugin can add further variants.
```ts cordis-catalog
'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void
@@ -145,7 +145,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated, aborted, or
Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md)
Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts)
#### `agent/turn-start` — emit
@@ -34,6 +34,6 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab
- The loop no longer emits `agent/step-start`/`agent/step-end`; `closeStep` appends `step/end` only, and a throwing `step/end` session-event listener is the surviving step-boundary-listener failure path (contained by `closeStep``failTurn`, the turn closes balanced).
- Tests that observed step boundaries via the removed emits now observe the durable `step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting, a throwing boundary listener failing the turn balanced) is unchanged; only the feed they read moved to the canonical one. Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved together.
- One behavior genuinely shifts and is documented in its test: a throwing `step/start` session-event listener throws INSIDE `session.append('step/start')`, before the loop marks the step open, so no `step/end` is owed (the old `agent/step-start` emit fired after the step was open). The turn still closes balanced with an error.
- The loop marks the step open (`stepOpen = true`) BEFORE appending `step/start`, because `Session.append` pushes the event to the log before notifying `session/event` listeners (validation throws happen earlier, before the push — see [the session append contract](../../../core-data-structures/session.md)). So a throwing `step/start` session-event listener runs with the step already open and the event already in the log: the loop's outer catch then calls `closeStep()`, which appends the balancing `step/end`, and the turn closes balanced with an error (`turn/start → step/start → step/end → turn/end` — verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first.
- This is a partial, conservative realization of the broader [proposed simplification "Stop mirroring durable boundaries as agent events"](../../proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md): that RFC proposes removing ALL boundary mirrors (including the turn boundaries and `agent/steering`) and migrating the stdio UI's turn rendering onto `session/event`. This RFC removes only the two step mirrors that have no live consumer; the turn mirrors stay until the stdio UI is migrated. The proposed RFC remains the home for finishing that migration.
- The cordis catalog (`docs/cordis-catalog/events-and-services.md`) is regenerated to drop the two events.
@@ -169,12 +169,22 @@ describe('HIGH: steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
})
it('steer() from a step/end session-event listener reaches the next request (/goal pattern)', async () => {
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
// The /goal pattern steers from a step boundary so the model addresses a
// standing goal before stopping. Step boundaries have no agent/* mirror, so
// the surviving hook point is the durable step/end session event. With a
// no-tools first step the default continuation is stop; the steering queued
// here must force the hasSteering override and reach the next request.
// here must force the `!shouldContinue && hasSteering` override so the SAME
// turn runs another step.
//
// The override is what this test guards, so it asserts the same-turn shape —
// NOT merely that the content reaches requests[1]. Without the override the
// turn would stop, and leftover steering is re-enqueued as a next-turn queued
// message, which ALSO lands in requests[1] (just one turn later). So a
// content-only assertion passes with the override disabled and guards
// nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with
// TWO steps and the steering recorded as a `steering/message` BEFORE step 2;
// re-enqueue fallback ⇒ TWO turns.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
textResponse('after goal reminder'),
@@ -192,8 +202,17 @@ describe('HIGH: steering from late extension points is never stranded', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
// steering from the step/end listener forced a second step (hasSteering
// override) and reached the next model request.
// Same-turn continuation: the steering forced step 2 within turn 1.
const events = [...agent.session.events]
expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(events.filter(e => e.type === 'step/start')).toHaveLength(2)
// The steered content is recorded as steering (same turn), BEFORE step 2 —
// not as a fresh turn's user/message. This is the mechanism the override uses.
const steeringIdx = events.findIndex(e => e.type === 'steering/message')
const step2Idx = events.map(e => e.type).lastIndexOf('step/start')
expect(steeringIdx).toBeGreaterThanOrEqual(0)
expect(steeringIdx).toBeLessThan(step2Idx)
// and it reached the next model request.
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end')
})
+3 -2
View File
@@ -197,8 +197,9 @@ declare module 'cordis' {
'agent/turn-start'(agent: Agent, turn: number): void
/**
* A turn ended. `reason` distinguishes a clean stop from a truncated,
* aborted, or hook-rejected one (`completed` | `aborted` | `error` |
* `disposed` | `max-tokens` | `rejected` | `interrupted`).
* aborted, failed, disposed, or crash-interrupted one (`completed` |
* `aborted` | `error` | `disposed` | `max-tokens` | `interrupted`); the
* reason union is merge-extensible, so a plugin can add further variants.
* @mode emit
*/
'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void