mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
PR #5676 (an unrelated v5 settings-migration fix) inadvertently reverted the model-selection assertion in the ACP integration test back to a flaky form that asserts the session's current model is present in availableModels. In CI (no real auth) the default current model is not in availableModels, so the assertion fails with "expected false to be true", breaking the Release workflow's "Integration Tests (No Sandbox)" job and gating the Publish Release step. Restore the robust form that explicitly picks an openai model from availableModels (matching the version that shipped in every successful release since March), which avoids the auth-dependent current-model mismatch.
1016 lines
30 KiB
TypeScript
1016 lines
30 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { createInterface } from 'node:readline';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { TestRig } from '../test-helper.js';
|
|
|
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
const INITIAL_PROMPT = 'Create a quick note (smoke test).';
|
|
const IS_SANDBOX =
|
|
process.env['QWEN_SANDBOX'] &&
|
|
process.env['QWEN_SANDBOX']!.toLowerCase() !== 'false';
|
|
|
|
type PendingRequest = {
|
|
resolve: (value: unknown) => void;
|
|
reject: (reason: Error) => void;
|
|
timeout: NodeJS.Timeout;
|
|
};
|
|
|
|
type UsageMetadata = {
|
|
promptTokens?: number | null;
|
|
completionTokens?: number | null;
|
|
thoughtsTokens?: number | null;
|
|
totalTokens?: number | null;
|
|
cachedTokens?: number | null;
|
|
};
|
|
|
|
type SessionUpdateNotification = {
|
|
sessionId?: string;
|
|
update?: {
|
|
sessionUpdate?: string;
|
|
availableCommands?: Array<{
|
|
name: string;
|
|
description: string;
|
|
input?: { hint: string } | null;
|
|
}>;
|
|
content?: {
|
|
type: string;
|
|
text?: string;
|
|
};
|
|
modeId?: string;
|
|
currentModeId?: string;
|
|
_meta?: {
|
|
usage?: UsageMetadata;
|
|
};
|
|
};
|
|
};
|
|
|
|
type PermissionRequest = {
|
|
id: number;
|
|
sessionId?: string;
|
|
toolCall?: {
|
|
toolCallId: string;
|
|
title: string;
|
|
kind: string;
|
|
status: string;
|
|
content?: Array<{
|
|
type: string;
|
|
text?: string;
|
|
path?: string;
|
|
oldText?: string;
|
|
newText?: string;
|
|
}>;
|
|
};
|
|
options?: Array<{
|
|
optionId: string;
|
|
name: string;
|
|
kind: string;
|
|
}>;
|
|
};
|
|
|
|
type PermissionHandler = (
|
|
request: PermissionRequest,
|
|
) => { optionId: string } | { outcome: 'cancelled' };
|
|
|
|
/**
|
|
* Sets up an ACP test environment with all necessary utilities.
|
|
* @param useNewFlag - If true, uses --acp; if false, uses --experimental-acp (for backward compatibility testing)
|
|
*/
|
|
function setupAcpTest(
|
|
rig: TestRig,
|
|
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean },
|
|
) {
|
|
const pending = new Map<number, PendingRequest>();
|
|
let nextRequestId = 1;
|
|
const sessionUpdates: SessionUpdateNotification[] = [];
|
|
const permissionRequests: PermissionRequest[] = [];
|
|
const stderr: string[] = [];
|
|
|
|
// Default permission handler: auto-approve all
|
|
const permissionHandler =
|
|
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
|
|
|
|
// Use --acp by default, but allow testing with --experimental-acp for backward compatibility
|
|
const acpFlag =
|
|
options?.useNewFlag !== false ? '--acp' : '--experimental-acp';
|
|
|
|
const agent = spawn(
|
|
'node',
|
|
[rig.bundlePath, acpFlag, '--no-chat-recording'],
|
|
{
|
|
cwd: rig.testDir!,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
},
|
|
);
|
|
|
|
agent.stderr?.on('data', (chunk) => {
|
|
stderr.push(chunk.toString());
|
|
});
|
|
|
|
const rl = createInterface({ input: agent.stdout });
|
|
|
|
const send = (json: unknown) => {
|
|
agent.stdin.write(`${JSON.stringify(json)}\n`);
|
|
};
|
|
|
|
const sendResponse = (id: number, result: unknown) => {
|
|
send({ jsonrpc: '2.0', id, result });
|
|
};
|
|
|
|
const sendRequest = (method: string, params?: unknown) =>
|
|
new Promise<unknown>((resolve, reject) => {
|
|
const id = nextRequestId++;
|
|
const timeout = setTimeout(() => {
|
|
pending.delete(id);
|
|
reject(new Error(`Request ${id} (${method}) timed out`));
|
|
}, REQUEST_TIMEOUT_MS);
|
|
pending.set(id, { resolve, reject, timeout });
|
|
send({ jsonrpc: '2.0', id, method, params });
|
|
});
|
|
|
|
const handleResponse = (msg: {
|
|
id: number;
|
|
result?: unknown;
|
|
error?: { message?: string };
|
|
}) => {
|
|
const waiter = pending.get(msg.id);
|
|
if (!waiter) {
|
|
return;
|
|
}
|
|
clearTimeout(waiter.timeout);
|
|
pending.delete(msg.id);
|
|
if (msg.error) {
|
|
const error = new Error(msg.error.message ?? 'Unknown error');
|
|
(error as Error & { response?: unknown }).response = msg.error;
|
|
waiter.reject(error);
|
|
} else {
|
|
waiter.resolve(msg.result);
|
|
}
|
|
};
|
|
|
|
const handleMessage = (msg: {
|
|
id?: number;
|
|
method?: string;
|
|
params?: SessionUpdateNotification & {
|
|
path?: string;
|
|
content?: string;
|
|
sessionId?: string;
|
|
toolCall?: PermissionRequest['toolCall'];
|
|
options?: PermissionRequest['options'];
|
|
};
|
|
result?: unknown;
|
|
error?: { message?: string };
|
|
}) => {
|
|
if (typeof msg.id !== 'undefined' && ('result' in msg || 'error' in msg)) {
|
|
handleResponse(
|
|
msg as {
|
|
id: number;
|
|
result?: unknown;
|
|
error?: { message?: string };
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (msg.method === 'session/update') {
|
|
sessionUpdates.push({
|
|
sessionId: msg.params?.sessionId,
|
|
update: msg.params?.update,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
msg.method === 'session/request_permission' &&
|
|
typeof msg.id === 'number'
|
|
) {
|
|
// Track permission request
|
|
const permRequest: PermissionRequest = {
|
|
id: msg.id,
|
|
sessionId: msg.params?.sessionId,
|
|
toolCall: msg.params?.toolCall,
|
|
options: msg.params?.options,
|
|
};
|
|
permissionRequests.push(permRequest);
|
|
|
|
// Use custom handler or default
|
|
const response = permissionHandler(permRequest);
|
|
if ('outcome' in response) {
|
|
sendResponse(msg.id, { outcome: response });
|
|
} else {
|
|
sendResponse(msg.id, {
|
|
outcome: { optionId: response.optionId, outcome: 'selected' },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.method === 'fs/read_text_file' && typeof msg.id === 'number') {
|
|
try {
|
|
const content = readFileSync(msg.params?.path ?? '', 'utf8');
|
|
sendResponse(msg.id, { content });
|
|
} catch (e) {
|
|
sendResponse(msg.id, { content: `ERROR: ${(e as Error).message}` });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.method === 'fs/write_text_file' && typeof msg.id === 'number') {
|
|
try {
|
|
writeFileSync(
|
|
msg.params?.path ?? '',
|
|
msg.params?.content ?? '',
|
|
'utf8',
|
|
);
|
|
sendResponse(msg.id, null);
|
|
} catch (e) {
|
|
sendResponse(msg.id, { message: (e as Error).message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// JSON-RPC requires every request to get a response. Reject unknown
|
|
// agent->client requests (e.g. optional extension methods like
|
|
// craft/drainMidTurnQueue) with -32601 so the agent fails fast instead
|
|
// of awaiting a reply that never comes.
|
|
if (typeof msg.id === 'number' && typeof msg.method === 'string') {
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id: msg.id,
|
|
error: { code: -32601, message: 'Method not found' },
|
|
});
|
|
}
|
|
};
|
|
|
|
rl.on('line', (line) => {
|
|
if (!line.trim()) return;
|
|
try {
|
|
const msg = JSON.parse(line);
|
|
handleMessage(msg);
|
|
} catch {
|
|
// Ignore non-JSON output from the agent.
|
|
}
|
|
});
|
|
|
|
const waitForExit = () =>
|
|
new Promise<void>((resolve) => {
|
|
if (agent.exitCode !== null || agent.signalCode) {
|
|
resolve();
|
|
return;
|
|
}
|
|
agent.once('exit', () => resolve());
|
|
});
|
|
|
|
const cleanup = async () => {
|
|
rl.close();
|
|
agent.kill();
|
|
pending.forEach(({ timeout }) => clearTimeout(timeout));
|
|
pending.clear();
|
|
await waitForExit();
|
|
};
|
|
|
|
return {
|
|
sendRequest,
|
|
sendResponse,
|
|
cleanup,
|
|
stderr,
|
|
sessionUpdates,
|
|
permissionRequests,
|
|
};
|
|
}
|
|
|
|
(IS_SANDBOX ? describe.skip : describe)('acp integration', () => {
|
|
it('basic smoke test', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp load session');
|
|
|
|
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
|
|
|
try {
|
|
const initResult = await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: true, writeTextFile: true },
|
|
},
|
|
});
|
|
expect(initResult).toBeDefined();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((initResult as any).agentInfo.version).toBeDefined();
|
|
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as { sessionId: string };
|
|
expect(newSession.sessionId).toBeTruthy();
|
|
|
|
const promptResult = await sendRequest('session/prompt', {
|
|
sessionId: newSession.sessionId,
|
|
prompt: [{ type: 'text', text: INITIAL_PROMPT }],
|
|
});
|
|
expect(promptResult).toBeDefined();
|
|
} catch (e) {
|
|
if (stderr.length) {
|
|
console.error('Agent stderr:', stderr.join(''));
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('initializes and allows setting mode', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp mode and model');
|
|
|
|
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
|
|
|
try {
|
|
// Test 1: Initialize and verify modes are returned
|
|
const initResult = (await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: true, writeTextFile: true },
|
|
},
|
|
})) as { protocolVersion: number };
|
|
|
|
expect(initResult).toBeDefined();
|
|
expect(initResult.protocolVersion).toBe(1);
|
|
|
|
// Test 2: Authenticate
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
// Test 3: Create a new session
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as {
|
|
sessionId: string;
|
|
models: {
|
|
availableModels: Array<{ modelId: string }>;
|
|
};
|
|
};
|
|
expect(newSession.sessionId).toBeTruthy();
|
|
expect(newSession.models.availableModels.length).toBeGreaterThan(0);
|
|
|
|
// Test 4: Set approval mode to 'yolo'
|
|
const setModeResult = (await sendRequest('session/set_mode', {
|
|
sessionId: newSession.sessionId,
|
|
modeId: 'yolo',
|
|
})) as unknown;
|
|
expect(setModeResult).toEqual({});
|
|
|
|
// Test 5: Set approval mode to 'auto-edit'
|
|
const setModeResult2 = (await sendRequest('session/set_mode', {
|
|
sessionId: newSession.sessionId,
|
|
modeId: 'auto-edit',
|
|
})) as unknown;
|
|
expect(setModeResult2).toEqual({});
|
|
|
|
// Test 6: Set approval mode back to 'default'
|
|
const setModeResult3 = (await sendRequest('session/set_mode', {
|
|
sessionId: newSession.sessionId,
|
|
modeId: 'default',
|
|
})) as unknown;
|
|
expect(setModeResult3).toEqual({});
|
|
} catch (e) {
|
|
if (stderr.length) {
|
|
console.error('Agent stderr:', stderr.join(''));
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('returns internal error details when model auth is required', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp auth methods in error data');
|
|
|
|
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
|
|
|
try {
|
|
await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: true, writeTextFile: true },
|
|
},
|
|
});
|
|
|
|
// Create a new session first
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as {
|
|
sessionId: string;
|
|
models: {
|
|
availableModels: Array<{ modelId: string }>;
|
|
};
|
|
};
|
|
|
|
// Choose a qwen-oauth model to trigger auth-required path deterministically.
|
|
const qwenOauthModel = newSession.models.availableModels.find((model) =>
|
|
model.modelId.includes('qwen-oauth'),
|
|
);
|
|
expect(qwenOauthModel).toBeDefined();
|
|
await expect(
|
|
sendRequest('session/set_config_option', {
|
|
sessionId: newSession.sessionId,
|
|
configId: 'model',
|
|
value: qwenOauthModel!.modelId,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
response: {
|
|
code: -32603,
|
|
message: 'Internal error',
|
|
data: {
|
|
details: expect.any(String),
|
|
},
|
|
},
|
|
});
|
|
} catch (e) {
|
|
if (stderr.length) {
|
|
console.error('Agent stderr:', stderr.join(''));
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('supports session/set_config_option for mode and model', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp set config option');
|
|
|
|
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
|
|
|
try {
|
|
// Initialize
|
|
await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: true, writeTextFile: true },
|
|
},
|
|
});
|
|
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
// Create a new session
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as {
|
|
sessionId: string;
|
|
models: {
|
|
availableModels: Array<{ modelId: string }>;
|
|
};
|
|
};
|
|
expect(newSession.sessionId).toBeTruthy();
|
|
|
|
// Test: Set mode using set_config_option
|
|
const setModeResult = (await sendRequest('session/set_config_option', {
|
|
sessionId: newSession.sessionId,
|
|
configId: 'mode',
|
|
value: 'yolo',
|
|
})) as {
|
|
configOptions: Array<{
|
|
id: string;
|
|
currentValue: string;
|
|
options: Array<{ value: string; name: string; description: string }>;
|
|
}>;
|
|
};
|
|
|
|
expect(setModeResult).toBeDefined();
|
|
expect(Array.isArray(setModeResult.configOptions)).toBe(true);
|
|
expect(setModeResult.configOptions.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// Find mode option
|
|
const modeOption = setModeResult.configOptions.find(
|
|
(opt) => opt.id === 'mode',
|
|
);
|
|
expect(modeOption).toBeDefined();
|
|
expect(modeOption!.currentValue).toBe('yolo');
|
|
expect(Array.isArray(modeOption!.options)).toBe(true);
|
|
expect(modeOption!.options.some((o) => o.value === 'yolo')).toBe(true);
|
|
|
|
// Find model option
|
|
const modelOption = setModeResult.configOptions.find(
|
|
(opt) => opt.id === 'model',
|
|
);
|
|
expect(modelOption).toBeDefined();
|
|
expect(modelOption!.currentValue).toBeTruthy();
|
|
|
|
// Test: Set model using set_config_option
|
|
// Use openai model to avoid auth issues
|
|
const openaiModel = newSession.models.availableModels.find((model) =>
|
|
model.modelId.includes('openai'),
|
|
);
|
|
expect(openaiModel).toBeDefined();
|
|
|
|
const setModelResult = (await sendRequest('session/set_config_option', {
|
|
sessionId: newSession.sessionId,
|
|
configId: 'model',
|
|
value: openaiModel!.modelId,
|
|
})) as {
|
|
configOptions: Array<{
|
|
id: string;
|
|
currentValue: string;
|
|
options: Array<{ value: string; name: string; description: string }>;
|
|
}>;
|
|
};
|
|
|
|
expect(setModelResult).toBeDefined();
|
|
expect(Array.isArray(setModelResult.configOptions)).toBe(true);
|
|
|
|
// Verify model was updated
|
|
const updatedModelOption = setModelResult.configOptions.find(
|
|
(opt) => opt.id === 'model',
|
|
);
|
|
expect(updatedModelOption).toBeDefined();
|
|
expect(updatedModelOption!.currentValue).toBe(openaiModel!.modelId);
|
|
} catch (e) {
|
|
if (stderr.length) {
|
|
console.error('Agent stderr:', stderr.join(''));
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('returns error for invalid configId in set_config_option', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp set config option error');
|
|
|
|
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
|
|
|
try {
|
|
// Initialize
|
|
await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: true, writeTextFile: true },
|
|
},
|
|
});
|
|
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
// Create a new session
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as { sessionId: string };
|
|
expect(newSession.sessionId).toBeTruthy();
|
|
|
|
// Test: Invalid configId should return error
|
|
await expect(
|
|
sendRequest('session/set_config_option', {
|
|
sessionId: newSession.sessionId,
|
|
configId: 'invalid_config',
|
|
value: 'some_value',
|
|
}),
|
|
).rejects.toMatchObject({
|
|
response: {
|
|
code: -32602,
|
|
message: 'Invalid params: Unsupported configId: invalid_config',
|
|
},
|
|
});
|
|
} catch (e) {
|
|
if (stderr.length) {
|
|
console.error('Agent stderr:', stderr.join(''));
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('receives available_commands_update with slash commands after session creation', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp slash commands');
|
|
|
|
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
|
|
|
|
try {
|
|
// Initialize
|
|
await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: true, writeTextFile: true },
|
|
},
|
|
});
|
|
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
// Create a new session
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as { sessionId: string };
|
|
expect(newSession.sessionId).toBeTruthy();
|
|
|
|
// Wait for available_commands_update to be received
|
|
await delay(1000);
|
|
|
|
// Verify available_commands_update is received
|
|
const commandsUpdate = sessionUpdates.find(
|
|
(update) =>
|
|
update.update?.sessionUpdate === 'available_commands_update',
|
|
);
|
|
|
|
expect(commandsUpdate).toBeDefined();
|
|
expect(commandsUpdate?.update?.availableCommands).toBeDefined();
|
|
expect(Array.isArray(commandsUpdate?.update?.availableCommands)).toBe(
|
|
true,
|
|
);
|
|
|
|
// Verify that the 'init' command is present (the only allowed built-in command for ACP)
|
|
const initCommand = commandsUpdate?.update?.availableCommands?.find(
|
|
(cmd) => cmd.name === 'init',
|
|
);
|
|
expect(initCommand).toBeDefined();
|
|
expect(initCommand?.description).toBeTruthy();
|
|
|
|
// Note: We don't test /init execution here because it triggers a complex
|
|
// multi-step process (listing files, reading up to 10 files, generating QWEN.md)
|
|
// that can take 30-60+ seconds, exceeding the request timeout.
|
|
// The slash command execution path is tested via simpler prompts in other tests.
|
|
} catch (e) {
|
|
if (stderr.length) {
|
|
console.error('Agent stderr:', stderr.join(''));
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('handles exit plan mode with permission request and mode update notification', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp exit plan mode');
|
|
|
|
// Track which permission requests we've seen
|
|
const planModeRequests: PermissionRequest[] = [];
|
|
|
|
const { sendRequest, cleanup, stderr, sessionUpdates, permissionRequests } =
|
|
setupAcpTest(rig, {
|
|
permissionHandler: (request) => {
|
|
// Track all permission requests for later verification
|
|
// Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode
|
|
if (request.toolCall?.kind === 'switch_mode') {
|
|
planModeRequests.push(request);
|
|
// Return proceed_always to switch to auto-edit mode
|
|
return { optionId: 'proceed_always' };
|
|
}
|
|
// Auto-approve all other requests
|
|
return { optionId: 'proceed_once' };
|
|
},
|
|
});
|
|
|
|
try {
|
|
// Initialize
|
|
await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: true, writeTextFile: true },
|
|
},
|
|
});
|
|
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
// Create a new session
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as { sessionId: string };
|
|
expect(newSession.sessionId).toBeTruthy();
|
|
|
|
// Set mode to 'plan' to enable plan mode
|
|
const setModeResult = (await sendRequest('session/set_mode', {
|
|
sessionId: newSession.sessionId,
|
|
modeId: 'plan',
|
|
})) as unknown;
|
|
expect(setModeResult).toEqual({});
|
|
|
|
// Send a prompt that should trigger the LLM to call exit_plan_mode
|
|
// The prompt is designed to trigger planning behavior
|
|
const promptResult = await sendRequest('session/prompt', {
|
|
sessionId: newSession.sessionId,
|
|
prompt: [
|
|
{
|
|
type: 'text',
|
|
text: 'Create a simple hello world function in Python. Make a brief plan and when ready, use the exit_plan_mode tool to present it for approval.',
|
|
},
|
|
],
|
|
});
|
|
expect(promptResult).toBeDefined();
|
|
|
|
// Give time for all notifications to be processed
|
|
await delay(1000);
|
|
|
|
// Verify: If exit_plan_mode was called, we should have received:
|
|
// 1. A permission request with kind: "switch_mode"
|
|
// 2. A current_mode_update notification after approval
|
|
|
|
// Check for switch_mode permission requests
|
|
const switchModeRequests = permissionRequests.filter(
|
|
(req) => req.toolCall?.kind === 'switch_mode',
|
|
);
|
|
|
|
// Check for current_mode_update notifications
|
|
const modeUpdateNotifications = sessionUpdates.filter(
|
|
(update) => update.update?.sessionUpdate === 'current_mode_update',
|
|
);
|
|
|
|
// If the LLM called exit_plan_mode, verify the flow
|
|
if (switchModeRequests.length > 0) {
|
|
// Verify permission request structure
|
|
const permReq = switchModeRequests[0];
|
|
expect(permReq.toolCall).toBeDefined();
|
|
expect(permReq.toolCall?.kind).toBe('switch_mode');
|
|
expect(permReq.toolCall?.status).toBe('pending');
|
|
expect(permReq.options).toBeDefined();
|
|
expect(Array.isArray(permReq.options)).toBe(true);
|
|
|
|
// Verify options include appropriate choices
|
|
const optionKinds = permReq.options?.map((opt) => opt.kind) ?? [];
|
|
expect(optionKinds).toContain('allow_once');
|
|
expect(optionKinds).toContain('allow_always');
|
|
|
|
// After approval, should have received current_mode_update
|
|
expect(modeUpdateNotifications.length).toBeGreaterThan(0);
|
|
|
|
// Verify mode update structure
|
|
const modeUpdate = modeUpdateNotifications[0];
|
|
expect(modeUpdate.sessionId).toBe(newSession.sessionId);
|
|
expect(modeUpdate.update?.currentModeId).toBeDefined();
|
|
// Mode should be auto-edit since we approved with proceed_always
|
|
expect(modeUpdate.update?.currentModeId).toBe('auto-edit');
|
|
}
|
|
|
|
// Note: If the LLM didn't call exit_plan_mode, that's acceptable
|
|
// since LLM behavior is non-deterministic. The test setup and structure
|
|
// is verified regardless.
|
|
} catch (e) {
|
|
if (stderr.length) {
|
|
console.error('Agent stderr:', stderr.join(''));
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('blocks write tools in plan mode (issue #1806)', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp plan mode enforcement');
|
|
|
|
const toolCallEvents: Array<{
|
|
toolName: string;
|
|
status: string;
|
|
error?: string;
|
|
}> = [];
|
|
|
|
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig, {
|
|
permissionHandler: (request) => {
|
|
// Cancel exit_plan_mode to keep plan mode active
|
|
if (request.toolCall?.kind === 'switch_mode') {
|
|
return { outcome: 'cancelled' };
|
|
}
|
|
return { optionId: 'proceed_once' };
|
|
},
|
|
});
|
|
|
|
try {
|
|
await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
|
});
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as { sessionId: string };
|
|
|
|
// Set mode to 'plan'
|
|
const setModeResult = (await sendRequest('session/set_mode', {
|
|
sessionId: newSession.sessionId,
|
|
modeId: 'plan',
|
|
})) as unknown;
|
|
expect(setModeResult).toEqual({});
|
|
|
|
// Try to create a file - this should be blocked by plan mode
|
|
const promptResult = await sendRequest('session/prompt', {
|
|
sessionId: newSession.sessionId,
|
|
prompt: [
|
|
{
|
|
type: 'text',
|
|
text: 'Create a file called test.txt with content "Hello World"',
|
|
},
|
|
],
|
|
});
|
|
expect(promptResult).toBeDefined();
|
|
|
|
// Give time for tool calls to be processed
|
|
await delay(2000);
|
|
|
|
// Collect tool call events from session updates
|
|
sessionUpdates.forEach((update) => {
|
|
if (update.update?.sessionUpdate === 'tool_call_update') {
|
|
const toolUpdate = update.update as {
|
|
sessionUpdate: string;
|
|
toolName?: string;
|
|
status?: string;
|
|
error?: { message?: string };
|
|
};
|
|
if (toolUpdate.toolName) {
|
|
toolCallEvents.push({
|
|
toolName: toolUpdate.toolName,
|
|
status: toolUpdate.status ?? 'unknown',
|
|
error: toolUpdate.error?.message,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Verify that if write_file was attempted, it was blocked
|
|
const writeFileEvents = toolCallEvents.filter(
|
|
(e) => e.toolName === 'write_file',
|
|
);
|
|
|
|
// If the LLM tried to call write_file in plan mode, it should have been blocked
|
|
if (writeFileEvents.length > 0) {
|
|
const blockedEvent = writeFileEvents.find(
|
|
(e) => e.status === 'error' && e.error?.includes('Plan mode'),
|
|
);
|
|
expect(blockedEvent).toBeDefined();
|
|
expect(blockedEvent?.error).toContain('Plan mode is active');
|
|
}
|
|
|
|
// Verify the file was NOT created
|
|
const fs = await import('fs');
|
|
const path = await import('path');
|
|
const testFilePath = path.join(rig.testDir!, 'test.txt');
|
|
const fileExists = fs.existsSync(testFilePath);
|
|
expect(fileExists).toBe(false);
|
|
} catch (e) {
|
|
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('receives usage metadata in agent_message_chunk updates', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp usage metadata');
|
|
|
|
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
|
|
|
|
try {
|
|
await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
|
});
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as { sessionId: string };
|
|
|
|
await sendRequest('session/prompt', {
|
|
sessionId: newSession.sessionId,
|
|
prompt: [{ type: 'text', text: 'Say "hello".' }],
|
|
});
|
|
|
|
await delay(500);
|
|
|
|
// Find updates with usage metadata
|
|
const updatesWithUsage = sessionUpdates.filter(
|
|
(u) =>
|
|
u.update?.sessionUpdate === 'agent_message_chunk' &&
|
|
u.update?._meta?.usage,
|
|
);
|
|
|
|
expect(updatesWithUsage.length).toBeGreaterThan(0);
|
|
|
|
const usage = updatesWithUsage[0].update?._meta?.usage;
|
|
expect(usage).toBeDefined();
|
|
expect(
|
|
typeof usage?.promptTokens === 'number' ||
|
|
typeof usage?.totalTokens === 'number',
|
|
).toBe(true);
|
|
} catch (e) {
|
|
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
(IS_SANDBOX ? describe.skip : describe)(
|
|
'acp flag backward compatibility',
|
|
() => {
|
|
it('should work with deprecated --experimental-acp flag and show warning', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp backward compatibility');
|
|
|
|
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
|
|
useNewFlag: false,
|
|
});
|
|
|
|
try {
|
|
const initResult = await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: true, writeTextFile: true },
|
|
},
|
|
});
|
|
expect(initResult).toBeDefined();
|
|
|
|
// Verify deprecation warning is shown
|
|
const stderrOutput = stderr.join('');
|
|
expect(stderrOutput).toContain('--experimental-acp is deprecated');
|
|
expect(stderrOutput).toContain('Please use --acp instead');
|
|
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as { sessionId: string };
|
|
expect(newSession.sessionId).toBeTruthy();
|
|
|
|
// Verify functionality still works
|
|
const promptResult = await sendRequest('session/prompt', {
|
|
sessionId: newSession.sessionId,
|
|
prompt: [{ type: 'text', text: 'Say hello.' }],
|
|
});
|
|
expect(promptResult).toBeDefined();
|
|
} catch (e) {
|
|
if (stderr.length) {
|
|
console.error('Agent stderr:', stderr.join(''));
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('should work with new --acp flag without warnings', async () => {
|
|
const rig = new TestRig();
|
|
rig.setup('acp new flag');
|
|
|
|
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
|
|
useNewFlag: true,
|
|
});
|
|
|
|
try {
|
|
const initResult = await sendRequest('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: true, writeTextFile: true },
|
|
},
|
|
});
|
|
expect(initResult).toBeDefined();
|
|
|
|
// Verify no deprecation warning is shown
|
|
const stderrOutput = stderr.join('');
|
|
expect(stderrOutput).not.toContain('--experimental-acp is deprecated');
|
|
|
|
await sendRequest('authenticate', { methodId: 'openai' });
|
|
|
|
const newSession = (await sendRequest('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
})) as { sessionId: string };
|
|
expect(newSession.sessionId).toBeTruthy();
|
|
|
|
// Verify functionality works
|
|
const promptResult = await sendRequest('session/prompt', {
|
|
sessionId: newSession.sessionId,
|
|
prompt: [{ type: 'text', text: 'Say hello.' }],
|
|
});
|
|
expect(promptResult).toBeDefined();
|
|
} catch (e) {
|
|
if (stderr.length) {
|
|
console.error('Agent stderr:', stderr.join(''));
|
|
}
|
|
throw e;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
},
|
|
);
|