Skip to content
34 changes: 33 additions & 1 deletion packages/channels/base/src/AcpBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { RequestPermissionResponse } from '@agentclientprotocol/sdk';
import {
ACP_EVENT_LOOP_STALL_RESTART_MS,
ACP_PERMISSION_RESPONSE_TIMEOUT_MS,
ACP_START_TIMEOUT_MS,
AcpBridge,
} from './AcpBridge.js';
import { CHANNEL_LOOP_MCP_SERVER_NAME } from './ChannelLoopTools.js';
Expand Down Expand Up @@ -47,6 +48,7 @@ const child = vi.hoisted(() => {
});
}

let initializeImplementation: () => Promise<void> = () => Promise.resolve();
return {
instances: [] as MockChild[],
clients: [] as Array<{
Expand All @@ -57,6 +59,13 @@ const child = vi.hoisted(() => {
cancel: ReturnType<typeof vi.fn>;
}>,
MockChild,
initializeImplementation: () => initializeImplementation(),
resetInitializeImplementation: () => {
initializeImplementation = () => Promise.resolve();
},
setInitializeImplementation: (implementation: () => Promise<void>) => {
initializeImplementation = implementation;
},
spawn: vi.fn(() => {
const instance = new MockChild();
child.instances.push(instance);
Expand All @@ -80,7 +89,7 @@ vi.mock('@agentclientprotocol/sdk', () => ({
ClientSideConnection: vi.fn().mockImplementation((createClient) => {
const client = createClient();
const connection = {
initialize: vi.fn().mockResolvedValue(undefined),
initialize: vi.fn(() => child.initializeImplementation()),
cancel: vi.fn().mockResolvedValue(undefined),
};
child.clients.push(client);
Expand Down Expand Up @@ -131,6 +140,29 @@ describe('AcpBridge', () => {
child.clients.length = 0;
child.connections.length = 0;
child.spawn.mockClear();
child.resetInitializeImplementation();
});

it('times out bridge initialization and stops the child', async () => {
vi.useFakeTimers();
try {
child.setInitializeImplementation(() => new Promise(() => {}));
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
});

const start = bridge.start();
const rejection = expect(start).rejects.toThrow(
`ACP initialization timed out after ${ACP_START_TIMEOUT_MS}ms`,
);
await vi.advanceTimersByTimeAsync(1000 + ACP_START_TIMEOUT_MS);

await rejection;
expect(child.instances[0]!.kill).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});

it('registers the channel loop MCP server once across concurrent calls', async () => {
Expand Down
37 changes: 32 additions & 5 deletions packages/channels/base/src/AcpBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface AcpBridgeOptions {
}

export const ACP_EVENT_LOOP_STALL_RESTART_MS = 5 * 60 * 1000;
export const ACP_START_TIMEOUT_MS = 30 * 1000;
export const ACP_PERMISSION_RESPONSE_TIMEOUT_MS = 5 * 60 * 1000;
const ACP_EVENT_LOOP_STALL_RE =
/^\[perf\] acp agent event loop stall: max=(\d+(?:\.\d+)?)ms/m;
Expand Down Expand Up @@ -178,11 +179,20 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
stream,
);

await this.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
});
await this.registerChannelLoopMcpServer();
try {
await withTimeout(
this.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
}),
ACP_START_TIMEOUT_MS,
`ACP initialization timed out after ${ACP_START_TIMEOUT_MS}ms`,
);
await this.registerChannelLoopMcpServer();
} catch (error) {
this.stop();
throw error;
}
}

registerChannelLoopToolHandler(handler: ChannelLoopToolHandler): void {
Expand Down Expand Up @@ -622,6 +632,23 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
}
}

async function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
timer.unref?.();
});
try {
return await Promise.race([operation, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}

function isSkippedMcpRegistration(result: unknown): boolean {
return (
typeof result === 'object' &&
Expand Down
Loading
Loading