refactor(agent-loop): simplify observable state machine

This commit is contained in:
_Kerman
2026-07-24 21:18:48 +08:00
parent fb0ef82aa6
commit b73eb7663c
131 changed files with 2011 additions and 4292 deletions
@@ -89,7 +89,7 @@ describe('config-driven session id', () => {
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')]))
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), model: 'mock' }] }
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), provider: 'mock', model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
let first: Agent | undefined
@@ -131,20 +131,26 @@ describe('config-driven session id', () => {
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const first = ctx.agents.get(sessionId) as Agent
const flushGate = Promise.withResolvers<undefined>()
let flushStarted = false
ctx.on('session/flush', (session) => {
if (session !== first.session) return
flushStarted = true
return flushGate.promise
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
first.ctx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject([{ type: 'text', text: 'persist before replacement' }], {
source: { kind: 'plugin', plugin: 'test' },
})
expect(flushStarted).toBe(true)
await expect.poll(async () => {
try {
return JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)
} catch {
return ''
}
}).toContain('persist before replacement')
const firstDisposal = firstLoop.dispose()
await expect.poll(() => first.status).toBe('disposed')
await cleanupStarted.promise
expect(first.status).toBe('idle')
const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
const secondLoop = await ctx.plugin(AgentLoop, config)
@@ -152,7 +158,7 @@ describe('config-driven session id', () => {
expect(ctx.agents.get(sessionId)).toBe(first)
expect(failures).toEqual([])
flushGate.resolve(undefined)
cleanupGate.resolve(undefined)
await firstDisposal
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const second = ctx.agents.get(sessionId) as Agent
@@ -175,21 +181,31 @@ describe('config-driven session id', () => {
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const first = ctx.agents.get(sessionId) as Agent
const flushGate = Promise.withResolvers<undefined>()
ctx.on('session/flush', (session) => {
if (session === first.session) return flushGate.promise
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
first.ctx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject([{ type: 'text', text: 'persist before cancellation' }], {
source: { kind: 'plugin', plugin: 'test' },
})
await expect.poll(async () => {
try {
return JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)
} catch {
return ''
}
}).toContain('persist before cancellation')
const firstDisposal = firstLoop.dispose()
await expect.poll(() => first.status).toBe('disposed')
await cleanupStarted.promise
expect(first.status).toBe('idle')
const secondLoop = await ctx.plugin(AgentLoop, config)
await secondLoop.dispose()
expect(ctx.agents.get(sessionId)).toBe(first)
flushGate.resolve(undefined)
cleanupGate.resolve(undefined)
await firstDisposal
expect(ctx.agents.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
@@ -268,14 +284,14 @@ describe('config-driven session id', () => {
})
it.each(['resolve', 'reject'] as const)(
'joins an exact-id persistence lookup that will %s before AgentLoop disposal completes',
'abandons an exact-id persistence lookup that later %s when AgentLoop disposal starts',
async (outcome) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-'))
dirs.push(root)
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
const listing = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.list>>>()
vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise)
const loading = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.load>>>()
vi.spyOn(ctx.sessionPersistence, 'load').mockReturnValue(loading.promise)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
@@ -283,14 +299,20 @@ describe('config-driven session id', () => {
const loop = await ctx.plugin(AgentLoop, {
agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }],
})
let disposed = false
const disposal = loop.dispose().then(() => { disposed = true })
await loop.dispose()
if (outcome === 'resolve') {
loading.resolve({
meta: {
id: SessionId('config-exact-dispose'),
version: 0,
createdAt: Date.now(),
},
events: [],
})
} else {
loading.reject(new Error('startup cancelled by teardown'))
}
await Promise.resolve()
expect(disposed).toBe(false)
if (outcome === 'resolve') listing.resolve([])
else listing.reject(new Error('startup cancelled by teardown'))
await disposal
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
expect(failures).toEqual([])
expect(warn).not.toHaveBeenCalled()