Refactoring packages/cli/src/ui tests (#12482)

Co-authored-by: riddhi <duttariddhi@google.com>
This commit is contained in:
Riddhi Dutta
2025-11-03 23:40:57 +05:30
committed by GitHub
parent 93f14ce626
commit 19ea68b838
5 changed files with 810 additions and 1093 deletions

View File

@@ -0,0 +1,93 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`useReactToolScheduler > should handle live output updates 1`] = `
{
"callId": "liveCall",
"contentLength": 12,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
"responseParts": [
{
"functionResponse": {
"id": "liveCall",
"name": "mockToolWithLiveOutput",
"response": {
"output": "Final output",
},
},
},
],
"resultDisplay": "Final display",
}
`;
exports[`useReactToolScheduler > should handle tool requiring confirmation - approved 1`] = `
{
"callId": "callConfirm",
"contentLength": 16,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
"responseParts": [
{
"functionResponse": {
"id": "callConfirm",
"name": "mockToolRequiresConfirmation",
"response": {
"output": "Confirmed output",
},
},
},
],
"resultDisplay": "Confirmed display",
}
`;
exports[`useReactToolScheduler > should handle tool requiring confirmation - cancelled by user 1`] = `
{
"callId": "callConfirmCancel",
"contentLength": 59,
"error": undefined,
"errorType": undefined,
"responseParts": [
{
"functionResponse": {
"id": "callConfirmCancel",
"name": "mockToolRequiresConfirmation",
"response": {
"error": "[Operation Cancelled] Reason: User cancelled the operation.",
},
},
},
],
"resultDisplay": {
"fileDiff": "Mock tool requires confirmation",
"fileName": "mockToolRequiresConfirmation.ts",
"newContent": undefined,
"originalContent": undefined,
},
}
`;
exports[`useReactToolScheduler > should schedule and execute a tool call successfully 1`] = `
{
"callId": "call1",
"contentLength": 11,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
"responseParts": [
{
"functionResponse": {
"id": "call1",
"name": "mockTool",
"response": {
"output": "Tool output",
},
},
},
],
"resultDisplay": "Formatted tool output",
}
`;

View File

@@ -431,34 +431,39 @@ describe('useSlashCommandProcessor', () => {
});
describe('Action Result Handling', () => {
it('should handle "dialog: theme" action', async () => {
const command = createTestCommand({
name: 'themecmd',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'theme' }),
});
const result = await setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
describe('Dialog actions', () => {
it.each([
{
dialogType: 'theme',
commandName: 'themecmd',
mockFn: mockOpenThemeDialog,
},
{
dialogType: 'model',
commandName: 'modelcmd',
mockFn: mockOpenModelDialog,
},
])(
'should handle "dialog: $dialogType" action',
async ({ dialogType, commandName, mockFn }) => {
const command = createTestCommand({
name: commandName,
action: vi
.fn()
.mockResolvedValue({ type: 'dialog', dialog: dialogType }),
});
const result = await setupProcessorHook([command]);
await waitFor(() =>
expect(result.current.slashCommands).toHaveLength(1),
);
await act(async () => {
await result.current.handleSlashCommand('/themecmd');
});
await act(async () => {
await result.current.handleSlashCommand(`/${commandName}`);
});
expect(mockOpenThemeDialog).toHaveBeenCalled();
});
it('should handle "dialog: model" action', async () => {
const command = createTestCommand({
name: 'modelcmd',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'model' }),
});
const result = await setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/modelcmd');
});
expect(mockOpenModelDialog).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalled();
},
);
});
it('should handle "load_history" action', async () => {
@@ -1007,98 +1012,67 @@ describe('useSlashCommandProcessor', () => {
vi.mocked(logSlashCommand).mockClear();
});
it('should log a simple slash command', async () => {
const result = await setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands?.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/logtest');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
it.each([
{
command: '/logtest',
expectedLog: {
command: 'logtest',
subcommand: undefined,
status: SlashCommandStatus.SUCCESS,
}),
);
});
it('logs nothing for a bogus command', async () => {
const result = await setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands?.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/bogusbogusbogus');
});
expect(logSlashCommand).not.toHaveBeenCalled();
});
it('logs a failure event for a failed command', async () => {
const result = await setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands?.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/fail');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
},
desc: 'simple slash command',
},
{
command: '/fail',
expectedLog: {
command: 'fail',
status: 'error',
subcommand: undefined,
}),
);
});
it('should log a slash command with a subcommand', async () => {
const result = await setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands?.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/logwithsub sub');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
},
desc: 'failure event for failed command',
},
{
command: '/logwithsub sub',
expectedLog: {
command: 'logwithsub',
subcommand: 'sub',
}),
);
});
it('should log the command path when an alias is used', async () => {
const result = await setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands?.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/la');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
},
desc: 'slash command with subcommand',
},
{
command: '/la',
expectedLog: {
command: 'logalias',
}),
);
},
desc: 'command path when alias is used',
},
])('should log $desc', async ({ command, expectedLog }) => {
const result = await setupProcessorHook(loggingTestCommands);
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
await act(async () => {
await result.current.handleSlashCommand(command);
});
await waitFor(() => {
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining(expectedLog),
);
});
});
it('should not log for unknown commands', async () => {
it.each([
{ command: '/bogusbogusbogus', desc: 'bogus command' },
{ command: '/unknown', desc: 'unknown command' },
])('should not log for $desc', async ({ command }) => {
const result = await setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands?.length).toBeGreaterThan(0),
);
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
await act(async () => {
await result.current.handleSlashCommand('/unknown');
await result.current.handleSlashCommand(command);
});
expect(logSlashCommand).not.toHaveBeenCalled();
});
});

View File

@@ -360,6 +360,100 @@ describe('useGeminiStream', () => {
};
};
// Helper to create mock tool calls - reduces boilerplate
const createMockToolCall = (
toolName: string,
callId: string,
confirmationType: 'edit' | 'info',
mockOnConfirm: Mock,
status: TrackedToolCall['status'] = 'awaiting_approval',
): TrackedWaitingToolCall => ({
request: {
callId,
name: toolName,
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: status as 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails:
confirmationType === 'edit'
? {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirm,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
}
: {
type: 'info',
title: `${toolName} confirmation`,
onConfirm: mockOnConfirm,
prompt: `Execute ${toolName}?`,
},
tool: {
name: toolName,
displayName: toolName,
description: `${toolName} description`,
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
});
// Helper to render hook with default parameters - reduces boilerplate
const renderHookWithDefaults = (
options: {
shellModeActive?: boolean;
onCancelSubmit?: () => void;
setShellInputFocused?: (focused: boolean) => void;
performMemoryRefresh?: () => Promise<void>;
onAuthError?: () => void;
onEditorClose?: () => void;
setModelSwitched?: Mock;
modelSwitched?: boolean;
} = {},
) => {
const {
shellModeActive = false,
onCancelSubmit = () => {},
setShellInputFocused = () => {},
performMemoryRefresh = () => Promise.resolve(),
onAuthError = () => {},
onEditorClose = () => {},
setModelSwitched = vi.fn(),
modelSwitched = false,
} = options;
return renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
shellModeActive,
() => 'vscode' as EditorType,
onAuthError,
performMemoryRefresh,
modelSwitched,
setModelSwitched,
onEditorClose,
onCancelSubmit,
setShellInputFocused,
80,
24,
),
);
};
it('should not submit tool responses if not all tool calls are completed', () => {
const toolCalls: TrackedToolCall[] = [
{
@@ -1473,62 +1567,8 @@ describe('useGeminiStream', () => {
it('should auto-approve all pending tool calls when switching to YOLO mode', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirm,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
{
request: {
callId: 'call2',
name: 'read_file',
args: { path: '/test/file.txt' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'info',
title: 'Read File',
onConfirm: mockOnConfirm,
prompt: 'Read /test/file.txt?',
},
tool: {
name: 'read_file',
displayName: 'read_file',
description: 'Read file',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
createMockToolCall('replace', 'call1', 'edit', mockOnConfirm),
createMockToolCall('read_file', 'call2', 'info', mockOnConfirm),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1539,12 +1579,7 @@ describe('useGeminiStream', () => {
// Both tool calls should be auto-approved
expect(mockOnConfirm).toHaveBeenCalledTimes(2);
expect(mockOnConfirm).toHaveBeenNthCalledWith(
1,
ToolConfirmationOutcome.ProceedOnce,
);
expect(mockOnConfirm).toHaveBeenNthCalledWith(
2,
expect(mockOnConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
});
@@ -1555,92 +1590,9 @@ describe('useGeminiStream', () => {
const mockOnConfirmRead = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirmReplace,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
{
request: {
callId: 'call2',
name: 'write_file',
args: { path: '/test/new.txt', content: 'content' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirmWrite,
fileName: 'new.txt',
filePath: '/test/new.txt',
fileDiff: 'fake diff',
originalContent: null,
newContent: 'content',
},
tool: {
name: 'write_file',
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
{
request: {
callId: 'call3',
name: 'read_file',
args: { path: '/test/file.txt' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'info',
title: 'Read File',
onConfirm: mockOnConfirmRead,
prompt: 'Read /test/file.txt?',
},
tool: {
name: 'read_file',
displayName: 'read_file',
description: 'Read file',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
createMockToolCall('replace', 'call1', 'edit', mockOnConfirmReplace),
createMockToolCall('write_file', 'call2', 'edit', mockOnConfirmWrite),
createMockToolCall('read_file', 'call3', 'info', mockOnConfirmRead),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1650,11 +1602,9 @@ describe('useGeminiStream', () => {
});
// Only replace and write_file should be auto-approved
expect(mockOnConfirmReplace).toHaveBeenCalledTimes(1);
expect(mockOnConfirmReplace).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
expect(mockOnConfirmWrite).toHaveBeenCalledTimes(1);
expect(mockOnConfirmWrite).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
@@ -1666,36 +1616,7 @@ describe('useGeminiStream', () => {
it('should not auto-approve any tools when switching to REQUIRE_CONFIRMATION mode', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirm,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
createMockToolCall('replace', 'call1', 'edit', mockOnConfirm),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1718,66 +1639,8 @@ describe('useGeminiStream', () => {
.mockRejectedValue(new Error('Approval failed'));
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirmSuccess,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
{
request: {
callId: 'call2',
name: 'write_file',
args: { path: '/test/file.txt', content: 'content' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirmError,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: null,
newContent: 'content',
},
tool: {
name: 'write_file',
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
createMockToolCall('replace', 'call1', 'edit', mockOnConfirmSuccess),
createMockToolCall('write_file', 'call2', 'edit', mockOnConfirmError),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1787,8 +1650,8 @@ describe('useGeminiStream', () => {
});
// Both confirmation methods should be called
expect(mockOnConfirmSuccess).toHaveBeenCalledTimes(1);
expect(mockOnConfirmError).toHaveBeenCalledTimes(1);
expect(mockOnConfirmSuccess).toHaveBeenCalled();
expect(mockOnConfirmError).toHaveBeenCalled();
// Error should be logged
expect(debuggerSpy).toHaveBeenCalledWith(
@@ -2006,115 +1869,53 @@ describe('useGeminiStream', () => {
vi.mocked(tokenLimit).mockReturnValue(100);
});
it('should add message without suggestion when remaining tokens are > 75% of limit', async () => {
// Setup mock to return a stream with ContextWindowWillOverflow event
// Limit is 100, remaining is 80 (> 75)
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ContextWindowWillOverflow,
value: {
estimatedRequestTokenCount: 20,
remainingTokenCount: 80,
},
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
() => {},
80,
24,
),
);
// Submit a query
await act(async () => {
await result.current.submitQuery('Test overflow');
});
// Check that the message was added without suggestion
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'info',
text: `Sending this message (20 tokens) might exceed the remaining context window limit (80 tokens).`,
},
expect.any(Number),
it.each([
{
name: 'without suggestion when remaining tokens are > 75% of limit',
requestTokens: 20,
remainingTokens: 80,
expectedMessage:
'Sending this message (20 tokens) might exceed the remaining context window limit (80 tokens).',
},
{
name: 'with suggestion when remaining tokens are < 75% of limit',
requestTokens: 30,
remainingTokens: 70,
expectedMessage:
'Sending this message (30 tokens) might exceed the remaining context window limit (70 tokens). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
},
])(
'should add message $name',
async ({ requestTokens, remainingTokens, expectedMessage }) => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ContextWindowWillOverflow,
value: {
estimatedRequestTokenCount: requestTokens,
remainingTokenCount: remainingTokens,
},
};
})(),
);
});
});
it('should add message with suggestion when remaining tokens are < 75% of limit', async () => {
// Setup mock to return a stream with ContextWindowWillOverflow event
// Limit is 100, remaining is 70 (< 75)
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ContextWindowWillOverflow,
value: {
estimatedRequestTokenCount: 30,
remainingTokenCount: 70,
const { result } = renderHookWithDefaults();
await act(async () => {
await result.current.submitQuery('Test overflow');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'info',
text: expectedMessage,
},
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
() => {},
80,
24,
),
);
// Submit a query
await act(async () => {
await result.current.submitQuery('Test overflow');
});
// Check that the message was added with suggestion
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'info',
text: `Sending this message (30 tokens) might exceed the remaining context window limit (70 tokens). Please try reducing the size of your message or use the \`/compress\` command to compress the chat history.`,
},
expect.any(Number),
);
});
});
expect.any(Number),
);
});
},
);
});
it('should call onCancelSubmit when ContextWindowWillOverflow event is received', async () => {
@@ -2166,160 +1967,59 @@ describe('useGeminiStream', () => {
});
});
it('should not add message for STOP finish reason', async () => {
// Setup mock to return a stream with STOP finish reason
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Complete response',
};
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
() => {},
80,
24,
),
);
// Submit a query
await act(async () => {
await result.current.submitQuery('Test normal completion');
});
// Wait a bit to ensure no message is added
await new Promise((resolve) => setTimeout(resolve, 100));
// Check that no info message was added for STOP
const infoMessages = mockAddItem.mock.calls.filter(
(call) => call[0].type === 'info',
);
expect(infoMessages).toHaveLength(0);
});
it('should not add message for FINISH_REASON_UNSPECIFIED', async () => {
// Setup mock to return a stream with FINISH_REASON_UNSPECIFIED
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Response with unspecified finish',
};
yield {
type: ServerGeminiEventType.Finished,
value: {
reason: 'FINISH_REASON_UNSPECIFIED',
usageMetadata: undefined,
},
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
() => {},
80,
24,
),
);
// Submit a query
await act(async () => {
await result.current.submitQuery('Test unspecified finish');
});
// Wait a bit to ensure no message is added
await new Promise((resolve) => setTimeout(resolve, 100));
// Check that no info message was added
const infoMessages = mockAddItem.mock.calls.filter(
(call) => call[0].type === 'info',
);
expect(infoMessages).toHaveLength(0);
});
it('should add appropriate messages for other finish reasons', async () => {
const testCases = [
{
reason: 'SAFETY',
message: '⚠️ Response stopped due to safety reasons.',
},
{
reason: 'RECITATION',
message: '⚠️ Response stopped due to recitation policy.',
},
{
reason: 'LANGUAGE',
message: '⚠️ Response stopped due to unsupported language.',
},
{
reason: 'BLOCKLIST',
message: '⚠️ Response stopped due to forbidden terms.',
},
{
reason: 'PROHIBITED_CONTENT',
message: '⚠️ Response stopped due to prohibited content.',
},
{
reason: 'SPII',
message:
'⚠️ Response stopped due to sensitive personally identifiable information.',
},
{ reason: 'OTHER', message: '⚠️ Response stopped for other reasons.' },
{
reason: 'MALFORMED_FUNCTION_CALL',
message: '⚠️ Response stopped due to malformed function call.',
},
{
reason: 'IMAGE_SAFETY',
message: '⚠️ Response stopped due to image safety violations.',
},
{
reason: 'UNEXPECTED_TOOL_CALL',
message: '⚠️ Response stopped due to unexpected tool call.',
},
];
for (const { reason, message } of testCases) {
// Reset mocks for each test case
mockAddItem.mockClear();
it.each([
{
reason: 'STOP',
shouldAddMessage: false,
},
{
reason: 'FINISH_REASON_UNSPECIFIED',
shouldAddMessage: false,
},
{
reason: 'SAFETY',
message: '⚠️ Response stopped due to safety reasons.',
},
{
reason: 'RECITATION',
message: '⚠️ Response stopped due to recitation policy.',
},
{
reason: 'LANGUAGE',
message: '⚠️ Response stopped due to unsupported language.',
},
{
reason: 'BLOCKLIST',
message: '⚠️ Response stopped due to forbidden terms.',
},
{
reason: 'PROHIBITED_CONTENT',
message: '⚠️ Response stopped due to prohibited content.',
},
{
reason: 'SPII',
message:
'⚠️ Response stopped due to sensitive personally identifiable information.',
},
{
reason: 'OTHER',
message: '⚠️ Response stopped for other reasons.',
},
{
reason: 'MALFORMED_FUNCTION_CALL',
message: '⚠️ Response stopped due to malformed function call.',
},
{
reason: 'IMAGE_SAFETY',
message: '⚠️ Response stopped due to image safety violations.',
},
{
reason: 'UNEXPECTED_TOOL_CALL',
message: '⚠️ Response stopped due to unexpected tool call.',
},
])(
'should handle $reason finish reason correctly',
async ({ reason, shouldAddMessage = true, message }) => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
@@ -2333,44 +2033,35 @@ describe('useGeminiStream', () => {
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
vi.fn(),
80,
24,
),
);
const { result } = renderHookWithDefaults();
await act(async () => {
await result.current.submitQuery(`Test ${reason}`);
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'info',
text: message,
},
expect.any(Number),
if (shouldAddMessage) {
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'info',
text: message,
},
expect.any(Number),
);
});
} else {
// Verify state returns to idle without any info messages
await waitFor(() => {
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
const infoMessages = mockAddItem.mock.calls.filter(
(call) => call[0].type === 'info',
);
});
}
});
expect(infoMessages).toHaveLength(0);
}
},
);
});
it('should process @include commands, adding user turn after processing to prevent race conditions', async () => {

View File

@@ -201,6 +201,28 @@ describe('useReactToolScheduler', () => {
| ((outcome: ToolConfirmationOutcome) => void | Promise<void>)
| undefined;
const advanceAndSettle = async () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
};
const scheduleAndWaitForExecution = async (
schedule: (
req: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => void,
request: ToolCallRequestInfo | ToolCallRequestInfo[],
) => {
act(() => {
schedule(request, new AbortController().signal);
});
await advanceAndSettle();
await advanceAndSettle();
await advanceAndSettle();
};
beforeEach(() => {
onComplete = vi.fn();
capturedOnConfirmForTest = undefined;
@@ -259,7 +281,6 @@ describe('useReactToolScheduler', () => {
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
const { result } = renderScheduler();
const schedule = result.current[1];
const request: ToolCallRequestInfo = {
callId: 'call1',
name: 'mockTool',
@@ -271,42 +292,19 @@ describe('useReactToolScheduler', () => {
completedToolCalls = calls;
});
act(() => {
schedule(request, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await scheduleAndWaitForExecution(result.current[1], request);
expect(mockTool.execute).toHaveBeenCalledWith(request.args);
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'success',
request,
response: expect.objectContaining({
resultDisplay: 'Formatted tool output',
responseParts: [
{
functionResponse: {
id: 'call1',
name: 'mockTool',
response: { output: 'Tool output' },
},
},
],
}),
}),
]);
expect(completedToolCalls).toHaveLength(1);
expect(completedToolCalls[0].status).toBe('success');
expect(completedToolCalls[0].request).toBe(request);
if (
completedToolCalls[0].status === 'success' ||
completedToolCalls[0].status === 'error'
) {
expect(completedToolCalls[0].response).toMatchSnapshot();
}
});
it('should clear previous tool calls when scheduling new ones', async () => {
@@ -412,113 +410,83 @@ describe('useReactToolScheduler', () => {
});
});
it('should handle tool not found', async () => {
mockToolRegistry.getTool.mockReturnValue(undefined);
const { result } = renderScheduler();
const schedule = result.current[1];
const request: ToolCallRequestInfo = {
callId: 'call1',
name: 'nonexistentTool',
args: {},
} as any;
it.each([
{
desc: 'tool not found',
setup: () => {
mockToolRegistry.getTool.mockReturnValue(undefined);
},
request: {
callId: 'call1',
name: 'nonexistentTool',
args: {},
} as any,
expectedErrorContains: [
'Tool "nonexistentTool" not found in registry',
'Did you mean one of:',
],
},
{
desc: 'error during shouldConfirmExecute',
setup: () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
const confirmError = new Error('Confirmation check failed');
(mockTool.shouldConfirmExecute as Mock).mockRejectedValue(confirmError);
},
request: {
callId: 'call1',
name: 'mockTool',
args: {},
} as any,
expectedError: new Error('Confirmation check failed'),
},
{
desc: 'error during execute',
setup: () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
const execError = new Error('Execution failed');
(mockTool.execute as Mock).mockRejectedValue(execError);
},
request: {
callId: 'call1',
name: 'mockTool',
args: {},
} as any,
expectedError: new Error('Execution failed'),
},
])(
'should handle $desc',
async ({ setup, request, expectedErrorContains, expectedError }) => {
setup();
const { result } = renderScheduler();
let completedToolCalls: ToolCall[] = [];
onComplete.mockImplementation((calls) => {
completedToolCalls = calls;
});
let completedToolCalls: ToolCall[] = [];
onComplete.mockImplementation((calls) => {
completedToolCalls = calls;
});
act(() => {
schedule(request, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await scheduleAndWaitForExecution(result.current[1], request);
expect(completedToolCalls).toHaveLength(1);
expect(completedToolCalls[0].status).toBe('error');
expect(completedToolCalls[0].request).toBe(request);
expect((completedToolCalls[0] as any).response.error.message).toContain(
'Tool "nonexistentTool" not found in registry',
);
expect((completedToolCalls[0] as any).response.error.message).toContain(
'Did you mean one of:',
);
});
expect(completedToolCalls).toHaveLength(1);
expect(completedToolCalls[0].status).toBe('error');
expect(completedToolCalls[0].request).toBe(request);
it('should handle error during shouldConfirmExecute', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
const confirmError = new Error('Confirmation check failed');
(mockTool.shouldConfirmExecute as Mock).mockRejectedValue(confirmError);
if (expectedErrorContains) {
expectedErrorContains.forEach((errorText) => {
expect(
(completedToolCalls[0] as any).response.error.message,
).toContain(errorText);
});
}
const { result } = renderScheduler();
const schedule = result.current[1];
const request: ToolCallRequestInfo = {
callId: 'call1',
name: 'mockTool',
args: {},
} as any;
let completedToolCalls: ToolCall[] = [];
onComplete.mockImplementation((calls) => {
completedToolCalls = calls;
});
act(() => {
schedule(request, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(completedToolCalls).toHaveLength(1);
expect(completedToolCalls[0].status).toBe('error');
expect(completedToolCalls[0].request).toBe(request);
expect((completedToolCalls[0] as any).response.error).toBe(confirmError);
});
it('should handle error during execute', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
const execError = new Error('Execution failed');
(mockTool.execute as Mock).mockRejectedValue(execError);
const { result } = renderScheduler();
const schedule = result.current[1];
const request: ToolCallRequestInfo = {
callId: 'call1',
name: 'mockTool',
args: {},
} as any;
let completedToolCalls: ToolCall[] = [];
onComplete.mockImplementation((calls) => {
completedToolCalls = calls;
});
act(() => {
schedule(request, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(completedToolCalls).toHaveLength(1);
expect(completedToolCalls[0].status).toBe('error');
expect(completedToolCalls[0].request).toBe(request);
expect((completedToolCalls[0] as any).response.error).toBe(execError);
});
if (expectedError) {
expect((completedToolCalls[0] as any).response.error.message).toBe(
expectedError.message,
);
}
},
);
it('should handle tool requiring confirmation - approved', async () => {
mockToolRegistry.getTool.mockReturnValue(mockToolRequiresConfirmation);
@@ -539,9 +507,7 @@ describe('useReactToolScheduler', () => {
act(() => {
schedule(request, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await advanceAndSettle();
const waitingCall = result.current[0][0] as any;
expect(waitingCall.status).toBe('awaiting_approval');
@@ -552,36 +518,24 @@ describe('useReactToolScheduler', () => {
await capturedOnConfirmForTest?.(ToolConfirmationOutcome.ProceedOnce);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await advanceAndSettle();
await advanceAndSettle();
await advanceAndSettle();
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
expect(mockToolRequiresConfirmation.execute).toHaveBeenCalled();
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'success',
request,
response: expect.objectContaining({
resultDisplay: 'Confirmed display',
responseParts: expect.arrayContaining([
expect.objectContaining({
functionResponse: expect.objectContaining({
response: { output: expectedOutput },
}),
}),
]),
}),
}),
]);
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
expect(completedCalls[0].status).toBe('success');
expect(completedCalls[0].request).toBe(request);
if (
completedCalls[0].status === 'success' ||
completedCalls[0].status === 'error'
) {
expect(completedCalls[0].response).toMatchSnapshot();
}
});
it('should handle tool requiring confirmation - cancelled by user', async () => {
@@ -597,9 +551,7 @@ describe('useReactToolScheduler', () => {
act(() => {
schedule(request, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await advanceAndSettle();
const waitingCall = result.current[0][0] as any;
expect(waitingCall.status).toBe('awaiting_approval');
@@ -609,34 +561,23 @@ describe('useReactToolScheduler', () => {
await act(async () => {
await capturedOnConfirmForTest?.(ToolConfirmationOutcome.Cancel);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await advanceAndSettle();
await advanceAndSettle();
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
ToolConfirmationOutcome.Cancel,
);
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'cancelled',
request,
response: expect.objectContaining({
responseParts: expect.arrayContaining([
expect.objectContaining({
functionResponse: expect.objectContaining({
response: expect.objectContaining({
error:
'[Operation Cancelled] Reason: User cancelled the operation.',
}),
}),
}),
]),
}),
}),
]);
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
expect(completedCalls[0].status).toBe('cancelled');
expect(completedCalls[0].request).toBe(request);
if (
completedCalls[0].status === 'success' ||
completedCalls[0].status === 'error' ||
completedCalls[0].status === 'cancelled'
) {
expect(completedCalls[0].response).toMatchSnapshot();
}
});
it('should handle live output updates', async () => {
@@ -662,7 +603,6 @@ describe('useReactToolScheduler', () => {
);
const { result } = renderScheduler();
const schedule = result.current[1];
const request: ToolCallRequestInfo = {
callId: 'liveCall',
name: 'mockToolWithLiveOutput',
@@ -670,11 +610,9 @@ describe('useReactToolScheduler', () => {
} as any;
act(() => {
schedule(request, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
result.current[1](request, new AbortController().signal);
});
await advanceAndSettle();
expect(liveUpdateFn).toBeDefined();
expect(result.current[0][0].status).toBe('executing');
@@ -682,16 +620,12 @@ describe('useReactToolScheduler', () => {
await act(async () => {
liveUpdateFn?.('Live output 1');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await advanceAndSettle();
await act(async () => {
liveUpdateFn?.('Live output 2');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await advanceAndSettle();
act(() => {
resolveExecutePromise({
@@ -699,29 +633,18 @@ describe('useReactToolScheduler', () => {
returnDisplay: 'Final display',
} as ToolResult);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await advanceAndSettle();
await advanceAndSettle();
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'success',
request,
response: expect.objectContaining({
resultDisplay: 'Final display',
responseParts: expect.arrayContaining([
expect.objectContaining({
functionResponse: expect.objectContaining({
response: { output: 'Final output' },
}),
}),
]),
}),
}),
]);
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
expect(completedCalls[0].status).toBe('success');
expect(completedCalls[0].request).toBe(request);
if (
completedCalls[0].status === 'success' ||
completedCalls[0].status === 'error'
) {
expect(completedCalls[0].response).toMatchSnapshot();
}
expect(result.current[0]).toEqual([]);
});
@@ -874,7 +797,6 @@ describe('useReactToolScheduler', () => {
response: expect.objectContaining({ resultDisplay: 'done display' }),
}),
]);
// Wait for request2 to complete
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(0);