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:
Michael Bleigh
2026-05-11 15:16:24 -07:00
15 changed files with 442 additions and 109 deletions
+5 -1
View File
@@ -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,
@@ -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"> &gt; 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');
});
});
+19 -2
View File
@@ -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;
}
}
-67
View File
@@ -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[] = [
+1 -9
View File
@@ -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);
}
}
+3 -1
View File
@@ -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)
}
}
+4 -1
View File
@@ -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,
);
}
/**