docs: trim generated prose
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user