docs: trim generated prose

This commit is contained in:
Tianyi Cui
2026-07-12 03:36:43 +08:00
parent 3dca90261c
commit 75838e10b5
323 changed files with 2857 additions and 11833 deletions

View File

@@ -140,10 +140,8 @@ describe('ReactLoopAgent', () => {
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Non-serializable injected content makes Session.append throw AFTER
// turn/start was recorded. The turn/end must still be appended (finally),
// AND the durability checkpoint must still fire — the balanced turn is in
// memory and a crash before the next turn/dispose would otherwise lose it.
// Non-serializable injected content makes Session.append throw after turn/start was
// recorded.
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
@@ -159,10 +157,7 @@ describe('ReactLoopAgent', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// A session/event listener that throws on the synthetic turn/end. Append
// pushes before notifying, so turn/end is in the log (turn balanced) but the
// throw must NOT skip the durability checkpoint — the flush decision is made
// from the log, not a flag set after the (throwing) append.
// A session/event listener that throws on the synthetic turn/end.
let threw = false
ctx.on('session/event', (_s, event) => {
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
@@ -202,10 +197,8 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
// the log stays empty, not left with a dangling turn/start.
// A non-serializable source makes the turn/start append throw before the event is pushed
// (Session.append validates before push), so NO turn opens.
expect(() => {
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/non-JSON-serializable/)
@@ -326,10 +319,9 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// while running (not the fast path), then the disposer settles it and chains
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
// internal driver disposer keeps the emit synchronous.
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter while running (not
// the fast path), then the disposer settles it and chains `done` (loop exit), not an eager
// resolve.
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -355,11 +347,9 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
// disposing the OWNING fiber runs the agent's listener disposers, which would
// have dropped a ctx.on-based waiter before the 'disposed' transition and
// hung the promise. With internal waiters, the fiber disposer still settles
// it. Regression for the round-3 whenIdle finding.
// The waiter is internal agent state, not an effect-scoped ctx.on listener: disposing the
// OWNING fiber runs the agent's listener disposers, which would have dropped a ctx.on-based
// waiter before the 'disposed' transition and hung the promise.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
@@ -377,10 +367,8 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
// The disposer emits agent/status('disposed') BEFORE the driver loop
// unwinds, so whenIdle() must chain `done` (true quiescence) on the
// disposed path. Dispose a running agent, then assert whenIdle() resolves
// only after `done` — i.e. the loop has actually exited.
// The disposer emits agent/status('disposed') before the driver loop unwinds, so whenIdle()
// must chain `done` (true quiescence) on the disposed path.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent

View File

@@ -1,12 +1,8 @@
/**
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the
* broad verb — it clears queued + steering work, aborts an in-flight step, and
* drops a turn about to start — whereas a bare step abort (the loop's private
* `AbortController`) kills only the current step and leaves the queue intact.
* These tests exercise every window where a cancel can land (idle, pre-step,
* mid-step, continuation) and the marker's arm/reset rules that keep a cancel
* from leaking to a later prompt or hanging `whenIdle()`.
*
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it
* clears queued + steering work, aborts an in-flight step, and drops a turn about to start —
* whereas a bare step abort (the loop's private `AbortController`) kills only the current step
* and leaves the queue intact.
* @module dsh-agent-loop/tests/cancel
*/
@@ -95,10 +91,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Queue work, then register a whenIdle() waiter while in the pre-step window
// (status idle, hasQueued true) — it does NOT take the fast path. Then cancel.
// The skip path must settle this waiter directly (no running→idle transition
// ever fires), or it would hang forever.
// Queue work, then register a whenIdle() waiter while in the pre-step window (status idle,
// hasQueued true) — it does not take the fast path.
send(agent, 'q')
const idle = agent.whenIdle()
agent.cancel('pre-step')
@@ -234,12 +228,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// The first composition is interrupted mid-waterfall and — like an
// abort-aware listener bailing on a firing signal — contributes nothing.
// Caching that degraded result would silently strip the prefix from every
// later request of this instance; the loop must discard it and recompose
// on the next send, and the SECOND composition's value must be what the
// wire and the header log carry.
// The first composition is interrupted mid-waterfall and — like an abort-aware listener
// bailing on a firing signal — contributes nothing.
const opener: Message = { role: 'user', content: [{ type: 'text', text: 'fresh opener' }] }
let compositions = 0
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
@@ -268,10 +258,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A turn/start listener fires right after turn/start is appended, BEFORE any
// AbortController is installed for the step. Cancelling there must still drop
// the step (the turn-scoped marker, not the step AbortController, is what
// catches this) — no model step runs.
// A turn/start listener fires right after turn/start is appended, before any
// AbortController is installed for the step.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
@@ -400,10 +388,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running
// listener can cancel in the gap between the loop's pre-step check and
// runTurn. The second check (after the running flip) must drop the turn —
// runTurn would otherwise throw on the now-empty queue.
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running listener can cancel
// in the gap between the loop's pre-step check and runTurn.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -421,11 +407,7 @@ describe('Agent.cancel()', () => {
})
it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => {
// The window-1 early-resolve race has a window-2 twin: a synchronous
// agent/status('running') listener cancels the about-to-run turn AND queues a
// replacement. window 2 must NOT settle waiters (via setStatus('idle')) while
// the replacement is still queued-and-unrun — it must fall through and run it,
// so whenIdle() resolves on the replacement turn's running→idle, not before.
// Cancellation must not settle idle while replacement work remains queued.
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -451,11 +433,8 @@ describe('Agent.cancel()', () => {
})
it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => {
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel()
// clears A; prompt B is queued BEFORE the loop resumes from the idle wait.
// The window-1 cancel branch must NOT settle the waiter while B is still
// queued-and-unrun — whenIdle() must wait for B's turn to actually run and
// settle (the quiescence contract), not resolve before B's first event.
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel() clears A;
// prompt B is queued before the loop resumes from the idle wait.
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -465,9 +444,8 @@ describe('Agent.cancel()', () => {
agent.cancel('drop A') // arms marker, clears A
send(agent, 'B') // B races in before the loop resumes
// whenIdle() must resolve only AFTER B's turn fully ran — by which point B's
// user message and a turn/end are in the log. (Before the fix it resolved
// immediately, with zero events, then B ran afterward.)
// whenIdle() must resolve only after B's turn fully ran — by which point B's user message
// and a turn/end are in the log.
await idle
expect(userTexts(agent)).toContain('B')
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)

View File

@@ -101,9 +101,7 @@ describe('config-driven session id', () => {
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
// Run 2: a CONFIG agent with resumeSessionId continues that session. The
// resume is deferred until sessionPersistence loads (ctx.inject), so wait
// for the agent to appear, then assert it is on the resumed id with history.
// Run 2: a CONFIG agent with resumeSessionId continues that session.
const ctx2 = new Context()
await ctx2.plugin(LlmService)
await ctx2.plugin(SessionStore)

View File

@@ -37,11 +37,7 @@ function send(agent: ReactLoopAgent, text: string) {
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
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
// never enters the log. runTurn sees no logged turn/start and rethrows; the
// runLoop backstop reports via agent/error (step 0) + the logger and the
// driver survives. This is the ONLY path that reaches the backstop.
// Pre-append validation reports through agent/error without corrupting the log.
const adapter = new MockAdapter([textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })

View File

@@ -70,9 +70,8 @@ describe('Inbox', () => {
r1()
await p1
// Now enqueue: the first waiter's wakeup (which was overwritten) won't
// fire, and the second waiter's wakeup was cleared by cancel.
// The enqueue calls wakeup?.() but wakeup was cleared — no crash, no hang.
// Now enqueue: the first waiter's wakeup (which was overwritten) won't fire, and the second
// waiter's wakeup was cleared by cancel.
inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } })
// The overwrite path + finally cleanup are exercised
})

View File

@@ -117,14 +117,9 @@ describe('agent/prompt-submit', () => {
})
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
// The merge of the interception seams with master's compaction seam pins one
// ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting
// context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step
// before the single deriveMessages(). So a compaction listener on
// `agent/pre-step` must observe the surface AFTER the prompt rewrite/inject —
// otherwise it would measure/compact stale history. This cross-test proves
// the two seams compose in the right order (each is covered in isolation
// elsewhere; this asserts they see each other's effects on the same turn).
// The merge of the interception seams with master's compaction seam pins one ordering:
// `agent/prompt-submit` runs (rewriting the prompt and injecting context) before the step
// loop, and `agent/pre-step` fires inside the step before the single deriveMessages().
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -189,9 +184,7 @@ describe('agent/prompt-submit', () => {
})
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
// Two prompts queued into ONE turn: block "secret", allow "safe". The turn is
// NOT rejected (a prompt was allowed), so without a durable prompt/blocked the
// vetoed prompt and its reason would vanish from the log entirely.
// Two prompts queued into one turn: block "secret", allow "safe".
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -618,11 +611,9 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
})
describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => {
// The whole point of the interception taxonomy: a "native hook" needs no
// dsh-hook-protocol, no external command, no hook/* log — it is an ordinary
// cordis plugin subscribing to the canonical events and returning typed
// decisions. This proves all four seams compose end-to-end through the REAL
// loop, with NO hook/* SessionEvents involved (those belong to the bridge lib).
// The whole point of the interception taxonomy: a "native hook" needs no dsh-hook-protocol,
// no external command, no hook/* log — it is an ordinary cordis plugin subscribing to the
// canonical events and returning typed decisions.
const NativeGuard = {
name: 'native-guard',
apply(ctx: Context) {

View File

@@ -183,11 +183,7 @@ describe('agent loop', () => {
})
it('contains a strict-variable render failure: the turn errors, the loop keeps serving turns', async () => {
// A persona claiming {{cwd}} on a session with NO cwd is a deployment
// authoring error — renderPrompt throws, the turn ends with an error, and
// the same agent must then RUN a later turn to completion (not merely
// report idle status): a rescue listener supplies the variable and the
// follow-up prompt reaches the model.
// A missing cwd variable must fail one turn without preventing a later valid turn.
const adapter = new MockAdapter([textResponse('ok after rescue')])
const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = []
@@ -522,9 +518,8 @@ describe('agent loop', () => {
})
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
// A listener appending a surface node in pre-step lands it BEFORE step/start
// in the log — proving the seam fires outside the step. The node is still in
// the derived request for that step (derive happens after step/start).
// A listener appending a surface node in pre-step lands it before step/start in the log —
// proving the seam fires outside the step.
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -557,10 +552,9 @@ describe('agent loop', () => {
})
it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => {
// The seam fires before step/start, so a throw escapes to runTurn's outer
// catch: the not-yet-open step closes as a no-op, the failure surfaces via
// agent/error, and the turn ends `error` (recorded on the durable turn/end).
// The loop survives and a follow-up prompt still runs.
// The seam fires before step/start, so a throw escapes to runTurn's outer catch: the
// not-yet-open step closes as a no-op, the failure surfaces via agent/error, and the turn
// ends `error` (recorded on the durable turn/end).
const adapter = new MockAdapter([textResponse('second turn ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -633,10 +627,8 @@ describe('agent loop', () => {
})
it('a max-tokens step earlier in a turn still surfaces as max-tokens after a later completed step', async () => {
// Step 1 is cut off (max-tokens, no tool calls → would stop by default), so
// continuation must be FORCED to reach step 2 which finishes normally
// (stop). The rule "any max-tokens step surfaces as max-tokens" means the
// turn ends max-tokens even though the LAST step completed cleanly.
// Step 1 is cut off (max-tokens, no tool calls → would stop by default), so continuation
// must be FORCED to reach step 2 which finishes normally (stop).
const adapter = new MockAdapter([
maxTokensResponse('first half'),
textResponse('second half'),
@@ -718,11 +710,8 @@ describe('agent loop', () => {
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
// No-data-loss: a max-tokens step whose only content was a dropped tool call
// has EMPTY assistant content, but its usage must still be represented. It
// rides on an (empty-content) assistant/message — there is no standalone
// usage event — and that empty message is skipped by deriveMessages(), so
// the derived history above is NOT corrupted by a spurious assistant turn.
// No-data-loss: a max-tokens step whose only content was a dropped tool call has EMPTY
// assistant content, but its usage must still be represented.
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({
turn: 1, step: 1, content: [], usage: { inputTokens: 10, outputTokens: 5 },
@@ -730,10 +719,9 @@ describe('agent loop', () => {
})
it('appends no assistant/message for a max-tokens step with empty content and no usage', async () => {
// A max-tokens step truncated to a dropped tool call AND with no usage chunk
// has nothing to record: empty content and no accounting → no assistant/message
// (the empty-content host exists only to carry usage). The turn still ends
// max-tokens.
// A max-tokens step truncated to a dropped tool call AND with no usage chunk has nothing to
// record: empty content and no accounting → no assistant/message (the empty-content host
// exists only to carry usage).
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'tool-call' },

View File

@@ -1,12 +1,5 @@
/**
* Property-based tests for the agent loop's inbox/turn scheduling (the
* property-testing RFC). Deterministic by construction: schedules are driven
* through the `agent/status` settle signal (no wall-clock sleeps), so a flake
* is a finding, not timing noise.
*
* Invariants: every sent message appears exactly once in the log (none lost);
* turn numbers strictly increase; status transitions follow the legal machine
* idle→running→idle (and →disposed at teardown).
* Property-based tests for the agent loop's inbox/turn scheduling (the property-testing RFC).
*/
import { describe, expect, it } from 'vitest'
@@ -146,10 +139,8 @@ describe('agent loop scheduling properties', () => {
const ctx = await harness()
try {
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
// Capture an idle waiter before EACH send; the last one is guaranteed
// to resolve because the final send always triggers (or joins) a turn
// that ends idle. Awaiting an already-resolved waiter is a no-op, so a
// trailing settle step can't cause a hang.
// Capture an idle waiter before EACH send; the last one is guaranteed to resolve
// because the final send always triggers (or joins) a turn that ends idle.
let lastIdle: Promise<void> | undefined
for (const step of steps) {
const idle = nextIdle(ctx, agent)

View File

@@ -9,15 +9,12 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
/**
* With-key proof that log-derived requests translate into REAL provider cache
* hits: a multi-step tool turn (plus a follow-up turn) against the live
* DeepSeek API must report `cacheReadTokens > 0` on every request after the
* first — the adapter maps the provider's `prompt_cache_hit_tokens`, and the
* per-step usage recorded on `assistant/message` events is the production
* observable for cache behavior (the reconstructability RFC's measurement
* layer: prefix stability is corollary #1). Mocks prove the requests are
* append-extensions; only the real API proves those bytes actually hit the
* provider cache. Key-gated — skips entirely without $DEEPSEEK_API_KEY.
* With-key proof that log-derived requests translate into real provider cache hits: a
* multi-step tool turn (plus a follow-up turn) against the live DeepSeek API must report
* `cacheReadTokens > 0` on every request after the first — the adapter maps the provider's
* `prompt_cache_hit_tokens`, and the per-step usage recorded on `assistant/message` events is
* the production observable for cache behavior (the reconstructability RFC's measurement
* layer: prefix stability is corollary #1).
*/
// Long enough that the shared request prefix comfortably spans the provider's

View File

@@ -1,11 +1,8 @@
/**
* Loop-level reconstructability: every request the loop sends is a pure
* function of the session log — messages are the derivation at the step/start
* boundary, the header is the fold of request/header* events — and every
* request is an append-extension of its predecessor unless a logged event
* (compaction replace, header change) explains the difference. The requests
* recorded by the mock adapter are the observable; the offline-rebuild test
* at the bottom is the theorem stated end-to-end.
* Loop-level reconstructability: every request the loop sends is a pure function of the
* session log — messages are the derivation at the step/start boundary, the header is the fold
* of request/header* events — and every request is an append-extension of its predecessor
* unless a logged event (compaction replace, header change) explains the difference.
*/
import { describe, expect, it } from 'vitest'

View File

@@ -412,10 +412,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
})
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
// wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)
// — without an explicit flush or clean dispose, the notice must still reach
// disk, since a crash before the next turn would otherwise lose it.
// Lifecycle 1: run a turn, then inject context while idle.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
@@ -437,10 +434,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
})
it('an idle inject() survives persist + resume (turn-enclosed, not dropped as crash tail)', async () => {
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
// wraps its context/message in a one-shot turn so it is turn-enclosed —
// otherwise scanLog would treat the trailing context as a crash tail and
// drop it on reload (the bug this guards).
// Lifecycle 1: run a turn, then inject context while idle.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent

View File

@@ -114,10 +114,8 @@ describe('HIGH: abort during tool execution ends the turn', () => {
parameters: {},
async execute() {
executed.push('aborter')
// Fire the in-flight step's AbortController directly (the loop registers
// it on the agent). This is the bare step-abort path — distinct from
// cancel(), which would also clear the inbox; here the subject is the
// loop's response to its running step being aborted mid-tool.
// Fire the in-flight step's AbortController directly (the loop registers it on the
// agent).
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
return [{ type: 'text', text: 'done' }]
},
@@ -171,21 +169,8 @@ describe('HIGH: steering from late extension points is never stranded', () => {
})
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 `!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.
// The /goal pattern steers from a step boundary so the model addresses a standing goal
// before stopping.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
textResponse('after goal reminder'),
@@ -252,11 +237,9 @@ describe('HIGH: steering from late extension points is never stranded', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.steer([{ type: 'text', text: 'redirect' }])
// Abort ONLY the in-flight step, via its AbortController directly — NOT
// cancel(), which clears the inbox and would drop the queued steering this
// test proves survives a step abort. There is no public step-only abort
// verb (cancel() is the only public stop primitive), so reach the private
// controller the loop registered.
// Abort only the in-flight step, via its AbortController directly — not cancel(), which
// clears the inbox and would drop the queued steering this test proves survives a step
// abort.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
await waitForIdle(ctx, agent)
@@ -497,10 +480,9 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => {
describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => {
it('translates finish {kind:error} into a turn error with a logged error event', async () => {
// The second sanctioned adapter error path (besides throwing): an
// adapter that cannot throw mid-stream ends the stream with a
// finish-error chunk (e.g. the pi-ai adapter mapping a provider 401).
// The loop must NOT log a normal assistant/message + completed turn.
// The second sanctioned adapter error path (besides throwing): an adapter that cannot throw
// mid-stream ends the stream with a finish-error chunk (e.g. the pi-ai adapter mapping a
// provider 401).
const errorStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } },
]
@@ -567,10 +549,8 @@ describe('P1-6: a step/start session-event listener sees the event already in th
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
// 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.)
// Session.append pushes the event before notifying session/event listeners, so a step/start
// listener always finds the matching event already in the log.
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/start') return
@@ -593,10 +573,7 @@ describe('P1-6: a step/start session-event listener sees the event already in th
})
describe('P1-5: a started turn (and any open step) is always closed on a boundary throw', () => {
// Harness with the invariants plugin loaded as an oracle: it throws on
// append if the log goes unbalanced (turn/end while a step is open,
// turn/start while a turn is open, etc.), so a regression surfaces as an
// InvariantError on the NEXT turn's append rather than a silent imbalance.
// Invariants turn latent log imbalance into an immediate test failure.
async function balancedHarness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -628,14 +605,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
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 loop marks
// the step open BEFORE appending step/start (Session.append pushes before
// notifying, so a post-push listener throw still leaves stepOpen=true), so
// the outer catch's closeStep() appends the balancing step/end — the turn
// stays enclosed. The invariants oracle (balancedHarness) rejects any
// imbalance, so a green run proves turn/start → step/start → step/end →
// turn/end nesting holds.
// Step boundaries have no agent/* mirror; a throwing step/start session-event listener is
// the surviving step-boundary-listener failure.
let threw = false
ctx.on('session/event', (_s, event) => {
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
@@ -720,13 +691,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests
// disposal AND throws. The throw escapes the pre-step `await` (line ~419) to
// the loop's outer catch — BEFORE the post-pre-step disposal check at ~422
// gets to run — so the catch sees `isDisposed() && !errorReported` and must
// PRESERVE reason=disposed rather than overwrite it with the listener's throw
// (disposal is not a failure). This is the surviving path to that sub-branch
// now that there is no turn-boundary emit to throw from.
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests disposal AND
// throws.
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
@@ -763,14 +729,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on the turn/start append still balances the turn', async () => {
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a listener throwing on turn/start leaves turn/start IN THE LOG. The
// loop must therefore still owe (and append) a turn/end — deciding "owed"
// from the log via isTurnOpen, not a "turn started" flag that the throw
// skipped. Otherwise the turn stays permanently open and poisons the next
// turn/replay (the turn-enclosure RFC). (Uses the plain harness — NOT the invariants
// oracle — because the throwing listener is itself a session/event
// subscriber.)
// Session.append pushes the event before notifying session/event listeners, so a listener
// throwing on turn/start leaves turn/start IN THE LOG.
const adapter = new MockAdapter([textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
@@ -787,10 +747,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// The error was surfaced exactly once via agent/error.
expect(errors.map(e => e.message)).toEqual(['boom turn/start append'])
// The turn is BALANCED: turn/start is in the log (it was pushed before the
// listener threw), so a turn/end was owed and appended — no open turn. The
// last turn-boundary event being turn/end is exactly the loop's isTurnOpen
// check (no open turn remains).
// The turn is BALANCED: turn/start is in the log (it was pushed before the listener threw),
// so a turn/end was owed and appended — no open turn.
const types = [...agent.session.events].map(e => e.type)
expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
@@ -805,11 +763,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
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. (Step
// boundaries have no agent/* mirror; the session-event listener is the path.)
// 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.
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' })
@@ -848,13 +803,7 @@ 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 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 — 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.)
// A step/end listener failure must not prevent turn/end finalization.
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)
@@ -884,12 +833,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
// closeTurn appends turn/end; Session.append pushes it BEFORE notifying
// session/event listeners, so a throwing listener leaves turn/end in the log
// (the turn is balanced) but must not escape — from the normal-path closeTurn
// it would otherwise propagate; the append is contained so the loop continues.
// Turn boundaries are durable session events only (no agent/* mirror), so this
// session/event append-notify throw is the sole turn-end-listener failure path.
// closeTurn appends turn/end; Session.append pushes it before notifying session/event
// listeners, so a throwing listener leaves turn/end in the log (the turn is balanced) but
// must not escape — from the normal-path closeTurn it would otherwise propagate; the append
// is contained so the loop continues.
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
@@ -931,9 +878,6 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
}))
// A post-execute listener transforms the result (accept-with-replacement).
// The loop must still record the tool/result under the model's authoritative
// call.id (the loop ignores result.callId — which the registry always sets to
// exec.callId anyway — and uses call.id, the model-transcript id).
ctx.on('tools/post-execute', (exec, _result) => {
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
@@ -966,11 +910,8 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => {
it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => {
// An empty stream yields zero assistant/chunk events (finish defaults to
// `stop`), so chunkSeqs is empty. A step-result listener injects content, so
// the content-or-usage guard fires and an assistant/message is appended. Its
// sourceEventSeqs MUST be omitted (not `[]`) — the surface invariant rejects
// an empty sourceEventSeqs, and the dev invariants plugin would throw on it.
// An empty stream yields zero assistant/chunk events (finish defaults to `stop`), so
// chunkSeqs is empty.
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
await ctx.plugin(Invariants)
@@ -997,12 +938,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
// Block `system-prompt/assemble` on a promise. Start disposal (which
// calls stop() synchronously, setting status=disposed), then release the
// block. The loop must check isDisposed() after assembly and end the turn
// `disposed` — no LLM call. Don't await fiber.dispose() before releasing
// the blocker: the dispose chain awaits agent.done, which hangs until the
// loop unblocks.
// Block `system-prompt/assemble` on a promise.
const adapter = new MockAdapter(['hang'])
let releaseAssemble!: () => void
const blocked = new Promise<void>(r => void (releaseAssemble = r))
@@ -1113,9 +1049,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
})
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
// Block the `agent/pre-step` serial seam on a promise we control, then
// dispose the agent's fiber. When the block releases, the loop must see
// isDisposed() at the post-seam check and end the turn disposed.
// Block the `agent/pre-step` serial seam on a promise we control, then dispose the agent's
// fiber.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))

View File

@@ -364,11 +364,9 @@ describe('agent scope lifecycle', () => {
})
it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => {
// ds-review-bot regression: agent/* listeners are typed
// `this: Scoped<Agent>`, and ReactLoopAgent's send/steer/cancel read the
// native-private #carrier — a proxy-receiver carrier made
// `this.send(...)` throw TypeError. The carrier binds methods to the real
// agent, so driving through the event `this` is a working supported shape.
// ds-review-bot regression: agent/* listeners are typed `this: Scoped<Agent>`, and
// ReactLoopAgent's send/steer/cancel read the native-private #carrier — a proxy-receiver
// carrier made `this.send(...)` throw TypeError.
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -403,11 +401,9 @@ describe('agent scope lifecycle', () => {
order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
})
// Open a turn so the drain has real work: the loop must finish it BEFORE
// the registry entry goes away (the agent/disposed contract: "its fiber
// and any in-flight turn have been torn down"). Wait for the turn to be
// OPEN in the log — a dispose landing in the pre-step window would drop
// the queued prompt without ever opening a turn.
// Open a turn so the drain has real work: the loop must finish it before the registry entry
// goes away (the agent/disposed contract: "its fiber and any in-flight turn have been torn
// down").
const turnOpen = new Promise<void>((resolve) => {
const off = ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/start') { off(); resolve() }

View File

@@ -1,10 +1,8 @@
/**
* Loop-level tool-order determinism: the request/header event — and therefore
* the frozen request the adapter receives — carries the assembly's canonical
* tool order (system-prompt's `toolOrder` config, or lexicographic name
* order), regardless of the order tool plugins happened to register in.
* Registration order is a plugin-load artifact (concurrent dynamic imports
* race), so nothing downstream of the registry may depend on it.
* Loop-level tool-order determinism: the request/header event — and therefore the frozen
* request the adapter receives — carries the assembly's canonical tool order (system-prompt's
* `toolOrder` config, or lexicographic name order), regardless of the order tool plugins
* happened to register in.
*/
import { describe, expect, it } from 'vitest'
@@ -93,11 +91,7 @@ describe('loop-level canonical tool order', () => {
})
it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => {
// The assemble rejection escapes to runTurn's outer catch: the open turn
// closes with an `error` reason (agent/error mirrors it), no step opens,
// no request/header is logged, the adapter never sees a request, and the
// agent returns to idle — a misconfigured deployment fails every turn
// deterministically instead of silently reordering nothing.
// Unknown tool order fails before step or request creation and returns the agent to idle.
const adapter = new MockAdapter([textResponse('never sent')])
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
registerNamed(ctx, 'alpha')