refactor(agent): return request retry action

This commit is contained in:
_Kerman
2026-07-27 21:17:49 +08:00
parent c6073f07c2
commit ed67ad53d2
83 changed files with 277 additions and 309 deletions

View File

@@ -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') },

View File

@@ -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)

View File

@@ -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)

View File

@@ -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')
})