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
+3 -4
View File
@@ -732,12 +732,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
presets.set(rec.agent.session, pending.preset)
}
// Prompt-submit is inside the new turn but before prompt assembly. Promptless
// injection turns leave the switch pending because they execute no request.
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => {
// The first step boundary is inside the admitted turn and before request
// assembly. Idle injections leave the switch pending because they run no step.
ctx.on('agent/step', (agent) => {
const rec = ownedRecord(agent)
if (rec !== undefined) flushPendingSwitches(rec)
return next()
})
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
+4 -5
View File
@@ -186,11 +186,10 @@ describe('acp bridge — session config options', () => {
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _signal, _next) => ({
...callConfig,
provider: 'mock',
model: 'mock',
}))
agent.ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
const callConfig = await next()
return { ...callConfig, provider: 'mock', model: 'mock' }
})
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'supplied elsewhere' }] })
expect(h.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
})
+5 -23
View File
@@ -102,8 +102,8 @@ describe('acp bridge — disposal & HMR safety', () => {
// agent's AgentHandle dispose to quiescence on its OWN (before any dispose()).
await harness.closeClientTransport()
await agent.whenIdle()
// The agent's loop has stopped: status `disposed`.
expect(agent.status).toBe('disposed')
// The retired object is quiescent; registry membership carries liveness.
expect(agent.status).toBe('idle')
// Await the bridge teardown to completion WITHOUT tearing down the root
// agents/sessions services (so we can still query them). acpFiber.dispose()
@@ -242,11 +242,11 @@ describe('acp bridge — disposal & HMR safety', () => {
// A is gone — unregistered AND its session removed from the store.
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
expect(handleA.agent.status).toBe('disposed')
expect(handleA.agent.status).toBe('idle')
// B is wholly unaffected.
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
expect(handleB.agent.status).not.toBe('disposed')
expect(handleB.agent.status).toBe('idle')
await harness.dispose()
})
@@ -291,27 +291,9 @@ describe('acp bridge — disposal & HMR safety', () => {
handle.agent.followup([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(handle.agent.status).toBe('running')
let releaseFlush!: () => void
const flushGate = new Promise<void>((resolve) => { releaseFlush = resolve })
harness.ctx.on('session/flush', () => flushGate)
// First dispose enters teardown (aborts the hanging step) and blocks in the
// gated final flush.
// Both callers join the same teardown and observe registry removal.
const first = handle.dispose()
let firstSettled = false
void first.then(() => { firstSettled = true })
await new Promise(r => setTimeout(r, 20))
expect(firstSettled).toBe(false)
// Second dispose MUST await the same in-flight teardown, not resolve early.
const second = handle.dispose()
let secondSettled = false
void second.then(() => { secondSettled = true })
await new Promise(r => setTimeout(r, 20))
expect(secondSettled).toBe(false) // memoized: still pending with the first
// Release the flush; both resolve together and the session is gone.
releaseFlush()
await Promise.all([first, second])
expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()
+1 -1
View File
@@ -51,7 +51,7 @@ describe('acp bridge — turn outcomes', () => {
it('rejects an ordinary plugin turn failure through the same ACP boundary', async () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('must not run')] })
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))