fix: align lifecycle consumers with durable inbox semantics

This commit is contained in:
_Kerman
2026-07-30 17:56:02 +08:00
parent a43c6da1b9
commit 97111cb5b3
7 changed files with 124 additions and 291 deletions

View File

@@ -119,7 +119,7 @@ describe('agent/prompt-submit', () => {
await idle
expect(observed).toHaveLength(1)
expect(observed[0]).toBe(input)
expect(observed[0]).not.toBe(input)
expect(observed[0]).toMatchObject({
content: [{ type: 'text', text: 'accepted text' }],
source: { kind: 'plugin', plugin: 'accepted source' },
@@ -237,7 +237,10 @@ describe('agent/prompt-submit', () => {
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
let claimed: UserMessage[] = []
let firstAdmission = true
ctx.on('agent/prompt-submit', async (_agent, messages) => {
if (!firstAdmission) return { kind: 'allow', messages }
firstAdmission = false
claimed = messages
entered.resolve(undefined)
return decision.promise
@@ -271,21 +274,24 @@ describe('agent/prompt-submit', () => {
'turn/start',
'user/message',
'user/message',
'steering/message',
'user/message',
])
expect(staged[1]?.type === 'user/message' && staged[1].data.content)
.toEqual([{ type: 'text', text: 'admitted prompt' }])
expect(staged[2]?.type === 'user/message' && staged[2].data.content)
.toEqual([{ type: 'text', text: 'attached context' }])
expect(staged[3]?.type === 'steering/message' && staged[3].data.message.content)
expect(staged[3]?.type === 'user/message' && staged[3].data.content)
.toEqual([{ type: 'text', text: 'admission steering' }])
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('admitted prompt')
expect(request).toContain('attached context')
expect(request).toContain('admission steering')
const firstRequest = JSON.stringify(adapter.requests[0]?.messages)
expect(firstRequest).toContain('admitted prompt')
expect(firstRequest).not.toContain('attached context')
expect(firstRequest).not.toContain('admission steering')
const nextRequest = JSON.stringify(adapter.requests[1]?.messages)
expect(nextRequest).toContain('attached context')
expect(nextRequest).toContain('admission steering')
})
it('keeps admission-time outbox input staged when admission is blocked', async () => {
it('cancels admission-time input when admission is blocked', async () => {
const adapter = new MockAdapter([textResponse('retried')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' })
@@ -307,8 +313,8 @@ describe('agent/prompt-submit', () => {
decision.resolve({ kind: 'block', reason: 'policy' })
await blockedIdle
expect(agent.inbox.nextStep).toHaveLength(2)
expect(events(agent)).toEqual([])
expect(agent.inbox.nextStep).toHaveLength(0)
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
expect(adapter.requests).toEqual([])
disposeBlock()
@@ -317,17 +323,13 @@ describe('agent/prompt-submit', () => {
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',
'user/message',
])
expect(staged.map(event => event.type)).toEqual(['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')
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('staged context')
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('staged steering')
})
it('orders rejected-admission outbox input before a later admitted prompt', async () => {
it('cancels later queued work when an admission is blocked', async () => {
const adapter = new MockAdapter([textResponse('continued')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), {
@@ -361,23 +363,12 @@ describe('agent/prompt-submit', () => {
send(agent, 'later prompt')
await idle
const staged = events(agent).filter(event =>
event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
expect(staged.map(event => event.type)).toEqual([
'turn/start',
'user/message',
'steering/message',
'user/message',
])
expect(staged[1]?.type === 'user/message' && staged[1].data.content)
.toEqual([{ type: 'text', text: 'earlier state change' }])
expect(staged[2]?.type === 'steering/message' && staged[2].data.message.content)
.toEqual([{ type: 'text', text: 'earlier steering' }])
expect(staged[3]?.type === 'user/message' && staged[3].data.content)
.toEqual([{ type: 'text', text: 'later prompt' }])
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
expect(agent.inbox.hasPending).toBe(false)
expect(adapter.requests).toEqual([])
})
it('commits context-only injection when admission closes without a turn', async () => {
it('cancels context-only injection when admission closes without a turn', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { provider: 'mock', model: 'mock' })
@@ -399,51 +390,31 @@ describe('agent/prompt-submit', () => {
await idle
const log = events(agent)
expect(log.map(event => event.type)).toEqual(['user/message'])
expect(log[0]?.type === 'user/message' && log[0].data.content)
.toEqual([{ type: 'text', text: 'independent context' }])
expect(log.some(event => event.type === 'user/message')).toBe(false)
expect(agent.inbox.hasPending).toBe(false)
expect(adapter.requests).toEqual([])
})
it('retains rejected-admission context when its idle append fails', async () => {
const adapter = new MockAdapter([textResponse('retried')])
it('leaves inbox state unchanged when its durable append fails', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), {
provider: 'mock',
model: 'mock',
})
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
vi.spyOn(agent.session, 'append').mockImplementationOnce(() => {
throw new Error('append unavailable')
})
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' } }))
await entered.promise
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'retained context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
decision.resolve({ kind: 'block', reason: 'policy' })
await agent.whenIdle()
expect(() => {
send(agent, 'blocked prompt')
}).toThrow('append unavailable')
expect(events(agent)).toEqual([])
expect(warned).toHaveBeenCalledWith(expect.stringContaining('append unavailable'))
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)
expect(agent.inbox.hasPending).toBe(false)
expect(agent.status).toBe('idle')
})
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
it('a blocked prompt cancels adjacent queued prompts', async () => {
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -457,22 +428,18 @@ describe('agent/prompt-submit', () => {
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
// The rejected admission is dropped; the allowed prompt owns the only turn.
send(agent, 'secret')
send(agent, 'safe')
await waitForIdle(ctx, agent)
const log = events(agent)
// The allowed prompt became a user/message and drove exactly one model call.
const userMsgs = log.filter(e => e.type === 'user/message')
expect(userMsgs).toHaveLength(1)
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(reasons).toEqual([{ kind: 'completed' }])
expect(log.filter(e => e.type === 'user/message')).toHaveLength(0)
expect(adapter.requests).toHaveLength(0)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(0)
expect(reasons).toEqual([])
})
it('a throwing prompt-submit listener drops that admission while an adjacent message survives', async () => {
it('a throwing prompt-submit listener reports the driver error and retains adjacent work', async () => {
const adapter = new MockAdapter([textResponse('after')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -497,14 +464,14 @@ describe('agent/prompt-submit', () => {
send(agent, 'first')
send(agent, 'second')
await idle
expect(errors).toEqual([])
expect(errors).toEqual([expect.objectContaining({ message: 'prompt hook broke' })])
const log = events(agent)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
expect(reasons).toEqual([{ kind: 'completed' }])
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(0)
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(0)
expect(reasons).toEqual([])
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(1)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
expect(adapter.requests).toHaveLength(0)
expect(agent.inbox.nextTurn).toHaveLength(2)
})
})