refactor(agent-loop): simplify observable state machine
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' }] }))
|
||||
|
||||
Reference in New Issue
Block a user