mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
545 lines
17 KiB
TypeScript
545 lines
17 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { Session } from './Session.js';
|
|
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
|
|
import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core';
|
|
import * as core from '@qwen-code/qwen-code-core';
|
|
import type {
|
|
AgentSideConnection,
|
|
PromptRequest,
|
|
} from '@agentclientprotocol/sdk';
|
|
import type { LoadedSettings } from '../../config/settings.js';
|
|
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
|
|
|
|
vi.mock('../../nonInteractiveCliCommands.js', () => ({
|
|
getAvailableCommands: vi.fn(),
|
|
handleSlashCommand: vi.fn(),
|
|
}));
|
|
|
|
describe('Session', () => {
|
|
let mockChat: GeminiChat;
|
|
let mockConfig: Config;
|
|
let mockClient: AgentSideConnection;
|
|
let mockSettings: LoadedSettings;
|
|
let session: Session;
|
|
let currentModel: string;
|
|
let currentAuthType: AuthType;
|
|
let switchModelSpy: ReturnType<typeof vi.fn>;
|
|
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
|
|
let mockToolRegistry: { getTool: ReturnType<typeof vi.fn> };
|
|
|
|
beforeEach(() => {
|
|
currentModel = 'qwen3-code-plus';
|
|
currentAuthType = AuthType.USE_OPENAI;
|
|
switchModelSpy = vi
|
|
.fn()
|
|
.mockImplementation(async (authType: AuthType, modelId: string) => {
|
|
currentAuthType = authType;
|
|
currentModel = modelId;
|
|
});
|
|
|
|
mockChat = {
|
|
sendMessageStream: vi.fn(),
|
|
addHistory: vi.fn(),
|
|
} as unknown as GeminiChat;
|
|
|
|
mockToolRegistry = { getTool: vi.fn() };
|
|
const fileService = { shouldGitIgnoreFile: vi.fn().mockReturnValue(false) };
|
|
|
|
mockConfig = {
|
|
setApprovalMode: vi.fn(),
|
|
switchModel: switchModelSpy,
|
|
getModel: vi.fn().mockImplementation(() => currentModel),
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
getWorkingDir: vi.fn().mockReturnValue(process.cwd()),
|
|
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
|
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
|
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordUserMessage: vi.fn(),
|
|
recordUiTelemetryEvent: vi.fn(),
|
|
recordToolResult: vi.fn(),
|
|
}),
|
|
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
|
getFileService: vi.fn().mockReturnValue(fileService),
|
|
getFileFilteringRespectGitIgnore: vi.fn().mockReturnValue(true),
|
|
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
|
|
getTargetDir: vi.fn().mockReturnValue(process.cwd()),
|
|
getDebugMode: vi.fn().mockReturnValue(false),
|
|
getAuthType: vi.fn().mockImplementation(() => currentAuthType),
|
|
} as unknown as Config;
|
|
|
|
mockClient = {
|
|
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
|
requestPermission: vi.fn().mockResolvedValue({
|
|
outcome: { outcome: 'selected', optionId: 'proceed_once' },
|
|
}),
|
|
extNotification: vi.fn().mockResolvedValue(undefined),
|
|
} as unknown as AgentSideConnection;
|
|
|
|
mockSettings = {
|
|
merged: {},
|
|
} as LoadedSettings;
|
|
|
|
getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands)
|
|
.getAvailableCommands as unknown as ReturnType<typeof vi.fn>;
|
|
getAvailableCommandsSpy.mockResolvedValue([]);
|
|
|
|
session = new Session(
|
|
'test-session-id',
|
|
mockChat,
|
|
mockConfig,
|
|
mockClient,
|
|
mockSettings,
|
|
);
|
|
});
|
|
|
|
describe('setMode', () => {
|
|
it.each([
|
|
['plan', ApprovalMode.PLAN],
|
|
['default', ApprovalMode.DEFAULT],
|
|
['auto-edit', ApprovalMode.AUTO_EDIT],
|
|
['yolo', ApprovalMode.YOLO],
|
|
] as const)('maps %s mode', async (modeId, expected) => {
|
|
await session.setMode({
|
|
sessionId: 'test-session-id',
|
|
modeId,
|
|
});
|
|
|
|
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
|
|
});
|
|
});
|
|
|
|
describe('setModel', () => {
|
|
it('sets model via config and returns current model', async () => {
|
|
const requested = `qwen3-coder-plus(${AuthType.USE_OPENAI})`;
|
|
await session.setModel({
|
|
sessionId: 'test-session-id',
|
|
modelId: ` ${requested} `,
|
|
});
|
|
|
|
expect(mockConfig.switchModel).toHaveBeenCalledWith(
|
|
AuthType.USE_OPENAI,
|
|
'qwen3-coder-plus',
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('rejects empty/whitespace model IDs', async () => {
|
|
await expect(
|
|
session.setModel({
|
|
sessionId: 'test-session-id',
|
|
modelId: ' ',
|
|
}),
|
|
).rejects.toThrow('Invalid params');
|
|
|
|
expect(mockConfig.switchModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('propagates errors from config.switchModel', async () => {
|
|
const configError = new Error('Invalid model');
|
|
switchModelSpy.mockRejectedValueOnce(configError);
|
|
|
|
await expect(
|
|
session.setModel({
|
|
sessionId: 'test-session-id',
|
|
modelId: `invalid-model(${AuthType.USE_OPENAI})`,
|
|
}),
|
|
).rejects.toThrow('Invalid model');
|
|
});
|
|
});
|
|
|
|
describe('sendAvailableCommandsUpdate', () => {
|
|
it('sends available_commands_update from getAvailableCommands()', async () => {
|
|
getAvailableCommandsSpy.mockResolvedValueOnce([
|
|
{
|
|
name: 'init',
|
|
description: 'Initialize project context',
|
|
},
|
|
]);
|
|
|
|
await session.sendAvailableCommandsUpdate();
|
|
|
|
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
|
|
mockConfig,
|
|
expect.any(AbortSignal),
|
|
);
|
|
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
|
|
sessionId: 'test-session-id',
|
|
update: {
|
|
sessionUpdate: 'available_commands_update',
|
|
availableCommands: [
|
|
{
|
|
name: 'init',
|
|
description: 'Initialize project context',
|
|
input: null,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('swallows errors and does not throw', async () => {
|
|
getAvailableCommandsSpy.mockRejectedValueOnce(
|
|
new Error('Command discovery failed'),
|
|
);
|
|
|
|
await expect(
|
|
session.sendAvailableCommandsUpdate(),
|
|
).resolves.toBeUndefined();
|
|
expect(mockClient.sessionUpdate).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('prompt', () => {
|
|
it('passes resolved paths to read_many_files tool', async () => {
|
|
const tempDir = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), 'qwen-acp-session-'),
|
|
);
|
|
const fileName = 'README.md';
|
|
const filePath = path.join(tempDir, fileName);
|
|
|
|
try {
|
|
await fs.writeFile(filePath, '# Test\n', 'utf8');
|
|
|
|
const readManyFilesSpy = vi
|
|
.spyOn(core, 'readManyFiles')
|
|
.mockResolvedValue({
|
|
contentParts: 'file content',
|
|
files: [],
|
|
});
|
|
|
|
mockConfig.getTargetDir = vi.fn().mockReturnValue(tempDir);
|
|
mockChat.sendMessageStream = vi
|
|
.fn()
|
|
.mockResolvedValue((async function* () {})());
|
|
|
|
const promptRequest: PromptRequest = {
|
|
sessionId: 'test-session-id',
|
|
prompt: [
|
|
{ type: 'text', text: 'Check this file' },
|
|
{
|
|
type: 'resource_link',
|
|
name: fileName,
|
|
uri: `file://${fileName}`,
|
|
},
|
|
],
|
|
};
|
|
|
|
await session.prompt(promptRequest);
|
|
|
|
expect(readManyFilesSpy).toHaveBeenCalledWith(mockConfig, {
|
|
paths: [fileName],
|
|
signal: expect.any(AbortSignal),
|
|
});
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('runs prompt inside runtime output dir context', async () => {
|
|
const runtimeDir = path.resolve('runtime', 'from-settings');
|
|
core.Storage.setRuntimeBaseDir(runtimeDir);
|
|
session = new Session(
|
|
'test-session-id',
|
|
mockChat,
|
|
mockConfig,
|
|
mockClient,
|
|
mockSettings,
|
|
);
|
|
const runWithRuntimeBaseDirSpy = vi.spyOn(
|
|
core.Storage,
|
|
'runWithRuntimeBaseDir',
|
|
);
|
|
|
|
mockChat.sendMessageStream = vi
|
|
.fn()
|
|
.mockResolvedValue((async function* () {})());
|
|
|
|
const promptRequest: PromptRequest = {
|
|
sessionId: 'test-session-id',
|
|
prompt: [{ type: 'text', text: 'hello' }],
|
|
};
|
|
|
|
await session.prompt(promptRequest);
|
|
|
|
expect(runWithRuntimeBaseDirSpy).toHaveBeenCalledWith(
|
|
runtimeDir,
|
|
process.cwd(),
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it('hides allow-always options when confirmation already forbids them', async () => {
|
|
const executeSpy = vi.fn().mockResolvedValue({
|
|
llmContent: 'ok',
|
|
returnDisplay: 'ok',
|
|
});
|
|
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
|
|
const invocation = {
|
|
params: { path: '/tmp/file.txt' },
|
|
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
|
|
getConfirmationDetails: vi.fn().mockResolvedValue({
|
|
type: 'info',
|
|
title: 'Need permission',
|
|
prompt: 'Allow?',
|
|
hideAlwaysAllow: true,
|
|
onConfirm: onConfirmSpy,
|
|
}),
|
|
getDescription: vi.fn().mockReturnValue('Inspect file'),
|
|
toolLocations: vi.fn().mockReturnValue([]),
|
|
execute: executeSpy,
|
|
};
|
|
const tool = {
|
|
name: 'read_file',
|
|
kind: core.Kind.Read,
|
|
build: vi.fn().mockReturnValue(invocation),
|
|
};
|
|
|
|
mockToolRegistry.getTool.mockReturnValue(tool);
|
|
mockConfig.getApprovalMode = vi
|
|
.fn()
|
|
.mockReturnValue(ApprovalMode.DEFAULT);
|
|
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
|
|
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
|
|
(async function* () {
|
|
yield {
|
|
type: core.StreamEventType.CHUNK,
|
|
value: {
|
|
functionCalls: [
|
|
{
|
|
id: 'call-1',
|
|
name: 'read_file',
|
|
args: { path: '/tmp/file.txt' },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
})(),
|
|
);
|
|
|
|
await session.prompt({
|
|
sessionId: 'test-session-id',
|
|
prompt: [{ type: 'text', text: 'run tool' }],
|
|
});
|
|
|
|
expect(mockClient.requestPermission).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: [
|
|
expect.objectContaining({ kind: 'allow_once' }),
|
|
expect.objectContaining({ kind: 'reject_once' }),
|
|
],
|
|
}),
|
|
);
|
|
const options = (mockClient.requestPermission as ReturnType<typeof vi.fn>)
|
|
.mock.calls[0][0].options as Array<{ kind: string }>;
|
|
expect(options.some((option) => option.kind === 'allow_always')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('allows info confirmation tools in plan mode', async () => {
|
|
const executeSpy = vi.fn().mockResolvedValue({
|
|
llmContent: 'ok',
|
|
returnDisplay: 'ok',
|
|
});
|
|
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
|
|
const invocation = {
|
|
params: {
|
|
url: 'https://example.com/docs',
|
|
prompt: 'Summarize the docs',
|
|
},
|
|
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
|
|
getConfirmationDetails: vi.fn().mockResolvedValue({
|
|
type: 'info',
|
|
title: 'Confirm Web Fetch',
|
|
prompt: 'Allow fetching docs?',
|
|
urls: ['https://example.com/docs'],
|
|
onConfirm: onConfirmSpy,
|
|
}),
|
|
getDescription: vi.fn().mockReturnValue('Fetch docs'),
|
|
toolLocations: vi.fn().mockReturnValue([]),
|
|
execute: executeSpy,
|
|
};
|
|
const tool = {
|
|
name: 'web_fetch',
|
|
kind: core.Kind.Fetch,
|
|
build: vi.fn().mockReturnValue(invocation),
|
|
};
|
|
|
|
mockToolRegistry.getTool.mockReturnValue(tool);
|
|
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN);
|
|
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
|
|
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
|
|
(async function* () {
|
|
yield {
|
|
type: core.StreamEventType.CHUNK,
|
|
value: {
|
|
functionCalls: [
|
|
{
|
|
id: 'call-info-plan',
|
|
name: 'web_fetch',
|
|
args: {
|
|
url: 'https://example.com/docs',
|
|
prompt: 'Summarize the docs',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
})(),
|
|
);
|
|
|
|
await session.prompt({
|
|
sessionId: 'test-session-id',
|
|
prompt: [{ type: 'text', text: 'research the docs first' }],
|
|
});
|
|
|
|
expect(mockClient.requestPermission).toHaveBeenCalled();
|
|
expect(onConfirmSpy).toHaveBeenCalledWith(
|
|
core.ToolConfirmationOutcome.ProceedOnce,
|
|
{ answers: undefined },
|
|
);
|
|
expect(executeSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns permission error for disabled tools (L1 isToolEnabled check)', async () => {
|
|
const executeSpy = vi.fn();
|
|
const invocation = {
|
|
params: { path: '/tmp/file.txt' },
|
|
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
|
|
getConfirmationDetails: vi.fn().mockResolvedValue({
|
|
type: 'info',
|
|
title: 'Need permission',
|
|
prompt: 'Allow?',
|
|
onConfirm: vi.fn(),
|
|
}),
|
|
getDescription: vi.fn().mockReturnValue('Write file'),
|
|
toolLocations: vi.fn().mockReturnValue([]),
|
|
execute: executeSpy,
|
|
};
|
|
const tool = {
|
|
name: 'write_file',
|
|
kind: core.Kind.Edit,
|
|
build: vi.fn().mockReturnValue(invocation),
|
|
};
|
|
|
|
mockToolRegistry.getTool.mockReturnValue(tool);
|
|
mockConfig.getApprovalMode = vi
|
|
.fn()
|
|
.mockReturnValue(ApprovalMode.DEFAULT);
|
|
// Mock a PermissionManager that denies the tool
|
|
mockConfig.getPermissionManager = vi.fn().mockReturnValue({
|
|
isToolEnabled: vi.fn().mockResolvedValue(false),
|
|
});
|
|
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
|
|
(async function* () {
|
|
yield {
|
|
type: core.StreamEventType.CHUNK,
|
|
value: {
|
|
functionCalls: [
|
|
{
|
|
id: 'call-denied',
|
|
name: 'write_file',
|
|
args: { path: '/tmp/file.txt' },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
})(),
|
|
);
|
|
|
|
await session.prompt({
|
|
sessionId: 'test-session-id',
|
|
prompt: [{ type: 'text', text: 'write something' }],
|
|
});
|
|
|
|
// Tool should NOT have been executed
|
|
expect(executeSpy).not.toHaveBeenCalled();
|
|
// No permission dialog should have been opened
|
|
expect(mockClient.requestPermission).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('respects permission-request hook allow decisions without opening ACP permission dialog', async () => {
|
|
const hookSpy = vi
|
|
.spyOn(core, 'firePermissionRequestHook')
|
|
.mockResolvedValue({
|
|
hasDecision: true,
|
|
shouldAllow: true,
|
|
updatedInput: { path: '/tmp/updated.txt' },
|
|
denyMessage: undefined,
|
|
});
|
|
const executeSpy = vi.fn().mockResolvedValue({
|
|
llmContent: 'ok',
|
|
returnDisplay: 'ok',
|
|
});
|
|
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
|
|
const invocation = {
|
|
params: { path: '/tmp/original.txt' },
|
|
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
|
|
getConfirmationDetails: vi.fn().mockResolvedValue({
|
|
type: 'info',
|
|
title: 'Need permission',
|
|
prompt: 'Allow?',
|
|
onConfirm: onConfirmSpy,
|
|
}),
|
|
getDescription: vi.fn().mockReturnValue('Inspect file'),
|
|
toolLocations: vi.fn().mockReturnValue([]),
|
|
execute: executeSpy,
|
|
};
|
|
const tool = {
|
|
name: 'read_file',
|
|
kind: core.Kind.Read,
|
|
build: vi.fn().mockReturnValue(invocation),
|
|
};
|
|
|
|
mockToolRegistry.getTool.mockReturnValue(tool);
|
|
mockConfig.getApprovalMode = vi
|
|
.fn()
|
|
.mockReturnValue(ApprovalMode.DEFAULT);
|
|
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
|
|
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
|
|
mockConfig.getMessageBus = vi.fn().mockReturnValue({});
|
|
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
|
|
(async function* () {
|
|
yield {
|
|
type: core.StreamEventType.CHUNK,
|
|
value: {
|
|
functionCalls: [
|
|
{
|
|
id: 'call-2',
|
|
name: 'read_file',
|
|
args: { path: '/tmp/original.txt' },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
})(),
|
|
);
|
|
|
|
try {
|
|
await session.prompt({
|
|
sessionId: 'test-session-id',
|
|
prompt: [{ type: 'text', text: 'run tool' }],
|
|
});
|
|
} finally {
|
|
hookSpy.mockRestore();
|
|
}
|
|
|
|
expect(mockClient.requestPermission).not.toHaveBeenCalled();
|
|
expect(onConfirmSpy).toHaveBeenCalledWith(
|
|
core.ToolConfirmationOutcome.ProceedOnce,
|
|
);
|
|
expect(invocation.params).toEqual({ path: '/tmp/updated.txt' });
|
|
expect(executeSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|