mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
In ACP mode, the Mcp server list sent by the IDE client can include
SSE (type: "sse") and HTTP (type: "http") transports, but the previous
implementation only handled stdio servers via toStdioServer(). Non-stdio
servers were silently skipped (continue), so any SSE/HTTP-configured
MCP server would never be registered.
Changes:
- Add toSseServer() helper: detects type=="sse" servers and maps them
to MCPServerConfig(url=..., headers=...)
- Add toHttpServer() helper: detects type=="http" servers and maps them
to MCPServerConfig(httpUrl=..., headers=...)
- Refactor newSessionConfig() loop to handle all three transport types
- Declare mcpCapabilities: { sse: true, http: true } in agentCapabilities
so IDE clients know this agent supports these transports without needing
a transparent proxy
- Export the three helper functions for unit testing
Tests:
- Unit tests for toStdioServer / toSseServer / toHttpServer helpers
(type discrimination, mutual exclusion)
- Integration-style tests for QwenAgent.initialize() mcpCapabilities
- Integration-style tests for newSession() with SSE/HTTP MCP servers,
verifying MCPServerConfig is constructed with the correct arguments
(url vs httpUrl, headers passthrough, empty-headers → undefined)
Fixes #3472
896 lines
26 KiB
TypeScript
896 lines
26 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
vi,
|
|
beforeEach,
|
|
afterEach,
|
|
afterAll,
|
|
type MockInstance,
|
|
} from 'vitest';
|
|
|
|
// Mock cleanup module before importing anything else
|
|
const { mockRunExitCleanup } = vi.hoisted(() => ({
|
|
mockRunExitCleanup: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock('../utils/cleanup.js', () => ({
|
|
runExitCleanup: mockRunExitCleanup,
|
|
}));
|
|
|
|
// Mock the ACP SDK
|
|
const { mockConnectionState } = vi.hoisted(() => {
|
|
const state = {
|
|
resolve: () => {},
|
|
promise: null as unknown as Promise<void>,
|
|
reset() {
|
|
state.promise = new Promise<void>((r) => {
|
|
state.resolve = r;
|
|
});
|
|
},
|
|
};
|
|
state.reset();
|
|
return { mockConnectionState: state };
|
|
});
|
|
|
|
vi.mock('@agentclientprotocol/sdk', () => ({
|
|
AgentSideConnection: vi.fn().mockImplementation(() => ({
|
|
get closed() {
|
|
return mockConnectionState.promise;
|
|
},
|
|
})),
|
|
ndJsonStream: vi.fn().mockReturnValue({}),
|
|
RequestError: class RequestError extends Error {
|
|
static authRequired = vi
|
|
.fn()
|
|
.mockImplementation((data: unknown, msg: string) => {
|
|
const err = new Error(msg);
|
|
Object.assign(err, data);
|
|
return err;
|
|
});
|
|
static invalidParams = vi
|
|
.fn()
|
|
.mockImplementation((data: unknown, msg: string) => {
|
|
const err = new Error(msg);
|
|
Object.assign(err, data);
|
|
return err;
|
|
});
|
|
},
|
|
PROTOCOL_VERSION: '1.0.0',
|
|
}));
|
|
|
|
// Mock stream conversion
|
|
vi.mock('node:stream', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:stream')>();
|
|
return {
|
|
...actual,
|
|
Writable: { ...actual.Writable, toWeb: vi.fn().mockReturnValue({}) },
|
|
Readable: { ...actual.Readable, toWeb: vi.fn().mockReturnValue({}) },
|
|
};
|
|
});
|
|
|
|
// Mock core dependencies
|
|
vi.mock('@qwen-code/qwen-code-core', () => ({
|
|
createDebugLogger: () => ({
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
info: vi.fn(),
|
|
}),
|
|
APPROVAL_MODE_INFO: {},
|
|
APPROVAL_MODES: [],
|
|
AuthType: {},
|
|
clearCachedCredentialFile: vi.fn(),
|
|
QwenOAuth2Event: {},
|
|
qwenOAuth2Events: { on: vi.fn(), off: vi.fn() },
|
|
MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({
|
|
_args: args,
|
|
})),
|
|
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() }));
|
|
vi.mock('./service/filesystem.js', () => ({
|
|
AcpFileSystemService: vi.fn(),
|
|
}));
|
|
vi.mock('../config/settings.js', () => ({
|
|
SettingScope: {},
|
|
loadSettings: vi.fn(),
|
|
}));
|
|
vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn() }));
|
|
vi.mock('./session/Session.js', () => ({ Session: vi.fn() }));
|
|
vi.mock('../utils/acpModelUtils.js', () => ({
|
|
formatAcpModelId: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
runAcpAgent,
|
|
toStdioServer,
|
|
toSseServer,
|
|
toHttpServer,
|
|
} 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, MCPServerConfig } from '@qwen-code/qwen-code-core';
|
|
import type { McpServer } from '@agentclientprotocol/sdk';
|
|
import { AgentSideConnection } from '@agentclientprotocol/sdk';
|
|
import { loadSettings } from '../config/settings.js';
|
|
import { loadCliConfig } from '../config/config.js';
|
|
import { Session } from './session/Session.js';
|
|
|
|
describe('runAcpAgent shutdown cleanup', () => {
|
|
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 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
|
|
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);
|
|
|
|
// Mock process.exit to prevent actually exiting
|
|
processExitSpy = vi
|
|
.spyOn(process, 'exit')
|
|
.mockImplementation((() => undefined) as unknown as typeof process.exit);
|
|
|
|
// Mock stdin/stdout destroy
|
|
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('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
|
|
sigTermListeners[0]('SIGTERM');
|
|
|
|
// runExitCleanup is async, wait for it
|
|
await vi.waitFor(() => {
|
|
expect(mockRunExitCleanup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(processExitSpy).toHaveBeenCalledWith(0);
|
|
});
|
|
|
|
// Resolve connection.closed so the promise settles
|
|
mockConnectionState.resolve();
|
|
await agentPromise;
|
|
});
|
|
|
|
it('calls runExitCleanup and process.exit on SIGINT', async () => {
|
|
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);
|
|
|
|
// Wait for signal handlers to be registered
|
|
await vi.waitFor(() => {
|
|
expect(sigIntListeners.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
sigIntListeners[0]('SIGINT');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockRunExitCleanup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(processExitSpy).toHaveBeenCalledWith(0);
|
|
});
|
|
|
|
mockConnectionState.resolve();
|
|
await agentPromise;
|
|
});
|
|
|
|
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');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockRunExitCleanup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
mockConnectionState.resolve();
|
|
await agentPromise;
|
|
});
|
|
|
|
it('still exits even if runExitCleanup throws', async () => {
|
|
mockRunExitCleanup.mockRejectedValueOnce(new Error('cleanup failed'));
|
|
|
|
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()
|
|
await vi.waitFor(() => {
|
|
expect(processExitSpy).toHaveBeenCalledWith(0);
|
|
});
|
|
|
|
mockConnectionState.resolve();
|
|
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;
|
|
});
|
|
|
|
it('fires SessionEnd hook only once when SIGTERM triggers before connection.closed', async () => {
|
|
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sigTermListeners.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// Trigger SIGTERM first
|
|
sigTermListeners[0]('SIGTERM');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
|
|
SessionEndReason.Other,
|
|
);
|
|
});
|
|
|
|
// Now resolve connection.closed - this should NOT trigger another SessionEnd
|
|
mockConnectionState.resolve();
|
|
|
|
// Wait for the agent to complete
|
|
await agentPromise;
|
|
|
|
// SessionEnd should have been called exactly once
|
|
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unit tests for toStdioServer / toSseServer / toHttpServer helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('toStdioServer', () => {
|
|
const stdioServer = {
|
|
name: 'my-stdio',
|
|
command: 'node',
|
|
args: ['server.js'],
|
|
env: [],
|
|
} as unknown as McpServer;
|
|
|
|
const sseServer = {
|
|
type: 'sse',
|
|
name: 'my-sse',
|
|
url: 'http://localhost:3000/sse',
|
|
headers: [],
|
|
} as unknown as McpServer;
|
|
|
|
it('returns the server when it is a stdio server', () => {
|
|
expect(toStdioServer(stdioServer)).toBe(stdioServer);
|
|
});
|
|
|
|
it('returns undefined for SSE server', () => {
|
|
expect(toStdioServer(sseServer)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for HTTP server', () => {
|
|
const httpServer = {
|
|
type: 'http',
|
|
name: 'my-http',
|
|
url: 'http://localhost:3000/mcp',
|
|
headers: [],
|
|
} as unknown as McpServer;
|
|
expect(toStdioServer(httpServer)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('toSseServer', () => {
|
|
it('returns the server when type is sse', () => {
|
|
const sseServer = {
|
|
type: 'sse',
|
|
name: 'my-sse',
|
|
url: 'http://localhost:3000/sse',
|
|
headers: [],
|
|
} as unknown as McpServer;
|
|
const result = toSseServer(sseServer);
|
|
expect(result).toBe(sseServer);
|
|
expect(result?.type).toBe('sse');
|
|
});
|
|
|
|
it('returns undefined for stdio server', () => {
|
|
const stdioServer = {
|
|
name: 'my-stdio',
|
|
command: 'node',
|
|
args: [],
|
|
env: [],
|
|
} as unknown as McpServer;
|
|
expect(toSseServer(stdioServer)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for http server', () => {
|
|
const httpServer = {
|
|
type: 'http',
|
|
name: 'my-http',
|
|
url: 'http://localhost:3000/mcp',
|
|
headers: [],
|
|
} as unknown as McpServer;
|
|
expect(toSseServer(httpServer)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('toHttpServer', () => {
|
|
it('returns the server when type is http', () => {
|
|
const httpServer = {
|
|
type: 'http',
|
|
name: 'my-http',
|
|
url: 'http://localhost:3000/mcp',
|
|
headers: [],
|
|
} as unknown as McpServer;
|
|
const result = toHttpServer(httpServer);
|
|
expect(result).toBe(httpServer);
|
|
expect(result?.type).toBe('http');
|
|
});
|
|
|
|
it('returns undefined for stdio server', () => {
|
|
const stdioServer = {
|
|
name: 'my-stdio',
|
|
command: 'node',
|
|
args: [],
|
|
env: [],
|
|
} as unknown as McpServer;
|
|
expect(toHttpServer(stdioServer)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for sse server', () => {
|
|
const sseServer = {
|
|
type: 'sse',
|
|
name: 'my-sse',
|
|
url: 'http://localhost:3000/sse',
|
|
headers: [],
|
|
} as unknown as McpServer;
|
|
expect(toHttpServer(sseServer)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests for QwenAgent.initialize() mcpCapabilities + newSession SSE/HTTP
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('QwenAgent MCP SSE/HTTP support', () => {
|
|
// We need to capture the agent factory from AgentSideConnection constructor
|
|
let capturedAgentFactory:
|
|
| ((conn: AgentSideConnectionLike) => AgentLike)
|
|
| undefined;
|
|
|
|
type AgentSideConnectionLike = { closed: Promise<void> };
|
|
type AgentLike = {
|
|
initialize: (args: Record<string, unknown>) => Promise<unknown>;
|
|
newSession: (args: Record<string, unknown>) => Promise<unknown>;
|
|
};
|
|
|
|
let mockConfig: Config;
|
|
let processExitSpy: MockInstance<typeof process.exit>;
|
|
let stdinDestroySpy: MockInstance<typeof process.stdin.destroy>;
|
|
let stdoutDestroySpy: MockInstance<typeof process.stdout.destroy>;
|
|
|
|
const mockArgv = {} as CliArgs;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockConnectionState.reset();
|
|
capturedAgentFactory = undefined;
|
|
|
|
// Override AgentSideConnection mock to capture factory
|
|
vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => {
|
|
capturedAgentFactory = factory as typeof capturedAgentFactory;
|
|
return {
|
|
get closed() {
|
|
return mockConnectionState.promise;
|
|
},
|
|
} as unknown as InstanceType<typeof AgentSideConnection>;
|
|
});
|
|
|
|
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'),
|
|
getModelsConfig: vi.fn().mockReturnValue({
|
|
getCurrentAuthType: vi.fn().mockReturnValue('api-key'),
|
|
}),
|
|
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
|
} as unknown as Config;
|
|
|
|
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();
|
|
});
|
|
|
|
it('initialize response includes mcpCapabilities with sse and http', async () => {
|
|
const mockSettings = {
|
|
merged: { mcpServers: {} },
|
|
} as unknown as LoadedSettings;
|
|
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);
|
|
|
|
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
|
|
|
const fakeConn = {
|
|
get closed() {
|
|
return mockConnectionState.promise;
|
|
},
|
|
} as AgentSideConnectionLike;
|
|
|
|
const agent = capturedAgentFactory!(fakeConn) as AgentLike;
|
|
const response = await agent.initialize({ clientCapabilities: {} });
|
|
|
|
expect(response).toMatchObject({
|
|
agentCapabilities: {
|
|
mcpCapabilities: {
|
|
sse: true,
|
|
http: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
mockConnectionState.resolve();
|
|
await agentPromise;
|
|
});
|
|
|
|
function makeInnerConfig() {
|
|
return {
|
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
getModelsConfig: vi.fn().mockReturnValue({
|
|
getCurrentAuthType: vi.fn().mockReturnValue('api-key'),
|
|
}),
|
|
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
|
getModel: vi.fn().mockReturnValue('m'),
|
|
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
|
getAvailableModels: vi.fn().mockReturnValue([]),
|
|
getModes: vi.fn().mockReturnValue([]),
|
|
getApprovalMode: vi.fn().mockReturnValue('default'),
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
getAuthType: vi.fn().mockReturnValue('api-key'),
|
|
getAllConfiguredModels: vi.fn().mockReturnValue([]),
|
|
getGeminiClient: vi.fn().mockReturnValue({
|
|
isInitialized: vi.fn().mockReturnValue(true),
|
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
getFileSystemService: vi.fn().mockReturnValue(undefined),
|
|
setFileSystemService: vi.fn(),
|
|
getHookSystem: vi.fn().mockReturnValue(undefined),
|
|
getDisableAllHooks: vi.fn().mockReturnValue(true),
|
|
hasHooksForEvent: vi.fn().mockReturnValue(false),
|
|
};
|
|
}
|
|
|
|
function makeSessionSettings() {
|
|
return {
|
|
merged: { mcpServers: {} },
|
|
getUserHooks: vi.fn().mockReturnValue({}),
|
|
getProjectHooks: vi.fn().mockReturnValue({}),
|
|
} as unknown as LoadedSettings;
|
|
}
|
|
|
|
async function setupSessionMocks(sessionId: string) {
|
|
const innerConfig = makeInnerConfig();
|
|
vi.mocked(loadSettings).mockReturnValue(makeSessionSettings());
|
|
vi.mocked(loadCliConfig).mockResolvedValue(
|
|
innerConfig as unknown as Config,
|
|
);
|
|
vi.mocked(Session).mockImplementation(
|
|
() =>
|
|
({
|
|
getId: vi.fn().mockReturnValue(sessionId),
|
|
getConfig: vi.fn().mockReturnValue(innerConfig),
|
|
sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
|
|
replayHistory: vi.fn().mockResolvedValue(undefined),
|
|
installRewriter: vi.fn(),
|
|
}) as unknown as InstanceType<typeof Session>,
|
|
);
|
|
return innerConfig;
|
|
}
|
|
|
|
it('newSession with SSE MCP server creates MCPServerConfig with url', async () => {
|
|
await setupSessionMocks('session-sse');
|
|
|
|
const agentPromise = runAcpAgent(
|
|
mockConfig,
|
|
makeSessionSettings(),
|
|
mockArgv,
|
|
);
|
|
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
|
|
|
const agent = capturedAgentFactory!({
|
|
get closed() {
|
|
return mockConnectionState.promise;
|
|
},
|
|
}) as AgentLike;
|
|
|
|
await agent.newSession({
|
|
cwd: '/tmp',
|
|
mcpServers: [
|
|
{
|
|
type: 'sse',
|
|
name: 'my-sse-server',
|
|
url: 'http://localhost:3001/sse',
|
|
headers: [{ name: 'Authorization', value: 'Bearer token123' }],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(MCPServerConfig).toHaveBeenCalledWith(
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
'http://localhost:3001/sse',
|
|
undefined,
|
|
{ Authorization: 'Bearer token123' },
|
|
);
|
|
|
|
mockConnectionState.resolve();
|
|
await agentPromise;
|
|
});
|
|
|
|
it('newSession with HTTP MCP server creates MCPServerConfig with httpUrl', async () => {
|
|
await setupSessionMocks('session-http');
|
|
|
|
const agentPromise = runAcpAgent(
|
|
mockConfig,
|
|
makeSessionSettings(),
|
|
mockArgv,
|
|
);
|
|
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
|
|
|
const agent = capturedAgentFactory!({
|
|
get closed() {
|
|
return mockConnectionState.promise;
|
|
},
|
|
}) as AgentLike;
|
|
|
|
await agent.newSession({
|
|
cwd: '/tmp',
|
|
mcpServers: [
|
|
{
|
|
type: 'http',
|
|
name: 'my-http-server',
|
|
url: 'http://localhost:3002/mcp',
|
|
headers: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(MCPServerConfig).toHaveBeenCalledWith(
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
'http://localhost:3002/mcp',
|
|
undefined,
|
|
);
|
|
|
|
mockConnectionState.resolve();
|
|
await agentPromise;
|
|
});
|
|
|
|
it('newSession with SSE MCP server and empty headers passes undefined for headers', async () => {
|
|
await setupSessionMocks('session-sse-noheaders');
|
|
|
|
const agentPromise = runAcpAgent(
|
|
mockConfig,
|
|
makeSessionSettings(),
|
|
mockArgv,
|
|
);
|
|
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
|
|
|
const agent = capturedAgentFactory!({
|
|
get closed() {
|
|
return mockConnectionState.promise;
|
|
},
|
|
}) as AgentLike;
|
|
|
|
await agent.newSession({
|
|
cwd: '/tmp',
|
|
mcpServers: [
|
|
{
|
|
type: 'sse',
|
|
name: 'no-header-sse',
|
|
url: 'http://localhost:3003/sse',
|
|
headers: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(MCPServerConfig).toHaveBeenCalledWith(
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
'http://localhost:3003/sse',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
|
|
mockConnectionState.resolve();
|
|
await agentPromise;
|
|
});
|
|
});
|