refactor(agent): return request retry action
This commit is contained in:
@@ -255,21 +255,8 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
|
||||
})
|
||||
})
|
||||
|
||||
describe('retry() edges', () => {
|
||||
it('throws while a turn runs with no request-error window open', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('retry-busy'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
// Wait until the hung request is in flight (the run owns this.abort).
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(() => { agent.retry() }).toThrow('cannot retry while busy')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await agent.whenIdle()
|
||||
})
|
||||
|
||||
it('ignores a retry request arriving after the recovery window was aborted', async () => {
|
||||
describe('request-error action edges', () => {
|
||||
it('ignores a retry action returned after the turn was aborted', async () => {
|
||||
const { LlmError } = await import('@deepseek-ai/dsh-llm')
|
||||
const adapter = new MockAdapter([
|
||||
() => { throw new LlmError('busy', 'RATE_LIMIT') },
|
||||
@@ -278,10 +265,8 @@ describe('retry() edges', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/request-error', async (subject) => {
|
||||
// Cancellation lands first; the window survives structurally but its
|
||||
// signal is aborted, so the request must not arm a retry turn.
|
||||
subject.cancel({ kind: 'user' })
|
||||
subject.retry()
|
||||
return { kind: 'retry' }
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -302,11 +287,9 @@ describe('retry() edges', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, signal, next) => {
|
||||
await next()
|
||||
// Recovery completes and requested the retry, but the turn signal
|
||||
// aborts before the loop reads the window.
|
||||
subject.retry()
|
||||
subject.cancel({ kind: 'user' })
|
||||
expect(signal.aborted).toBe(true)
|
||||
return { kind: 'retry' }
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -348,29 +331,6 @@ describe('stream failure edges', () => {
|
||||
})
|
||||
|
||||
describe('post-turn continuation edges', () => {
|
||||
it('an agent/settled listener that starts a retry preempts continueOrIdle', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('settled-preempt'), { provider: 'mock', model: 'mock' })
|
||||
let injected = false
|
||||
ctx.on('agent/settled', (subject) => {
|
||||
if (subject !== agent || injected) return
|
||||
expect(subject.status).toBe('running')
|
||||
injected = true
|
||||
// retry() installs the next run synchronously, so the following
|
||||
// continueOrIdle() sees its abort owner and yields to it.
|
||||
subject.retry()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const starts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(starts).toHaveLength(2)
|
||||
// The busy interval never broke between the turns: one running->idle cycle.
|
||||
})
|
||||
|
||||
it('whenIdle resolves for a waiter whose awaited run fails', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -472,8 +432,8 @@ describe('turn close failure containment', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('recovery without a retry request', () => {
|
||||
it('a completed recovery that never calls retry() leaves the failed turn terminal', async () => {
|
||||
describe('recovery without a retry action', () => {
|
||||
it('a completed recovery that returns no action leaves the failed turn terminal', async () => {
|
||||
const { LlmError } = await import('@deepseek-ai/dsh-llm')
|
||||
const adapter = new MockAdapter([
|
||||
() => { throw new LlmError('down', 'SERVICE_UNAVAILABLE') },
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('agent/prompt-submit', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
@@ -307,13 +307,17 @@ describe('agent/prompt-submit', () => {
|
||||
expect(events(agent)).toEqual([])
|
||||
expect(adapter.requests).toEqual([])
|
||||
|
||||
const retryIdle = waitForIdle(ctx, agent)
|
||||
agent.retry()
|
||||
await retryIdle
|
||||
disposeBlock()
|
||||
send(agent, 'resume')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const staged = events(agent).filter(event =>
|
||||
event.type === 'user/message' || event.type === 'steering/message')
|
||||
expect(staged.map(event => event.type)).toEqual(['user/message', 'steering/message'])
|
||||
expect(staged.map(event => event.type)).toEqual([
|
||||
'user/message',
|
||||
'steering/message',
|
||||
'user/message',
|
||||
])
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt')
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged context')
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
|
||||
@@ -408,7 +412,7 @@ describe('agent/prompt-submit', () => {
|
||||
})
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
@@ -425,9 +429,9 @@ describe('agent/prompt-submit', () => {
|
||||
expect(events(agent)).toEqual([])
|
||||
expect(warned).toHaveBeenCalledWith(expect.stringContaining('append unavailable'))
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.retry()
|
||||
await idle
|
||||
disposeBlock()
|
||||
send(agent, 'resume')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(events(agent).some(event => event.type === 'user/message'
|
||||
&& JSON.stringify(event.data.content).includes('retained context'))).toBe(true)
|
||||
|
||||
@@ -317,7 +317,7 @@ describe('agent loop', () => {
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('second idle steer')
|
||||
})
|
||||
|
||||
it('keeps steering staged after a failed step until retry', async () => {
|
||||
it('keeps steering staged after a failed step until the next admitted turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('recovered')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
|
||||
@@ -336,9 +336,8 @@ describe('agent loop', () => {
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.retry()
|
||||
await idle
|
||||
send(agent, 'resume')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('agent/request-error', () => {
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets each failed request schedule a retry before its turn closes', async () => {
|
||||
it('lets each failed request return a retry action before its turn closes', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
fail('busy', 'RATE_LIMIT'),
|
||||
fail('unavailable', 'SERVICE_UNAVAILABLE'),
|
||||
@@ -71,8 +71,7 @@ describe('agent/request-error', () => {
|
||||
data: { turn, step },
|
||||
})
|
||||
seen.push({ turn, step, failure })
|
||||
subject.retry()
|
||||
subject.retry()
|
||||
return { kind: 'retry' }
|
||||
})
|
||||
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
@@ -104,13 +103,13 @@ describe('agent/request-error', () => {
|
||||
expect(settledTurns).toEqual([3])
|
||||
})
|
||||
|
||||
it('lets cancellation win over a retry request', async () => {
|
||||
it('lets cancellation win over a retry action', async () => {
|
||||
const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/request-error', async (subject) => {
|
||||
subject.retry()
|
||||
subject.cancel({ kind: 'user' })
|
||||
return { kind: 'retry' }
|
||||
})
|
||||
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
@@ -124,15 +123,14 @@ describe('agent/request-error', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not honor a retry requested by a failing recovery listener', async () => {
|
||||
it('does not retry when the recovery listener fails before returning its action', async () => {
|
||||
const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('request-error-recovery-failed'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
ctx.on('agent/request-error', async (subject) => {
|
||||
subject.retry()
|
||||
ctx.on('agent/request-error', async () => {
|
||||
throw new Error('recovery failed')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user