fix pre-step lifecycle regressions

This commit is contained in:
_Kerman
2026-07-31 19:40:59 +08:00
parent fcc2b5e282
commit 8e88b17c9f
37 changed files with 331 additions and 265 deletions

View File

@@ -13,7 +13,7 @@ const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
/**
* Full-loop integration: a scripted mock model drives the REAL plan-mode plugin
* through the agent loop — the pending-intent flush at the request boundary, the
* through the agent loop — the pending-intent flush at the step boundary, the
* assembly the soft layer shapes (the exit tool + mode section), and the
* `request/header` snapshots every transition leaves.
* Only the model is mocked; the loop, the session log, and the plugin are
@@ -127,12 +127,16 @@ describe('plan mode through the agent loop', () => {
expect(second.data.header.system).toContain('plan mode')
})
it('a mode flip at error settlement shapes the retry before its assembly', async () => {
it('a mode flip at error settlement waits until the step after a same-step retry', async () => {
const failedRequest = [{
type: 'finish',
reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } },
}] satisfies StreamChunk[]
const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')])
const adapter = new MockAdapter([
failedRequest,
textResponse('Recovered with the original step assembly.'),
textResponse('Entered plan mode on the next step.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (subject, _context, _signal, next) => {
@@ -147,16 +151,26 @@ describe('plan mode through the agent loop', () => {
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.section)
expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.section)
expect(adapter.requests[1]?.system).not.toContain(PLAN_CONFIG.section)
expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
const nextIdle = waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue with the plan' }], source: { kind: 'user' } }))
await nextIdle
expect(adapter.requests).toHaveLength(3)
expect(adapter.requests[2]?.system).toContain(PLAN_CONFIG.section)
expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools)
const log = agent.session.events
const planMode = findEvent(log, 'plan/mode')
const firstEnd = log.find(event => event.type === 'step/end'
&& event.data.turn === 1 && event.data.step === 1)
const retryStart = log.find(event => event.type === 'step/start'
const nextStart = log.find(event => event.type === 'step/start'
&& event.data.turn === 2 && event.data.step === 1)
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
expect(planMode.seq).toBeLessThan(nextStart?.seq ?? 0)
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)
const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
expect(notice?.type === 'user/message' && notice.data.content).toEqual([

View File

@@ -249,22 +249,17 @@ describe('ctx.planMode: get/set', () => {
})
describe('the boundary flush', () => {
it('does not flush during pre-step and commits from the following step/start', async () => {
it('flushes from pre-step before the following step/start', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
// Pre-step only composes narration. The pending intent survives until the
// turn-enclosed step/start event commits it before request assembly.
await boundary(ctx, agent, 'pre-step')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('removes the step/start flush when the plugin fiber is disposed', async () => {
it('removes the pre-step flush when the plugin fiber is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -273,8 +268,7 @@ describe('the boundary flush', () => {
openTurn(agent.session)
ctx.planMode.set(agent, true)
await fiber.dispose()
const event = agent.session.append('step/start', { turn: 1, step: 1 })
ctx.emit('session/event', agent.session, event)
await boundary(ctx, agent, 'pre-step')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
@@ -366,7 +360,7 @@ describe('the boundary flush', () => {
expect(ctx.planMode.get(agent).pending).toBeUndefined()
})
it('pre-step never appends, so a broken backend surfaces only at step/start', async () => {
it('contains a pre-step append failure and keeps the intent pending', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
@@ -379,8 +373,6 @@ describe('the boundary flush', () => {
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'pre-step')
expect(warn).not.toHaveBeenCalled()
await boundary(ctx, agent, 'step-start')
expect(warn).toHaveBeenCalledOnce()
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
@@ -602,7 +594,7 @@ describe('/plan', () => {
.toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' })
expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false })
expect(enteringSteer).not.toHaveBeenCalled()
await boundary(ctx, entering, 'step/end')
await boundary(ctx, entering, 'step-start')
expect(ctx.planMode.get(entering)).toEqual({ active: false })
expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
@@ -616,7 +608,7 @@ describe('/plan', () => {
expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
expect(activeSteer).not.toHaveBeenCalled()
await boundary(ctx, active, 'step/end')
await boundary(ctx, active, 'step-start')
expect(ctx.planMode.get(active)).toEqual({ active: false })
})