review fix: close one-turn lifecycle gaps
This commit is contained in:
@@ -117,6 +117,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle.setStatus('running')
|
handle.setStatus('running')
|
||||||
|
if (handle.isDisposed()) break
|
||||||
|
|
||||||
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
||||||
// status only when no replacement prompt was queued by that listener.
|
// status only when no replacement prompt was queued by that listener.
|
||||||
|
|||||||
@@ -88,6 +88,36 @@ describe('Agent.cancel()', () => {
|
|||||||
expect(agent.status).toBe('idle')
|
expect(agent.status).toBe('idle')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('disposal from the running notification drops queued work before turn start', async () => {
|
||||||
|
const adapter = new MockAdapter([textResponse('should not run')])
|
||||||
|
const ctx = await harness(adapter)
|
||||||
|
const handle = await ctx.agents.create({
|
||||||
|
agentId: AgentId('a-dispose-running'),
|
||||||
|
sessionId: SessionId('dispose-running-session'),
|
||||||
|
agentOptions: { model: 'mock' },
|
||||||
|
})
|
||||||
|
const agent = handle.agent as ReactLoopAgent
|
||||||
|
|
||||||
|
const running = Promise.withResolvers<undefined>()
|
||||||
|
let disposalDone: Promise<void> | undefined
|
||||||
|
ctx.on('agent/status', (subject, status) => {
|
||||||
|
if (subject !== agent || status !== 'running') return
|
||||||
|
disposalDone = handle.dispose()
|
||||||
|
running.resolve(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
send(agent, 'drop before claim')
|
||||||
|
await running.promise
|
||||||
|
if (disposalDone === undefined) throw new Error('running listener did not start disposal')
|
||||||
|
await disposalDone
|
||||||
|
await agent.done
|
||||||
|
|
||||||
|
expect(agent.status).toBe('disposed')
|
||||||
|
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||||
|
expect(userTexts(agent)).toEqual([])
|
||||||
|
expect(adapter.requests).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
|
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('x')])
|
const adapter = new MockAdapter([textResponse('x')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
|
|||||||
@@ -239,7 +239,13 @@ describe('agent/prompt-submit', () => {
|
|||||||
return { kind: 'allow' as const }
|
return { kind: 'allow' as const }
|
||||||
})
|
})
|
||||||
const errors: Error[] = []
|
const errors: Error[] = []
|
||||||
|
const reasons: TurnEndReason[] = []
|
||||||
|
const statuses: string[] = []
|
||||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||||
|
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) })
|
||||||
|
ctx.on('session/event', (session, event) => {
|
||||||
|
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||||
|
})
|
||||||
|
|
||||||
const idle = waitForIdle(ctx, agent)
|
const idle = waitForIdle(ctx, agent)
|
||||||
send(agent, 'first')
|
send(agent, 'first')
|
||||||
@@ -251,6 +257,11 @@ describe('agent/prompt-submit', () => {
|
|||||||
const log = events(agent)
|
const log = events(agent)
|
||||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
||||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2)
|
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2)
|
||||||
|
expect(reasons).toEqual([
|
||||||
|
{ kind: 'error', step: 0, message: 'prompt hook broke' },
|
||||||
|
{ kind: 'completed' },
|
||||||
|
])
|
||||||
|
expect(statuses).toEqual(['running', 'idle'])
|
||||||
expect(adapter.requests).toHaveLength(1)
|
expect(adapter.requests).toHaveLength(1)
|
||||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
|
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -349,14 +349,24 @@ describe('agent loop', () => {
|
|||||||
expect(flat).toContain('change of plans')
|
expect(flat).toContain('change of plans')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('steering while idle behaves like send (starts a turn)', async () => {
|
it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
agent.steer([{ type: 'text', text: 'hello' }])
|
const idle = waitForIdle(ctx, agent)
|
||||||
await waitForIdle(ctx, agent)
|
agent.steer([{ type: 'text', text: 'first idle steer' }])
|
||||||
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
|
agent.steer([{ type: 'text', text: 'second idle steer' }])
|
||||||
|
await idle
|
||||||
|
|
||||||
|
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||||
|
expect(agent.session.events
|
||||||
|
.filter(event => event.type === 'user/message')
|
||||||
|
.map(event => event.data.content)).toEqual([
|
||||||
|
[{ type: 'text', text: 'first idle steer' }],
|
||||||
|
[{ type: 'text', text: 'second idle steer' }],
|
||||||
|
])
|
||||||
|
expect(adapter.requests).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
|
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
|
||||||
@@ -893,6 +903,51 @@ describe('agent loop', () => {
|
|||||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('holds a turn-end listener send behind the closing turn checkpoint', async () => {
|
||||||
|
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
|
||||||
|
const ctx = await harness(adapter)
|
||||||
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
|
const firstFlush = Promise.withResolvers<undefined>()
|
||||||
|
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||||
|
let flushes = 0
|
||||||
|
ctx.on('session/flush', async (session) => {
|
||||||
|
if (session !== agent.session) return
|
||||||
|
flushes += 1
|
||||||
|
if (flushes === 1) {
|
||||||
|
firstFlush.resolve(undefined)
|
||||||
|
await releaseFirstFlush.promise
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const turns: number[] = []
|
||||||
|
const statuses: string[] = []
|
||||||
|
ctx.on('agent/status', (subject, status) => {
|
||||||
|
if (subject === agent) statuses.push(status)
|
||||||
|
})
|
||||||
|
ctx.on('session/event', (session, event) => {
|
||||||
|
if (session !== agent.session) return
|
||||||
|
if (event.type === 'turn/start') turns.push(event.data.turn)
|
||||||
|
if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message')
|
||||||
|
})
|
||||||
|
|
||||||
|
const idle = waitForIdle(ctx, agent)
|
||||||
|
send(agent, 'first message')
|
||||||
|
await firstFlush.promise
|
||||||
|
|
||||||
|
expect(turns).toEqual([1])
|
||||||
|
expect(adapter.requests).toHaveLength(1)
|
||||||
|
|
||||||
|
releaseFirstFlush.resolve(undefined)
|
||||||
|
await idle
|
||||||
|
|
||||||
|
expect(turns).toEqual([1, 2])
|
||||||
|
expect(statuses).toEqual(['running', 'idle'])
|
||||||
|
expect(adapter.requests).toHaveLength(2)
|
||||||
|
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
|
||||||
|
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
|
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
|
|||||||
Reference in New Issue
Block a user