test(acp): cover the continuable drain ordering and its failure path

Pins that the bridge releases the Activation forest before its own sessions, and
that a failed drain is reported without stranding that teardown. Reads the one
teardown method structurally so the bridge keeps no dependency on the subagent
seam.
This commit is contained in:
Dudu-0223
2026-07-30 15:15:38 +08:00
committed by Tianyi Cui
parent 03973cb074
commit 542a01c407
2 changed files with 51 additions and 1 deletions

View File

@@ -43,6 +43,16 @@ export const name = 'acp'
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
export const inject = ['agents']
/**
* The single continuable-subagent teardown the bridge needs. Declared
* structurally so this package does not depend on the subagent seam for one
* shutdown hook; an absent service means nothing continuable was materialized.
*/
interface ContinuableDrain {
/** Close continuable admission, then dispose every live Activation child-first. */
drainContinuable(): Promise<void>
}
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
@@ -331,7 +341,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
// Activations own descendant teardown. Drain that forest child-first
// BEFORE disposing the top-level agents, so no descendant is left holding
// a runtime its owner already released.
const subagents = ctx.get('subagents')
// Read the one teardown method structurally: the bridge needs no other
// part of the subagent seam, so it does not depend on that package.
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
if (subagents !== undefined) {
try {
await subagents.drainContinuable()

View File

@@ -25,6 +25,44 @@ describe('ACP connection ownership', () => {
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('drains continuable subagents before disposing its own sessions', async () => {
harness = await makeBridgeHarness()
const order: string[] = []
// A continuable Activation outlives the turn that started it, so the bridge
// must release that forest before the agents whose runtime it depends on.
harness.ctx.provide('subagents', {
drainContinuable: () => {
order.push('drained')
return Promise.resolve()
},
} as never, true)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
harness.ctx.on('agent/disposed', () => { order.push('agent disposed') })
await harness.acpFiber.dispose()
expect(order).toEqual(['drained', 'agent disposed'])
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('reports a failed continuable drain and still disposes its sessions', async () => {
harness = await makeBridgeHarness()
const warnings: string[] = []
harness.ctx.logger.warn = (message: string) => { warnings.push(message) }
harness.ctx.provide('subagents', {
drainContinuable: () => Promise.reject(new Error('activation teardown failed')),
} as never, true)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.acpFiber.dispose()
// A stuck descendant must not strand the bridge's own teardown.
expect(warnings.some(warning => warning.includes('continuable subagent teardown failed'))).toBe(true)
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })