From f8198a25d8ecaf5e36ed248c60825253b83f6dd7 Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Mon, 11 May 2026 16:09:38 -0400 Subject: [PATCH 1/6] fix(routing): Refactor tool turn handling for the conversation history in NumericalClassifierStrategy to prevent 400 Bad Request (#26761) --- .../numericalClassifierStrategy.test.ts | 237 ++++++++++++++++-- .../strategies/numericalClassifierStrategy.ts | 23 +- 2 files changed, 234 insertions(+), 26 deletions(-) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index dcfdff786b..f400dfc51b 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -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 () => { diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index 8bcfb3da67..0e2401c8f1 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -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) From 1340c960714932bdfa52d74e9bc7dcf6e329ecaa Mon Sep 17 00:00:00 2001 From: Coco Sheng Date: Mon, 11 May 2026 16:19:01 -0400 Subject: [PATCH 2/6] fix(core): handle malformed projects.json in ProjectRegistry (#26885) --- .../core/src/config/projectRegistry.test.ts | 61 +++++++++++++++++++ packages/core/src/config/projectRegistry.ts | 21 ++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/core/src/config/projectRegistry.test.ts b/packages/core/src/config/projectRegistry.test.ts index e7e532bb2b..84d266b278 100644 --- a/packages/core/src/config/projectRegistry.test.ts +++ b/packages/core/src/config/projectRegistry.test.ts @@ -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'); + }); }); diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts index 1aec0b7ad2..9b816583eb 100644 --- a/packages/core/src/config/projectRegistry.ts +++ b/packages/core/src/config/projectRegistry.ts @@ -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; } +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 { 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; + } } From c0d5ab1f1efac07adbde1c805144ee395738648e Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Mon, 11 May 2026 16:26:48 -0400 Subject: [PATCH 3/6] fix(ui): added a gutter width to the input prompt width calculation (#26882) --- .../src/ui/components/InputPrompt.test.tsx | 28 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 4 ++- ...orrectly-at-the-end-of-the-line-2.snap.svg | 11 ++++++++ .../__snapshots__/InputPrompt.test.tsx.snap | 27 ++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-2.snap.svg diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 3608f00e3d..4e7e10b34c 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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( + , + { uiActions }, + ); + + await waitFor(() => { + expect(clean(lastFrame())).toContain(fullLine); + }); + unmount(); + }); + }); }); function clean(str: string | undefined): string { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 67fefe0656..cd37e56abd 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ ? `line-${item.absoluteVisualIdx}` : `ghost-${item.index}` } - width={inputWidth} + width={inputWidth + SCROLLBAR_GUTTER_WIDTH} backgroundColor={listBackgroundColor} containerHeight={Math.min( buffer.viewportHeight, diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-2.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-2.snap.svg new file mode 100644 index 0000000000..2f9e66a77a --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-2.snap.svg @@ -0,0 +1,11 @@ + + + + + ──────────────────────────────────────────────────────────────────────────────────────────────────── + > hello + ──────────────────────────────────────────────────────────────────────────────────────────────────── + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index db449ce4d7..04cba9385d 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -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 From 8e58df72c603bd792c7f597bc1bb82389ea03ca1 Mon Sep 17 00:00:00 2001 From: Suhaan Raqeeb Khavas Date: Tue, 12 May 2026 02:16:08 +0530 Subject: [PATCH 4/6] fix: prevent EISDIR crash when customIgnoreFilePaths contains directories (#19868) (#19898) Co-authored-by: Tommaso Sciortino --- .../src/services/fileDiscoveryService.test.ts | 44 +++++++++++++++++++ .../core/src/services/fileDiscoveryService.ts | 3 +- packages/core/src/utils/filesearch/ignore.ts | 4 +- packages/core/src/utils/ignoreFileParser.ts | 5 ++- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/fileDiscoveryService.test.ts b/packages/core/src/services/fileDiscoveryService.test.ts index c205463bc2..63ddc06616 100644 --- a/packages/core/src/services/fileDiscoveryService.test.ts +++ b/packages/core/src/services/fileDiscoveryService.test.ts @@ -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', () => { diff --git a/packages/core/src/services/fileDiscoveryService.ts b/packages/core/src/services/fileDiscoveryService.ts index 28b55894b6..d58f31a749 100644 --- a/packages/core/src/services/fileDiscoveryService.ts +++ b/packages/core/src/services/fileDiscoveryService.ts @@ -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); } } diff --git a/packages/core/src/utils/filesearch/ignore.ts b/packages/core/src/utils/filesearch/ignore.ts index b8b2635c19..bd5cd5d6e9 100644 --- a/packages/core/src/utils/filesearch/ignore.ts +++ b/packages/core/src/utils/filesearch/ignore.ts @@ -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) } } diff --git a/packages/core/src/utils/ignoreFileParser.ts b/packages/core/src/utils/ignoreFileParser.ts index 991826e3f0..ee7284bfa6 100644 --- a/packages/core/src/utils/ignoreFileParser.ts +++ b/packages/core/src/utils/ignoreFileParser.ts @@ -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, + ); } /** From e1b3ce5b3671d3bae98e368bb5ba395fa6c6ed97 Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Mon, 11 May 2026 17:07:54 -0400 Subject: [PATCH 5/6] revert 6b9b778d821728427eea07b1b97ba07378137d0b (#26893) --- packages/core/src/core/geminiChat.test.ts | 67 ----------------------- packages/core/src/core/geminiChat.ts | 10 +--- 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 4f4cce3b54..49fc72c364 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -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[] = [ diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 3f3cbe6d65..6a728884a5 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -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; } } From 84fc5cd5335474854527e9fa1e5c8b9c57dcd903 Mon Sep 17 00:00:00 2001 From: Neil Nair <65729206+Neil-N4@users.noreply.github.com> Date: Mon, 11 May 2026 14:21:42 -0700 Subject: [PATCH 6/6] Fix/vscode run current file ts (#22894) Co-authored-by: Spencer --- .vscode/launch.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 0294e27ed4..01f0ba59a9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -43,9 +43,13 @@ { "type": "node", "request": "launch", - "name": "Launch Program", + "name": "CLI: Run Current File", + "runtimeExecutable": "node", + "runtimeArgs": ["--import", "tsx"], "skipFiles": ["/**"], "program": "${file}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", "outFiles": ["${workspaceFolder}/**/*.js"] }, {