|
@@ -706,61 +706,80 @@ describe('provider-routed retry policy', () => {
|
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('does not make plugin disposal wait for a delegated recovery policy', async () => {
|
|
|
|
|
|
|
+ it('drains delegated recovery before completing plugin disposal', async () => {
|
|
|
const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')])
|
|
const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')])
|
|
|
const mounted = await harness(adapter, { mock: alwaysConfig() })
|
|
const mounted = await harness(adapter, { mock: alwaysConfig() })
|
|
|
context = mounted.ctx
|
|
context = mounted.ctx
|
|
|
- const downstream = Promise.withResolvers<RequestErrorDecision>()
|
|
|
|
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
- context.on('agent/request-error', () => {
|
|
|
|
|
|
|
+ const order: string[] = []
|
|
|
|
|
+ context.on('agent/request-error', async () => {
|
|
|
entered.resolve(undefined)
|
|
entered.resolve(undefined)
|
|
|
- return downstream.promise
|
|
|
|
|
|
|
+ await release.promise
|
|
|
|
|
+ order.push('downstream')
|
|
|
|
|
+ return { action: 'retry' }
|
|
|
})
|
|
})
|
|
|
const agent = context.agentLoop.create(SessionId('retry-delegated-disposal'), {
|
|
const agent = context.agentLoop.create(SessionId('retry-delegated-disposal'), {
|
|
|
provider: 'mock',
|
|
provider: 'mock',
|
|
|
model: 'mock',
|
|
model: 'mock',
|
|
|
})
|
|
})
|
|
|
- const idle = waitForIdle(context, agent)
|
|
|
|
|
|
|
+ const idle = waitForIdle(context, agent).then(() => { order.push('idle') })
|
|
|
agent.followup([{ type: 'text', text: 'go' }])
|
|
agent.followup([{ type: 'text', text: 'go' }])
|
|
|
await entered.promise
|
|
await entered.promise
|
|
|
|
|
|
|
|
- const disposing = mounted.retryFiber.dispose()
|
|
|
|
|
|
|
+ const disposing = mounted.retryFiber.dispose().then(() => { order.push('disposed') })
|
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
|
const outcome = await Promise.race([
|
|
const outcome = await Promise.race([
|
|
|
disposing.then(() => 'disposed' as const),
|
|
disposing.then(() => 'disposed' as const),
|
|
|
new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }),
|
|
new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }),
|
|
|
])
|
|
])
|
|
|
if (timer !== undefined) clearTimeout(timer)
|
|
if (timer !== undefined) clearTimeout(timer)
|
|
|
- downstream.resolve({ action: 'fail' })
|
|
|
|
|
|
|
+ expect(outcome).toBe('blocked')
|
|
|
|
|
+
|
|
|
|
|
+ release.resolve(undefined)
|
|
|
await disposing
|
|
await disposing
|
|
|
await idle
|
|
await idle
|
|
|
|
|
|
|
|
- expect(outcome).toBe('disposed')
|
|
|
|
|
|
|
+ expect(order[0]).toBe('downstream')
|
|
|
|
|
+ expect(order).toEqual(expect.arrayContaining(['disposed', 'idle']))
|
|
|
expect(adapter.requests).toHaveLength(1)
|
|
expect(adapter.requests).toHaveLength(1)
|
|
|
|
|
+ expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('lets turn cancellation interrupt a delegated recovery policy', async () => {
|
|
|
|
|
|
|
+ it('drains delegated recovery before turn cancellation reaches idle', async () => {
|
|
|
const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')])
|
|
const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')])
|
|
|
const mounted = await harness(adapter, { mock: alwaysConfig() })
|
|
const mounted = await harness(adapter, { mock: alwaysConfig() })
|
|
|
context = mounted.ctx
|
|
context = mounted.ctx
|
|
|
const downstream = Promise.withResolvers<RequestErrorDecision>()
|
|
const downstream = Promise.withResolvers<RequestErrorDecision>()
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
- context.on('agent/request-error', () => {
|
|
|
|
|
|
|
+ const order: string[] = []
|
|
|
|
|
+ context.on('agent/request-error', async () => {
|
|
|
entered.resolve(undefined)
|
|
entered.resolve(undefined)
|
|
|
- return downstream.promise
|
|
|
|
|
|
|
+ const decision = await downstream.promise
|
|
|
|
|
+ order.push('downstream')
|
|
|
|
|
+ return decision
|
|
|
})
|
|
})
|
|
|
const agent = context.agentLoop.create(SessionId('retry-delegated-cancel'), {
|
|
const agent = context.agentLoop.create(SessionId('retry-delegated-cancel'), {
|
|
|
provider: 'mock',
|
|
provider: 'mock',
|
|
|
model: 'mock',
|
|
model: 'mock',
|
|
|
})
|
|
})
|
|
|
- const idle = waitForIdle(context, agent)
|
|
|
|
|
|
|
+ const idle = waitForIdle(context, agent).then(() => { order.push('idle') })
|
|
|
agent.followup([{ type: 'text', text: 'go' }])
|
|
agent.followup([{ type: 'text', text: 'go' }])
|
|
|
await entered.promise
|
|
await entered.promise
|
|
|
|
|
|
|
|
agent.cancel({ kind: 'user' })
|
|
agent.cancel({ kind: 'user' })
|
|
|
|
|
+ let timer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
|
+ const outcome = await Promise.race([
|
|
|
|
|
+ idle.then(() => 'idle' as const),
|
|
|
|
|
+ new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }),
|
|
|
|
|
+ ])
|
|
|
|
|
+ if (timer !== undefined) clearTimeout(timer)
|
|
|
|
|
+ expect(outcome).toBe('blocked')
|
|
|
|
|
+
|
|
|
|
|
+ downstream.resolve({ action: 'retry' })
|
|
|
await idle
|
|
await idle
|
|
|
- downstream.resolve({ action: 'fail' })
|
|
|
|
|
|
|
|
|
|
|
|
+ expect(order).toEqual(['downstream', 'idle'])
|
|
|
expect(adapter.requests).toHaveLength(1)
|
|
expect(adapter.requests).toHaveLength(1)
|
|
|
expect(agent.session.events.at(-1)).toMatchObject({
|
|
expect(agent.session.events.at(-1)).toMatchObject({
|
|
|
type: 'turn/end',
|
|
type: 'turn/end',
|
|
@@ -773,8 +792,10 @@ describe('provider-routed retry policy', () => {
|
|
|
const mounted = await harness(adapter, { mock: alwaysConfig() })
|
|
const mounted = await harness(adapter, { mock: alwaysConfig() })
|
|
|
context = mounted.ctx
|
|
context = mounted.ctx
|
|
|
const downstream = Promise.withResolvers<RequestErrorDecision>()
|
|
const downstream = Promise.withResolvers<RequestErrorDecision>()
|
|
|
|
|
+ const entered = Promise.withResolvers<undefined>()
|
|
|
context.on('agent/request-error', (agent) => {
|
|
context.on('agent/request-error', (agent) => {
|
|
|
agent.cancel({ kind: 'user' })
|
|
agent.cancel({ kind: 'user' })
|
|
|
|
|
+ entered.resolve(undefined)
|
|
|
return downstream.promise
|
|
return downstream.promise
|
|
|
})
|
|
})
|
|
|
const agent = context.agentLoop.create(SessionId('retry-delegated-sync-cancel'), {
|
|
const agent = context.agentLoop.create(SessionId('retry-delegated-sync-cancel'), {
|
|
@@ -784,8 +805,17 @@ describe('provider-routed retry policy', () => {
|
|
|
const idle = waitForIdle(context, agent)
|
|
const idle = waitForIdle(context, agent)
|
|
|
|
|
|
|
|
agent.followup([{ type: 'text', text: 'go' }])
|
|
agent.followup([{ type: 'text', text: 'go' }])
|
|
|
|
|
+ await entered.promise
|
|
|
|
|
+ let timer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
|
+ const outcome = await Promise.race([
|
|
|
|
|
+ idle.then(() => 'idle' as const),
|
|
|
|
|
+ new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }),
|
|
|
|
|
+ ])
|
|
|
|
|
+ if (timer !== undefined) clearTimeout(timer)
|
|
|
|
|
+ expect(outcome).toBe('blocked')
|
|
|
|
|
+
|
|
|
|
|
+ downstream.resolve({ action: 'retry' })
|
|
|
await idle
|
|
await idle
|
|
|
- downstream.resolve({ action: 'fail' })
|
|
|
|
|
|
|
|
|
|
expect(adapter.requests).toHaveLength(1)
|
|
expect(adapter.requests).toHaveLength(1)
|
|
|
expect(agent.session.events.at(-1)).toMatchObject({
|
|
expect(agent.session.events.at(-1)).toMatchObject({
|