mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-08 09:59:14 +00:00
Compare commits
1 Commits
fix/tool-r
...
fix/missin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61aad5a162 |
@@ -630,6 +630,67 @@ describe('BaseJsonOutputAdapter', () => {
|
|||||||
|
|
||||||
expect(state.blocks).toHaveLength(0);
|
expect(state.blocks).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should preserve whitespace in thinking content', () => {
|
||||||
|
const state = adapter.exposeCreateMessageState();
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
|
||||||
|
adapter.exposeAppendThinking(
|
||||||
|
state,
|
||||||
|
'',
|
||||||
|
'The user just said "Hello"',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.blocks).toHaveLength(1);
|
||||||
|
expect(state.blocks[0]).toMatchObject({
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'The user just said "Hello"',
|
||||||
|
});
|
||||||
|
// Verify spaces are preserved
|
||||||
|
const block = state.blocks[0] as { thinking: string };
|
||||||
|
expect(block.thinking).toContain('user just');
|
||||||
|
expect(block.thinking).not.toContain('userjust');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve whitespace when appending multiple thinking fragments', () => {
|
||||||
|
const state = adapter.exposeCreateMessageState();
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
|
||||||
|
// Simulate streaming thinking content in fragments
|
||||||
|
adapter.exposeAppendThinking(state, '', 'The user just', null);
|
||||||
|
adapter.exposeAppendThinking(state, '', ' said "Hello"', null);
|
||||||
|
adapter.exposeAppendThinking(
|
||||||
|
state,
|
||||||
|
'',
|
||||||
|
'. This is a simple greeting',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.blocks).toHaveLength(1);
|
||||||
|
const block = state.blocks[0] as { thinking: string };
|
||||||
|
// Verify the complete text with all spaces preserved
|
||||||
|
expect(block.thinking).toBe(
|
||||||
|
'The user just said "Hello". This is a simple greeting',
|
||||||
|
);
|
||||||
|
// Verify specific space preservation
|
||||||
|
expect(block.thinking).toContain('user just ');
|
||||||
|
expect(block.thinking).toContain(' said');
|
||||||
|
expect(block.thinking).toContain('". This');
|
||||||
|
expect(block.thinking).not.toContain('userjust');
|
||||||
|
expect(block.thinking).not.toContain('justsaid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve leading and trailing whitespace in description', () => {
|
||||||
|
const state = adapter.exposeCreateMessageState();
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
|
||||||
|
adapter.exposeAppendThinking(state, '', ' content with spaces ', null);
|
||||||
|
|
||||||
|
expect(state.blocks).toHaveLength(1);
|
||||||
|
const block = state.blocks[0] as { thinking: string };
|
||||||
|
expect(block.thinking).toBe(' content with spaces ');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('appendToolUse', () => {
|
describe('appendToolUse', () => {
|
||||||
|
|||||||
@@ -816,9 +816,18 @@ export abstract class BaseJsonOutputAdapter {
|
|||||||
parentToolUseId?: string | null,
|
parentToolUseId?: string | null,
|
||||||
): void {
|
): void {
|
||||||
const actualParentToolUseId = parentToolUseId ?? null;
|
const actualParentToolUseId = parentToolUseId ?? null;
|
||||||
const fragment = [subject?.trim(), description?.trim()]
|
|
||||||
.filter((value) => value && value.length > 0)
|
// Build fragment without trimming to preserve whitespace in streaming content
|
||||||
.join(': ');
|
// Only filter out null/undefined/empty values
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (subject && subject.length > 0) {
|
||||||
|
parts.push(subject);
|
||||||
|
}
|
||||||
|
if (description && description.length > 0) {
|
||||||
|
parts.push(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = parts.join(': ');
|
||||||
if (!fragment) {
|
if (!fragment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,68 @@ describe('StreamJsonOutputAdapter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should preserve whitespace in thinking content (issue #1356)', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: '',
|
||||||
|
description: 'The user just said "Hello"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
const block = message.message.content[0] as {
|
||||||
|
type: string;
|
||||||
|
thinking: string;
|
||||||
|
};
|
||||||
|
expect(block.type).toBe('thinking');
|
||||||
|
expect(block.thinking).toBe('The user just said "Hello"');
|
||||||
|
// Verify spaces are preserved
|
||||||
|
expect(block.thinking).toContain('user just');
|
||||||
|
expect(block.thinking).not.toContain('userjust');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve whitespace when streaming multiple thinking fragments (issue #1356)', () => {
|
||||||
|
// Simulate streaming thinking content in multiple events
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: '',
|
||||||
|
description: 'The user just',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: '',
|
||||||
|
description: ' said "Hello"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: '',
|
||||||
|
description: '. This is a simple greeting',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
const block = message.message.content[0] as {
|
||||||
|
type: string;
|
||||||
|
thinking: string;
|
||||||
|
};
|
||||||
|
expect(block.thinking).toBe(
|
||||||
|
'The user just said "Hello". This is a simple greeting',
|
||||||
|
);
|
||||||
|
// Verify specific spaces are preserved
|
||||||
|
expect(block.thinking).toContain('user just ');
|
||||||
|
expect(block.thinking).toContain(' said');
|
||||||
|
expect(block.thinking).not.toContain('userjust');
|
||||||
|
expect(block.thinking).not.toContain('justsaid');
|
||||||
|
});
|
||||||
|
|
||||||
it('should append tool use from ToolCallRequest events', () => {
|
it('should append tool use from ToolCallRequest events', () => {
|
||||||
adapter.processEvent({
|
adapter.processEvent({
|
||||||
type: GeminiEventType.ToolCallRequest,
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
|||||||
@@ -298,9 +298,7 @@ describe('runNonInteractive', () => {
|
|||||||
mockConfig,
|
mockConfig,
|
||||||
expect.objectContaining({ name: 'testTool' }),
|
expect.objectContaining({ name: 'testTool' }),
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
expect.objectContaining({
|
undefined,
|
||||||
outputUpdateHandler: expect.any(Function),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
// Verify first call has isContinuation: false
|
// Verify first call has isContinuation: false
|
||||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||||
@@ -1779,84 +1777,4 @@ describe('runNonInteractive', () => {
|
|||||||
{ isContinuation: false },
|
{ isContinuation: false },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should print tool output to console in text mode (non-Task tools)', async () => {
|
|
||||||
// Test that tool output is printed to stdout in text mode
|
|
||||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
|
||||||
type: GeminiEventType.ToolCallRequest,
|
|
||||||
value: {
|
|
||||||
callId: 'tool-1',
|
|
||||||
name: 'run_in_terminal',
|
|
||||||
args: { command: 'npm outdated' },
|
|
||||||
isClientInitiated: false,
|
|
||||||
prompt_id: 'prompt-id-tool-output',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock tool execution with outputUpdateHandler being called
|
|
||||||
mockCoreExecuteToolCall.mockImplementation(
|
|
||||||
async (_config, _request, _signal, options) => {
|
|
||||||
// Simulate tool calling outputUpdateHandler with output chunks
|
|
||||||
if (options?.outputUpdateHandler) {
|
|
||||||
options.outputUpdateHandler('tool-1', 'Package outdated\n');
|
|
||||||
options.outputUpdateHandler('tool-1', 'npm@1.0.0 -> npm@2.0.0\n');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
responseParts: [
|
|
||||||
{
|
|
||||||
functionResponse: {
|
|
||||||
id: 'tool-1',
|
|
||||||
name: 'run_in_terminal',
|
|
||||||
response: {
|
|
||||||
output: 'Package outdated\nnpm@1.0.0 -> npm@2.0.0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
|
||||||
toolCallEvent,
|
|
||||||
{
|
|
||||||
type: GeminiEventType.Finished,
|
|
||||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
|
||||||
{ type: GeminiEventType.Content, value: 'Dependencies checked' },
|
|
||||||
{
|
|
||||||
type: GeminiEventType.Finished,
|
|
||||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
mockGeminiClient.sendMessageStream
|
|
||||||
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
|
|
||||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
|
||||||
|
|
||||||
await runNonInteractive(
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
'Check dependencies',
|
|
||||||
'prompt-id-tool-output',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify that executeToolCall was called with outputUpdateHandler
|
|
||||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
|
||||||
mockConfig,
|
|
||||||
expect.objectContaining({ name: 'run_in_terminal' }),
|
|
||||||
expect.any(AbortSignal),
|
|
||||||
expect.objectContaining({
|
|
||||||
outputUpdateHandler: expect.any(Function),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify tool output was written to stdout
|
|
||||||
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
|
|
||||||
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
|
|
||||||
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
|
||||||
Config,
|
|
||||||
ToolCallRequestInfo,
|
|
||||||
ToolResultDisplay,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||||
import type { LoadedSettings } from './config/settings.js';
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
import {
|
import {
|
||||||
@@ -337,7 +333,7 @@ export async function runNonInteractive(
|
|||||||
? options.controlService.permission.getToolCallUpdateCallback()
|
? options.controlService.permission.getToolCallUpdateCallback()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Create output handler for Task tool (for subagent execution)
|
// Only pass outputUpdateHandler for Task tool
|
||||||
const isTaskTool = finalRequestInfo.name === 'task';
|
const isTaskTool = finalRequestInfo.name === 'task';
|
||||||
const taskToolProgress = isTaskTool
|
const taskToolProgress = isTaskTool
|
||||||
? createTaskToolProgressHandler(
|
? createTaskToolProgressHandler(
|
||||||
@@ -347,41 +343,20 @@ export async function runNonInteractive(
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const taskToolProgressHandler = taskToolProgress?.handler;
|
const taskToolProgressHandler = taskToolProgress?.handler;
|
||||||
|
|
||||||
// Create output handler for non-Task tools in text mode (for console output)
|
|
||||||
const nonTaskOutputHandler =
|
|
||||||
!isTaskTool && !adapter
|
|
||||||
? (callId: string, outputChunk: ToolResultDisplay) => {
|
|
||||||
// Print tool output to console in text mode
|
|
||||||
if (typeof outputChunk === 'string') {
|
|
||||||
process.stdout.write(outputChunk);
|
|
||||||
} else if (
|
|
||||||
outputChunk &&
|
|
||||||
typeof outputChunk === 'object' &&
|
|
||||||
'ansiOutput' in outputChunk
|
|
||||||
) {
|
|
||||||
// Handle ANSI output - just print as string for now
|
|
||||||
process.stdout.write(String(outputChunk.ansiOutput));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Combine output handlers
|
|
||||||
const outputUpdateHandler =
|
|
||||||
taskToolProgressHandler || nonTaskOutputHandler;
|
|
||||||
|
|
||||||
const toolResponse = await executeToolCall(
|
const toolResponse = await executeToolCall(
|
||||||
config,
|
config,
|
||||||
finalRequestInfo,
|
finalRequestInfo,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
outputUpdateHandler || toolCallUpdateCallback
|
isTaskTool && taskToolProgressHandler
|
||||||
? {
|
? {
|
||||||
...(outputUpdateHandler && { outputUpdateHandler }),
|
outputUpdateHandler: taskToolProgressHandler,
|
||||||
...(toolCallUpdateCallback && {
|
onToolCallsUpdate: toolCallUpdateCallback,
|
||||||
onToolCallsUpdate: toolCallUpdateCallback,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: toolCallUpdateCallback
|
||||||
|
? {
|
||||||
|
onToolCallsUpdate: toolCallUpdateCallback,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||||
|
|||||||
@@ -6,11 +6,7 @@
|
|||||||
|
|
||||||
import { vi, type Mock, type MockInstance } from 'vitest';
|
import { vi, type Mock, type MockInstance } from 'vitest';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
||||||
OutputFormat,
|
|
||||||
FatalInputError,
|
|
||||||
ToolErrorType,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
handleError,
|
handleError,
|
||||||
@@ -69,7 +65,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||||||
describe('errors', () => {
|
describe('errors', () => {
|
||||||
let mockConfig: Config;
|
let mockConfig: Config;
|
||||||
let processExitSpy: MockInstance;
|
let processExitSpy: MockInstance;
|
||||||
let processStderrWriteSpy: MockInstance;
|
|
||||||
let consoleErrorSpy: MockInstance;
|
let consoleErrorSpy: MockInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -79,11 +74,6 @@ describe('errors', () => {
|
|||||||
// Mock console.error
|
// Mock console.error
|
||||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
// Mock process.stderr.write
|
|
||||||
processStderrWriteSpy = vi
|
|
||||||
.spyOn(process.stderr, 'write')
|
|
||||||
.mockImplementation(() => true);
|
|
||||||
|
|
||||||
// Mock process.exit to throw instead of actually exiting
|
// Mock process.exit to throw instead of actually exiting
|
||||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||||
throw new Error(`process.exit called with code: ${code}`);
|
throw new Error(`process.exit called with code: ${code}`);
|
||||||
@@ -94,13 +84,11 @@ describe('errors', () => {
|
|||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
||||||
getDebugMode: vi.fn().mockReturnValue(true),
|
getDebugMode: vi.fn().mockReturnValue(true),
|
||||||
isInteractive: vi.fn().mockReturnValue(false),
|
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
processStderrWriteSpy.mockRestore();
|
|
||||||
processExitSpy.mockRestore();
|
processExitSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,87 +432,6 @@ describe('errors', () => {
|
|||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('permission denied warnings', () => {
|
|
||||||
it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => {
|
|
||||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
|
||||||
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
|
||||||
(
|
|
||||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
||||||
).mockReturnValue(OutputFormat.TEXT);
|
|
||||||
|
|
||||||
handleToolError(
|
|
||||||
toolName,
|
|
||||||
toolError,
|
|
||||||
mockConfig,
|
|
||||||
ToolErrorType.EXECUTION_DENIED,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(processStderrWriteSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining(
|
|
||||||
'Warning: Tool "test-tool" requires user approval',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(processStderrWriteSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('use the -y flag (YOLO mode)'),
|
|
||||||
);
|
|
||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show warning when EXECUTION_DENIED in interactive mode', () => {
|
|
||||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
|
||||||
(mockConfig.isInteractive as Mock).mockReturnValue(true);
|
|
||||||
(
|
|
||||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
||||||
).mockReturnValue(OutputFormat.TEXT);
|
|
||||||
|
|
||||||
handleToolError(
|
|
||||||
toolName,
|
|
||||||
toolError,
|
|
||||||
mockConfig,
|
|
||||||
ToolErrorType.EXECUTION_DENIED,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
|
||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show warning when EXECUTION_DENIED in JSON mode', () => {
|
|
||||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
|
||||||
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
|
||||||
(
|
|
||||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
||||||
).mockReturnValue(OutputFormat.JSON);
|
|
||||||
|
|
||||||
handleToolError(
|
|
||||||
toolName,
|
|
||||||
toolError,
|
|
||||||
mockConfig,
|
|
||||||
ToolErrorType.EXECUTION_DENIED,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
|
||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show warning for non-EXECUTION_DENIED errors', () => {
|
|
||||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
|
||||||
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
|
||||||
(
|
|
||||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
||||||
).mockReturnValue(OutputFormat.TEXT);
|
|
||||||
|
|
||||||
handleToolError(
|
|
||||||
toolName,
|
|
||||||
toolError,
|
|
||||||
mockConfig,
|
|
||||||
ToolErrorType.FILE_NOT_FOUND,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
|
||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleCancellationError', () => {
|
describe('handleCancellationError', () => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
parseAndFormatApiError,
|
parseAndFormatApiError,
|
||||||
FatalTurnLimitedError,
|
FatalTurnLimitedError,
|
||||||
FatalCancellationError,
|
FatalCancellationError,
|
||||||
ToolErrorType,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
export function getErrorMessage(error: unknown): string {
|
export function getErrorMessage(error: unknown): string {
|
||||||
@@ -103,24 +102,10 @@ export function handleToolError(
|
|||||||
toolName: string,
|
toolName: string,
|
||||||
toolError: Error,
|
toolError: Error,
|
||||||
config: Config,
|
config: Config,
|
||||||
errorCode?: string | number,
|
_errorCode?: string | number,
|
||||||
resultDisplay?: string,
|
resultDisplay?: string,
|
||||||
): void {
|
): void {
|
||||||
// Check if this is a permission denied error in non-interactive mode
|
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
|
||||||
const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED;
|
|
||||||
const isNonInteractive = !config.isInteractive();
|
|
||||||
const isTextMode = config.getOutputFormat() === OutputFormat.TEXT;
|
|
||||||
|
|
||||||
// Show warning for permission denied errors in non-interactive text mode
|
|
||||||
if (isExecutionDenied && isNonInteractive && isTextMode) {
|
|
||||||
const warningMessage =
|
|
||||||
`Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` +
|
|
||||||
`To enable automatic tool execution, use the -y flag (YOLO mode):\n` +
|
|
||||||
`Example: qwen -p 'your prompt' -y\n\n`;
|
|
||||||
process.stderr.write(warningMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always log detailed error in debug mode
|
|
||||||
if (config.getDebugMode()) {
|
if (config.getDebugMode()) {
|
||||||
console.error(
|
console.error(
|
||||||
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
||||||
|
|||||||
@@ -169,44 +169,6 @@ describe('ShellTool', () => {
|
|||||||
});
|
});
|
||||||
expect(invocation.getDescription()).not.toContain('[background]');
|
expect(invocation.getDescription()).not.toContain('[background]');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('is_background parameter coercion', () => {
|
|
||||||
it('should accept string "true" as boolean true', () => {
|
|
||||||
const invocation = shellTool.build({
|
|
||||||
command: 'npm run dev',
|
|
||||||
is_background: 'true' as unknown as boolean,
|
|
||||||
});
|
|
||||||
expect(invocation).toBeDefined();
|
|
||||||
expect(invocation.getDescription()).toContain('[background]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept string "false" as boolean false', () => {
|
|
||||||
const invocation = shellTool.build({
|
|
||||||
command: 'npm run build',
|
|
||||||
is_background: 'false' as unknown as boolean,
|
|
||||||
});
|
|
||||||
expect(invocation).toBeDefined();
|
|
||||||
expect(invocation.getDescription()).not.toContain('[background]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept string "True" as boolean true', () => {
|
|
||||||
const invocation = shellTool.build({
|
|
||||||
command: 'npm run dev',
|
|
||||||
is_background: 'True' as unknown as boolean,
|
|
||||||
});
|
|
||||||
expect(invocation).toBeDefined();
|
|
||||||
expect(invocation.getDescription()).toContain('[background]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept string "False" as boolean false', () => {
|
|
||||||
const invocation = shellTool.build({
|
|
||||||
command: 'npm run build',
|
|
||||||
is_background: 'False' as unknown as boolean,
|
|
||||||
});
|
|
||||||
expect(invocation).toBeDefined();
|
|
||||||
expect(invocation.getDescription()).not.toContain('[background]');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('execute', () => {
|
describe('execute', () => {
|
||||||
|
|||||||
@@ -122,91 +122,4 @@ describe('SchemaValidator', () => {
|
|||||||
};
|
};
|
||||||
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
|
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('boolean string coercion', () => {
|
|
||||||
const booleanSchema = {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
is_background: {
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['is_background'],
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should coerce string "true" to boolean true', () => {
|
|
||||||
const params = { is_background: 'true' };
|
|
||||||
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
|
|
||||||
expect(params.is_background).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should coerce string "True" to boolean true', () => {
|
|
||||||
const params = { is_background: 'True' };
|
|
||||||
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
|
|
||||||
expect(params.is_background).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should coerce string "TRUE" to boolean true', () => {
|
|
||||||
const params = { is_background: 'TRUE' };
|
|
||||||
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
|
|
||||||
expect(params.is_background).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should coerce string "false" to boolean false', () => {
|
|
||||||
const params = { is_background: 'false' };
|
|
||||||
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
|
|
||||||
expect(params.is_background).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should coerce string "False" to boolean false', () => {
|
|
||||||
const params = { is_background: 'False' };
|
|
||||||
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
|
|
||||||
expect(params.is_background).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should coerce string "FALSE" to boolean false', () => {
|
|
||||||
const params = { is_background: 'FALSE' };
|
|
||||||
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
|
|
||||||
expect(params.is_background).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle nested objects with string booleans', () => {
|
|
||||||
const nestedSchema = {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
options: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
enabled: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const params = { options: { enabled: 'true' } };
|
|
||||||
expect(SchemaValidator.validate(nestedSchema, params)).toBeNull();
|
|
||||||
expect((params.options as unknown as { enabled: boolean }).enabled).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not affect non-boolean strings', () => {
|
|
||||||
const mixedSchema = {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: { type: 'string' },
|
|
||||||
is_active: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const params = { name: 'trueman', is_active: 'true' };
|
|
||||||
expect(SchemaValidator.validate(mixedSchema, params)).toBeNull();
|
|
||||||
expect(params.name).toBe('trueman');
|
|
||||||
expect(params.is_active).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass through actual boolean values unchanged', () => {
|
|
||||||
const params = { is_background: true };
|
|
||||||
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
|
|
||||||
expect(params.is_background).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ export class SchemaValidator {
|
|||||||
return 'Value of params must be an object';
|
return 'Value of params must be an object';
|
||||||
}
|
}
|
||||||
const validate = ajValidator.compile(schema);
|
const validate = ajValidator.compile(schema);
|
||||||
let valid = validate(data);
|
const valid = validate(data);
|
||||||
if (!valid && validate.errors) {
|
if (!valid && validate.errors) {
|
||||||
// Coerce string boolean values ("true"/"false") to actual booleans
|
// Find any True or False values and lowercase them
|
||||||
fixBooleanValues(data as Record<string, unknown>);
|
fixBooleanCasing(data as Record<string, unknown>);
|
||||||
|
|
||||||
|
const validate = ajValidator.compile(schema);
|
||||||
|
const valid = validate(data);
|
||||||
|
|
||||||
valid = validate(data);
|
|
||||||
if (!valid && validate.errors) {
|
if (!valid && validate.errors) {
|
||||||
return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
|
return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
|
||||||
}
|
}
|
||||||
@@ -55,29 +57,13 @@ export class SchemaValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function fixBooleanCasing(data: Record<string, unknown>) {
|
||||||
* Coerces string boolean values to actual booleans.
|
|
||||||
* This handles cases where LLMs return "true"/"false" strings instead of boolean values,
|
|
||||||
* which is common with self-hosted LLMs.
|
|
||||||
*
|
|
||||||
* Converts:
|
|
||||||
* - "true", "True", "TRUE" -> true
|
|
||||||
* - "false", "False", "FALSE" -> false
|
|
||||||
*/
|
|
||||||
function fixBooleanValues(data: Record<string, unknown>) {
|
|
||||||
for (const key of Object.keys(data)) {
|
for (const key of Object.keys(data)) {
|
||||||
if (!(key in data)) continue;
|
if (!(key in data)) continue;
|
||||||
const value = data[key];
|
|
||||||
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof data[key] === 'object') {
|
||||||
fixBooleanValues(value as Record<string, unknown>);
|
fixBooleanCasing(data[key] as Record<string, unknown>);
|
||||||
} else if (typeof value === 'string') {
|
} else if (data[key] === 'True') data[key] = 'true';
|
||||||
const lower = value.toLowerCase();
|
else if (data[key] === 'False') data[key] = 'false';
|
||||||
if (lower === 'true') {
|
|
||||||
data[key] = true;
|
|
||||||
} else if (lower === 'false') {
|
|
||||||
data[key] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user