mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-14 23:31:13 -07:00
Refactoring packages/cli/src/ui tests (#12482)
Co-authored-by: riddhi <duttariddhi@google.com>
This commit is contained in:
@@ -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",
|
||||
}
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user