fix(agent-loop): park empty turn batches, keep max-tokens sticky, and settle headless at idle

- turn boundary now returns false for an empty admitted batch (claimed
  input removed before the wake) instead of opening a turn and spending a
  model call on nothing; the step boundary already had the symmetric guard.
- a max-token step stays sticky when steering or injected work continues
  the turn: a later completed step no longer downgrades the outcome,
  matching the TurnEndReasonMap contract.
- session/queue wire schema accepts the context placement (previously the
  zod union rejected injected-context snapshots wholesale and the client
  silently dropped the whole frame); schema tests cover all placements.
- headless runs settle at whole-agent idle instead of the first turn/end,
  honoring the one-shot idle-to-idle contract.
- flush JSDoc names the real callers (checkpoint policy, goal-session,
  teardown, self-flushing consumers); apiproxy zh README loses its stale
  duplicate history section; ACP note/README record the delivered error
  rejection and turnless-cancelled behaviors.
This commit is contained in:
_Kerman
2026-08-03 20:36:26 +08:00
parent 81ac6db785
commit ab56e0df0a
15 changed files with 101 additions and 50 deletions

View File

@@ -187,6 +187,20 @@ describe('abort during tool execution ends the turn', () => {
.toBeUndefined()
})
it('parks an empty admitted batch instead of opening a turn', async () => {
const adapter = new MockAdapter([textResponse('must not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
// The wake microtask has not run yet: remove the only pending message so
// the admission batch is empty.
agent.inbox.remove('next-turn', agent.inbox.nextTurn[0]!.id)
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(agent.inbox.nextTurn).toHaveLength(0)
})
it('parks result context finalized after disposal cancellation without opening another turn', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
const ctx = await harness(adapter)

View File

@@ -967,7 +967,9 @@ describe('agent loop', () => {
source: { kind: 'plugin', plugin: 'max-tokens-test' },
},
])
expect(reasons).toEqual([{ kind: 'completed' }])
// A max-token step is sticky: the later completed step must not
// downgrade the turn outcome.
expect(reasons).toEqual([{ kind: 'max-tokens' }])
})
it('a completed step after no max-tokens keeps the turn completed (max-tokens does not leak across turns)', async () => {