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

@@ -26,50 +26,17 @@ function send(agent: Agent, text: string): void {
}
describe('Agent', () => {
it('does not echo caller-owned message identities from delivery methods', async () => {
const adapter = new MockAdapter([
textResponse('one'),
textResponse('two'),
textResponse('three'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const message = (text: string) => createUserMessage({
content: [{ type: 'text' as const, text }],
source: { kind: 'user' as const },
})
const call = (method: 'send' | 'inject' | 'followup' | 'steer', args: unknown[]): unknown => {
const implementation: unknown = Reflect.get(agent, method)
if (typeof implementation !== 'function') throw new Error(`missing Agent.${method}`)
return Reflect.apply(implementation, agent, args)
}
expect(call('send', [message('quiet'), {
target: 'next-turn',
wakeup: false,
}])).toBeUndefined()
expect(call('inject', [message('context')])).toBeUndefined()
expect(call('followup', [message('followup')])).toBeUndefined()
expect(call('steer', [message('steering')])).toBeUndefined()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(3)
})
it('idle inject() appends context without opening a turn or requesting a flush', async () => {
it('idle inject() durably stages context without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } }))
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(agent.session.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
await agent.whenIdle()
expect(flushes).toBe(0)
})
it('inject() preserves an explicitly empty plugin source', async () => {
@@ -79,7 +46,7 @@ describe('Agent', () => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } }))
const injected = agent.session.events.at(-1)
expect(injected?.type === 'user/message' && injected.data.source)
expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source)
.toEqual({ kind: 'plugin', plugin: '' })
})
@@ -119,79 +86,6 @@ describe('Agent', () => {
expect(statuses).toEqual(['running', 'idle'])
})
it('awaits the turn-end checkpoint before claiming the next queued turn', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const flushedTurns: number[] = []
ctx.on('session/flush', async (session) => {
const turnEnd = session.events.findLast(event => event.type === 'turn/end')
flushedTurns.push(turnEnd?.data.turn ?? 0)
if (turnEnd?.data.turn === 1) await firstFlush.promise
})
send(agent, 'first')
send(agent, 'second')
await vi.waitFor(() => { expect(flushedTurns).toEqual([1]) })
expect(adapter.requests).toHaveLength(1)
firstFlush.resolve(undefined)
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
expect(flushedTurns).toEqual([1, 2])
})
it('keeps whenIdle pending through the final turn checkpoint', async () => {
const ctx = await harness(new MockAdapter([textResponse('done')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const flush = Promise.withResolvers<undefined>()
let flushStarted = false
ctx.on('session/flush', () => {
flushStarted = true
return flush.promise
})
send(agent, 'go')
await vi.waitFor(() => { expect(flushStarted).toBe(true) })
let idleSettled = false
const idle = agent.whenIdle().then(() => { idleSettled = true })
await Promise.resolve()
expect(idleSettled).toBe(false)
flush.resolve(undefined)
await idle
expect(agent.status).toBe('idle')
})
it('reports a rejected turn-end checkpoint and continues queued work', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const failure = new Error('disk unavailable')
const errors: { turn: number; step: number; error: unknown }[] = []
let flushes = 0
ctx.on('session/flush', () => {
flushes += 1
if (flushes === 1) throw failure
})
ctx.on('agent/error', (subject, turn, step, error) => {
if (subject === agent) errors.push({ turn, step, error })
})
send(agent, 'first')
send(agent, 'second')
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
expect(flushes).toBe(2)
expect(errors).toEqual([{ turn: 1, step: 1, error: failure }])
expect(warning).toHaveBeenCalledWith(expect.stringContaining('session/flush failed at turn 1: disk unavailable'))
warning.mockRestore()
})
it('whenIdle() resolves immediately without active work', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })

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

View File

@@ -257,10 +257,6 @@ describe('request stability across the loop', () => {
}
}([])
const ctx = await harness(adapter)
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
provider: 'mock',
model: 'mock',
@@ -269,7 +265,9 @@ describe('request stability across the loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toContain(failure)
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'error', error: failure.message } },
})
expect(adapter.requests).toHaveLength(0)
},
)
@@ -316,19 +314,13 @@ describe('request stability across the loop', () => {
send(agent, 'first')
await waitForIdle(ctx, agent)
// A pre-step listener compacts turn 1's history before turn 2's step —
// the sanctioned surface rewrite, landing OUTSIDE the step.
const preStep = ctx.on('agent/step', () => {
preStep()
const session = agent.session
const nodes = session.surface.nodes
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '[summary of turn 1]' }],
source: { kind: 'plugin', plugin: 'test-compact' },
}), {
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
sourceEventSeqs: [nodes[0]!, nodes[1]!],
})
const nodes = agent.session.surface.nodes
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '[summary of turn 1]' }],
source: { kind: 'plugin', plugin: 'test-compact' },
}), {
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
sourceEventSeqs: [nodes[0]!, nodes[1]!],
})
send(agent, 'second')
@@ -398,10 +390,6 @@ describe('request stability across the loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
ctx.on('llm/stream', (options, next) => {
// The historical failure mode this design kills: a listener rewriting
// request content in place. The freeze turns it into a loud error.
@@ -415,8 +403,10 @@ describe('request stability across the loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toMatch(/not extensible|frozen|read only|readonly/i)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } })
if (turnEnd?.type !== 'turn/end' || turnEnd.data.reason.kind !== 'error') throw new Error()
expect(turnEnd.data.reason.error).toMatch(/not extensible|frozen|read only|readonly/i)
})
it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => {

View File

@@ -540,9 +540,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await waitForIdle(ctx1, a1)
a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background task 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } }))
await a1.whenIdle()
await ctx1.fiber.dispose()
await ctx1.sessions.flush(a1.session)
// Lifecycle 2: resume; the injected context is still in the derived history.
// Lifecycle 2: resume; the injected context is still pending and becomes
// model-visible when the next turn admits it.
const adapter2 = new MockAdapter([textResponse('next')])
const ctx2 = new Context()
await ctx2.plugin(LlmService)
@@ -553,10 +554,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const loaded = await ctx2.sessionPersistence.load(SessionId('inject-sess'))
expect(loaded.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true)
expect(JSON.stringify(loaded.events)).toContain('background task 42 finished')
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
expect(JSON.stringify(a2.inbox.nextStep)).toContain('background task 42 finished')
a2.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
await waitForIdle(ctx2, a2)
const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose()
await ctx1.fiber.dispose()
})
it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {

View File

@@ -648,10 +648,6 @@ describe('tool-call scheduler: failure quiescence', () => {
? new Promise((_resolve, reject) => { rejectFirst = reject })
: dispatch(exec).then(() => { throw drainedError })
const agent = ctx.agentLoop.create(SessionId('scheduler-failure'), { provider: 'mock', model: 'mock' })
const errors: unknown[] = []
ctx.on('agent/error', (subject, _turn, _step, error) => {
if (subject === agent) errors.push(error)
})
let idle = false
const idlePromise = waitForIdle(ctx, agent).then(() => { idle = true })
@@ -664,15 +660,16 @@ describe('tool-call scheduler: failure quiescence', () => {
const startedBeforeDrain = [...gated.started]
const idleBeforeDrain = idle
const errorsBeforeDrain = [...errors]
const turnEndBeforeDrain = events(agent).find(event => event.type === 'turn/end')
for (const id of gated.pending()) gated.release(id)
await idlePromise
expect(startedBeforeDrain).toEqual(['2'])
expect(idleBeforeDrain).toBe(false)
expect(errorsBeforeDrain).toEqual([])
expect(turnEndBeforeDrain).toBeUndefined()
expect(gated.pending()).toEqual([])
expect(errors).toEqual([schedulerError])
expect(errors[0]).toBe(schedulerError)
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'error', error: schedulerError.message } },
})
})
})