mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Sync upstream Gemini-CLI v0.8.2 (#838)
This commit is contained in:
@@ -4,34 +4,62 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ToolRegistry,
|
||||
ServerGeminiStreamEvent,
|
||||
SessionMetrics,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
type Config,
|
||||
type ToolRegistry,
|
||||
executeToolCall,
|
||||
ToolErrorType,
|
||||
shutdownTelemetry,
|
||||
GeminiEventType,
|
||||
type ServerGeminiStreamEvent,
|
||||
OutputFormat,
|
||||
uiTelemetryService,
|
||||
FatalInputError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { type Part } from '@google/genai';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { vi } from 'vitest';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
|
||||
// Mock core modules
|
||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
|
||||
class MockChatRecordingService {
|
||||
initialize = vi.fn();
|
||||
recordMessage = vi.fn();
|
||||
recordMessageTokens = vi.fn();
|
||||
recordToolCalls = vi.fn();
|
||||
}
|
||||
|
||||
return {
|
||||
...original,
|
||||
executeToolCall: vi.fn(),
|
||||
shutdownTelemetry: vi.fn(),
|
||||
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetCommands = vi.hoisted(() => vi.fn());
|
||||
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./services/CommandService.js', () => ({
|
||||
CommandService: {
|
||||
create: mockCommandServiceCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('runNonInteractive', () => {
|
||||
let mockConfig: Config;
|
||||
let mockSettings: LoadedSettings;
|
||||
let mockToolRegistry: ToolRegistry;
|
||||
let mockCoreExecuteToolCall: vi.Mock;
|
||||
let mockShutdownTelemetry: vi.Mock;
|
||||
@@ -39,16 +67,24 @@ describe('runNonInteractive', () => {
|
||||
let processStdoutSpy: vi.SpyInstance;
|
||||
let mockGeminiClient: {
|
||||
sendMessageStream: vi.Mock;
|
||||
getChatRecordingService: vi.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
|
||||
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
|
||||
|
||||
mockCommandServiceCreate.mockResolvedValue({
|
||||
getCommands: mockGetCommands,
|
||||
});
|
||||
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processStdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code}) called`);
|
||||
});
|
||||
|
||||
mockToolRegistry = {
|
||||
getTool: vi.fn(),
|
||||
@@ -57,6 +93,12 @@ describe('runNonInteractive', () => {
|
||||
|
||||
mockGeminiClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getChatRecordingService: vi.fn(() => ({
|
||||
initialize: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -64,12 +106,40 @@ describe('runNonInteractive', () => {
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(10),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/test/project/.gemini/tmp'),
|
||||
},
|
||||
getIdeMode: vi.fn().mockReturnValue(false),
|
||||
getFullContext: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getOutputFormat: vi.fn().mockReturnValue('text'),
|
||||
getFolderTrustFeature: vi.fn().mockReturnValue(false),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
system: { path: '', settings: {} },
|
||||
systemDefaults: { path: '', settings: {} },
|
||||
user: { path: '', settings: {} },
|
||||
workspace: { path: '', settings: {} },
|
||||
errors: [],
|
||||
setValue: vi.fn(),
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
enforcedType: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
migratedInMemorScopes: new Set(),
|
||||
forScope: vi.fn(),
|
||||
computeMergedSettings: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { handleAtCommand } = await import(
|
||||
'./ui/hooks/atCommandProcessor.js'
|
||||
);
|
||||
@@ -95,12 +165,21 @@ describe('runNonInteractive', () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Hello' },
|
||||
{ type: GeminiEventType.Content, value: ' World' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive(mockConfig, 'Test input', 'prompt-id-1');
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Test input',
|
||||
'prompt-id-1',
|
||||
);
|
||||
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
[{ text: 'Test input' }],
|
||||
@@ -130,13 +209,22 @@ describe('runNonInteractive', () => {
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream
|
||||
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
|
||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||
|
||||
await runNonInteractive(mockConfig, 'Use a tool', 'prompt-id-2');
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Use a tool',
|
||||
'prompt-id-2',
|
||||
);
|
||||
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
@@ -185,12 +273,21 @@ describe('runNonInteractive', () => {
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Sorry, let me try again.',
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream
|
||||
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
|
||||
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
|
||||
|
||||
await runNonInteractive(mockConfig, 'Trigger tool error', 'prompt-id-3');
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Trigger tool error',
|
||||
'prompt-id-3',
|
||||
);
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
@@ -222,7 +319,12 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4'),
|
||||
runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Initial fail',
|
||||
'prompt-id-4',
|
||||
),
|
||||
).rejects.toThrow(apiError);
|
||||
});
|
||||
|
||||
@@ -240,12 +342,17 @@ describe('runNonInteractive', () => {
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
error: new Error('Tool "nonexistentTool" not found in registry.'),
|
||||
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
|
||||
responseParts: [],
|
||||
});
|
||||
const finalResponse: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: "Sorry, I can't find that tool.",
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream
|
||||
@@ -254,6 +361,7 @@ describe('runNonInteractive', () => {
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Trigger tool not found',
|
||||
'prompt-id-5',
|
||||
);
|
||||
@@ -271,10 +379,13 @@ describe('runNonInteractive', () => {
|
||||
it('should exit when max session turns are exceeded', async () => {
|
||||
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
|
||||
await expect(
|
||||
runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'),
|
||||
).rejects.toThrow(
|
||||
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
||||
);
|
||||
runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Trigger loop',
|
||||
'prompt-id-6',
|
||||
),
|
||||
).rejects.toThrow('process.exit(53) called');
|
||||
});
|
||||
|
||||
it('should preprocess @include commands before sending to the model', async () => {
|
||||
@@ -302,13 +413,17 @@ describe('runNonInteractive', () => {
|
||||
// Mock a simple stream response from the Gemini client
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Summary complete.' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
// 4. Run the non-interactive mode with the raw input
|
||||
await runNonInteractive(mockConfig, rawInput, 'prompt-id-7');
|
||||
await runNonInteractive(mockConfig, mockSettings, rawInput, 'prompt-id-7');
|
||||
|
||||
// 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
@@ -320,4 +435,442 @@ describe('runNonInteractive', () => {
|
||||
// 6. Assert the final output is correct
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
|
||||
});
|
||||
|
||||
it('should process input and write JSON output with stats', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Hello World' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
const mockMetrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: {
|
||||
accept: 0,
|
||||
reject: 0,
|
||||
modify: 0,
|
||||
auto_accept: 0,
|
||||
},
|
||||
byName: {},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
},
|
||||
};
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics);
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Test input',
|
||||
'prompt-id-1',
|
||||
);
|
||||
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ response: 'Hello World', stats: mockMetrics }, null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it('should write JSON output with stats for tool-only commands (no text response)', async () => {
|
||||
// Test the scenario where a command completes successfully with only tool calls
|
||||
// but no text response - this would have caught the original bug
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-tool-only',
|
||||
},
|
||||
};
|
||||
const toolResponse: Part[] = [{ text: 'Tool executed successfully' }];
|
||||
mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse });
|
||||
|
||||
// First call returns only tool call, no content
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
toolCallEvent,
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
|
||||
// Second call returns no content (tool-only completion)
|
||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream
|
||||
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
|
||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
const mockMetrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 1,
|
||||
totalSuccess: 1,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 100,
|
||||
totalDecisions: {
|
||||
accept: 1,
|
||||
reject: 0,
|
||||
modify: 0,
|
||||
auto_accept: 0,
|
||||
},
|
||||
byName: {
|
||||
testTool: {
|
||||
count: 1,
|
||||
success: 1,
|
||||
fail: 0,
|
||||
durationMs: 100,
|
||||
decisions: {
|
||||
accept: 1,
|
||||
reject: 0,
|
||||
modify: 0,
|
||||
auto_accept: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
},
|
||||
};
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics);
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Execute tool only',
|
||||
'prompt-id-tool-only',
|
||||
);
|
||||
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
|
||||
// This should output JSON with empty response but include stats
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ response: '', stats: mockMetrics }, null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it('should write JSON output with stats for empty response commands', async () => {
|
||||
// Test the scenario where a command completes but produces no content at all
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
const mockMetrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: {
|
||||
accept: 0,
|
||||
reject: 0,
|
||||
modify: 0,
|
||||
auto_accept: 0,
|
||||
},
|
||||
byName: {},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
},
|
||||
};
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics);
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Empty response test',
|
||||
'prompt-id-empty',
|
||||
);
|
||||
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
);
|
||||
|
||||
// This should output JSON with empty response but include stats
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ response: '', stats: mockMetrics }, null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors in JSON format', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
const testError = new Error('Invalid input provided');
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() => {
|
||||
throw testError;
|
||||
});
|
||||
|
||||
// Mock console.error to capture JSON error output
|
||||
const consoleErrorJsonSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
let thrownError: Error | null = null;
|
||||
try {
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Test input',
|
||||
'prompt-id-error',
|
||||
);
|
||||
// Should not reach here
|
||||
expect.fail('Expected process.exit to be called');
|
||||
} catch (error) {
|
||||
thrownError = error as Error;
|
||||
}
|
||||
|
||||
// Should throw because of mocked process.exit
|
||||
expect(thrownError?.message).toBe('process.exit(1) called');
|
||||
|
||||
expect(consoleErrorJsonSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'Error',
|
||||
message: 'Invalid input provided',
|
||||
code: 1,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle FatalInputError with custom exit code in JSON format', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
const fatalError = new FatalInputError('Invalid command syntax provided');
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() => {
|
||||
throw fatalError;
|
||||
});
|
||||
|
||||
// Mock console.error to capture JSON error output
|
||||
const consoleErrorJsonSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
let thrownError: Error | null = null;
|
||||
try {
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Invalid syntax',
|
||||
'prompt-id-fatal',
|
||||
);
|
||||
// Should not reach here
|
||||
expect.fail('Expected process.exit to be called');
|
||||
} catch (error) {
|
||||
thrownError = error as Error;
|
||||
}
|
||||
|
||||
// Should throw because of mocked process.exit with custom exit code
|
||||
expect(thrownError?.message).toBe('process.exit(42) called');
|
||||
|
||||
expect(consoleErrorJsonSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'FatalInputError',
|
||||
message: 'Invalid command syntax provided',
|
||||
code: 42,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute a slash command that returns a prompt', async () => {
|
||||
const mockCommand = {
|
||||
name: 'testcommand',
|
||||
description: 'a test command',
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Prompt from command' }],
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Response from command' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/testcommand',
|
||||
'prompt-id-slash',
|
||||
);
|
||||
|
||||
// Ensure the prompt sent to the model is from the command, not the raw input
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||
});
|
||||
|
||||
it('should throw FatalInputError if a command requires confirmation', async () => {
|
||||
const mockCommand = {
|
||||
name: 'confirm',
|
||||
description: 'a command that needs confirmation',
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'confirm_shell_commands',
|
||||
commands: ['rm -rf /'],
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/confirm',
|
||||
'prompt-id-confirm',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Exiting due to a confirmation prompt requested by the command.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat an unknown slash command as a regular prompt', async () => {
|
||||
// No commands are mocked, so any slash command is "unknown"
|
||||
mockGetCommands.mockReturnValue([]);
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Response to unknown' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/unknowncommand',
|
||||
'prompt-id-unknown',
|
||||
);
|
||||
|
||||
// Ensure the raw input is sent to the model
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||
});
|
||||
|
||||
it('should throw for unhandled command result types', async () => {
|
||||
const mockCommand = {
|
||||
name: 'noaction',
|
||||
description: 'unhandled type',
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'unhandled',
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/noaction',
|
||||
'prompt-id-unhandled',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Exiting due to command result that is not supported in non-interactive mode.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass arguments to the slash command action', async () => {
|
||||
const mockAction = vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Prompt from command' }],
|
||||
});
|
||||
const mockCommand = {
|
||||
name: 'testargs',
|
||||
description: 'a test command',
|
||||
action: mockAction,
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Acknowledged' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/testargs arg1 arg2',
|
||||
'prompt-id-args',
|
||||
);
|
||||
|
||||
expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2');
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user