Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 264 additions & 15 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
afterAll,
type MockInstance,
} from 'vitest';

// Mock cleanup module before importing anything else
const { mockRunExitCleanup } = vi.hoisted(() => ({
Expand Down Expand Up @@ -56,6 +65,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
}),
APPROVAL_MODE_INFO: {},
APPROVAL_MODES: [],
Expand All @@ -66,6 +76,14 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
MCPServerConfig: {},
SessionService: vi.fn(),
tokenLimit: vi.fn(),
SessionStartSource: {
Startup: 'startup',
Resume: 'resume',
},
SessionEndReason: {
PromptInputExit: 'prompt_input_exit',
Other: 'other',
},
}));

vi.mock('./authMethods.js', () => ({ buildAuthMethods: vi.fn() }));
Expand All @@ -83,26 +101,39 @@ import { runAcpAgent } from './acpAgent.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
import type { CliArgs } from '../config/config.js';
import { SessionEndReason } from '@qwen-code/qwen-code-core';

describe('runAcpAgent shutdown cleanup', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let processExitSpy: any;
let processExitSpy: MockInstance<typeof process.exit>;
let processOnSpy: MockInstance<typeof process.on>;
let processOffSpy: MockInstance<typeof process.off>;
let stdinDestroySpy: MockInstance<typeof process.stdin.destroy>;
let stdoutDestroySpy: MockInstance<typeof process.stdout.destroy>;
let sigTermListeners: NodeJS.SignalsListener[];
let sigIntListeners: NodeJS.SignalsListener[];
let mockConfig: Config;

const mockConfig = {} as Config;
const mockSettings = { merged: {} } as LoadedSettings;
const mockArgv = {} as CliArgs;

beforeEach(() => {
vi.clearAllMocks();
// Reset mockConfig after clearAllMocks
mockConfig = {
initialize: vi.fn().mockResolvedValue(undefined),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDisableAllHooks: vi.fn().mockReturnValue(false),
hasHooksForEvent: vi.fn().mockReturnValue(false),
getModel: vi.fn().mockReturnValue('test-model'),
} as unknown as Config;

mockRunExitCleanup.mockResolvedValue(undefined);
mockConnectionState.reset();
sigTermListeners = [];
sigIntListeners = [];

// Intercept signal handler registration
vi.spyOn(process, 'on').mockImplementation(((
processOnSpy = vi.spyOn(process, 'on').mockImplementation(((
event: string,
listener: (...args: unknown[]) => void,
) => {
Expand All @@ -113,33 +144,55 @@ describe('runAcpAgent shutdown cleanup', () => {
return process;
}) as typeof process.on);

vi.spyOn(process, 'off').mockImplementation(
(() => process) as typeof process.off,
);
processOffSpy = vi.spyOn(process, 'off').mockImplementation(((
event: string,
listener: (...args: unknown[]) => void,
) => {
if (event === 'SIGTERM') {
sigTermListeners = sigTermListeners.filter((l) => l !== listener);
}
if (event === 'SIGINT') {
sigIntListeners = sigIntListeners.filter((l) => l !== listener);
}
return process;
}) as typeof process.off);

// Mock process.exit to prevent actually exiting
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as unknown as typeof process.exit);

// Mock stdin/stdout destroy
vi.spyOn(process.stdin, 'destroy').mockImplementation(() => process.stdin);
vi.spyOn(process.stdout, 'destroy').mockImplementation(
() => process.stdout,
);
stdinDestroySpy = vi
.spyOn(process.stdin, 'destroy')
.mockImplementation(() => process.stdin);
stdoutDestroySpy = vi
.spyOn(process.stdout, 'destroy')
.mockImplementation(() => process.stdout);
});

afterEach(() => {
processExitSpy.mockRestore();
vi.restoreAllMocks();
stdinDestroySpy.mockRestore();
stdoutDestroySpy.mockRestore();
vi.clearAllMocks();
});

afterAll(() => {
processOnSpy.mockRestore();
processOffSpy.mockRestore();
});

it('calls runExitCleanup and process.exit on SIGTERM', async () => {
// Start runAcpAgent (it will await connection.closed)
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);

// Wait for signal handlers to be registered
await vi.waitFor(() => {
expect(sigTermListeners.length).toBeGreaterThan(0);
});

// Simulate SIGTERM from IDE
expect(sigTermListeners.length).toBeGreaterThan(0);
sigTermListeners[0]('SIGTERM');

// runExitCleanup is async, wait for it
Expand All @@ -159,7 +212,11 @@ describe('runAcpAgent shutdown cleanup', () => {
it('calls runExitCleanup and process.exit on SIGINT', async () => {
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);

expect(sigIntListeners.length).toBeGreaterThan(0);
// Wait for signal handlers to be registered
await vi.waitFor(() => {
expect(sigIntListeners.length).toBeGreaterThan(0);
});

sigIntListeners[0]('SIGINT');

await vi.waitFor(() => {
Expand All @@ -177,6 +234,11 @@ describe('runAcpAgent shutdown cleanup', () => {
it('only runs shutdown once even if multiple signals arrive', async () => {
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);

// Wait for signal handlers to be registered
await vi.waitFor(() => {
expect(sigTermListeners.length).toBeGreaterThan(0);
});

// Send SIGTERM twice
sigTermListeners[0]('SIGTERM');
sigTermListeners[0]('SIGTERM');
Expand All @@ -194,6 +256,11 @@ describe('runAcpAgent shutdown cleanup', () => {

const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);

// Wait for signal handlers to be registered
await vi.waitFor(() => {
expect(sigTermListeners.length).toBeGreaterThan(0);
});

sigTermListeners[0]('SIGTERM');

// process.exit should still be called via .finally()
Expand All @@ -205,3 +272,185 @@ describe('runAcpAgent shutdown cleanup', () => {
await agentPromise;
});
});

describe('runAcpAgent SessionEnd hooks', () => {
let processExitSpy: MockInstance<typeof process.exit>;
let processOnSpy: MockInstance<typeof process.on>;
let processOffSpy: MockInstance<typeof process.off>;
let stdinDestroySpy: MockInstance<typeof process.stdin.destroy>;
let stdoutDestroySpy: MockInstance<typeof process.stdout.destroy>;
let sigTermListeners: NodeJS.SignalsListener[];
let sigIntListeners: NodeJS.SignalsListener[];
let mockConfig: Config;
let mockHookSystem: {
fireSessionEndEvent: ReturnType<typeof vi.fn>;
fireSessionStartEvent: ReturnType<typeof vi.fn>;
};

const mockSettings = { merged: {} } as LoadedSettings;
const mockArgv = {} as CliArgs;

beforeEach(() => {
vi.clearAllMocks();
mockHookSystem = {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
};
mockConfig = {
initialize: vi.fn().mockResolvedValue(undefined),
getHookSystem: vi.fn().mockReturnValue(mockHookSystem),
getDisableAllHooks: vi.fn().mockReturnValue(false),
hasHooksForEvent: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('test-model'),
} as unknown as Config;

mockRunExitCleanup.mockResolvedValue(undefined);
mockConnectionState.reset();
sigTermListeners = [];
sigIntListeners = [];

processOnSpy = vi.spyOn(process, 'on').mockImplementation(((
event: string,
listener: (...args: unknown[]) => void,
) => {
if (event === 'SIGTERM')
sigTermListeners.push(listener as NodeJS.SignalsListener);
if (event === 'SIGINT')
sigIntListeners.push(listener as NodeJS.SignalsListener);
return process;
}) as typeof process.on);

processOffSpy = vi.spyOn(process, 'off').mockImplementation(((
event: string,
listener: (...args: unknown[]) => void,
) => {
if (event === 'SIGTERM') {
sigTermListeners = sigTermListeners.filter((l) => l !== listener);
}
if (event === 'SIGINT') {
sigIntListeners = sigIntListeners.filter((l) => l !== listener);
}
return process;
}) as typeof process.off);

processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as unknown as typeof process.exit);

stdinDestroySpy = vi
.spyOn(process.stdin, 'destroy')
.mockImplementation(() => process.stdin);
stdoutDestroySpy = vi
.spyOn(process.stdout, 'destroy')
.mockImplementation(() => process.stdout);
});

afterEach(() => {
processExitSpy.mockRestore();
stdinDestroySpy.mockRestore();
stdoutDestroySpy.mockRestore();
vi.clearAllMocks();
});

afterAll(() => {
processOnSpy.mockRestore();
processOffSpy.mockRestore();
});

it('fires SessionEnd hook with Other reason on SIGTERM', async () => {
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);

await vi.waitFor(() => {
expect(sigTermListeners.length).toBeGreaterThan(0);
});

sigTermListeners[0]('SIGTERM');

await vi.waitFor(() => {
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Other,
);
});

mockConnectionState.resolve();
await agentPromise;
});

it('fires SessionEnd hook with Other reason on SIGINT', async () => {
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);

await vi.waitFor(() => {
expect(sigIntListeners.length).toBeGreaterThan(0);
});

sigIntListeners[0]('SIGINT');

await vi.waitFor(() => {
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Other,
);
});

mockConnectionState.resolve();
await agentPromise;
});

it('fires SessionEnd hook with PromptInputExit on connection.closed', async () => {
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);

// Resolve connection to simulate IDE disconnect
mockConnectionState.resolve();

await vi.waitFor(() => {
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.PromptInputExit,
);
});

await agentPromise;
});

it('does not fire SessionEnd hook when hooks are disabled', async () => {
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true);

const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);

await vi.waitFor(() => {
expect(sigTermListeners.length).toBeGreaterThan(0);
});

sigTermListeners[0]('SIGTERM');

await vi.waitFor(() => {
expect(mockRunExitCleanup).toHaveBeenCalled();
});

// SessionEnd hook should NOT be called
expect(mockHookSystem.fireSessionEndEvent).not.toHaveBeenCalled();

mockConnectionState.resolve();
await agentPromise;
});

it('does not fire SessionEnd hook when event not registered', async () => {
mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(false);

const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);

await vi.waitFor(() => {
expect(sigTermListeners.length).toBeGreaterThan(0);
});

sigTermListeners[0]('SIGTERM');

await vi.waitFor(() => {
expect(mockRunExitCleanup).toHaveBeenCalled();
});

// SessionEnd hook should NOT be called
expect(mockHookSystem.fireSessionEndEvent).not.toHaveBeenCalled();

mockConnectionState.resolve();
await agentPromise;
});
});
Loading
Loading