mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0
This commit is contained in:
@@ -5,44 +5,36 @@
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
Mock,
|
||||
MockInstance,
|
||||
} from 'vitest';
|
||||
import type { Mock, MockInstance } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useGeminiStream, mergePartListUnions } from './useGeminiStream.js';
|
||||
import { useGeminiStream } from './useGeminiStream.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
import * as atCommandProcessor from './atCommandProcessor.js';
|
||||
import {
|
||||
useReactToolScheduler,
|
||||
import type {
|
||||
TrackedToolCall,
|
||||
TrackedCompletedToolCall,
|
||||
TrackedExecutingToolCall,
|
||||
TrackedCancelledToolCall,
|
||||
} from './useReactToolScheduler.js';
|
||||
import {
|
||||
import { useReactToolScheduler } from './useReactToolScheduler.js';
|
||||
import type {
|
||||
Config,
|
||||
EditorType,
|
||||
AuthType,
|
||||
GeminiClient,
|
||||
GeminiEventType as ServerGeminiEventType,
|
||||
AnyToolInvocation,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
AuthType,
|
||||
GeminiEventType as ServerGeminiEventType,
|
||||
ToolErrorType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Part, PartListUnion } from '@google/genai';
|
||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import {
|
||||
HistoryItem,
|
||||
MessageType,
|
||||
SlashCommandProcessorResult,
|
||||
StreamingState,
|
||||
} from '../types.js';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type { HistoryItem, SlashCommandProcessorResult } from '../types.js';
|
||||
import { MessageType, StreamingState } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
// --- MOCKS ---
|
||||
const mockSendMessageStream = vi
|
||||
@@ -138,125 +130,6 @@ vi.mock('./slashCommandProcessor.js', () => ({
|
||||
|
||||
// --- END MOCKS ---
|
||||
|
||||
describe('mergePartListUnions', () => {
|
||||
it('should merge multiple PartListUnion arrays', () => {
|
||||
const list1: PartListUnion = [{ text: 'Hello' }];
|
||||
const list2: PartListUnion = [
|
||||
{ inlineData: { mimeType: 'image/png', data: 'abc' } },
|
||||
];
|
||||
const list3: PartListUnion = [{ text: 'World' }, { text: '!' }];
|
||||
const result = mergePartListUnions([list1, list2, list3]);
|
||||
expect(result).toEqual([
|
||||
{ text: 'Hello' },
|
||||
{ inlineData: { mimeType: 'image/png', data: 'abc' } },
|
||||
{ text: 'World' },
|
||||
{ text: '!' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty arrays in the input list', () => {
|
||||
const list1: PartListUnion = [{ text: 'First' }];
|
||||
const list2: PartListUnion = [];
|
||||
const list3: PartListUnion = [{ text: 'Last' }];
|
||||
const result = mergePartListUnions([list1, list2, list3]);
|
||||
expect(result).toEqual([{ text: 'First' }, { text: 'Last' }]);
|
||||
});
|
||||
|
||||
it('should handle a single PartListUnion array', () => {
|
||||
const list1: PartListUnion = [
|
||||
{ text: 'One' },
|
||||
{ inlineData: { mimeType: 'image/jpeg', data: 'xyz' } },
|
||||
];
|
||||
const result = mergePartListUnions([list1]);
|
||||
expect(result).toEqual(list1);
|
||||
});
|
||||
|
||||
it('should return an empty array if all input arrays are empty', () => {
|
||||
const list1: PartListUnion = [];
|
||||
const list2: PartListUnion = [];
|
||||
const result = mergePartListUnions([list1, list2]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle input list being empty', () => {
|
||||
const result = mergePartListUnions([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should correctly merge when PartListUnion items are single Parts not in arrays', () => {
|
||||
const part1: Part = { text: 'Single part 1' };
|
||||
const part2: Part = { inlineData: { mimeType: 'image/gif', data: 'gif' } };
|
||||
const listContainingSingleParts: PartListUnion[] = [
|
||||
part1,
|
||||
[part2],
|
||||
{ text: 'Another single part' },
|
||||
];
|
||||
const result = mergePartListUnions(listContainingSingleParts);
|
||||
expect(result).toEqual([
|
||||
{ text: 'Single part 1' },
|
||||
{ inlineData: { mimeType: 'image/gif', data: 'gif' } },
|
||||
{ text: 'Another single part' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle a mix of arrays and single parts, including empty arrays and undefined/null parts if they were possible (though PartListUnion typing restricts this)', () => {
|
||||
const list1: PartListUnion = [{ text: 'A' }];
|
||||
const list2: PartListUnion = [];
|
||||
const part3: Part = { text: 'B' };
|
||||
const list4: PartListUnion = [
|
||||
{ text: 'C' },
|
||||
{ inlineData: { mimeType: 'text/plain', data: 'D' } },
|
||||
];
|
||||
const result = mergePartListUnions([list1, list2, part3, list4]);
|
||||
expect(result).toEqual([
|
||||
{ text: 'A' },
|
||||
{ text: 'B' },
|
||||
{ text: 'C' },
|
||||
{ inlineData: { mimeType: 'text/plain', data: 'D' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve the order of parts from the input arrays', () => {
|
||||
const listA: PartListUnion = [{ text: '1' }, { text: '2' }];
|
||||
const listB: PartListUnion = [{ text: '3' }];
|
||||
const listC: PartListUnion = [{ text: '4' }, { text: '5' }];
|
||||
const result = mergePartListUnions([listA, listB, listC]);
|
||||
expect(result).toEqual([
|
||||
{ text: '1' },
|
||||
{ text: '2' },
|
||||
{ text: '3' },
|
||||
{ text: '4' },
|
||||
{ text: '5' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle cases where some PartListUnion items are single Parts and others are arrays of Parts', () => {
|
||||
const singlePart1: Part = { text: 'First single' };
|
||||
const arrayPart1: Part[] = [
|
||||
{ text: 'Array item 1' },
|
||||
{ text: 'Array item 2' },
|
||||
];
|
||||
const singlePart2: Part = {
|
||||
inlineData: { mimeType: 'application/json', data: 'e30=' },
|
||||
}; // {}
|
||||
const arrayPart2: Part[] = [{ text: 'Last array item' }];
|
||||
|
||||
const result = mergePartListUnions([
|
||||
singlePart1,
|
||||
arrayPart1,
|
||||
singlePart2,
|
||||
arrayPart2,
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ text: 'First single' },
|
||||
{ text: 'Array item 1' },
|
||||
{ text: 'Array item 2' },
|
||||
{ inlineData: { mimeType: 'application/json', data: 'e30=' } },
|
||||
{ text: 'Last array item' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Tests for useGeminiStream Hook ---
|
||||
describe('useGeminiStream', () => {
|
||||
let mockAddItem: Mock;
|
||||
@@ -313,6 +186,7 @@ describe('useGeminiStream', () => {
|
||||
getProjectRoot: vi.fn(() => '/test/dir'),
|
||||
getCheckpointingEnabled: vi.fn(() => false),
|
||||
getGeminiClient: mockGetGeminiClient,
|
||||
getApprovalMode: () => ApprovalMode.DEFAULT,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
addHistory: vi.fn(),
|
||||
@@ -456,7 +330,7 @@ describe('useGeminiStream', () => {
|
||||
callId: 'call1',
|
||||
responseParts: [{ text: 'tool 1 response' }],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
errorType: undefined, // FIX: Added missing property
|
||||
resultDisplay: 'Tool 1 success display',
|
||||
},
|
||||
tool: {
|
||||
@@ -505,12 +379,8 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
|
||||
it('should submit tool responses when all tool calls are completed and ready', async () => {
|
||||
const toolCall1ResponseParts: PartListUnion = [
|
||||
{ text: 'tool 1 final response' },
|
||||
];
|
||||
const toolCall2ResponseParts: PartListUnion = [
|
||||
{ text: 'tool 2 final response' },
|
||||
];
|
||||
const toolCall1ResponseParts: Part[] = [{ text: 'tool 1 final response' }];
|
||||
const toolCall2ResponseParts: Part[] = [{ text: 'tool 2 final response' }];
|
||||
const completedToolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
@@ -593,10 +463,10 @@ describe('useGeminiStream', () => {
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const expectedMergedResponse = mergePartListUnions([
|
||||
toolCall1ResponseParts,
|
||||
toolCall2ResponseParts,
|
||||
]);
|
||||
const expectedMergedResponse = [
|
||||
...toolCall1ResponseParts,
|
||||
...toolCall2ResponseParts,
|
||||
];
|
||||
expect(mockSendMessageStream).toHaveBeenCalledWith(
|
||||
expectedMergedResponse,
|
||||
expect.any(AbortSignal),
|
||||
@@ -704,7 +574,7 @@ describe('useGeminiStream', () => {
|
||||
],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
errorType: undefined, // FIX: Added missing property
|
||||
},
|
||||
responseSubmittedToGemini: false,
|
||||
};
|
||||
@@ -733,7 +603,7 @@ describe('useGeminiStream', () => {
|
||||
],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
errorType: undefined, // FIX: Added missing property
|
||||
},
|
||||
responseSubmittedToGemini: false,
|
||||
};
|
||||
@@ -836,7 +706,7 @@ describe('useGeminiStream', () => {
|
||||
callId: 'call1',
|
||||
responseParts: toolCallResponseParts,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
errorType: undefined, // FIX: Added missing property
|
||||
resultDisplay: 'Tool 1 success display',
|
||||
},
|
||||
endTime: Date.now(),
|
||||
@@ -1219,6 +1089,42 @@ describe('useGeminiStream', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call handleSlashCommand for line comments', async () => {
|
||||
const { result, mockSendMessageStream: localMockSendMessageStream } =
|
||||
renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('// This is a line comment');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
expect(localMockSendMessageStream).toHaveBeenCalledWith(
|
||||
'// This is a line comment',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call handleSlashCommand for block comments', async () => {
|
||||
const { result, mockSendMessageStream: localMockSendMessageStream } =
|
||||
renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/* This is a block comment */');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
expect(localMockSendMessageStream).toHaveBeenCalledWith(
|
||||
'/* This is a block comment */',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Refresh on save_memory', () => {
|
||||
@@ -1239,7 +1145,7 @@ describe('useGeminiStream', () => {
|
||||
responseParts: [{ text: 'Memory saved' }],
|
||||
resultDisplay: 'Success: Memory saved',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
errorType: undefined, // FIX: Added missing property
|
||||
},
|
||||
tool: {
|
||||
name: 'save_memory',
|
||||
@@ -1587,6 +1493,64 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should process @include commands, adding user turn after processing to prevent race conditions', async () => {
|
||||
const rawQuery = '@include file.txt Summarize this.';
|
||||
const processedQueryParts = [
|
||||
{ text: 'Summarize this with content from @file.txt' },
|
||||
{ text: 'File content...' },
|
||||
];
|
||||
const userMessageTimestamp = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(userMessageTimestamp);
|
||||
|
||||
handleAtCommandSpy.mockResolvedValue({
|
||||
processedQuery: processedQueryParts,
|
||||
shouldProceed: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
mockConfig.getGeminiClient() as GeminiClient,
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false, // shellModeActive
|
||||
vi.fn(), // getPreferredEditor
|
||||
vi.fn(), // onAuthError
|
||||
vi.fn(), // performMemoryRefresh
|
||||
false, // modelSwitched
|
||||
vi.fn(), // setModelSwitched
|
||||
vi.fn(), // onEditorClose
|
||||
vi.fn(), // onCancelSubmit
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery(rawQuery);
|
||||
});
|
||||
|
||||
expect(handleAtCommandSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: rawQuery,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.USER,
|
||||
text: rawQuery,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
|
||||
// FIX: The expectation now matches the actual call signature.
|
||||
expect(mockSendMessageStream).toHaveBeenCalledWith(
|
||||
processedQueryParts, // Argument 1: The parts array directly
|
||||
expect.any(AbortSignal), // Argument 2: An AbortSignal
|
||||
expect.any(String), // Argument 3: The prompt_id string
|
||||
);
|
||||
});
|
||||
describe('Thought Reset', () => {
|
||||
it('should reset thought to null when starting a new prompt', async () => {
|
||||
// First, simulate a response with a thought
|
||||
@@ -1936,63 +1900,4 @@ describe('useGeminiStream', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should process @include commands, adding user turn after processing to prevent race conditions', async () => {
|
||||
const rawQuery = '@include file.txt Summarize this.';
|
||||
const processedQueryParts = [
|
||||
{ text: 'Summarize this with content from @file.txt' },
|
||||
{ text: 'File content...' },
|
||||
];
|
||||
const userMessageTimestamp = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(userMessageTimestamp);
|
||||
|
||||
handleAtCommandSpy.mockResolvedValue({
|
||||
processedQuery: processedQueryParts,
|
||||
shouldProceed: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
mockConfig.getGeminiClient() as GeminiClient,
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
false,
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery(rawQuery);
|
||||
});
|
||||
|
||||
expect(handleAtCommandSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: rawQuery,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.USER,
|
||||
text: rawQuery,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
|
||||
// FIX: This expectation now correctly matches the actual function call signature.
|
||||
expect(mockSendMessageStream).toHaveBeenCalledWith(
|
||||
processedQueryParts, // Argument 1: The parts array directly
|
||||
expect.any(AbortSignal), // Argument 2: An AbortSignal
|
||||
expect.any(String), // Argument 3: The prompt_id string
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user