mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 05:42:54 -07:00
Merge branch 'mb/atui/02-tool-state' of github.com:google-gemini/gemini-cli into mb/atui/02-tool-state
This commit is contained in:
Vendored
+5
-1
@@ -43,9 +43,13 @@
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"name": "CLI: Run Current File",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["--import", "tsx"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"program": "${file}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"outFiles": ["${workspaceFolder}/**/*.js"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5332,6 +5332,34 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal buffer rendering', () => {
|
||||
it('does not clip the last char of a visual line whose width equals inputWidth', async () => {
|
||||
const fullLine = '1234567890'; // 10 chars, exactly props.inputWidth
|
||||
props.inputWidth = 10;
|
||||
props.suggestionsWidth = 10;
|
||||
vi.spyOn(props.config, 'getUseTerminalBuffer').mockReturnValue(true);
|
||||
mockBuffer.text = fullLine;
|
||||
mockBuffer.lines = [fullLine];
|
||||
mockBuffer.allVisualLines = [fullLine];
|
||||
mockBuffer.viewportVisualLines = [fullLine];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualToTransformedMap = [0];
|
||||
mockBuffer.transformationsByLine = [[]];
|
||||
mockBuffer.cursor = [0, fullLine.length];
|
||||
mockBuffer.visualCursor = [0, fullLine.length];
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{ uiActions },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clean(lastFrame())).toContain(fullLine);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clean(str: string | undefined): string {
|
||||
|
||||
@@ -93,6 +93,8 @@ import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
|
||||
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
const SCROLLBAR_GUTTER_WIDTH = 1;
|
||||
|
||||
/**
|
||||
* Returns if the terminal can be trusted to handle paste events atomically
|
||||
* rather than potentially sending multiple paste events separated by line
|
||||
@@ -1868,7 +1870,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
? `line-${item.absoluteVisualIdx}`
|
||||
: `ghost-${item.index}`
|
||||
}
|
||||
width={inputWidth}
|
||||
width={inputWidth + SCROLLBAR_GUTTER_WIDTH}
|
||||
backgroundColor={listBackgroundColor}
|
||||
containerHeight={Math.min(
|
||||
buffer.viewportHeight,
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> > hello </text>
|
||||
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -60,6 +60,12 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 2`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello 👍 world
|
||||
@@ -168,6 +174,27 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> [Pasted Text: 10 lines]
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> [Pasted Text: 10 lines]
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> [Pasted Text: 10 lines]
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello
|
||||
|
||||
@@ -374,4 +374,65 @@ describe('ProjectRegistry', () => {
|
||||
|
||||
readFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('recovers gracefully if registry is an empty object (invalid schema)', async () => {
|
||||
// 1. Write an empty object which is valid JSON but invalid schema
|
||||
fs.writeFileSync(registryPath, '{}');
|
||||
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
await registry.initialize();
|
||||
|
||||
// 2. It should not crash and should allow adding new projects
|
||||
const projectPath = path.join(tempDir, 'my-project');
|
||||
const shortId = await registry.getShortId(projectPath);
|
||||
|
||||
expect(shortId).toBe('my-project');
|
||||
|
||||
// 3. Verify it healed the file
|
||||
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(data.projects).toBeDefined();
|
||||
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
|
||||
});
|
||||
|
||||
it('recovers gracefully if registry projects property is an array (invalid schema)', async () => {
|
||||
// 1. Write an object where 'projects' is an array
|
||||
fs.writeFileSync(registryPath, JSON.stringify({ projects: [] }));
|
||||
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
await registry.initialize();
|
||||
|
||||
// 2. It should reset and allow adding new projects correctly
|
||||
const projectPath = path.join(tempDir, 'my-project');
|
||||
const shortId = await registry.getShortId(projectPath);
|
||||
|
||||
expect(shortId).toBe('my-project');
|
||||
|
||||
// 3. Verify it healed the file to an object
|
||||
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(data.projects).toBeDefined();
|
||||
expect(Array.isArray(data.projects)).toBe(false);
|
||||
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
|
||||
});
|
||||
|
||||
it('recovers gracefully if registry contains malicious slugs (path traversal)', async () => {
|
||||
// 1. Write a registry with a path traversal slug
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify({ projects: { '/some/path': '../../etc/passwd' } }),
|
||||
);
|
||||
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
await registry.initialize();
|
||||
|
||||
// 2. It should identify as invalid and reset
|
||||
const projectPath = path.join(tempDir, 'my-project');
|
||||
const shortId = await registry.getShortId(projectPath);
|
||||
|
||||
expect(shortId).toBe('my-project');
|
||||
|
||||
// 3. Verify it healed the file and didn't preserve the malicious entry
|
||||
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
|
||||
expect(Object.values(data.projects)).not.toContain('../../etc/passwd');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import { z } from 'zod';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
|
||||
@@ -16,6 +17,10 @@ export interface RegistryData {
|
||||
projects: Record<string, string>;
|
||||
}
|
||||
|
||||
const registryDataSchema = z.object({
|
||||
projects: z.record(z.string(), z.string().regex(/^[a-z0-9-]+$/)),
|
||||
});
|
||||
|
||||
const PROJECT_ROOT_FILE = '.project_root';
|
||||
const LOCK_TIMEOUT_MS = 10000;
|
||||
const LOCK_RETRY_DELAY_MS = 100;
|
||||
@@ -57,8 +62,16 @@ export class ProjectRegistry {
|
||||
private async loadData(): Promise<RegistryData> {
|
||||
try {
|
||||
const content = await fs.promises.readFile(this.registryPath, 'utf8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return JSON.parse(content);
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
|
||||
if (this.isValidRegistryData(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
debugLogger.warn(
|
||||
`Project registry at ${this.registryPath} has an invalid schema, resetting to empty.`,
|
||||
);
|
||||
return { projects: {} };
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return { projects: {} }; // Normal first run
|
||||
@@ -407,4 +420,8 @@ export class ProjectRegistry {
|
||||
.replace(/^-|-$/g, '') || 'project'
|
||||
);
|
||||
}
|
||||
|
||||
private isValidRegistryData(data: unknown): data is RegistryData {
|
||||
return registryDataSchema.safeParse(data).success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2904,73 +2904,6 @@ describe('GeminiChat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistory with curated: true', () => {
|
||||
it('should not drop model turns with function calls and empty text', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'test_tool', args: {} }, text: '' }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name: 'test_tool', response: {} } }],
|
||||
},
|
||||
];
|
||||
const chatWithHistory = new GeminiChat(mockConfig, '', [], history);
|
||||
|
||||
const curatedHistory = chatWithHistory.getHistory(true);
|
||||
|
||||
expect(curatedHistory.length).toBe(3);
|
||||
expect(curatedHistory[1].role).toBe('model');
|
||||
expect(curatedHistory[1].parts![0].functionCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not drop model turns with inlineData and empty text', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
inlineData: { mimeType: 'image/jpeg', data: 'base64...' },
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const chatWithHistory = new GeminiChat(mockConfig, '', [], history);
|
||||
|
||||
const curatedHistory = chatWithHistory.getHistory(true);
|
||||
|
||||
expect(curatedHistory.length).toBe(2);
|
||||
expect(curatedHistory[1].role).toBe('model');
|
||||
expect(curatedHistory[1].parts![0].inlineData).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not drop model turns with fileData and empty text', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
fileData: { mimeType: 'image/jpeg', fileUri: 'https://...' },
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const chatWithHistory = new GeminiChat(mockConfig, '', [], history);
|
||||
|
||||
const curatedHistory = chatWithHistory.getHistory(true);
|
||||
|
||||
expect(curatedHistory.length).toBe(2);
|
||||
expect(curatedHistory[1].role).toBe('model');
|
||||
expect(curatedHistory[1].parts![0].fileData).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripToolCallIdPrefixes', () => {
|
||||
it('should strip tool name prefix matching the tool name', () => {
|
||||
const contents: Content[] = [
|
||||
|
||||
@@ -146,15 +146,7 @@ function isValidContent(content: Content): boolean {
|
||||
if (part === undefined || Object.keys(part).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!part.thought &&
|
||||
!part.functionCall &&
|
||||
!part.functionResponse &&
|
||||
!part.inlineData &&
|
||||
!part.fileData &&
|
||||
part.text !== undefined &&
|
||||
part.text === ''
|
||||
) {
|
||||
if (!part.thought && part.text !== undefined && part.text === '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { NumericalClassifierStrategy } from './numericalClassifierStrategy.js';
|
||||
import {
|
||||
NumericalClassifierStrategy,
|
||||
HISTORY_TURNS_FOR_CONTEXT,
|
||||
} from './numericalClassifierStrategy.js';
|
||||
import type { RoutingContext } from '../routingStrategy.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
@@ -423,18 +426,21 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include tool-related history when sending to classifier', async () => {
|
||||
mockContext.history = [
|
||||
{ role: 'user', parts: [{ text: 'call a tool' }] },
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'test_tool' } }] },
|
||||
it('should strip leading tool turns when history starts with tool calls', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'leading_tool' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ functionResponse: { name: 'test_tool', response: { ok: true } } },
|
||||
{
|
||||
functionResponse: { name: 'leading_tool', response: { ok: true } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'another user turn' }] },
|
||||
{ role: 'model', parts: [{ text: 'text response 1' }] },
|
||||
{ role: 'user', parts: [{ text: 'text request 2' }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Simple.',
|
||||
complexity_score: 10,
|
||||
@@ -454,9 +460,9 @@ describe('NumericalClassifierStrategy', () => {
|
||||
.calls[0][0];
|
||||
const contents = generateJsonCall.contents;
|
||||
|
||||
// Expect leading tool turns (index 0 and 1) to be stripped, keeping only text turns (index 2 and 3)
|
||||
const expectedContents = [
|
||||
...mockContext.history,
|
||||
// The last user turn is the request part
|
||||
...history.slice(2),
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'simple task' }],
|
||||
@@ -466,12 +472,25 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(contents).toEqual(expectedContents);
|
||||
});
|
||||
|
||||
it('should respect HISTORY_TURNS_FOR_CONTEXT', async () => {
|
||||
const longHistory: Content[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
longHistory.push({ role: 'user', parts: [{ text: `Message ${i}` }] });
|
||||
}
|
||||
mockContext.history = longHistory;
|
||||
it('should preserve tool turns when they appear after a non-tool turn in the middle of history', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'turn 0 (before)' }] },
|
||||
{ role: 'model', parts: [{ text: 'turn 1 (before)' }] },
|
||||
{ role: 'user', parts: [{ text: 'turn 2 (before)' }] },
|
||||
{ role: 'model', parts: [{ text: 'turn 3 (before)' }] },
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'middle_tool' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ functionResponse: { name: 'middle_tool', response: { ok: true } } },
|
||||
],
|
||||
},
|
||||
{ role: 'model', parts: [{ text: 'turn 6 (after)' }] },
|
||||
{ role: 'user', parts: [{ text: 'turn 7 (after)' }] },
|
||||
{ role: 'model', parts: [{ text: 'turn 8 (after)' }] },
|
||||
{ role: 'user', parts: [{ text: 'turn 9 (after)' }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Simple.',
|
||||
complexity_score: 10,
|
||||
@@ -491,18 +510,188 @@ describe('NumericalClassifierStrategy', () => {
|
||||
.calls[0][0];
|
||||
const contents = generateJsonCall.contents;
|
||||
|
||||
// Manually calculate what the history should be
|
||||
const HISTORY_TURNS_FOR_CONTEXT = 8;
|
||||
const finalHistory = longHistory.slice(-HISTORY_TURNS_FOR_CONTEXT);
|
||||
// Expect all 8 sliced turns (starting from non-tool turn 2) to be preserved
|
||||
const expectedContents = [
|
||||
...history.slice(2),
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'simple task' }],
|
||||
},
|
||||
];
|
||||
|
||||
// Last part is the request
|
||||
const requestPart = {
|
||||
role: 'user',
|
||||
parts: [{ text: 'simple task' }],
|
||||
expect(contents).toEqual(expectedContents);
|
||||
});
|
||||
|
||||
it('should preserve tool turns when they appear at the very end of history following a non-tool turn', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'turn 0' }] },
|
||||
{ role: 'model', parts: [{ text: 'turn 1' }] },
|
||||
{ role: 'user', parts: [{ text: 'turn 2' }] },
|
||||
{ role: 'model', parts: [{ text: 'turn 3' }] },
|
||||
{ role: 'user', parts: [{ text: 'turn 4' }] },
|
||||
{ role: 'model', parts: [{ text: 'turn 5' }] },
|
||||
{ role: 'user', parts: [{ text: 'turn 6' }] },
|
||||
{ role: 'model', parts: [{ text: 'turn 7' }] },
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'end_tool' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ functionResponse: { name: 'end_tool', response: { ok: true } } },
|
||||
],
|
||||
},
|
||||
];
|
||||
mockContext.history = history;
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Simple.',
|
||||
complexity_score: 10,
|
||||
};
|
||||
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
|
||||
mockApiResponse,
|
||||
);
|
||||
|
||||
expect(contents).toEqual([...finalHistory, requestPart]);
|
||||
expect(contents).toHaveLength(9);
|
||||
await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
|
||||
.calls[0][0];
|
||||
const contents = generateJsonCall.contents;
|
||||
|
||||
// Expect all 8 sliced turns to be preserved because index 2 is a non-tool turn
|
||||
const expectedContents = [
|
||||
...history.slice(2),
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'simple task' }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(contents).toEqual(expectedContents);
|
||||
});
|
||||
|
||||
it('should send only the new request prompt if the entire history consists of tool-related turns', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool_A' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ functionResponse: { name: 'tool_A', response: { ok: true } } },
|
||||
],
|
||||
},
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool_B' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ functionResponse: { name: 'tool_B', response: { ok: true } } },
|
||||
],
|
||||
},
|
||||
];
|
||||
mockContext.history = history;
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Simple standalone task.',
|
||||
complexity_score: 10,
|
||||
};
|
||||
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
|
||||
mockApiResponse,
|
||||
);
|
||||
|
||||
await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
|
||||
.calls[0][0];
|
||||
const contents = generateJsonCall.contents;
|
||||
|
||||
// Expect all history turns to be filtered out, leaving exactly just the new request
|
||||
const expectedContents = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'simple task' }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(contents).toEqual(expectedContents);
|
||||
});
|
||||
|
||||
it('should respect HISTORY_TURNS_FOR_CONTEXT correctly when history has only text turns', async () => {
|
||||
const history: Content[] = [];
|
||||
for (let i = 0; i < HISTORY_TURNS_FOR_CONTEXT + 2; i++) {
|
||||
history.push({ role: 'user', parts: [{ text: `Message ${i}` }] });
|
||||
}
|
||||
mockContext.history = history;
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Simple.',
|
||||
complexity_score: 10,
|
||||
};
|
||||
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
|
||||
mockApiResponse,
|
||||
);
|
||||
|
||||
await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
|
||||
.calls[0][0];
|
||||
const contents = generateJsonCall.contents;
|
||||
|
||||
// Expect exactly the last 8 turns (history.slice(2))
|
||||
expect(contents).toEqual([
|
||||
...history.slice(2),
|
||||
{ role: 'user', parts: [{ text: 'simple task' }] },
|
||||
]);
|
||||
expect(contents).toHaveLength(HISTORY_TURNS_FOR_CONTEXT + 1);
|
||||
});
|
||||
|
||||
it('should respect HISTORY_TURNS_FOR_CONTEXT correctly when history starts with tool calls', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool_0' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ functionResponse: { name: 'tool_0', response: { ok: true } } },
|
||||
],
|
||||
},
|
||||
];
|
||||
for (let i = 0; i < HISTORY_TURNS_FOR_CONTEXT; i++) {
|
||||
history.push({ role: 'user', parts: [{ text: `Message ${i}` }] });
|
||||
}
|
||||
mockContext.history = history;
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Simple.',
|
||||
complexity_score: 10,
|
||||
};
|
||||
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
|
||||
mockApiResponse,
|
||||
);
|
||||
|
||||
await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
|
||||
.calls[0][0];
|
||||
const contents = generateJsonCall.contents;
|
||||
|
||||
// Expect exactly the last 8 text turns (history.slice(2))
|
||||
expect(contents).toEqual([
|
||||
...history.slice(2),
|
||||
{ role: 'user', parts: [{ text: 'simple task' }] },
|
||||
]);
|
||||
expect(contents).toHaveLength(HISTORY_TURNS_FOR_CONTEXT + 1);
|
||||
});
|
||||
|
||||
it('should use a fallback promptId if not found in context', async () => {
|
||||
|
||||
@@ -15,12 +15,16 @@ import type {
|
||||
import { resolveClassifierModel, isGemini3Model } from '../../config/models.js';
|
||||
import { createUserContent, Type } from '@google/genai';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import {
|
||||
isFunctionCall,
|
||||
isFunctionResponse,
|
||||
} from '../../utils/messageInspectors.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js';
|
||||
import { LlmRole } from '../../telemetry/types.js';
|
||||
|
||||
// The number of recent history turns to provide to the router for context.
|
||||
const HISTORY_TURNS_FOR_CONTEXT = 8;
|
||||
export const HISTORY_TURNS_FOR_CONTEXT = 8;
|
||||
|
||||
const FLASH_MODEL = 'flash';
|
||||
const PRO_MODEL = 'pro';
|
||||
@@ -115,7 +119,22 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
|
||||
|
||||
const promptId = getPromptIdWithFallback('classifier-router');
|
||||
|
||||
const finalHistory = context.history.slice(-HISTORY_TURNS_FOR_CONTEXT);
|
||||
const candidateSlice = context.history.slice(-HISTORY_TURNS_FOR_CONTEXT);
|
||||
|
||||
// Find the first non-tool turn. The server cannot always handle tool-related
|
||||
// turns in the first slots of the contents array, so we strip them if they appear at the start.
|
||||
let firstTextIndex = -1;
|
||||
for (let i = 0; i < candidateSlice.length; i++) {
|
||||
if (
|
||||
!isFunctionCall(candidateSlice[i]) &&
|
||||
!isFunctionResponse(candidateSlice[i])
|
||||
) {
|
||||
firstTextIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const finalHistory =
|
||||
firstTextIndex === -1 ? [] : candidateSlice.slice(firstTextIndex);
|
||||
|
||||
// Wrap the user's request in tags to prevent prompt injection
|
||||
const requestParts = Array.isArray(context.request)
|
||||
|
||||
@@ -502,6 +502,50 @@ describe('FileDiscoveryService', () => {
|
||||
const paths = service.getAllIgnoreFilePaths();
|
||||
expect(paths[0]).toBe(path.join(projectRoot, '.gitignore'));
|
||||
});
|
||||
|
||||
it('should exclude directories from getIgnoreFilePaths (#19868)', async () => {
|
||||
// Create a directory that shares a name with a customIgnoreFilePaths entry
|
||||
await fs.mkdir(path.join(projectRoot, 'node_modules'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const service = new FileDiscoveryService(projectRoot, {
|
||||
customIgnoreFilePaths: ['node_modules'],
|
||||
});
|
||||
const paths = service.getIgnoreFilePaths();
|
||||
|
||||
// node_modules/ is a directory, not a file — it should be excluded
|
||||
expect(paths).not.toContain(path.join(projectRoot, 'node_modules'));
|
||||
});
|
||||
|
||||
it('should exclude directories from getAllIgnoreFilePaths (#19868)', async () => {
|
||||
await fs.mkdir(path.join(projectRoot, 'node_modules'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const service = new FileDiscoveryService(projectRoot, {
|
||||
customIgnoreFilePaths: ['node_modules'],
|
||||
});
|
||||
const paths = service.getAllIgnoreFilePaths();
|
||||
|
||||
expect(paths).not.toContain(path.join(projectRoot, 'node_modules'));
|
||||
// .gitignore should still be present
|
||||
expect(paths).toContain(path.join(projectRoot, '.gitignore'));
|
||||
});
|
||||
|
||||
it('should not crash when customIgnoreFilePaths contains directory names (#19868)', async () => {
|
||||
await fs.mkdir(path.join(projectRoot, 'node_modules'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.mkdir(path.join(projectRoot, 'temp'), { recursive: true });
|
||||
|
||||
// This is the exact user scenario from issue #19868
|
||||
expect(() => {
|
||||
new FileDiscoveryService(projectRoot, {
|
||||
customIgnoreFilePaths: ['node_modules/', 'temp/', 'cache/'],
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIgnoredPaths', () => {
|
||||
|
||||
@@ -274,7 +274,8 @@ export class FileDiscoveryService {
|
||||
this.defaultFilterFileOptions.respectGitIgnore
|
||||
) {
|
||||
const gitIgnorePath = path.join(this.projectRoot, '.gitignore');
|
||||
if (fs.existsSync(gitIgnorePath)) {
|
||||
const stat = fs.statSync(gitIgnorePath, { throwIfNoEntry: false });
|
||||
if (stat?.isFile()) {
|
||||
paths.push(gitIgnorePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ export function loadIgnoreRules(
|
||||
const ignoreFiles = service.getAllIgnoreFilePaths();
|
||||
|
||||
for (const filePath of ignoreFiles) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
ignorer.add(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
// Skip files that can't be read (e.g. directories, permission errors)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,10 @@ export class IgnoreFileParser implements IgnoreFileFilter {
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((fileName) => path.join(this.projectRoot, fileName))
|
||||
.filter((filePath) => fs.existsSync(filePath));
|
||||
.filter(
|
||||
(filePath) =>
|
||||
fs.statSync(filePath, { throwIfNoEntry: false })?.isFile() ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user