From b79e5ce56d57f47ab917bfaddfdab9d257aa5979 Mon Sep 17 00:00:00 2001 From: Yuvraj Angad Singh <36276913+yuvrajangadsingh@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:24:49 +0530 Subject: [PATCH 01/26] fix(core): add error logging for IDE fetch failures (#17981) --- packages/core/src/ide/ide-connection-utils.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/core/src/ide/ide-connection-utils.ts b/packages/core/src/ide/ide-connection-utils.ts index 041c4c984a..2414064f5d 100644 --- a/packages/core/src/ide/ide-connection-utils.ts +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -215,13 +215,19 @@ export async function createProxyAwareFetch(ideServerHost: string) { }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const options = fetchOptions as unknown as import('undici').RequestInit; - const response = await fetchFn(url, options); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return new Response(response.body as ReadableStream | null, { - status: response.status, - statusText: response.statusText, - headers: [...response.headers.entries()], - }); + try { + const response = await fetchFn(url, options); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return new Response(response.body as ReadableStream | null, { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()], + }); + } catch (error) { + const urlString = typeof url === 'string' ? url : url.href; + logger.error(`IDE fetch failed for ${urlString}`, error); + throw error; + } }; } From 966eef14ee83138868c53e34bd761528bb305a09 Mon Sep 17 00:00:00 2001 From: Valery Teplyakov <31941254+Mervap@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:07:46 +0100 Subject: [PATCH 02/26] feat(acp): support set_mode interface (#18890) (#18891) Co-authored-by: Mervap --- .../cli/src/zed-integration/acpResume.test.ts | 26 ++++- .../zed-integration/zedIntegration.test.ts | 97 +++++++++++++++++++ .../cli/src/zed-integration/zedIntegration.ts | 63 +++++++++++- 3 files changed, 184 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/zed-integration/acpResume.test.ts b/packages/cli/src/zed-integration/acpResume.test.ts index d6e12aec25..869c27bf52 100644 --- a/packages/cli/src/zed-integration/acpResume.test.ts +++ b/packages/cli/src/zed-integration/acpResume.test.ts @@ -16,6 +16,7 @@ import { import { GeminiAgent } from './zedIntegration.js'; import * as acp from '@agentclientprotocol/sdk'; import { + ApprovalMode, AuthType, type Config, CoreToolCallStatus, @@ -62,6 +63,8 @@ describe('GeminiAgent Session Resume', () => { storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, + getApprovalMode: vi.fn().mockReturnValue('default'), + isPlanEnabled: vi.fn().mockReturnValue(false), } as unknown as Mocked; mockSettings = { merged: { @@ -149,7 +152,28 @@ describe('GeminiAgent Session Resume', () => { mcpServers: [], }); - expect(response).toEqual({}); + expect(response).toEqual({ + modes: { + availableModes: [ + { + id: ApprovalMode.DEFAULT, + name: 'Default', + description: 'Prompts for approval', + }, + { + id: ApprovalMode.AUTO_EDIT, + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { + id: ApprovalMode.YOLO, + name: 'YOLO', + description: 'Auto-approves all tools', + }, + ], + currentModeId: ApprovalMode.DEFAULT, + }, + }); // Verify resumeChat received the correct arguments expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith( diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/zed-integration/zedIntegration.test.ts index edc32f04b6..cc71dd9309 100644 --- a/packages/cli/src/zed-integration/zedIntegration.test.ts +++ b/packages/cli/src/zed-integration/zedIntegration.test.ts @@ -35,6 +35,7 @@ import { import { loadCliConfig, type CliArgs } from '../config/config.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { ApprovalMode } from '@google/gemini-cli-core/src/policy/types.js'; vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn(), @@ -119,6 +120,8 @@ describe('GeminiAgent', () => { subscribe: vi.fn(), unsubscribe: vi.fn(), }), + getApprovalMode: vi.fn().mockReturnValue('default'), + isPlanEnabled: vi.fn().mockReturnValue(false), } as unknown as Mocked>>; mockSettings = { merged: { @@ -185,6 +188,59 @@ describe('GeminiAgent', () => { expect(mockConfig.getGeminiClient).toHaveBeenCalled(); }); + it('should return modes without plan mode when plan is disabled', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false); + mockConfig.getApprovalMode = vi.fn().mockReturnValue('default'); + + const response = await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(response.modes).toEqual({ + availableModes: [ + { id: 'default', name: 'Default', description: 'Prompts for approval' }, + { + id: 'autoEdit', + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' }, + ], + currentModeId: 'default', + }); + }); + + it('should return modes with plan mode when plan is enabled', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true); + mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan'); + + const response = await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(response.modes).toEqual({ + availableModes: [ + { id: 'default', name: 'Default', description: 'Prompts for approval' }, + { + id: 'autoEdit', + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' }, + { id: 'plan', name: 'Plan', description: 'Read-only mode' }, + ], + currentModeId: 'plan', + }); + }); + it('should fail session creation if Gemini API key is missing', async () => { (loadSettings as unknown as Mock).mockImplementation(() => ({ merged: { @@ -306,6 +362,32 @@ describe('GeminiAgent', () => { expect(session.prompt).toHaveBeenCalled(); expect(result).toEqual({ stopReason: 'end_turn' }); }); + + it('should delegate setMode to session', async () => { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const session = ( + agent as unknown as { sessions: Map } + ).sessions.get('test-session-id'); + if (!session) throw new Error('Session not found'); + session.setMode = vi.fn().mockReturnValue({}); + + const result = await agent.setSessionMode({ + sessionId: 'test-session-id', + modeId: 'plan', + }); + + expect(session.setMode).toHaveBeenCalledWith('plan'); + expect(result).toEqual({}); + }); + + it('should throw error when setting mode on non-existent session', async () => { + await expect( + agent.setSessionMode({ + sessionId: 'unknown', + modeId: 'plan', + }), + ).rejects.toThrow('Session not found: unknown'); + }); }); describe('Session', () => { @@ -352,6 +434,8 @@ describe('Session', () => { getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), getDebugMode: vi.fn().mockReturnValue(false), getMessageBus: vi.fn().mockReturnValue(mockMessageBus), + setApprovalMode: vi.fn(), + isPlanEnabled: vi.fn().mockReturnValue(false), } as unknown as Mocked; mockConnection = { sessionUpdate: vi.fn(), @@ -822,4 +906,17 @@ describe('Session', () => { ].value; expect(mockInstance.build).toHaveBeenCalled(); }); + + it('should set mode on config', () => { + session.setMode(ApprovalMode.AUTO_EDIT); + expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.AUTO_EDIT, + ); + }); + + it('should throw error for invalid mode', () => { + expect(() => session.setMode('invalid-mode')).toThrow( + 'Invalid or unavailable mode: invalid-mode', + ); + }); }); diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 61c255a21c..44b1890ce2 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -36,6 +36,7 @@ import { Kind, partListUnionToString, LlmRole, + ApprovalMode, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; import { AcpFileSystemService } from './fileSystemService.js'; @@ -225,6 +226,10 @@ export class GeminiAgent { return { sessionId, + modes: { + availableModes: buildAvailableModes(config.isPlanEnabled()), + currentModeId: config.getApprovalMode(), + }, }; } @@ -276,7 +281,12 @@ export class GeminiAgent { // eslint-disable-next-line @typescript-eslint/no-floating-promises session.streamHistory(sessionData.messages); - return {}; + return { + modes: { + availableModes: buildAvailableModes(config.isPlanEnabled()), + currentModeId: config.getApprovalMode(), + }, + }; } private async initializeSessionConfig( @@ -377,6 +387,16 @@ export class GeminiAgent { } return session.prompt(params); } + + async setSessionMode( + params: acp.SetSessionModeRequest, + ): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + return session.setMode(params.modeId); + } } export class Session { @@ -398,6 +418,17 @@ export class Session { this.pendingPrompt = null; } + setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse { + const availableModes = buildAvailableModes(this.config.isPlanEnabled()); + const mode = availableModes.find((m) => m.id === modeId); + if (!mode) { + throw new Error(`Invalid or unavailable mode: ${modeId}`); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + this.config.setApprovalMode(mode.id as ApprovalMode); + return {}; + } + async streamHistory(messages: ConversationRecord['messages']): Promise { for (const msg of messages) { const contentString = partListUnionToString(msg.content); @@ -1273,3 +1304,33 @@ function toAcpToolKind(kind: Kind): acp.ToolKind { return 'other'; } } + +function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] { + const modes: acp.SessionMode[] = [ + { + id: ApprovalMode.DEFAULT, + name: 'Default', + description: 'Prompts for approval', + }, + { + id: ApprovalMode.AUTO_EDIT, + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { + id: ApprovalMode.YOLO, + name: 'YOLO', + description: 'Auto-approves all tools', + }, + ]; + + if (isPlanEnabled) { + modes.push({ + id: ApprovalMode.PLAN, + name: 'Plan', + description: 'Read-only mode', + }); + } + + return modes; +} From 880af43b023b78d9a3e45e8ed5df6aed7eca6b70 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Thu, 19 Feb 2026 10:59:33 -0500 Subject: [PATCH 03/26] fix(core): robust workspace-based IDE connection discovery (#18443) --- .../core/src/ide/ide-connection-utils.test.ts | 155 ++++++++++++++++++ packages/core/src/ide/ide-connection-utils.ts | 87 +++++++++- 2 files changed, 235 insertions(+), 7 deletions(-) diff --git a/packages/core/src/ide/ide-connection-utils.test.ts b/packages/core/src/ide/ide-connection-utils.test.ts index 19d955ca36..99e62951be 100644 --- a/packages/core/src/ide/ide-connection-utils.test.ts +++ b/packages/core/src/ide/ide-connection-utils.test.ts @@ -49,6 +49,8 @@ describe('ide-connection-utils', () => { vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir'); vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + vi.mocked(os.platform).mockReturnValue('linux'); + vi.spyOn(process, 'kill').mockImplementation(() => true); }); afterEach(() => { @@ -133,6 +135,159 @@ describe('ide-connection-utils', () => { expect(result).toEqual(validConfig); }); + it('should fall back to a different PID if it matches the current workspace', async () => { + const targetPid = 12345; + const otherPid = 67890; + const validConfig = { + port: '5678', + workspacePath: '/test/workspace', + }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([`gemini-ide-server-${otherPid}-111.json`]); + vi.mocked(fs.promises.readFile).mockResolvedValueOnce( + JSON.stringify(validConfig), + ); + + const result = await getConnectionConfigFromFile(targetPid); + + expect(result).toEqual(validConfig); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${otherPid}-111.json`, + ), + 'utf8', + ); + }); + + it('should prioritize the target PID over other PIDs', async () => { + const targetPid = 12345; + const otherPid = 67890; + const targetConfig = { port: '1111', workspacePath: '/test/workspace' }; + const otherConfig = { port: '2222', workspacePath: '/test/workspace' }; + + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + `gemini-ide-server-${otherPid}-1.json`, + `gemini-ide-server-${targetPid}-1.json`, + ]); + + // readFile will be called for both files in the sorted order. + // We expect targetPid file to be first. + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(targetConfig)) + .mockResolvedValueOnce(JSON.stringify(otherConfig)); + + const result = await getConnectionConfigFromFile(targetPid); + + expect(result).toEqual(targetConfig); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${targetPid}-1.json`, + ), + 'utf8', + ); + }); + + it('should prioritize an alive process over a dead one', async () => { + const targetPid = 12345; // target not present + const alivePid = 22222; + const deadPid = 11111; + const aliveConfig = { port: '2222', workspacePath: '/test/workspace' }; + const deadConfig = { port: '1111', workspacePath: '/test/workspace' }; + + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + `gemini-ide-server-${deadPid}-1.json`, + `gemini-ide-server-${alivePid}-1.json`, + ]); + + vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === alivePid) return true; + throw new Error('dead'); + }); + + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(aliveConfig)) + .mockResolvedValueOnce(JSON.stringify(deadConfig)); + + const result = await getConnectionConfigFromFile(targetPid); + + expect(result).toEqual(aliveConfig); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${alivePid}-1.json`, + ), + 'utf8', + ); + }); + + it('should prioritize the largest PID (newest) among alive processes', async () => { + const targetPid = 12345; // target not present + const oldPid = 20000; + const newPid = 30000; + const oldConfig = { port: '2000', workspacePath: '/test/workspace' }; + const newConfig = { port: '3000', workspacePath: '/test/workspace' }; + + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + `gemini-ide-server-${oldPid}-1.json`, + `gemini-ide-server-${newPid}-1.json`, + ]); + + // Both are alive + vi.spyOn(process, 'kill').mockImplementation(() => true); + + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(newConfig)) + .mockResolvedValueOnce(JSON.stringify(oldConfig)); + + const result = await getConnectionConfigFromFile(targetPid); + + expect(result).toEqual(newConfig); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${newPid}-1.json`, + ), + 'utf8', + ); + }); + it('should return the first valid config when multiple workspaces are valid', async () => { const config1 = { port: '1111', workspacePath: '/test/workspace' }; const config2 = { port: '2222', workspacePath: '/test/workspace2' }; diff --git a/packages/core/src/ide/ide-connection-utils.ts b/packages/core/src/ide/ide-connection-utils.ts index 2414064f5d..ce01b5997c 100644 --- a/packages/core/src/ide/ide-connection-utils.ts +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -10,6 +10,7 @@ import * as os from 'node:os'; import { EnvHttpProxyAgent } from 'undici'; import { debugLogger } from '../utils/debugLogger.js'; import { isSubpath, resolveToRealPath } from '../utils/paths.js'; +import { isNodeError } from '../utils/errors.js'; import { type IdeInfo } from './detect-ide.js'; const logger = { @@ -104,6 +105,8 @@ export function getStdioConfigFromEnv(): StdioConfig | undefined { return { command, args }; } +const IDE_SERVER_FILE_REGEX = /^gemini-ide-server-(\d+)-\d+\.json$/; + export async function getConnectionConfigFromFile( pid: number, ): Promise< @@ -139,12 +142,16 @@ export async function getConnectionConfigFromFile( return undefined; } - const fileRegex = new RegExp(`^gemini-ide-server-${pid}-\\d+\\.json$`); - const matchingFiles = portFiles.filter((file) => fileRegex.test(file)).sort(); + const matchingFiles = portFiles.filter((file) => + IDE_SERVER_FILE_REGEX.test(file), + ); + if (matchingFiles.length === 0) { return undefined; } + sortConnectionFiles(matchingFiles, pid); + let fileContents: string[]; try { fileContents = await Promise.all( @@ -181,20 +188,86 @@ export async function getConnectionConfigFromFile( } if (validWorkspaces.length === 1) { - return validWorkspaces[0]; + const selected = validWorkspaces[0]; + const fileIndex = parsedContents.indexOf(selected); + if (fileIndex !== -1) { + logger.debug(`Selected IDE connection file: ${matchingFiles[fileIndex]}`); + } + return selected; } const portFromEnv = getPortFromEnv(); if (portFromEnv) { - const matchingPort = validWorkspaces.find( + const matchingPortIndex = validWorkspaces.findIndex( (content) => String(content.port) === portFromEnv, ); - if (matchingPort) { - return matchingPort; + if (matchingPortIndex !== -1) { + const selected = validWorkspaces[matchingPortIndex]; + const fileIndex = parsedContents.indexOf(selected); + if (fileIndex !== -1) { + logger.debug( + `Selected IDE connection file (matched port from env): ${matchingFiles[fileIndex]}`, + ); + } + return selected; } } - return validWorkspaces[0]; + const selected = validWorkspaces[0]; + const fileIndex = parsedContents.indexOf(selected); + if (fileIndex !== -1) { + logger.debug( + `Selected first valid IDE connection file: ${matchingFiles[fileIndex]}`, + ); + } + return selected; +} + +// Sort files to prioritize the one matching the target pid, +// then by whether the process is still alive, then by newest (largest PID). +function sortConnectionFiles(files: string[], targetPid: number) { + files.sort((a, b) => { + const aMatch = a.match(IDE_SERVER_FILE_REGEX); + const bMatch = b.match(IDE_SERVER_FILE_REGEX); + const aPid = aMatch ? parseInt(aMatch[1], 10) : 0; + const bPid = bMatch ? parseInt(bMatch[1], 10) : 0; + + if (aPid === targetPid && bPid !== targetPid) { + return -1; + } + if (bPid === targetPid && aPid !== targetPid) { + return 1; + } + + const aIsAlive = isPidAlive(aPid); + const bIsAlive = isPidAlive(bPid); + + if (aIsAlive && !bIsAlive) { + return -1; + } + if (bIsAlive && !aIsAlive) { + return 1; + } + + // Newest PIDs first as a heuristic + return bPid - aPid; + }); +} + +function isPidAlive(pid: number): boolean { + if (pid <= 0) { + return false; + } + // Assume the process is alive since checking would introduce significant overhead. + if (os.platform() === 'win32') { + return true; + } + try { + process.kill(pid, 0); + return true; + } catch (e) { + return isNodeError(e) && e.code === 'EPERM'; + } } export async function createProxyAwareFetch(ideServerHost: string) { From 082f41f54d3227e111e028c0c2cce889c46177ec Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Thu, 19 Feb 2026 08:17:34 -0800 Subject: [PATCH 04/26] Deflake windows tests. (#19511) --- packages/cli/src/test-utils/render.tsx | 42 +++---- .../ConfigInitDisplay.test.tsx.snap | 12 ++ .../ExitPlanModeDialog.test.tsx.snap | 108 ++++++++++++++++++ 3 files changed, 136 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index ac6862e893..2375a0fba1 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -76,7 +76,6 @@ class XtermStdout extends EventEmitter { isTTY = true; private lastRenderOutput: string | undefined = undefined; - private lastRenderStaticContent: string | undefined = undefined; constructor(state: TerminalState, queue: { promise: Promise }) { super(); @@ -109,7 +108,6 @@ class XtermStdout extends EventEmitter { clear = () => { this.state.terminal.reset(); this.lastRenderOutput = undefined; - this.lastRenderStaticContent = undefined; }; dispose = () => { @@ -118,33 +116,23 @@ class XtermStdout extends EventEmitter { onRender = (staticContent: string, output: string) => { this.renderCount++; - this.lastRenderStaticContent = staticContent; this.lastRenderOutput = output; this.emit('render'); }; lastFrame = (options: { allowEmpty?: boolean } = {}) => { - let result: string; - // On Windows, xterm.js headless can sometimes have timing or rendering issues - // that lead to duplicated content or incorrect buffer state in tests. - // As a fallback, we can trust the raw output Ink provided during onRender. - if (os.platform() === 'win32') { - result = - (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''); - } else { - const buffer = this.state.terminal.buffer.active; - const allLines: string[] = []; - for (let i = 0; i < buffer.length; i++) { - allLines.push(buffer.getLine(i)?.translateToString(true) ?? ''); - } - - const trimmed = [...allLines]; - while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { - trimmed.pop(); - } - result = trimmed.join('\n'); + const buffer = this.state.terminal.buffer.active; + const allLines: string[] = []; + for (let i = 0; i < buffer.length; i++) { + allLines.push(buffer.getLine(i)?.translateToString(true) ?? ''); } + const trimmed = [...allLines]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { + trimmed.pop(); + } + const result = trimmed.join('\n'); + // Normalize for cross-platform snapshot stability: // Normalize any \r\n to \n const normalized = result.replace(/\r\n/g, '\n'); @@ -195,9 +183,7 @@ class XtermStdout extends EventEmitter { const currentFrame = stripAnsi( this.lastFrame({ allowEmpty: true }), ).trim(); - const expectedFrame = stripAnsi( - (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''), - ) + const expectedFrame = stripAnsi(this.lastRenderOutput ?? '') .trim() .replace(/\r\n/g, '\n'); @@ -336,7 +322,11 @@ export const render = ( terminalWidth?: number, ): RenderInstance => { const cols = terminalWidth ?? 100; - const rows = 40; + // We use 1000 rows to avoid windows with incorrect snapshots if a correct + // value was used (e.g. 40 rows). The alternatives to make things worse are + // windows unfortunately with odd duplicate content in the backbuffer + // which does not match actual behavior in xterm.js on windows. + const rows = 1000; const terminal = new Terminal({ cols, rows, diff --git a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap index 8d03baaa49..28929deee5 100644 --- a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap @@ -18,8 +18,20 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more " `; +exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = ` +" +Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more +" +`; + exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = ` " Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 " `; + +exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = ` +" +Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap index 587ded8f29..faa759a050 100644 --- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -27,6 +27,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; +exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool +● 3. Type your feedback... + +Enter to submit · Esc to cancel +" +`; + exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -54,6 +81,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; +exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool +● 3. Add tests + +Enter to submit · Esc to cancel +" +`; + exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = ` " Error reading plan: File not found " @@ -140,6 +194,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; +exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool +● 3. Type your feedback... + +Enter to submit · Esc to cancel +" +`; + exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -167,6 +248,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; +exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool +● 3. Add tests + +Enter to submit · Esc to cancel +" +`; + exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = ` " Error reading plan: File not found " From 5d235952baf01ef57eb3948cdb0c11666906f71a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E6=98=8E=E8=89=B2=E3=81=AE=E5=BF=98=E3=82=8C?= =?UTF-8?q?=E8=B7=AF?= Date: Fri, 20 Feb 2026 02:28:06 +0900 Subject: [PATCH 05/26] Fix: Avoid tool confirmation timeout when no UI listeners are present (#17955) --- .../core/src/confirmation-bus/message-bus.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core/src/confirmation-bus/message-bus.ts b/packages/core/src/confirmation-bus/message-bus.ts index b9033fd67d..1600f2f5b2 100644 --- a/packages/core/src/confirmation-bus/message-bus.ts +++ b/packages/core/src/confirmation-bus/message-bus.ts @@ -79,8 +79,21 @@ export class MessageBus extends EventEmitter { }); break; case PolicyDecision.ASK_USER: - // Pass through to UI for user confirmation - this.emitMessage(message); + // Pass through to UI for user confirmation if any listeners exist. + // If no listeners are registered (e.g., headless/ACP flows), + // immediately request user confirmation to avoid long timeouts. + if ( + this.listenerCount(MessageBusType.TOOL_CONFIRMATION_REQUEST) > 0 + ) { + this.emitMessage(message); + } else { + this.emitMessage({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: message.correlationId, + confirmed: false, + requiresUserConfirmation: true, + }); + } break; default: throw new Error(`Unknown policy decision: ${decision}`); From ba3e327ba19e82bf75ee30708892883d8fad1a3c Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Thu, 19 Feb 2026 09:43:53 -0800 Subject: [PATCH 06/26] format md file (#19474) From 09b623fbd7713b15210e7dfe115c60e923d3e9a2 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Thu, 19 Feb 2026 10:22:11 -0800 Subject: [PATCH 07/26] feat(cli): add experimental.useOSC52Copy setting (#19488) --- docs/cli/settings.md | 13 +++++----- docs/get-started/configuration.md | 11 ++++++-- packages/cli/src/config/settingsSchema.ts | 12 ++++++++- .../cli/src/ui/commands/copyCommand.test.ts | 26 ++++++++++++++++--- packages/cli/src/ui/commands/copyCommand.ts | 3 ++- .../cli/src/ui/utils/commandUtils.test.ts | 24 +++++++++++++++++ packages/cli/src/ui/utils/commandUtils.ts | 17 +++++++++--- schemas/settings.schema.json | 11 ++++++-- 8 files changed, 98 insertions(+), 19 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 58ba252ec9..e29ed30214 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -128,12 +128,13 @@ they appear in the UI. ### Experimental -| UI Label | Setting | Description | Default | -| -------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ------- | -| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | -| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` | -| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` | -| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | +| UI Label | Setting | Description | Default | +| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | +| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | +| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | +| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` | +| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | ### Skills diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 77065ab3c8..c61aab5d18 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -941,8 +941,15 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`experimental.useOSC52Paste`** (boolean): - - **Description:** Use OSC 52 sequence for pasting instead of clipboardy - (useful for remote sessions). + - **Description:** Use OSC 52 for pasting. This may be more robust than the + default system when using remote terminal sessions (if your terminal is + configured to allow it). + - **Default:** `false` + +- **`experimental.useOSC52Copy`** (boolean): + - **Description:** Use OSC 52 for copying. This may be more robust than the + default system when using remote terminal sessions (if your terminal is + configured to allow it). - **Default:** `false` - **`experimental.plan`** (boolean): diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 40dd6a10f2..1138614235 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1632,7 +1632,17 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: - 'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).', + 'Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).', + showInDialog: true, + }, + useOSC52Copy: { + type: 'boolean', + label: 'Use OSC 52 Copy', + category: 'Experimental', + requiresRestart: false, + default: false, + description: + 'Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).', showInDialog: true, }, plan: { diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index de75090401..e8aace1bcc 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -125,6 +125,7 @@ describe('copyCommand', () => { expect(mockCopyToClipboard).toHaveBeenCalledWith( 'Hi there! How can I help you?', + expect.anything(), ); }); @@ -143,7 +144,10 @@ describe('copyCommand', () => { const result = await copyCommand.action(mockContext, ''); - expect(mockCopyToClipboard).toHaveBeenCalledWith('Part 1: Part 2: Part 3'); + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'Part 1: Part 2: Part 3', + expect.anything(), + ); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -170,7 +174,10 @@ describe('copyCommand', () => { const result = await copyCommand.action(mockContext, ''); - expect(mockCopyToClipboard).toHaveBeenCalledWith('Text part more text'); + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'Text part more text', + expect.anything(), + ); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -201,7 +208,10 @@ describe('copyCommand', () => { const result = await copyCommand.action(mockContext, ''); - expect(mockCopyToClipboard).toHaveBeenCalledWith('Second AI response'); + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'Second AI response', + expect.anything(), + ); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -230,6 +240,11 @@ describe('copyCommand', () => { messageType: 'error', content: `Failed to copy to the clipboard. ${clipboardError.message}`, }); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'AI response', + expect.anything(), + ); }); it('should handle non-Error clipboard errors', async () => { @@ -253,6 +268,11 @@ describe('copyCommand', () => { messageType: 'error', content: `Failed to copy to the clipboard. ${rejectedValue}`, }); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'AI response', + expect.anything(), + ); }); it('should return info message when no text parts found in AI message', async () => { diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 07f529869b..c2c6ab13d1 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -38,7 +38,8 @@ export const copyCommand: SlashCommand = { if (lastAiOutput) { try { - await copyToClipboard(lastAiOutput); + const settings = context.services.settings.merged; + await copyToClipboard(lastAiOutput, settings); return { type: 'message', diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 6e64e292a5..737948ce98 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -14,6 +14,7 @@ import { copyToClipboard, getUrlOpenCommand, } from './commandUtils.js'; +import type { Settings } from '../../config/settingsSchema.js'; // Constants used by OSC-52 tests const ESC = '\u001B'; @@ -257,6 +258,29 @@ describe('commandUtils', () => { expect(mockClipboardyWrite).not.toHaveBeenCalled(); }); + it('uses OSC-52 when useOSC52Copy setting is enabled', async () => { + const testText = 'forced-osc52'; + const tty = makeWritable({ isTTY: true }); + mockFs.createWriteStream.mockImplementation(() => { + setTimeout(() => tty.emit('open'), 0); + return tty; + }); + + // NO environment signals for SSH/WSL/etc. + const settings = { + experimental: { useOSC52Copy: true }, + } as unknown as Settings; + + await copyToClipboard(testText, settings); + + const b64 = Buffer.from(testText, 'utf8').toString('base64'); + const expected = `${ESC}]52;c;${b64}${BEL}`; + + expect(tty.write).toHaveBeenCalledTimes(1); + expect(tty.write.mock.calls[0][0]).toBe(expected); + expect(mockClipboardyWrite).not.toHaveBeenCalled(); + }); + it('wraps OSC-52 for tmux when in SSH', async () => { const testText = 'tmux-copy'; const tty = makeWritable({ isTTY: true }); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index f87a4f583a..0d52c83863 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -9,6 +9,7 @@ import clipboardy from 'clipboardy'; import type { SlashCommand } from '../commands/types.js'; import fs from 'node:fs'; import type { Writable } from 'node:stream'; +import type { Settings } from '../../config/settingsSchema.js'; /** * Checks if a query string potentially represents an '@' command. @@ -157,8 +158,13 @@ const isWindowsTerminal = (): boolean => const isDumbTerm = (): boolean => (process.env['TERM'] ?? '') === 'dumb'; -const shouldUseOsc52 = (tty: TtyTarget): boolean => - Boolean(tty) && !isDumbTerm() && (isSSH() || isWSL() || isWindowsTerminal()); +const shouldUseOsc52 = (tty: TtyTarget, settings?: Settings): boolean => + Boolean(tty) && + !isDumbTerm() && + (settings?.experimental?.useOSC52Copy || + isSSH() || + isWSL() || + isWindowsTerminal()); const safeUtf8Truncate = (buf: Buffer, maxBytes: number): Buffer => { if (buf.length <= maxBytes) return buf; @@ -237,12 +243,15 @@ const writeAll = (stream: Writable, data: string): Promise => }); // Copies a string snippet to the clipboard with robust OSC-52 support. -export const copyToClipboard = async (text: string): Promise => { +export const copyToClipboard = async ( + text: string, + settings?: Settings, +): Promise => { if (!text) return; const tty = await pickTty(); - if (shouldUseOsc52(tty)) { + if (shouldUseOsc52(tty, settings)) { const osc = buildOsc52(text); const payload = inTmux() ? wrapForTmux(osc) diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 7847323ea2..abe8e97de0 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1587,8 +1587,15 @@ }, "useOSC52Paste": { "title": "Use OSC 52 Paste", - "description": "Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).", - "markdownDescription": "Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`", + "description": "Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).", + "markdownDescription": "Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "useOSC52Copy": { + "title": "Use OSC 52 Copy", + "description": "Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).", + "markdownDescription": "Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`", "default": false, "type": "boolean" }, From 372f41eab872e6d291ebe347055a9f0b06bdf423 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Thu, 19 Feb 2026 13:43:12 -0500 Subject: [PATCH 08/26] feat(cli): replace loading phrases boolean with enum setting (#19347) --- docs/cli/settings.md | 60 +++++++------- docs/get-started/configuration.md | 9 ++- packages/cli/src/config/settings.test.ts | 79 +++++++++++++++++++ packages/cli/src/config/settings.ts | 19 +++++ .../cli/src/config/settingsSchema.test.ts | 13 +++ packages/cli/src/config/settingsSchema.ts | 21 ++++- packages/cli/src/ui/AppContainer.tsx | 2 + .../cli/src/ui/components/Composer.test.tsx | 8 +- packages/cli/src/ui/components/Composer.tsx | 8 +- packages/cli/src/ui/constants/tips.ts | 2 +- .../src/ui/hooks/useLoadingIndicator.test.tsx | 20 ++++- .../cli/src/ui/hooks/useLoadingIndicator.ts | 4 + .../cli/src/ui/hooks/usePhraseCycler.test.tsx | 5 ++ packages/cli/src/ui/hooks/usePhraseCycler.ts | 67 ++++++++++------ packages/core/src/config/config.ts | 1 + schemas/settings.schema.json | 12 ++- 16 files changed, 260 insertions(+), 70 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index e29ed30214..318fdbea75 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -41,36 +41,36 @@ they appear in the UI. ### UI -| UI Label | Setting | Description | Default | -| ------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | -| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | -| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | -| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | -| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | -| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` | -| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | -| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` | -| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | -| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` | -| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | -| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | -| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` | -| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | -| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | -| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` | -| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | -| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | -| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | -| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | -| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | -| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` | -| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | -| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | -| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | -| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | -| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` | -| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | +| UI Label | Setting | Description | Default | +| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | +| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | +| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | +| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | +| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | +| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` | +| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | +| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` | +| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | +| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` | +| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | +| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | +| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` | +| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | +| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | +| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` | +| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | +| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | +| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | +| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | +| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | +| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` | +| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | +| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | +| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | +| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | +| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` | +| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | ### IDE diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index c61aab5d18..8e2164080f 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -305,13 +305,20 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Show the spinner during operations. - **Default:** `true` +- **`ui.loadingPhrases`** (enum): + - **Description:** What to show while the model is working: tips, witty + comments, both, or nothing. + - **Default:** `"tips"` + - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` + - **`ui.customWittyPhrases`** (array): - **Description:** Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults. - **Default:** `[]` - **`ui.accessibility.enableLoadingPhrases`** (boolean): - - **Description:** Enable loading phrases during operations. + - **Description:** @deprecated Use ui.loadingPhrases instead. Enable loading + phrases during operations. - **Default:** `true` - **Requires restart:** Yes diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index e88c9104dd..7b341b3ee0 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2032,6 +2032,85 @@ describe('Settings Loading and Merging', () => { }), }), ); + + // Check that enableLoadingPhrases: false was further migrated to loadingPhrases: 'off' + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'ui', + expect.objectContaining({ + loadingPhrases: 'off', + }), + ); + }); + + it('should migrate enableLoadingPhrases: false to loadingPhrases: off', () => { + const userSettingsContent = { + ui: { + accessibility: { + enableLoadingPhrases: false, + }, + }, + }; + + const loadedSettings = createMockSettings(userSettingsContent); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings); + + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'ui', + expect.objectContaining({ + loadingPhrases: 'off', + }), + ); + }); + + it('should not migrate enableLoadingPhrases: true to loadingPhrases', () => { + const userSettingsContent = { + ui: { + accessibility: { + enableLoadingPhrases: true, + }, + }, + }; + + const loadedSettings = createMockSettings(userSettingsContent); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings); + + // Should not set loadingPhrases when enableLoadingPhrases is true + const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui'); + for (const call of uiCalls) { + const uiValue = call[2] as Record; + expect(uiValue).not.toHaveProperty('loadingPhrases'); + } + }); + + it('should not overwrite existing loadingPhrases during migration', () => { + const userSettingsContent = { + ui: { + loadingPhrases: 'witty', + accessibility: { + enableLoadingPhrases: false, + }, + }, + }; + + const loadedSettings = createMockSettings(userSettingsContent); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings); + + // Should not overwrite existing loadingPhrases + const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui'); + for (const call of uiCalls) { + const uiValue = call[2] as Record; + if (uiValue['loadingPhrases'] !== undefined) { + expect(uiValue['loadingPhrases']).toBe('witty'); + } + } }); it('should prioritize new settings over deprecated ones and respect removeDeprecated flag', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 7644588407..2f6f2f7450 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -165,7 +165,10 @@ export interface SummarizeToolOutputSettings { tokenBudget?: number; } +export type LoadingPhrasesMode = 'tips' | 'witty' | 'all' | 'off'; + export interface AccessibilitySettings { + /** @deprecated Use ui.loadingPhrases instead. */ enableLoadingPhrases?: boolean; screenReader?: boolean; } @@ -928,6 +931,22 @@ export function migrateDeprecatedSettings( anyModified = true; } } + + // Migrate enableLoadingPhrases: false → loadingPhrases: 'off' + const enableLP = newAccessibility['enableLoadingPhrases']; + if ( + typeof enableLP === 'boolean' && + newUi['loadingPhrases'] === undefined + ) { + if (!enableLP) { + newUi['loadingPhrases'] = 'off'; + loadedSettings.setValue(scope, 'ui', newUi); + if (!settingsFile.readOnly) { + anyModified = true; + } + } + foundDeprecated.push('ui.accessibility.enableLoadingPhrases'); + } } } diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2a2b535eea..2638ac0347 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -83,6 +83,19 @@ describe('SettingsSchema', () => { ).toBe('boolean'); }); + it('should have loadingPhrases enum property', () => { + const definition = getSettingsSchema().ui?.properties?.loadingPhrases; + expect(definition).toBeDefined(); + expect(definition?.type).toBe('enum'); + expect(definition?.default).toBe('tips'); + expect(definition?.options?.map((o) => o.value)).toEqual([ + 'tips', + 'witty', + 'all', + 'off', + ]); + }); + it('should have checkpointing nested properties', () => { expect( getSettingsSchema().general?.properties?.checkpointing.properties diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 1138614235..70c5363659 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -672,6 +672,22 @@ const SETTINGS_SCHEMA = { description: 'Show the spinner during operations.', showInDialog: true, }, + loadingPhrases: { + type: 'enum', + label: 'Loading Phrases', + category: 'UI', + requiresRestart: false, + default: 'tips', + description: + 'What to show while the model is working: tips, witty comments, both, or nothing.', + showInDialog: true, + options: [ + { value: 'tips', label: 'Tips' }, + { value: 'witty', label: 'Witty' }, + { value: 'all', label: 'All' }, + { value: 'off', label: 'Off' }, + ], + }, customWittyPhrases: { type: 'array', label: 'Custom Witty Phrases', @@ -700,8 +716,9 @@ const SETTINGS_SCHEMA = { category: 'UI', requiresRestart: true, default: true, - description: 'Enable loading phrases during operations.', - showInDialog: true, + description: + '@deprecated Use ui.loadingPhrases instead. Enable loading phrases during operations.', + showInDialog: false, }, screenReader: { type: 'boolean', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a3b460555b..08bae44959 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1596,6 +1596,8 @@ Logging in with Google... Restarting Gemini CLI to continue. streamingState, shouldShowFocusHint, retryStatus, + loadingPhrasesMode: settings.merged.ui.loadingPhrases, + customWittyPhrases: settings.merged.ui.customWittyPhrases, }); const handleGlobalKeypress = useCallback( diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 946a041841..12deda3e76 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -391,16 +391,16 @@ describe('Composer', () => { expect(output).not.toContain('ShortcutsHint'); }); - it('renders LoadingIndicator without thought when accessibility disables loading phrases', async () => { + it('renders LoadingIndicator without thought when loadingPhrases is off', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Hidden', description: 'Should not show' }, }); - const config = createMockConfig({ - getAccessibility: vi.fn(() => ({ enableLoadingPhrases: false })), + const settings = createMockSettings({ + merged: { ui: { loadingPhrases: 'off' } }, }); - const { lastFrame } = await renderComposer(uiState, undefined, config); + const { lastFrame } = await renderComposer(uiState, settings); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index fd30e33858..fe2adba9ab 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -211,12 +211,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { thought={ uiState.streamingState === StreamingState.WaitingForConfirmation || - config.getAccessibility()?.enableLoadingPhrases === false + settings.merged.ui.loadingPhrases === 'off' ? undefined : uiState.thought } currentLoadingPhrase={ - config.getAccessibility()?.enableLoadingPhrases === false + settings.merged.ui.loadingPhrases === 'off' ? undefined : uiState.currentLoadingPhrase } @@ -255,12 +255,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { thought={ uiState.streamingState === StreamingState.WaitingForConfirmation || - config.getAccessibility()?.enableLoadingPhrases === false + settings.merged.ui.loadingPhrases === 'off' ? undefined : uiState.thought } currentLoadingPhrase={ - config.getAccessibility()?.enableLoadingPhrases === false + settings.merged.ui.loadingPhrases === 'off' ? undefined : uiState.currentLoadingPhrase } diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts index 4678efb54e..f061175adb 100644 --- a/packages/cli/src/ui/constants/tips.ts +++ b/packages/cli/src/ui/constants/tips.ts @@ -24,7 +24,7 @@ export const INFORMATIVE_TIPS = [ 'Show memory usage for performance monitoring (/settings)…', 'Show line numbers in the chat for easier reference (/settings)…', 'Show citations to see where the model gets information (/settings)…', - 'Disable loading phrases for a quieter experience (/settings)…', + 'Customize loading phrases: tips, witty, all, or off (/settings)…', 'Add custom witty phrases to the loading screen (settings.json)…', 'Use alternate screen buffer to preserve shell history (/settings)…', 'Choose a specific Gemini model for conversations (/settings)…', diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx index 23da2131b2..16ab6198ab 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx @@ -16,6 +16,7 @@ import { import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js'; import type { RetryAttemptPayload } from '@google/gemini-cli-core'; +import type { LoadingPhrasesMode } from '../../config/settings.js'; describe('useLoadingIndicator', () => { beforeEach(() => { @@ -33,21 +34,25 @@ describe('useLoadingIndicator', () => { initialStreamingState: StreamingState, initialShouldShowFocusHint: boolean = false, initialRetryStatus: RetryAttemptPayload | null = null, + loadingPhrasesMode: LoadingPhrasesMode = 'all', ) => { let hookResult: ReturnType; function TestComponent({ streamingState, shouldShowFocusHint, retryStatus, + mode, }: { streamingState: StreamingState; shouldShowFocusHint?: boolean; retryStatus?: RetryAttemptPayload | null; + mode?: LoadingPhrasesMode; }) { hookResult = useLoadingIndicator({ streamingState, shouldShowFocusHint: !!shouldShowFocusHint, retryStatus: retryStatus || null, + loadingPhrasesMode: mode, }); return null; } @@ -56,6 +61,7 @@ describe('useLoadingIndicator', () => { streamingState={initialStreamingState} shouldShowFocusHint={initialShouldShowFocusHint} retryStatus={initialRetryStatus} + mode={loadingPhrasesMode} />, ); return { @@ -68,7 +74,8 @@ describe('useLoadingIndicator', () => { streamingState: StreamingState; shouldShowFocusHint?: boolean; retryStatus?: RetryAttemptPayload | null; - }) => rerender(), + mode?: LoadingPhrasesMode; + }) => rerender(), }; }; @@ -221,4 +228,15 @@ describe('useLoadingIndicator', () => { expect(result.current.currentLoadingPhrase).toContain('Trying to reach'); expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3'); }); + + it('should show no phrases when loadingPhrasesMode is "off"', () => { + const { result } = renderLoadingIndicatorHook( + StreamingState.Responding, + false, + null, + 'off', + ); + + expect(result.current.currentLoadingPhrase).toBeUndefined(); + }); }); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index 2c8f6b0d1e..b6c85da6b8 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -12,11 +12,13 @@ import { getDisplayString, type RetryAttemptPayload, } from '@google/gemini-cli-core'; +import type { LoadingPhrasesMode } from '../../config/settings.js'; export interface UseLoadingIndicatorProps { streamingState: StreamingState; shouldShowFocusHint: boolean; retryStatus: RetryAttemptPayload | null; + loadingPhrasesMode?: LoadingPhrasesMode; customWittyPhrases?: string[]; } @@ -24,6 +26,7 @@ export const useLoadingIndicator = ({ streamingState, shouldShowFocusHint, retryStatus, + loadingPhrasesMode, customWittyPhrases, }: UseLoadingIndicatorProps) => { const [timerResetKey, setTimerResetKey] = useState(0); @@ -37,6 +40,7 @@ export const useLoadingIndicator = ({ isPhraseCyclingActive, isWaiting, shouldShowFocusHint, + loadingPhrasesMode, customWittyPhrases, ); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index 9926e017d8..837d953c3c 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -14,23 +14,27 @@ import { } from './usePhraseCycler.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; +import type { LoadingPhrasesMode } from '../../config/settings.js'; // Test component to consume the hook const TestComponent = ({ isActive, isWaiting, isInteractiveShellWaiting = false, + loadingPhrasesMode = 'all', customPhrases, }: { isActive: boolean; isWaiting: boolean; isInteractiveShellWaiting?: boolean; + loadingPhrasesMode?: LoadingPhrasesMode; customPhrases?: string[]; }) => { const phrase = usePhraseCycler( isActive, isWaiting, isInteractiveShellWaiting, + loadingPhrasesMode, customPhrases, ); return {phrase}; @@ -289,6 +293,7 @@ describe('usePhraseCycler', () => { ); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index ffc469f02a..8ddab6eef9 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -7,6 +7,7 @@ import { useState, useEffect, useRef } from 'react'; import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; +import type { LoadingPhrasesMode } from '../../config/settings.js'; export const PHRASE_CHANGE_INTERVAL_MS = 15000; export const INTERACTIVE_SHELL_WAITING_PHRASE = @@ -17,23 +18,20 @@ export const INTERACTIVE_SHELL_WAITING_PHRASE = * @param isActive Whether the phrase cycling should be active. * @param isWaiting Whether to show a specific waiting phrase. * @param shouldShowFocusHint Whether to show the shell focus hint. - * @param customPhrases Optional list of custom phrases to use. + * @param loadingPhrasesMode Which phrases to show: tips, witty, all, or off. + * @param customPhrases Optional list of custom phrases to use instead of built-in witty phrases. * @returns The current loading phrase. */ export const usePhraseCycler = ( isActive: boolean, isWaiting: boolean, shouldShowFocusHint: boolean, + loadingPhrasesMode: LoadingPhrasesMode = 'tips', customPhrases?: string[], ) => { - const loadingPhrases = - customPhrases && customPhrases.length > 0 - ? customPhrases - : WITTY_LOADING_PHRASES; - const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState< string | undefined - >(isActive ? loadingPhrases[0] : undefined); + >(undefined); const phraseIntervalRef = useRef(null); const hasShownFirstRequestTipRef = useRef(false); @@ -55,30 +53,43 @@ export const usePhraseCycler = ( return; } - if (!isActive) { + if (!isActive || loadingPhrasesMode === 'off') { setCurrentLoadingPhrase(undefined); return; } + const wittyPhrases = + customPhrases && customPhrases.length > 0 + ? customPhrases + : WITTY_LOADING_PHRASES; + const setRandomPhrase = () => { - if (customPhrases && customPhrases.length > 0) { - const randomIndex = Math.floor(Math.random() * customPhrases.length); - setCurrentLoadingPhrase(customPhrases[randomIndex]); - } else { - let phraseList; - // Show a tip on the first request after startup, then continue with 1/6 chance - if (!hasShownFirstRequestTipRef.current) { - // Show a tip during the first request + let phraseList: readonly string[]; + + switch (loadingPhrasesMode) { + case 'tips': phraseList = INFORMATIVE_TIPS; - hasShownFirstRequestTipRef.current = true; - } else { - // Roughly 1 in 6 chance to show a tip after the first request - const showTip = Math.random() < 1 / 6; - phraseList = showTip ? INFORMATIVE_TIPS : WITTY_LOADING_PHRASES; - } - const randomIndex = Math.floor(Math.random() * phraseList.length); - setCurrentLoadingPhrase(phraseList[randomIndex]); + break; + case 'witty': + phraseList = wittyPhrases; + break; + case 'all': + // Show a tip on the first request after startup, then continue with 1/6 chance + if (!hasShownFirstRequestTipRef.current) { + phraseList = INFORMATIVE_TIPS; + hasShownFirstRequestTipRef.current = true; + } else { + const showTip = Math.random() < 1 / 6; + phraseList = showTip ? INFORMATIVE_TIPS : wittyPhrases; + } + break; + default: + phraseList = INFORMATIVE_TIPS; + break; } + + const randomIndex = Math.floor(Math.random() * phraseList.length); + setCurrentLoadingPhrase(phraseList[randomIndex]); }; // Select an initial random phrase @@ -95,7 +106,13 @@ export const usePhraseCycler = ( phraseIntervalRef.current = null; } }; - }, [isActive, isWaiting, shouldShowFocusHint, customPhrases, loadingPhrases]); + }, [ + isActive, + isWaiting, + shouldShowFocusHint, + loadingPhrasesMode, + customPhrases, + ]); return currentLoadingPhrase; }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ad2b0a1a1b..33e02abf89 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -128,6 +128,7 @@ import { isSubpath } from '../utils/paths.js'; import { UserHintService } from './userHintService.js'; export interface AccessibilitySettings { + /** @deprecated Use ui.loadingPhrases instead. */ enableLoadingPhrases?: boolean; screenReader?: boolean; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index abe8e97de0..f15af605a3 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -405,6 +405,14 @@ "default": true, "type": "boolean" }, + "loadingPhrases": { + "title": "Loading Phrases", + "description": "What to show while the model is working: tips, witty comments, both, or nothing.", + "markdownDescription": "What to show while the model is working: tips, witty comments, both, or nothing.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `tips`", + "default": "tips", + "type": "string", + "enum": ["tips", "witty", "all", "off"] + }, "customWittyPhrases": { "title": "Custom Witty Phrases", "description": "Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults.", @@ -424,8 +432,8 @@ "properties": { "enableLoadingPhrases": { "title": "Enable Loading Phrases", - "description": "Enable loading phrases during operations.", - "markdownDescription": "Enable loading phrases during operations.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `true`", + "description": "@deprecated Use ui.loadingPhrases instead. Enable loading phrases during operations.", + "markdownDescription": "@deprecated Use ui.loadingPhrases instead. Enable loading phrases during operations.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `true`", "default": true, "type": "boolean" }, From a00eb3b8e6acb0632c3b35b72286e343a2e110ce Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:49:27 -0800 Subject: [PATCH 09/26] Update skill to adjust for generated results. (#19500) --- .gemini/skills/docs-changelog/SKILL.md | 38 +++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/.gemini/skills/docs-changelog/SKILL.md b/.gemini/skills/docs-changelog/SKILL.md index edd402b6bc..d3a2f63623 100644 --- a/.gemini/skills/docs-changelog/SKILL.md +++ b/.gemini/skills/docs-changelog/SKILL.md @@ -109,23 +109,41 @@ detailed **highlights** section for the release-specific page. - **Target File**: `docs/changelogs/latest.md` - Perform the following edits on the target file: - 1. Update the version in the main header. - 2. Update the "Released:" date. + 1. Update the version in the main header. The line should read, + `# Latest stable release: {{version}}` + 2. Update the rease date. The line should read, + `Released: {{release_date_month_dd_yyyy}}` 3. **Prepend** the processed "What's Changed" list from the temporary file - to the existing "What's Changed" list in the file. - 4. In the "Full Changelog" URL, replace only the trailing version with the - new patch version. + to the existing "What's Changed" list in `latest.md`. Do not change or + replace the existing list, **only add** to the beginning of it. + 4. In the "Full Changelog", edit **only** the end of the URL. Identify the + last part of the URL that looks like `...{previous_version}` and update + it to be `...{version}`. + + Example: assume the patch version is `v0.29.1`. Change + `Full Changelog: https://github.com/google-gemini/gemini-cli/compare/v0.28.2…v0.29.0` + to + `Full Changelog: https://github.com/google-gemini/gemini-cli/compare/v0.28.2…v0.29.1` ### B.2: Preview Patch (e.g., `v0.29.0-preview.3`) - **Target File**: `docs/changelogs/preview.md` - Perform the following edits on the target file: - 1. Update the version in the main header. - 2. Update the "Released:" date. + 1. Update the version in the main header. The line should read, + `# Preview release: {{version}}` + 2. Update the rease date. The line should read, + `Released: {{release_date_month_dd_yyyy}}` 3. **Prepend** the processed "What's Changed" list from the temporary file - to the existing "What's Changed" list in the file. - 4. In the "Full Changelog" URL, replace only the trailing version with the - new patch version. + to the existing "What's Changed" list in `preview.md`. Do not change or + replace the existing list, **only add** to the beginning of it. + 4. In the "Full Changelog", edit **only** the end of the URL. Identify the + last part of the URL that looks like `...{previous_version}` and update + it to be `...{version}`. + + Example: assume the patch version is `v0.29.0-preview.1`. Change + `Full Changelog: https://github.com/google-gemini/gemini-cli/compare/v0.28.2…v0.29.0-preview.0` + to + `Full Changelog: https://github.com/google-gemini/gemini-cli/compare/v0.28.2…v0.29.0-preview.1` --- From c276d0c7b6c2dec4c38335355eb4a903c2379409 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 19 Feb 2026 19:06:36 +0000 Subject: [PATCH 10/26] Fix message too large issue. (#19499) --- packages/core/src/agents/local-executor.ts | 7 ++++ packages/core/src/core/client.ts | 9 +++++ packages/core/src/core/turn.ts | 3 ++ .../services/chatCompressionService.test.ts | 4 ++- .../src/services/chatCompressionService.ts | 34 ++++++++++++++++--- 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 0d2f009a9e..bcb4e888ce 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -689,6 +689,13 @@ export class LocalAgentExecutor { chat.setHistory(newHistory); this.hasFailedCompressionAttempt = false; } + } else if (info.compressionStatus === CompressionStatus.CONTENT_TRUNCATED) { + if (newHistory) { + chat.setHistory(newHistory); + // Do NOT reset hasFailedCompressionAttempt. + // We only truncated content because summarization previously failed. + // We want to keep avoiding expensive summarization calls. + } } } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 951da7d6ef..0951eb397b 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1082,6 +1082,15 @@ export class GeminiClient { this.updateTelemetryTokenCount(); this.forceFullIdeContext = true; } + } else if (info.compressionStatus === CompressionStatus.CONTENT_TRUNCATED) { + if (newHistory) { + // We truncated content to save space, but summarization is still "failed". + // We update the chat context directly without resetting the failure flag. + this.getChat().setHistory(newHistory); + this.updateTelemetryTokenCount(); + // We don't reset the chat session fully like in COMPRESSED because + // this is a lighter-weight intervention. + } } return info; diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index f31050dd83..b868da8e4f 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -180,6 +180,9 @@ export enum CompressionStatus { /** The compression was not necessary and no action was taken */ NOOP, + + /** The compression was skipped due to previous failure, but content was truncated to budget */ + CONTENT_TRUNCATED, } export interface ChatCompressionInfo { diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 39b82869bd..4ddd38e25c 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -226,8 +226,10 @@ describe('ChatCompressionService', () => { false, mockModel, mockConfig, - true, + false, ); + // It should now attempt compression even if previously failed (logic removed) + // But since history is small, it will be NOOP due to threshold expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); expect(result.newHistory).toBeNull(); }); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 6f5366aad5..9878358966 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -240,10 +240,7 @@ export class ChatCompressionService { const curatedHistory = chat.getHistory(true); // Regardless of `force`, don't do anything if the history is empty. - if ( - curatedHistory.length === 0 || - (hasFailedCompressionAttempt && !force) - ) { + if (curatedHistory.length === 0) { return { newHistory: null, info: { @@ -285,6 +282,35 @@ export class ChatCompressionService { config, ); + // If summarization previously failed (and not forced), we only rely on truncation. + // We do NOT attempt to invoke the LLM for summarization again to avoid repeated failures/costs. + if (hasFailedCompressionAttempt && !force) { + const truncatedTokenCount = estimateTokenCountSync( + truncatedHistory.flatMap((c) => c.parts || []), + ); + + // If truncation reduced the size, we consider it a successful "compression" (truncation only). + if (truncatedTokenCount < originalTokenCount) { + return { + newHistory: truncatedHistory, + info: { + originalTokenCount, + newTokenCount: truncatedTokenCount, + compressionStatus: CompressionStatus.CONTENT_TRUNCATED, + }, + }; + } + + return { + newHistory: null, + info: { + originalTokenCount, + newTokenCount: originalTokenCount, + compressionStatus: CompressionStatus.NOOP, + }, + }; + } + const splitPoint = findCompressSplitPoint( truncatedHistory, 1 - COMPRESSION_PRESERVE_THRESHOLD, From 3408542a66f9e6987a3b5e5de92bb2e5ae89fb09 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Thu, 19 Feb 2026 12:03:52 -0800 Subject: [PATCH 11/26] fix(core): prevent duplicate tool approval entries in auto-saved.toml (#19487) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/agents/remote-invocation.ts | 4 +- .../src/tools/confirmation-policy.test.ts | 37 ++++++++----------- packages/core/src/tools/edit.ts | 13 ++----- packages/core/src/tools/enter-plan-mode.ts | 2 +- packages/core/src/tools/mcp-tool.ts | 2 +- packages/core/src/tools/memoryTool.ts | 2 +- packages/core/src/tools/shell.ts | 4 +- packages/core/src/tools/tools.ts | 4 +- packages/core/src/tools/web-fetch.test.ts | 7 ++-- packages/core/src/tools/web-fetch.ts | 13 ++----- packages/core/src/tools/write-file.ts | 13 ++----- 11 files changed, 39 insertions(+), 62 deletions(-) diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts index 10301f3090..41564944ec 100644 --- a/packages/core/src/agents/remote-invocation.ts +++ b/packages/core/src/agents/remote-invocation.ts @@ -117,8 +117,8 @@ export class RemoteAgentInvocation extends BaseToolInvocation< type: 'info', title: `Call Remote Agent: ${this.definition.displayName ?? this.definition.name}`, prompt: `Calling remote agent: "${this.params.query}"`, - onConfirm: async (outcome: ToolConfirmationOutcome) => { - await this.publishPolicyUpdate(outcome); + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // Policy updates are now handled centrally by the scheduler }, }; } diff --git a/packages/core/src/tools/confirmation-policy.test.ts b/packages/core/src/tools/confirmation-policy.test.ts index 30213ac4d9..72b6e11e21 100644 --- a/packages/core/src/tools/confirmation-policy.test.ts +++ b/packages/core/src/tools/confirmation-policy.test.ts @@ -136,17 +136,17 @@ describe('Tool Confirmation Policy Updates', () => { it.each([ { outcome: ToolConfirmationOutcome.ProceedAlways, - shouldPublish: false, + _shouldPublish: false, expectedApprovalMode: ApprovalMode.AUTO_EDIT, }, { outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave, - shouldPublish: true, - persist: true, + _shouldPublish: true, + _persist: true, }, ])( 'should handle $outcome correctly', - async ({ outcome, shouldPublish, persist, expectedApprovalMode }) => { + async ({ outcome, expectedApprovalMode }) => { const tool = create(mockConfig, mockMessageBus); // For file-based tools, ensure the file exists if needed @@ -172,26 +172,19 @@ describe('Tool Confirmation Policy Updates', () => { if (confirmation) { await confirmation.onConfirm(outcome); - if (shouldPublish) { - expect(mockMessageBus.publish).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageBusType.UPDATE_POLICY, - persist, - }), - ); - } else { - // Should not publish UPDATE_POLICY message for ProceedAlways - const publishCalls = (mockMessageBus.publish as any).mock.calls; - const hasUpdatePolicy = publishCalls.some( - (call: any) => call[0].type === MessageBusType.UPDATE_POLICY, - ); - expect(hasUpdatePolicy).toBe(false); - } + // Policy updates are no longer published by onConfirm; they are + // handled centrally by the schedulers. + const publishCalls = (mockMessageBus.publish as any).mock.calls; + const hasUpdatePolicy = publishCalls.some( + (call: any) => call[0].type === MessageBusType.UPDATE_POLICY, + ); + expect(hasUpdatePolicy).toBe(false); if (expectedApprovalMode !== undefined) { - expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( - expectedApprovalMode, - ); + // expectedApprovalMode in this test (AUTO_EDIT) is now handled + // by updatePolicy in the scheduler, so it should not be called + // here either. + expect(mockConfig.setApprovalMode).not.toHaveBeenCalled(); } } }, diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 8a48161662..130a05a8fe 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -14,7 +14,7 @@ import { BaseToolInvocation, Kind, type ToolCallConfirmationDetails, - ToolConfirmationOutcome, + type ToolConfirmationOutcome, type ToolEditConfirmationDetails, type ToolInvocation, type ToolLocation, @@ -725,14 +725,9 @@ class EditToolInvocation fileDiff, originalContent: editData.currentContent, newContent: editData.newContent, - onConfirm: async (outcome: ToolConfirmationOutcome) => { - if (outcome === ToolConfirmationOutcome.ProceedAlways) { - // No need to publish a policy update as the default policy for - // AUTO_EDIT already reflects always approving edit. - this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); - } else { - await this.publishPolicyUpdate(outcome); - } + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // Mode transitions (e.g. AUTO_EDIT) and policy updates are now + // handled centrally by the scheduler. if (ideConfirmation) { const result = await ideConfirmation; diff --git a/packages/core/src/tools/enter-plan-mode.ts b/packages/core/src/tools/enter-plan-mode.ts index feccb81089..9e1bed23a6 100644 --- a/packages/core/src/tools/enter-plan-mode.ts +++ b/packages/core/src/tools/enter-plan-mode.ts @@ -105,7 +105,7 @@ export class EnterPlanModeInvocation extends BaseToolInvocation< 'This will restrict the agent to read-only tools to allow for safe planning.', onConfirm: async (outcome: ToolConfirmationOutcome) => { this.confirmationOutcome = outcome; - await this.publishPolicyUpdate(outcome); + // Policy updates are now handled centrally by the scheduler }, }; } diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index c4d7a32038..280af4589a 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -130,7 +130,7 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation< DiscoveredMCPToolInvocation.allowlist.add(toolAllowListKey); } else if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) { DiscoveredMCPToolInvocation.allowlist.add(toolAllowListKey); - await this.publishPolicyUpdate(outcome); + // Persistent policy updates are now handled centrally by the scheduler } }, }; diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 31cc35077d..33cb9483e1 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -204,7 +204,7 @@ class MemoryToolInvocation extends BaseToolInvocation< if (outcome === ToolConfirmationOutcome.ProceedAlways) { MemoryToolInvocation.allowlist.add(allowlistKey); } - await this.publishPolicyUpdate(outcome); + // Policy updates are now handled centrally by the scheduler }, }; return confirmationDetails; diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index ff20b8a7b2..76db302f42 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -140,8 +140,8 @@ export class ShellToolInvocation extends BaseToolInvocation< command: this.params.command, rootCommand: rootCommandDisplay, rootCommands, - onConfirm: async (outcome: ToolConfirmationOutcome) => { - await this.publishPolicyUpdate(outcome); + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // Policy updates are now handled centrally by the scheduler }, }; return confirmationDetails; diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 3d90e80699..4e9972e37c 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -173,8 +173,8 @@ export abstract class BaseToolInvocation< type: 'info', title: `Confirm: ${this._toolDisplayName || this._toolName}`, prompt: this.getDescription(), - onConfirm: async (outcome: ToolConfirmationOutcome) => { - await this.publishPolicyUpdate(outcome); + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // Policy updates are now handled centrally by the scheduler }, }; return confirmationDetails; diff --git a/packages/core/src/tools/web-fetch.test.ts b/packages/core/src/tools/web-fetch.test.ts index ac483fccd9..f0c6ff2c7e 100644 --- a/packages/core/src/tools/web-fetch.test.ts +++ b/packages/core/src/tools/web-fetch.test.ts @@ -390,7 +390,7 @@ describe('WebFetchTool', () => { expect(confirmationDetails).toBe(false); }); - it('should call setApprovalMode when onConfirm is called with ProceedAlways', async () => { + it('should NOT call setApprovalMode when onConfirm is called with ProceedAlways (now handled by scheduler)', async () => { const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://example.com' }; const invocation = tool.build(params); @@ -408,9 +408,8 @@ describe('WebFetchTool', () => { ); } - expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.AUTO_EDIT, - ); + // Schedulers are now responsible for mode transitions via updatePolicy + expect(mockConfig.setApprovalMode).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 41d4b7a09d..214cf4916b 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -13,7 +13,7 @@ import { BaseDeclarativeTool, BaseToolInvocation, Kind, - ToolConfirmationOutcome, + type ToolConfirmationOutcome, } from './tools.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { ToolErrorType } from './tool-error.js'; @@ -246,14 +246,9 @@ ${textContent} title: `Confirm Web Fetch`, prompt: this.params.prompt, urls, - onConfirm: async (outcome: ToolConfirmationOutcome) => { - if (outcome === ToolConfirmationOutcome.ProceedAlways) { - // No need to publish a policy update as the default policy for - // AUTO_EDIT already reflects always approving web-fetch. - this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); - } else { - await this.publishPolicyUpdate(outcome); - } + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // Mode transitions (e.g. AUTO_EDIT) and policy updates are now + // handled centrally by the scheduler. }, }; return confirmationDetails; diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 9f4abfdf55..4d521db33c 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -25,7 +25,7 @@ import { BaseDeclarativeTool, BaseToolInvocation, Kind, - ToolConfirmationOutcome, + type ToolConfirmationOutcome, } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; @@ -228,14 +228,9 @@ class WriteFileToolInvocation extends BaseToolInvocation< fileDiff, originalContent, newContent: correctedContent, - onConfirm: async (outcome: ToolConfirmationOutcome) => { - if (outcome === ToolConfirmationOutcome.ProceedAlways) { - // No need to publish a policy update as the default policy for - // AUTO_EDIT already reflects always approving write-file. - this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); - } else { - await this.publishPolicyUpdate(outcome); - } + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // Mode transitions (e.g. AUTO_EDIT) and policy updates are now + // handled centrally by the scheduler. if (ideConfirmation) { const result = await ideConfirmation; From 264c7aceaacbe37a9cf5ed1f93ca08ea35bee626 Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:13:28 -0500 Subject: [PATCH 12/26] fix(core): resolve crash in ClearcutLogger when os.cpus() is empty (#19555) --- .../clearcut-logger/clearcut-logger.test.ts | 22 +++++++++++++++++++ .../clearcut-logger/clearcut-logger.ts | 11 ++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index a5bed404d9..c5a00bc11d 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -43,6 +43,7 @@ import { InstallationManager } from '../../utils/installationManager.js'; import si from 'systeminformation'; import type { Systeminformation } from 'systeminformation'; +import * as os from 'node:os'; interface CustomMatchers { toHaveMetadataValue: ([key, value]: [EventMetadataKey, string]) => R; @@ -120,6 +121,7 @@ vi.mock('node:os', async (importOriginal) => { return { ...actual, cpus: vi.fn(() => [{ model: 'Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz' }]), + availableParallelism: vi.fn(() => 8), totalmem: vi.fn(() => 32 * 1024 * 1024 * 1024), }; }); @@ -438,6 +440,26 @@ describe('ClearcutLogger', () => { }); }); + it('handles empty os.cpus() gracefully', async () => { + const { logger, loggerConfig } = setup({}); + vi.mocked(os.cpus).mockReturnValueOnce([]); + + await logger?.logStartSessionEvent(new StartSessionEvent(loggerConfig)); + + const event = logger?.createLogEvent(EventNames.API_ERROR, []); + const metadata = event?.event_metadata[0]; + + const cpuInfoEntry = metadata?.find( + (m) => m.gemini_cli_key === EventMetadataKey.GEMINI_CLI_CPU_INFO, + ); + expect(cpuInfoEntry).toBeUndefined(); + + const cpuCoresEntry = metadata?.find( + (m) => m.gemini_cli_key === EventMetadataKey.GEMINI_CLI_CPU_CORES, + ); + expect(cpuCoresEntry?.value).toBe('8'); + }); + type SurfaceDetectionTestCase = { name: string; env: Record; diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index a6a8b6d228..d0407aa0d6 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -615,14 +615,17 @@ export class ClearcutLogger { // Add hardware information only to the start session event const cpus = os.cpus(); - data.push( - { + if (cpus && cpus.length > 0) { + data.push({ gemini_cli_key: EventMetadataKey.GEMINI_CLI_CPU_INFO, value: cpus[0].model, - }, + }); + } + + data.push( { gemini_cli_key: EventMetadataKey.GEMINI_CLI_CPU_CORES, - value: cpus.length.toString(), + value: os.availableParallelism().toString(), }, { gemini_cli_key: EventMetadataKey.GEMINI_CLI_RAM_TOTAL_GB, From a468407098e6d5effe52b170230b717400e2fb55 Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:19:32 -0700 Subject: [PATCH 13/26] chore(core): improve encapsulation and remove unused exports (#19556) --- packages/core/src/agents/a2aUtils.ts | 2 +- packages/core/src/config/storage.ts | 7 +++++-- packages/core/src/ide/detect-ide.ts | 2 +- packages/core/src/mcp/oauth-provider.ts | 2 -- packages/core/src/scheduler/confirmation.ts | 2 +- .../src/services/chatCompressionService.ts | 6 +++--- packages/core/src/telemetry/loggers.ts | 18 ------------------ packages/core/src/telemetry/metrics.ts | 4 ++-- packages/core/src/telemetry/semantic.ts | 16 ++++++++-------- packages/core/src/utils/toolCallContext.ts | 2 +- 10 files changed, 22 insertions(+), 39 deletions(-) diff --git a/packages/core/src/agents/a2aUtils.ts b/packages/core/src/agents/a2aUtils.ts index 311658118b..f1e66309d6 100644 --- a/packages/core/src/agents/a2aUtils.ts +++ b/packages/core/src/agents/a2aUtils.ts @@ -28,7 +28,7 @@ export function extractMessageText(message: Message | undefined): string { /** * Extracts text from a single Part. */ -export function extractPartText(part: Part): string { +function extractPartText(part: Part): string { if (isTextPart(part)) { return part.text; } diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index bd0fec1c8e..2d7f5d8c2a 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -8,11 +8,14 @@ import * as path from 'node:path'; import * as os from 'node:os'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; -import { GEMINI_DIR, homedir } from '../utils/paths.js'; +import { + GEMINI_DIR, + homedir, + GOOGLE_ACCOUNTS_FILENAME, +} from '../utils/paths.js'; import { ProjectRegistry } from './projectRegistry.js'; import { StorageMigration } from './storageMigration.js'; -export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const OAUTH_FILE = 'oauth_creds.json'; const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts index 40aae11b7f..c07ef8254c 100644 --- a/packages/core/src/ide/detect-ide.ts +++ b/packages/core/src/ide/detect-ide.ts @@ -38,7 +38,7 @@ export function isCloudShell(): boolean { return !!(process.env['EDITOR_IN_CLOUD_SHELL'] || process.env['CLOUD_SHELL']); } -export function isJetBrains(): boolean { +function isJetBrains(): boolean { return !!process.env['TERMINAL_EMULATOR'] ?.toLowerCase() .includes('jetbrains'); diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index d0c8987d4a..95cec40f50 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -17,8 +17,6 @@ import { coreEvents } from '../utils/events.js'; import { debugLogger } from '../utils/debugLogger.js'; import { getConsentForOauth } from '../utils/authConsent.js'; -export const OAUTH_DISPLAY_MESSAGE_EVENT = 'oauth-display-message' as const; - /** * OAuth configuration for an MCP server. */ diff --git a/packages/core/src/scheduler/confirmation.ts b/packages/core/src/scheduler/confirmation.ts index 5d98dd526e..67ae26d2eb 100644 --- a/packages/core/src/scheduler/confirmation.ts +++ b/packages/core/src/scheduler/confirmation.ts @@ -59,7 +59,7 @@ export interface ResolutionResult { * @param correlationId The correlationId to match. * @param signal An AbortSignal to cancel the wait and cleanup listeners. */ -export async function awaitConfirmation( +async function awaitConfirmation( messageBus: MessageBus, correlationId: string, signal: AbortSignal, diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 9878358966..44ffe90cf2 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -37,18 +37,18 @@ import { LlmRole } from '../telemetry/types.js'; * Default threshold for compression token count as a fraction of the model's * token limit. If the chat history exceeds this threshold, it will be compressed. */ -export const DEFAULT_COMPRESSION_TOKEN_THRESHOLD = 0.5; +const DEFAULT_COMPRESSION_TOKEN_THRESHOLD = 0.5; /** * The fraction of the latest chat history to keep. A value of 0.3 * means that only the last 30% of the chat history will be kept after compression. */ -export const COMPRESSION_PRESERVE_THRESHOLD = 0.3; +const COMPRESSION_PRESERVE_THRESHOLD = 0.3; /** * The budget for function response tokens in the preserved history. */ -export const COMPRESSION_FUNCTION_RESPONSE_TOKEN_BUDGET = 50_000; +const COMPRESSION_FUNCTION_RESPONSE_TOKEN_BUDGET = 50_000; /** * Returns the index of the oldest item to keep when compressing. May return diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index bbc47cfac8..54bec22a65 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -32,7 +32,6 @@ import type { ConversationFinishedEvent, ChatCompressionEvent, MalformedJsonResponseEvent, - InvalidChunkEvent, ContentRetryEvent, ContentRetryFailureEvent, RipgrepFallbackEvent, @@ -65,7 +64,6 @@ import { recordToolCallMetrics, recordChatCompressionMetrics, recordFileOperationMetric, - recordInvalidChunk, recordContentRetry, recordContentRetryFailure, recordModelRoutingMetrics, @@ -470,22 +468,6 @@ export function logMalformedJsonResponse( }); } -export function logInvalidChunk( - config: Config, - event: InvalidChunkEvent, -): void { - ClearcutLogger.getInstance(config)?.logInvalidChunkEvent(event); - bufferTelemetryEvent(() => { - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - recordInvalidChunk(config); - }); -} - export function logContentRetry( config: Config, event: ContentRetryEvent, diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 5d8dd90bd1..f7869cb980 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -617,7 +617,7 @@ let baselineComparisonHistogram: Histogram | undefined; let isMetricsInitialized = false; let isPerformanceMonitoringEnabled = false; -export function getMeter(): Meter | undefined { +function getMeter(): Meter | undefined { if (!cliMeter) { cliMeter = metrics.getMeter(SERVICE_NAME); } @@ -1019,7 +1019,7 @@ function getGenAiOperationName(): GenAiOperationName { // Performance Monitoring Functions -export function initializePerformanceMonitoring(config: Config): void { +function initializePerformanceMonitoring(config: Config): void { const meter = getMeter(); if (!meter) return; diff --git a/packages/core/src/telemetry/semantic.ts b/packages/core/src/telemetry/semantic.ts index 23623b5b3e..c05d110e9f 100644 --- a/packages/core/src/telemetry/semantic.ts +++ b/packages/core/src/telemetry/semantic.ts @@ -240,7 +240,7 @@ export function toChatMessage(content?: Content): ChatMessage { return message; } -export function toOTelPart(part: Part): AnyPart { +function toOTelPart(part: Part): AnyPart { if (part.thought) { if (part.text) { return new ReasoningPart(part.text); @@ -287,7 +287,7 @@ export enum OTelRole { TOOL = 'tool', } -export function toOTelRole(role?: string): OTelRole { +function toOTelRole(role?: string): OTelRole { switch (role?.toLowerCase()) { case 'system': return OTelRole.SYSTEM; @@ -322,7 +322,7 @@ export enum OTelFinishReason { ERROR = 'error', } -export function toOTelFinishReason(finishReason?: string): OTelFinishReason { +function toOTelFinishReason(finishReason?: string): OTelFinishReason { switch (finishReason) { // we have significantly more finish reasons than the spec case FinishReason.FINISH_REASON_UNSPECIFIED: @@ -376,7 +376,7 @@ export interface ChatMessage { parts: AnyPart[]; } -export class TextPart { +class TextPart { readonly type = 'text'; content: string; @@ -385,7 +385,7 @@ export class TextPart { } } -export class ToolCallRequestPart { +class ToolCallRequestPart { readonly type = 'tool_call'; name?: string; id?: string; @@ -398,7 +398,7 @@ export class ToolCallRequestPart { } } -export class ToolCallResponsePart { +class ToolCallResponsePart { readonly type = 'tool_call_response'; response?: string; id?: string; @@ -409,7 +409,7 @@ export class ToolCallResponsePart { } } -export class ReasoningPart { +class ReasoningPart { readonly type = 'reasoning'; content: string; @@ -418,7 +418,7 @@ export class ReasoningPart { } } -export class GenericPart { +class GenericPart { type: string; [key: string]: unknown; diff --git a/packages/core/src/utils/toolCallContext.ts b/packages/core/src/utils/toolCallContext.ts index c371d23783..e89d20ddef 100644 --- a/packages/core/src/utils/toolCallContext.ts +++ b/packages/core/src/utils/toolCallContext.ts @@ -21,7 +21,7 @@ export interface ToolCallContext { /** * AsyncLocalStorage instance for tool call context. */ -export const toolCallContext = new AsyncLocalStorage(); +const toolCallContext = new AsyncLocalStorage(); /** * Runs a function within a tool call context. From ddc54584513643d6c8032073361c9c8a625c5e00 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 19 Feb 2026 16:06:37 -0500 Subject: [PATCH 14/26] =?UTF-8?q?Revert=20"Add=20generic=20searchable=20li?= =?UTF-8?q?st=20to=20back=20settings=20and=20extensions=20(=E2=80=A6=20(#1?= =?UTF-8?q?9434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: jacob314 --- .../cli/src/ui/components/SettingsDialog.tsx | 122 ++++++++++- .../components/shared/BaseSettingsDialog.tsx | 42 ++-- .../components/shared/SearchableList.test.tsx | 157 --------------- .../ui/components/shared/SearchableList.tsx | 189 ------------------ packages/cli/src/ui/hooks/useFuzzyList.ts | 151 -------------- 5 files changed, 132 insertions(+), 529 deletions(-) delete mode 100644 packages/cli/src/ui/components/shared/SearchableList.test.tsx delete mode 100644 packages/cli/src/ui/components/shared/SearchableList.tsx delete mode 100644 packages/cli/src/ui/hooks/useFuzzyList.ts diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 2bfbe7a9fa..fe3acbd1f1 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { Text } from 'ink'; +import { AsyncFzf } from 'fzf'; import type { Key } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import type { @@ -31,17 +32,27 @@ import { getEffectiveValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; +import { getCachedStringWidth } from '../utils/textUtils.js'; import { type SettingsValue, TOGGLE_TYPES, } from '../../config/settingsSchema.js'; import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useTextBuffer } from './shared/text-buffer.js'; import { - type SettingsDialogItem, BaseSettingsDialog, + type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; -import { useFuzzyList } from '../hooks/useFuzzyList.js'; + +interface FzfResult { + item: string; + start: number; + end: number; + score: number; + positions?: number[]; +} interface SettingsDialogProps { settings: LoadedSettings; @@ -70,6 +81,60 @@ export function SettingsDialog({ const [showRestartPrompt, setShowRestartPrompt] = useState(false); + // Search state + const [searchQuery, setSearchQuery] = useState(''); + const [filteredKeys, setFilteredKeys] = useState(() => + getDialogSettingKeys(), + ); + const { fzfInstance, searchMap } = useMemo(() => { + const keys = getDialogSettingKeys(); + const map = new Map(); + const searchItems: string[] = []; + + keys.forEach((key) => { + const def = getSettingDefinition(key); + if (def?.label) { + searchItems.push(def.label); + map.set(def.label.toLowerCase(), key); + } + }); + + const fzf = new AsyncFzf(searchItems, { + fuzzy: 'v2', + casing: 'case-insensitive', + }); + return { fzfInstance: fzf, searchMap: map }; + }, []); + + // Perform search + useEffect(() => { + let active = true; + if (!searchQuery.trim() || !fzfInstance) { + setFilteredKeys(getDialogSettingKeys()); + return; + } + + const doSearch = async () => { + const results = await fzfInstance.find(searchQuery); + + if (!active) return; + + const matchedKeys = new Set(); + results.forEach((res: FzfResult) => { + const key = searchMap.get(res.item.toLowerCase()); + if (key) matchedKeys.add(key); + }); + setFilteredKeys(Array.from(matchedKeys)); + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + doSearch(); + + return () => { + active = false; + }; + }, [searchQuery, fzfInstance, searchMap]); + // Local pending settings state for the selected scope const [pendingSettings, setPendingSettings] = useState(() => // Deep clone to avoid mutation @@ -117,8 +182,49 @@ export function SettingsDialog({ setShowRestartPrompt(newRestartRequired.size > 0); }, [selectedScope, settings, globalPendingChanges]); - // Generate items for SearchableList - const settingKeys = useMemo(() => getDialogSettingKeys(), []); + // Calculate max width for the left column (Label/Description) to keep values aligned or close + const maxLabelOrDescriptionWidth = useMemo(() => { + const allKeys = getDialogSettingKeys(); + let max = 0; + for (const key of allKeys) { + const def = getSettingDefinition(key); + if (!def) continue; + + const scopeMessage = getScopeMessageForSetting( + key, + selectedScope, + settings, + ); + const label = def.label || key; + const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : ''); + const lWidth = getCachedStringWidth(labelFull); + const dWidth = def.description + ? getCachedStringWidth(def.description) + : 0; + + max = Math.max(max, lWidth, dWidth); + } + return max; + }, [selectedScope, settings]); + + // Get mainAreaWidth for search buffer viewport + const { mainAreaWidth } = useUIState(); + const viewportWidth = mainAreaWidth - 8; + + // Search input buffer + const searchBuffer = useTextBuffer({ + initialText: '', + initialCursorOffset: 0, + viewport: { + width: viewportWidth, + height: 1, + }, + singleLine: true, + onChange: (text) => setSearchQuery(text), + }); + + // Generate items for BaseSettingsDialog + const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys(); const items: SettingsDialogItem[] = useMemo(() => { const scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; @@ -164,10 +270,6 @@ export function SettingsDialog({ }); }, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]); - const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ - items, - }); - // Scope selection handler const handleScopeChange = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); @@ -594,12 +696,12 @@ export function SettingsDialog({ borderColor={showRestartPrompt ? theme.status.warning : undefined} searchEnabled={showSearch} searchBuffer={searchBuffer} - items={filteredItems} + items={items} showScopeSelector={showScopeSelection} selectedScope={selectedScope} onScopeChange={handleScopeChange} maxItemsToShow={effectiveMaxItemsToShow} - maxLabelWidth={maxLabelWidth} + maxLabelWidth={maxLabelOrDescriptionWidth} onItemToggle={handleItemToggle} onEditCommit={handleEditCommit} onItemClear={handleItemClear} diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index e257600188..29592b479b 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -144,30 +144,28 @@ export function BaseSettingsDialog({ useEffect(() => { const prevItems = prevItemsRef.current; if (prevItems !== items) { - if (items.length === 0) { + const prevActiveItem = prevItems[activeIndex]; + if (prevActiveItem) { + const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); + if (newIndex !== -1) { + // Item still exists in the filtered list, keep focus on it + setActiveIndex(newIndex); + // Adjust scroll offset to ensure the item is visible + let newScroll = scrollOffset; + if (newIndex < scrollOffset) newScroll = newIndex; + else if (newIndex >= scrollOffset + maxItemsToShow) + newScroll = newIndex - maxItemsToShow + 1; + + const maxScroll = Math.max(0, items.length - maxItemsToShow); + setScrollOffset(Math.min(newScroll, maxScroll)); + } else { + // Item was filtered out, reset to the top + setActiveIndex(0); + setScrollOffset(0); + } + } else { setActiveIndex(0); setScrollOffset(0); - } else { - const prevActiveItem = prevItems[activeIndex]; - if (prevActiveItem) { - const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); - if (newIndex !== -1) { - // Item still exists in the filtered list, keep focus on it - setActiveIndex(newIndex); - // Adjust scroll offset to ensure the item is visible - let newScroll = scrollOffset; - if (newIndex < scrollOffset) newScroll = newIndex; - else if (newIndex >= scrollOffset + maxItemsToShow) - newScroll = newIndex - maxItemsToShow + 1; - - const maxScroll = Math.max(0, items.length - maxItemsToShow); - setScrollOffset(Math.min(newScroll, maxScroll)); - } else { - // Item was filtered out, reset to the top - setActiveIndex(0); - setScrollOffset(0); - } - } } prevItemsRef.current = items; } diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx deleted file mode 100644 index fa20352a8b..0000000000 --- a/packages/cli/src/ui/components/shared/SearchableList.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from '../../../test-utils/render.js'; -import { waitFor } from '../../../test-utils/async.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SearchableList, type SearchableListProps } from './SearchableList.js'; -import { KeypressProvider } from '../../contexts/KeypressContext.js'; -import { type GenericListItem } from '../../hooks/useFuzzyList.js'; - -// Mock UI State -vi.mock('../../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - mainAreaWidth: 100, - }), -})); - -const mockItems: GenericListItem[] = [ - { - key: 'item-1', - label: 'Item One', - description: 'Description for item one', - }, - { - key: 'item-2', - label: 'Item Two', - description: 'Description for item two', - }, - { - key: 'item-3', - label: 'Item Three', - description: 'Description for item three', - }, -]; - -describe('SearchableList', () => { - let mockOnSelect: ReturnType; - let mockOnClose: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - mockOnSelect = vi.fn(); - mockOnClose = vi.fn(); - }); - - const renderList = ( - props: Partial> = {}, - ) => { - const defaultProps: SearchableListProps = { - title: 'Test List', - items: mockItems, - onSelect: mockOnSelect, - onClose: mockOnClose, - ...props, - }; - - return render( - - - , - ); - }; - - it('should render all items initially', async () => { - const { lastFrame, waitUntilReady } = renderList(); - await waitUntilReady(); - const frame = lastFrame(); - - // Check for title - expect(frame).toContain('Test List'); - - // Check for items - expect(frame).toContain('Item One'); - expect(frame).toContain('Item Two'); - expect(frame).toContain('Item Three'); - - // Check for descriptions - expect(frame).toContain('Description for item one'); - }); - - it('should filter items based on search query', async () => { - const { lastFrame, stdin } = renderList(); - - // Type "Two" into search - await React.act(async () => { - stdin.write('Two'); - }); - - await waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('Item Two'); - expect(frame).not.toContain('Item One'); - expect(frame).not.toContain('Item Three'); - }); - }); - - it('should show "No items found." when no items match', async () => { - const { lastFrame, stdin } = renderList(); - - // Type something that won't match - await React.act(async () => { - stdin.write('xyz123'); - }); - - await waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('No items found.'); - }); - }); - - it('should handle selection with Enter', async () => { - const { stdin } = renderList(); - - // Select first item (default active) - await React.act(async () => { - stdin.write('\r'); // Enter - }); - - await waitFor(() => { - expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]); - }); - }); - - it('should handle navigation and selection', async () => { - const { stdin } = renderList(); - - // Navigate down to second item - await React.act(async () => { - stdin.write('\u001B[B'); // Down Arrow - }); - - // Select second item - await React.act(async () => { - stdin.write('\r'); // Enter - }); - - await waitFor(() => { - expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]); - }); - }); - - it('should handle close with Esc', async () => { - const { stdin } = renderList(); - - await React.act(async () => { - stdin.write('\u001B'); // Esc - }); - - await waitFor(() => { - expect(mockOnClose).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx deleted file mode 100644 index 07720ce5d6..0000000000 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { useState, useEffect } from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { TextInput } from './TextInput.js'; -import { useKeypress, type Key } from '../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; -import { - useFuzzyList, - type GenericListItem, -} from '../../hooks/useFuzzyList.js'; - -export interface SearchableListProps { - /** List title */ - title?: string; - /** Available items */ - items: T[]; - /** Callback when an item is selected */ - onSelect: (item: T) => void; - /** Callback when the list is closed (e.g. via Esc) */ - onClose?: () => void; - /** Initial search query */ - initialSearchQuery?: string; - /** Placeholder for search input */ - searchPlaceholder?: string; - /** Max items to show at once */ - maxItemsToShow?: number; -} - -/** - * A generic searchable list component. - */ -export function SearchableList({ - title, - items, - onSelect, - onClose, - initialSearchQuery = '', - searchPlaceholder = 'Search...', - maxItemsToShow = 10, -}: SearchableListProps): React.JSX.Element { - const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ - items, - initialQuery: initialSearchQuery, - }); - - const [activeIndex, setActiveIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - - // Reset selection when filtered items change - useEffect(() => { - setActiveIndex(0); - setScrollOffset(0); - }, [filteredItems]); - - // Calculate visible items - const visibleItems = filteredItems.slice( - scrollOffset, - scrollOffset + maxItemsToShow, - ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = scrollOffset + maxItemsToShow < filteredItems.length; - - useKeypress( - (key: Key) => { - // Navigation - if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { - const newIndex = - activeIndex > 0 ? activeIndex - 1 : filteredItems.length - 1; - setActiveIndex(newIndex); - if (newIndex === filteredItems.length - 1) { - setScrollOffset(Math.max(0, filteredItems.length - maxItemsToShow)); - } else if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - return; - } - if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { - const newIndex = - activeIndex < filteredItems.length - 1 ? activeIndex + 1 : 0; - setActiveIndex(newIndex); - if (newIndex === 0) { - setScrollOffset(0); - } else if (newIndex >= scrollOffset + maxItemsToShow) { - setScrollOffset(newIndex - maxItemsToShow + 1); - } - return; - } - - // Selection - if (keyMatchers[Command.RETURN](key)) { - const item = filteredItems[activeIndex]; - if (item) { - onSelect(item); - } - return; - } - - // Close - if (keyMatchers[Command.ESCAPE](key)) { - onClose?.(); - return; - } - }, - { isActive: true }, - ); - - return ( - - {/* Header */} - {title && ( - - {title} - - )} - - {/* Search Input */} - {searchBuffer && ( - - - - )} - - {/* List */} - - {visibleItems.length === 0 ? ( - No items found. - ) : ( - visibleItems.map((item, idx) => { - const index = scrollOffset + idx; - const isActive = index === activeIndex; - - return ( - - - {isActive ? '> ' : ' '} - - - - {item.label} - - - {item.description && ( - {item.description} - )} - - ); - }) - )} - - - {/* Footer/Scroll Indicators */} - {(showScrollUp || showScrollDown) && ( - - - {showScrollUp ? '▲ ' : ' '} - {filteredItems.length} items - {showScrollDown ? ' ▼' : ' '} - - - )} - - ); -} diff --git a/packages/cli/src/ui/hooks/useFuzzyList.ts b/packages/cli/src/ui/hooks/useFuzzyList.ts deleted file mode 100644 index 6d07b0ea75..0000000000 --- a/packages/cli/src/ui/hooks/useFuzzyList.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useMemo, useEffect } from 'react'; -import { AsyncFzf } from 'fzf'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { - useTextBuffer, - type TextBuffer, -} from '../components/shared/text-buffer.js'; -import { getCachedStringWidth } from '../utils/textUtils.js'; - -interface FzfResult { - item: string; - start: number; - end: number; - score: number; - positions?: number[]; -} - -export interface GenericListItem { - key: string; - label: string; - description?: string; - scopeMessage?: string; -} - -export interface UseFuzzyListProps { - items: T[]; - initialQuery?: string; - onSearch?: (query: string) => void; -} - -export interface UseFuzzyListResult { - filteredItems: T[]; - searchBuffer: TextBuffer | undefined; - searchQuery: string; - setSearchQuery: (query: string) => void; - maxLabelWidth: number; -} - -export function useFuzzyList({ - items, - initialQuery = '', - onSearch, -}: UseFuzzyListProps): UseFuzzyListResult { - // Search state - const [searchQuery, setSearchQuery] = useState(initialQuery); - const [filteredKeys, setFilteredKeys] = useState(() => - items.map((i) => i.key), - ); - - // FZF instance for fuzzy searching - const { fzfInstance, searchMap } = useMemo(() => { - const map = new Map(); - const searchItems: string[] = []; - - items.forEach((item) => { - searchItems.push(item.label); - map.set(item.label.toLowerCase(), item.key); - }); - - const fzf = new AsyncFzf(searchItems, { - fuzzy: 'v2', - casing: 'case-insensitive', - }); - return { fzfInstance: fzf, searchMap: map }; - }, [items]); - - // Perform search - useEffect(() => { - let active = true; - if (!searchQuery.trim() || !fzfInstance) { - setFilteredKeys(items.map((i) => i.key)); - return; - } - - const doSearch = async () => { - const results = await fzfInstance.find(searchQuery); - - if (!active) return; - - const matchedKeys = new Set(); - results.forEach((res: FzfResult) => { - const key = searchMap.get(res.item.toLowerCase()); - if (key) matchedKeys.add(key); - }); - setFilteredKeys(Array.from(matchedKeys)); - onSearch?.(searchQuery); - }; - - void doSearch().catch((error) => { - // eslint-disable-next-line no-console - console.error('Search failed:', error); - setFilteredKeys(items.map((i) => i.key)); // Reset to all items on error - }); - - return () => { - active = false; - }; - }, [searchQuery, fzfInstance, searchMap, items, onSearch]); - - // Get mainAreaWidth for search buffer viewport from UIState - const { mainAreaWidth } = useUIState(); - const viewportWidth = Math.max(20, mainAreaWidth - 8); - - // Search input buffer - const searchBuffer = useTextBuffer({ - initialText: searchQuery, - initialCursorOffset: searchQuery.length, - viewport: { - width: viewportWidth, - height: 1, - }, - singleLine: true, - onChange: (text) => setSearchQuery(text), - }); - - // Filtered items to display - const filteredItems = useMemo(() => { - if (!searchQuery) return items; - return items.filter((item) => filteredKeys.includes(item.key)); - }, [items, filteredKeys, searchQuery]); - - // Calculate max label width for alignment - const maxLabelWidth = useMemo(() => { - let max = 0; - // We use all items for consistent alignment even when filtered - items.forEach((item) => { - const labelFull = - item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : ''); - const lWidth = getCachedStringWidth(labelFull); - const dWidth = item.description - ? getCachedStringWidth(item.description) - : 0; - max = Math.max(max, lWidth, dWidth); - }); - return max; - }, [items]); - - return { - filteredItems, - searchBuffer, - searchQuery, - setSearchQuery, - maxLabelWidth, - }; -} From 806497389900034e45cd6a160a9d8e71e6ae1ef2 Mon Sep 17 00:00:00 2001 From: Yuna Seol Date: Thu, 19 Feb 2026 16:19:19 -0500 Subject: [PATCH 15/26] fix(core): improve error type extraction for telemetry (#19565) Co-authored-by: Yuna Seol --- .../src/core/loggingContentGenerator.test.ts | 14 ++++++ .../core/src/core/loggingContentGenerator.ts | 3 +- packages/core/src/utils/errors.test.ts | 49 +++++++++++++++++++ packages/core/src/utils/errors.ts | 9 ++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator.test.ts index dd354fa16f..45e5028553 100644 --- a/packages/core/src/core/loggingContentGenerator.test.ts +++ b/packages/core/src/core/loggingContentGenerator.test.ts @@ -32,6 +32,7 @@ import { LoggingContentGenerator } from './loggingContentGenerator.js'; import type { Config } from '../config/config.js'; import { UserTierId } from '../code_assist/types.js'; import { ApiRequestEvent, LlmRole } from '../telemetry/types.js'; +import { FatalAuthenticationError } from '../utils/errors.js'; describe('LoggingContentGenerator', () => { let wrapped: ContentGenerator; @@ -137,6 +138,19 @@ describe('LoggingContentGenerator', () => { const errorEvent = vi.mocked(logApiError).mock.calls[0][1]; expect(errorEvent.duration_ms).toBe(1000); }); + + describe('error type extraction', () => { + it('should extract error type correctly', async () => { + const req = { contents: [], model: 'm' }; + const error = new FatalAuthenticationError('test'); + vi.mocked(wrapped.generateContent).mockRejectedValue(error); + await expect( + loggingContentGenerator.generateContent(req, 'id', LlmRole.MAIN), + ).rejects.toThrow(); + const errorEvent = vi.mocked(logApiError).mock.calls[0][1]; + expect(errorEvent.error_type).toBe('FatalAuthenticationError'); + }); + }); }); describe('generateContentStream', () => { diff --git a/packages/core/src/core/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator.ts index 12a1722475..1544087ae0 100644 --- a/packages/core/src/core/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator.ts @@ -36,6 +36,7 @@ import { toContents } from '../code_assist/converter.js'; import { isStructuredError } from '../utils/quotaErrorDetection.js'; import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { getErrorType } from '../utils/errors.js'; interface StructuredError { status: number; @@ -167,7 +168,7 @@ export class LoggingContentGenerator implements ContentGenerator { serverDetails?: ServerDetails, ): void { const errorMessage = error instanceof Error ? error.message : String(error); - const errorType = error instanceof Error ? error.name : 'unknown'; + const errorType = getErrorType(error); logApiError( this.config, diff --git a/packages/core/src/utils/errors.test.ts b/packages/core/src/utils/errors.test.ts index 58c7004190..b3df89ef93 100644 --- a/packages/core/src/utils/errors.test.ts +++ b/packages/core/src/utils/errors.test.ts @@ -12,6 +12,14 @@ import { BadRequestError, ForbiddenError, getErrorMessage, + getErrorType, + FatalAuthenticationError, + FatalCancellationError, + FatalInputError, + FatalSandboxError, + FatalConfigError, + FatalTurnLimitedError, + FatalToolExecutionError, } from './errors.js'; describe('getErrorMessage', () => { @@ -201,3 +209,44 @@ describe('toFriendlyError', () => { expect(toFriendlyError(error)).toBe(error); }); }); + +describe('getErrorType', () => { + it('should return error name for standard errors', () => { + expect(getErrorType(new Error('test'))).toBe('Error'); + expect(getErrorType(new TypeError('test'))).toBe('TypeError'); + expect(getErrorType(new SyntaxError('test'))).toBe('SyntaxError'); + }); + + it('should return constructor name for custom errors', () => { + expect(getErrorType(new FatalAuthenticationError('test'))).toBe( + 'FatalAuthenticationError', + ); + expect(getErrorType(new FatalInputError('test'))).toBe('FatalInputError'); + expect(getErrorType(new FatalSandboxError('test'))).toBe( + 'FatalSandboxError', + ); + expect(getErrorType(new FatalConfigError('test'))).toBe('FatalConfigError'); + expect(getErrorType(new FatalTurnLimitedError('test'))).toBe( + 'FatalTurnLimitedError', + ); + expect(getErrorType(new FatalToolExecutionError('test'))).toBe( + 'FatalToolExecutionError', + ); + expect(getErrorType(new FatalCancellationError('test'))).toBe( + 'FatalCancellationError', + ); + expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError'); + expect(getErrorType(new UnauthorizedError('test'))).toBe( + 'UnauthorizedError', + ); + expect(getErrorType(new BadRequestError('test'))).toBe('BadRequestError'); + }); + + it('should return "unknown" for non-Error objects', () => { + expect(getErrorType('string error')).toBe('unknown'); + expect(getErrorType(123)).toBe('unknown'); + expect(getErrorType({})).toBe('unknown'); + expect(getErrorType(null)).toBe('unknown'); + expect(getErrorType(undefined)).toBe('unknown'); + }); +}); diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index 2bba4f8abe..dc7d5b33c5 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -26,6 +26,15 @@ export function getErrorMessage(error: unknown): string { } } +export function getErrorType(error: unknown): string { + if (!(error instanceof Error)) return 'unknown'; + + // Return constructor name if the generic 'Error' name is used (for custom errors) + return error.name === 'Error' + ? (error.constructor?.name ?? 'Error') + : error.name; +} + export class FatalError extends Error { constructor( message: string, From 2cba2ab37ac4ba2a462d013a2aa5c71408200e81 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Thu, 19 Feb 2026 16:31:09 -0500 Subject: [PATCH 16/26] fix: remove extra padding in Composer (#19529) --- packages/cli/src/ui/components/Composer.tsx | 2 +- .../components/__snapshots__/Composer.test.tsx.snap | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index fe2adba9ab..2adc370ed5 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -191,7 +191,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { {showUiDetails && } - + Snapshots > matches snapshot in idle state 1`] = ` -" - ShortcutsHint +" ShortcutsHint ──────────────────────────────────────────────────────────────────────────────────────────────────── ApprovalModeIndicator StatusDisplay InputPrompt: Type your message or @path/to/file @@ -11,22 +10,19 @@ Footer `; exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = ` -" - ShortcutsHint +" ShortcutsHint InputPrompt: Type your message or @path/to/file " `; exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = ` -" - LoadingIndicator +" LoadingIndicator InputPrompt: Type your message or @path/to/file " `; exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = ` " - ShortcutsHint ──────────────────────────────────────── ApprovalModeIndicator @@ -39,8 +35,7 @@ Footer `; exports[`Composer > Snapshots > matches snapshot while streaming 1`] = ` -" - LoadingIndicator: Thinking ShortcutsHint +" LoadingIndicator: Thinking ShortcutsHint ──────────────────────────────────────────────────────────────────────────────────────────────────── ApprovalModeIndicator InputPrompt: Type your message or @path/to/file From 537e56ffae2df937f5763f01663df67b8277fa4b Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Thu, 19 Feb 2026 17:47:08 -0500 Subject: [PATCH 17/26] feat(plan): support configuring custom plans storage directory (#19577) --- docs/cli/plan-mode.md | 41 +++++++ docs/cli/settings.md | 1 + docs/get-started/configuration.md | 6 + packages/cli/src/config/config.test.ts | 21 +++- packages/cli/src/config/config.ts | 1 + .../cli/src/config/settingsSchema.test.ts | 10 ++ packages/cli/src/config/settingsSchema.ts | 22 ++++ .../cli/src/ui/commands/planCommand.test.ts | 2 +- packages/cli/src/ui/commands/planCommand.ts | 2 +- .../ui/components/ExitPlanModeDialog.test.tsx | 4 +- .../src/ui/components/ExitPlanModeDialog.tsx | 4 +- .../components/ToolConfirmationQueue.test.tsx | 2 +- .../SettingsDialog.test.tsx.snap | 54 ++++----- packages/core/src/config/config.test.ts | 40 ++++++- packages/core/src/config/config.ts | 8 +- packages/core/src/config/storage.test.ts | 106 +++++++++++++++++- packages/core/src/config/storage.ts | 27 +++++ packages/core/src/core/prompts.test.ts | 8 +- .../core/src/prompts/promptProvider.test.ts | 4 +- packages/core/src/prompts/promptProvider.ts | 2 +- .../core/src/tools/enter-plan-mode.test.ts | 2 +- .../core/src/tools/exit-plan-mode.test.ts | 2 +- packages/core/src/tools/exit-plan-mode.ts | 10 +- schemas/settings.schema.json | 16 +++ 24 files changed, 337 insertions(+), 58 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 1f283a63aa..995e693bb2 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -127,6 +127,47 @@ To use a skill in Plan Mode, you can explicitly ask the agent to "use the [skill-name] skill to plan..." or the agent may autonomously activate it based on the task description. +### Custom Plan Directory and Policies + +By default, planning artifacts are stored in a managed temporary directory +outside your project: `~/.gemini/tmp///plans/`. + +You can configure a custom directory for plans in your `settings.json`. For +example, to store plans in a `.gemini/plans` directory within your project: + +```json +{ + "general": { + "plan": { + "directory": ".gemini/plans" + } + } +} +``` + +To maintain the safety of Plan Mode, user-configured paths for the plans +directory are restricted to the project root. This ensures that custom planning +locations defined within a project's workspace cannot be used to escape and +overwrite sensitive files elsewhere. Any user-configured directory must reside +within the project boundary. + +Because Plan Mode is read-only by default, using a custom directory requires +updating your [Policy Engine] configurations to allow `write_file` and `replace` +in that specific location. For example, to allow writing to the `.gemini/plans` +directory within your project, create a policy file at +`~/.gemini/policies/plan-custom-directory.toml`: + +```toml +[[rule]] +toolName = ["write_file", "replace"] +decision = "allow" +priority = 100 +modes = ["plan"] +# Adjust the pattern to match your custom directory. +# This example matches any .md file in a .gemini/plans directory within the project. +argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\"" +``` + ### Customizing Policies Plan Mode is designed to be read-only by default to ensure safety during the diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 318fdbea75..a7689fbcea 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -28,6 +28,7 @@ they appear in the UI. | Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | | Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | | Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | +| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | | Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | | Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 8e2164080f..ba86442a4d 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -131,6 +131,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`general.plan.directory`** (string): + - **Description:** The directory where planning artifacts are stored. If not + specified, defaults to the system temporary directory. + - **Default:** `undefined` + - **Requires restart:** Yes + - **`general.enablePromptCompletion`** (boolean): - **Description:** Enable AI-powered prompt completion suggestions while typing. diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0c52c9bc4b..809b31cd82 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -21,7 +21,11 @@ import { type MCPServerConfig, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; -import { type Settings, createTestMergedSettings } from './settings.js'; +import { + type Settings, + type MergedSettings, + createTestMergedSettings, +} from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -2599,6 +2603,21 @@ describe('loadCliConfig approval mode', () => { expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); + it('should pass planSettings.directory from settings to config', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + plan: { + directory: '.custom-plans', + }, + }, + } as unknown as MergedSettings); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + const plansDir = config.storage.getPlansDir(); + expect(plansDir).toContain('.custom-plans'); + }); + // --- Untrusted Folder Scenarios --- describe('when folder is NOT trusted', () => { beforeEach(() => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4a17ae8ecc..6b7f3460af 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -814,6 +814,7 @@ export async function loadCliConfig( enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, + planSettings: settings.general.plan, enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2638ac0347..ffe1dd2ac5 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -107,6 +107,16 @@ describe('SettingsSchema', () => { ).toBe('boolean'); }); + it('should have plan nested properties', () => { + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory, + ).toBeDefined(); + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory + .type, + ).toBe('string'); + }); + it('should have fileFiltering nested properties', () => { expect( getSettingsSchema().context.properties.fileFiltering.properties diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 70c5363659..f0e092b45b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -266,6 +266,27 @@ const SETTINGS_SCHEMA = { }, }, }, + plan: { + type: 'object', + label: 'Plan', + category: 'General', + requiresRestart: true, + default: {}, + description: 'Planning features configuration.', + showInDialog: false, + properties: { + directory: { + type: 'string', + label: 'Plan Directory', + category: 'General', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.', + showInDialog: true, + }, + }, + }, enablePromptCompletion: { type: 'boolean', label: 'Enable Prompt Completion', @@ -1313,6 +1334,7 @@ const SETTINGS_SCHEMA = { }, }, }, + useWriteTodos: { type: 'boolean', label: 'Use WriteTodos', diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index af556ae255..2608b44ca9 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -51,7 +51,7 @@ describe('planCommand', () => { getApprovalMode: vi.fn(), getFileSystemService: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), }, }, }, diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index c64b0048f4..d9cc6739da 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -43,7 +43,7 @@ export const planCommand: SlashCommand = { try { const content = await processSingleFileContent( approvedPlanPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getFileSystemService(), ); const fileName = path.basename(approvedPlanPath); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 36c7bb3437..26b61829a0 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -154,7 +154,7 @@ Implement a comprehensive authentication system with multiple providers. getIdeMode: () => false, isTrustedFolder: () => true, storage: { - getProjectTempPlansDir: () => mockPlansDir, + getPlansDir: () => mockPlansDir, }, getFileSystemService: (): FileSystemService => ({ readTextFile: vi.fn(), @@ -429,7 +429,7 @@ Implement a comprehensive authentication system with multiple providers. getIdeMode: () => false, isTrustedFolder: () => true, storage: { - getProjectTempPlansDir: () => mockPlansDir, + getPlansDir: () => mockPlansDir, }, getFileSystemService: (): FileSystemService => ({ readTextFile: vi.fn(), diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 9fc1adfc23..8777136d86 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { try { const pathError = await validatePlanPath( planPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getTargetDir(), ); if (ignore) return; @@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { const result = await processSingleFileContent( planPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getFileSystemService(), ); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index 4ea2f22796..345d00a263 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -49,7 +49,7 @@ describe('ToolConfirmationQueue', () => { readFile: vi.fn().mockResolvedValue('Plan content'), }), storage: { - getProjectTempPlansDir: () => '/mock/temp/plans', + getPlansDir: () => '/mock/temp/plans', }, } as unknown as Config; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 0dba43a791..3fec2244d7 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -22,6 +22,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -69,6 +69,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -78,9 +81,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -116,6 +116,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -125,9 +128,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -163,6 +163,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -172,9 +175,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -210,6 +210,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -219,9 +222,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -257,6 +257,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -266,9 +269,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ > Apply To │ @@ -304,6 +304,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -313,9 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -351,6 +351,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -360,9 +363,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -398,6 +398,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion true* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -407,9 +410,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c297a20ef6..1c8820f273 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -737,6 +737,42 @@ describe('Server Config (config.ts)', () => { ); }); + describe('Plan Settings', () => { + const testCases = [ + { + name: 'should pass custom plan directory to storage', + planSettings: { directory: 'custom-plans' }, + expected: 'custom-plans', + }, + { + name: 'should call setCustomPlansDir with undefined if directory is not provided', + planSettings: {}, + expected: undefined, + }, + { + name: 'should call setCustomPlansDir with undefined if planSettings is not provided', + planSettings: undefined, + expected: undefined, + }, + ]; + + testCases.forEach(({ name, planSettings, expected }) => { + it(`${name}`, () => { + const setCustomPlansDirSpy = vi.spyOn( + Storage.prototype, + 'setCustomPlansDir', + ); + new Config({ + ...baseParams, + planSettings, + }); + + expect(setCustomPlansDirSpy).toHaveBeenCalledWith(expected); + setCustomPlansDirSpy.mockRestore(); + }); + }); + }); + describe('Telemetry Settings', () => { it('should return default telemetry target if not provided', () => { const params: ConfigParameters = { @@ -2501,7 +2537,7 @@ describe('Plans Directory Initialization', () => { await config.initialize(); - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, { recursive: true, }); @@ -2518,7 +2554,7 @@ describe('Plans Directory Initialization', () => { await config.initialize(); - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, { recursive: true, }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 33e02abf89..5b57a81acf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -141,6 +141,10 @@ export interface SummarizeToolOutputSettings { tokenBudget?: number; } +export interface PlanSettings { + directory?: string; +} + export interface TelemetrySettings { enabled?: boolean; target?: TelemetryTarget; @@ -483,6 +487,7 @@ export interface ConfigParameters { toolOutputMasking?: Partial; disableLLMCorrection?: boolean; plan?: boolean; + planSettings?: PlanSettings; modelSteering?: boolean; onModelChange?: (model: string) => void; mcpEnabled?: boolean; @@ -836,6 +841,7 @@ export class Config { this.extensionManagement = params.extensionManagement ?? true; this.enableExtensionReloading = params.enableExtensionReloading ?? false; this.storage = new Storage(this.targetDir, this.sessionId); + this.storage.setCustomPlansDir(params.planSettings?.directory); this.fakeResponses = params.fakeResponses; this.recordResponses = params.recordResponses; @@ -949,7 +955,7 @@ export class Config { // Add plans directory to workspace context for plan file storage if (this.planEnabled) { - const plansDir = this.storage.getProjectTempPlansDir(); + const plansDir = this.storage.getPlansDir(); await fs.promises.mkdir(plansDir, { recursive: true }); this.workspaceContext.addDirectory(plansDir); } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 8d91ca1a3e..afb3eaeeeb 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -12,12 +12,14 @@ vi.unmock('./storageMigration.js'); import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, mkdirSync: vi.fn(), + realpathSync: vi.fn(actual.realpathSync), }; }); @@ -61,12 +63,11 @@ describe('Storage – initialize', () => { ).toHaveBeenCalledWith(projectRoot); // Verify migration calls - const shortId = 'project-slug'; // We can't easily get the hash here without repeating logic, but we can verify it's called twice expect(StorageMigration.migrateDirectory).toHaveBeenCalledTimes(2); // Verify identifier is set by checking a path - expect(storage.getProjectTempDir()).toContain(shortId); + expect(storage.getProjectTempDir()).toContain(PROJECT_SLUG); }); }); @@ -105,6 +106,12 @@ describe('Storage – additional helpers', () => { const projectRoot = '/tmp/project'; const storage = new Storage(projectRoot); + beforeEach(() => { + ProjectRegistry.prototype.getShortId = vi + .fn() + .mockReturnValue(PROJECT_SLUG); + }); + it('getWorkspaceSettingsPath returns project/.gemini/settings.json', () => { const expected = path.join(projectRoot, GEMINI_DIR, 'settings.json'); expect(storage.getWorkspaceSettingsPath()).toBe(expected); @@ -172,6 +179,101 @@ describe('Storage – additional helpers', () => { const expected = path.join(tempDir, sessionId, 'plans'); expect(storageWithSession.getProjectTempPlansDir()).toBe(expected); }); + + describe('getPlansDir', () => { + interface TestCase { + name: string; + customDir: string | undefined; + expected: string | (() => string); + expectedError?: string; + setup?: () => () => void; + } + + const testCases: TestCase[] = [ + { + name: 'custom relative path', + customDir: '.my-plans', + expected: path.resolve(projectRoot, '.my-plans'), + }, + { + name: 'custom absolute path outside throws', + customDir: '/absolute/path/to/plans', + expected: '', + expectedError: + "Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '/tmp/project'.", + }, + { + name: 'absolute path that happens to be inside project root', + customDir: path.join(projectRoot, 'internal-plans'), + expected: path.join(projectRoot, 'internal-plans'), + }, + { + name: 'relative path that stays within project root', + customDir: 'subdir/../plans', + expected: path.resolve(projectRoot, 'plans'), + }, + { + name: 'dot path', + customDir: '.', + expected: projectRoot, + }, + { + name: 'default behavior when customDir is undefined', + customDir: undefined, + expected: () => storage.getProjectTempPlansDir(), + }, + { + name: 'escaping relative path throws', + customDir: '../escaped-plans', + expected: '', + expectedError: + "Custom plans directory '../escaped-plans' resolves to '/tmp/escaped-plans', which is outside the project root '/tmp/project'.", + }, + { + name: 'hidden directory starting with ..', + customDir: '..plans', + expected: path.resolve(projectRoot, '..plans'), + }, + { + name: 'security escape via symbolic link throws', + customDir: 'symlink-to-outside', + setup: () => { + vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => { + if (p.toString().includes('symlink-to-outside')) { + return '/outside/project/root'; + } + return p.toString(); + }); + return () => vi.mocked(fs.realpathSync).mockRestore(); + }, + expected: '', + expectedError: + "Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.", + }, + ]; + + testCases.forEach(({ name, customDir, expected, expectedError, setup }) => { + it(`should handle ${name}`, async () => { + const cleanup = setup?.(); + try { + if (name.includes('default behavior')) { + await storage.initialize(); + } + + storage.setCustomPlansDir(customDir); + if (expectedError) { + expect(() => storage.getPlansDir()).toThrow(expectedError); + } else { + const expectedValue = + typeof expected === 'function' ? expected() : expected; + expect(storage.getPlansDir()).toBe(expectedValue); + } + } finally { + cleanup?.(); + } + }); + }); + }); }); describe('Storage - System Paths', () => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 2d7f5d8c2a..bce91f7991 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -12,6 +12,8 @@ import { GEMINI_DIR, homedir, GOOGLE_ACCOUNTS_FILENAME, + isSubpath, + resolveToRealPath, } from '../utils/paths.js'; import { ProjectRegistry } from './projectRegistry.js'; import { StorageMigration } from './storageMigration.js'; @@ -26,12 +28,17 @@ export class Storage { private readonly sessionId: string | undefined; private projectIdentifier: string | undefined; private initPromise: Promise | undefined; + private customPlansDir: string | undefined; constructor(targetDir: string, sessionId?: string) { this.targetDir = targetDir; this.sessionId = sessionId; } + setCustomPlansDir(dir: string | undefined): void { + this.customPlansDir = dir; + } + static getGlobalGeminiDir(): string { const homeDir = homedir(); if (!homeDir) { @@ -253,6 +260,26 @@ export class Storage { return path.join(this.getProjectTempDir(), 'plans'); } + getPlansDir(): string { + if (this.customPlansDir) { + const resolvedPath = path.resolve( + this.getProjectRoot(), + this.customPlansDir, + ); + const realProjectRoot = resolveToRealPath(this.getProjectRoot()); + const realResolvedPath = resolveToRealPath(resolvedPath); + + if (!isSubpath(realProjectRoot, realResolvedPath)) { + throw new Error( + `Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`, + ); + } + + return resolvedPath; + } + return this.getProjectTempPlansDir(); + } + getProjectTempTasksDir(): string { if (this.sessionId) { return path.join(this.getProjectTempDir(), this.sessionId, 'tasks'); diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index ce6f383009..0cee2f8ae4 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -89,9 +89,7 @@ describe('Core System Prompt (prompts.ts)', () => { getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), - getProjectTempPlansDir: vi - .fn() - .mockReturnValue('/tmp/project-temp/plans'), + getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), @@ -509,9 +507,7 @@ describe('Core System Prompt (prompts.ts)', () => { vi.mocked(mockConfig.getApprovalMode).mockReturnValue( ApprovalMode.PLAN, ); - vi.mocked(mockConfig.storage.getProjectTempPlansDir).mockReturnValue( - '/tmp/plans', - ); + vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue('/tmp/plans'); }); it('should include approved plan path when set in config', () => { diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index bdc8d553f3..d112b2f06f 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -38,9 +38,7 @@ describe('PromptProvider', () => { getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), - getProjectTempPlansDir: vi - .fn() - .mockReturnValue('/tmp/project-temp/plans'), + getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 2b7b7854eb..36ffddf71c 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -172,7 +172,7 @@ export class PromptProvider { 'planningWorkflow', () => ({ planModeToolsList, - plansDir: config.storage.getProjectTempPlansDir(), + plansDir: config.storage.getPlansDir(), approvedPlanPath: config.getApprovedPlanPath(), }), isPlanMode, diff --git a/packages/core/src/tools/enter-plan-mode.test.ts b/packages/core/src/tools/enter-plan-mode.test.ts index 0b1d0a37f0..48bc5b494e 100644 --- a/packages/core/src/tools/enter-plan-mode.test.ts +++ b/packages/core/src/tools/enter-plan-mode.test.ts @@ -24,7 +24,7 @@ describe('EnterPlanModeTool', () => { mockConfig = { setApprovalMode: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), } as unknown as Config['storage'], }; tool = new EnterPlanModeTool( diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index 3e226c5142..22de81fc7f 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -45,7 +45,7 @@ describe('ExitPlanModeTool', () => { setApprovalMode: vi.fn(), setApprovedPlanPath: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir), + getPlansDir: vi.fn().mockReturnValue(mockPlansDir), } as unknown as Config['storage'], }; tool = new ExitPlanModeTool( diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index a0540b11e3..c11eaa119e 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -57,7 +57,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< private config: Config, messageBus: MessageBus, ) { - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); const definition = getExitPlanModeDefinition(plansDir); super( EXIT_PLAN_MODE_TOOL_NAME, @@ -78,9 +78,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< // Since validateToolParamValues is synchronous, we use a basic synchronous check // for path traversal safety. High-level async validation is deferred to shouldConfirmExecute. - const plansDir = resolveToRealPath( - this.config.storage.getProjectTempPlansDir(), - ); + const plansDir = resolveToRealPath(this.config.storage.getPlansDir()); const resolvedPath = path.resolve( this.config.getTargetDir(), params.plan_path, @@ -111,7 +109,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< } override getSchema(modelId?: string) { - const plansDir = this.config.storage.getProjectTempPlansDir(); + const plansDir = this.config.storage.getPlansDir(); return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId); } } @@ -141,7 +139,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< const pathError = await validatePlanPath( this.params.plan_path, - this.config.storage.getProjectTempPlansDir(), + this.config.storage.getPlansDir(), this.config.getTargetDir(), ); if (pathError) { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f15af605a3..4c51c13e1b 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -105,6 +105,22 @@ }, "additionalProperties": false }, + "plan": { + "title": "Plan", + "description": "Planning features configuration.", + "markdownDescription": "Planning features configuration.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "directory": { + "title": "Plan Directory", + "description": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.", + "markdownDescription": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.\n\n- Category: `General`\n- Requires restart: `yes`", + "type": "string" + } + }, + "additionalProperties": false + }, "enablePromptCompletion": { "title": "Enable Prompt Completion", "description": "Enable AI-powered prompt completion suggestions while typing.", From d25c469f77237f55c50fb531f6bed06c9a8d4cc9 Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:47:39 -0800 Subject: [PATCH 18/26] Migrate files to resource or references folder. (#19503) --- CONTRIBUTING.md | 2 +- docs/changelogs/index.md | 3 +- docs/cli/cli-reference.md | 46 ++++++------- docs/cli/enterprise.md | 14 ++-- docs/cli/gemini-md.md | 2 +- docs/cli/model.md | 2 +- docs/cli/plan-mode.md | 2 +- docs/cli/sandbox.md | 6 +- docs/cli/telemetry.md | 2 +- docs/cli/themes.md | 6 +- docs/cli/tutorials/memory-management.md | 4 +- docs/cli/tutorials/session-management.md | 2 +- docs/core/index.md | 14 ++-- docs/core/subagents.md | 2 +- docs/extensions/reference.md | 2 +- docs/get-started/authentication.md | 8 +-- docs/get-started/index.md | 2 +- docs/hooks/best-practices.md | 2 +- docs/index.md | 65 ++++++------------- docs/redirects.json | 19 ++++++ docs/{cli => reference}/commands.md | 10 +-- .../configuration.md | 13 ++-- docs/{cli => reference}/keyboard-shortcuts.md | 0 docs/{core => reference}/memport.md | 0 docs/{core => reference}/policy-engine.md | 0 docs/{core => reference}/tools-api.md | 0 docs/{ => resources}/faq.md | 2 +- docs/{ => resources}/quota-and-pricing.md | 0 docs/{ => resources}/tos-privacy.md | 6 +- docs/{ => resources}/troubleshooting.md | 2 +- docs/{cli => resources}/uninstall.md | 0 docs/sidebar.json | 38 ++++++----- docs/tools/index.md | 4 +- docs/tools/internal-docs.md | 10 +-- docs/tools/shell.md | 6 +- scripts/generate-keybindings-doc.ts | 2 +- scripts/generate-settings-doc.ts | 2 +- 37 files changed, 151 insertions(+), 149 deletions(-) create mode 100644 docs/redirects.json rename docs/{cli => reference}/commands.md (98%) rename docs/{get-started => reference}/configuration.md (99%) rename docs/{cli => reference}/keyboard-shortcuts.md (100%) rename docs/{core => reference}/memport.md (100%) rename docs/{core => reference}/policy-engine.md (100%) rename docs/{core => reference}/tools-api.md (100%) rename docs/{ => resources}/faq.md (98%) rename docs/{ => resources}/quota-and-pricing.md (100%) rename docs/{ => resources}/tos-privacy.md (96%) rename docs/{ => resources}/troubleshooting.md (99%) rename docs/{cli => resources}/uninstall.md (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d8252f86c..7dfe898f14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -546,7 +546,7 @@ Before submitting your documentation pull request, please: If you have questions about contributing documentation: -- Check our [FAQ](/docs/faq.md). +- Check our [FAQ](/docs/resources/faq.md). - Review existing documentation for examples. - Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss your proposed changes. diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 990d116769..3cff4c123b 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -295,7 +295,8 @@ on GitHub. - **Experimental permission improvements:** We are now experimenting with a new policy engine in Gemini CLI. This allows users and administrators to create fine-grained policy for tool calls. Currently behind a flag. See - [policy engine documentation](../core/policy-engine.md) for more information. + [policy engine documentation](../reference/policy-engine.md) for more + information. - Blog: [https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/](https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/) - **Gemini 3 support for paid:** Gemini 3 support has been rolled out to all API diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index b44733916f..f8ff24bed6 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -26,29 +26,29 @@ and parameters. ## CLI Options -| Option | Alias | Type | Default | Description | -| -------------------------------- | ----- | ------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | -| `--version` | `-v` | - | - | Show CLI version number and exit | -| `--help` | `-h` | - | - | Show help information | -| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | -| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | -| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | -| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | -| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | -| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | -| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | -| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | -| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | -| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../core/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) | -| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | -| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | -| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) | -| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | -| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | -| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | -| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | -| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | +| Option | Alias | Type | Default | Description | +| -------------------------------- | ----- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | +| `--version` | `-v` | - | - | Show CLI version number and exit | +| `--help` | `-h` | - | - | Show help information | +| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | +| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | +| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | +| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | +| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | +| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | +| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | +| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | +| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../reference/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) | +| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | +| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | +| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) | +| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | +| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | +| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | +| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | +| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | ## Model selection diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index 31f7f89c83..b6d469755b 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -20,7 +20,7 @@ The most powerful tools for enterprise administration are the system-wide settings files. These files allow you to define a baseline configuration (`system-defaults.json`) and a set of overrides (`settings.json`) that apply to all users on a machine. For a complete overview of configuration options, see -the [Configuration documentation](../get-started/configuration.md). +the [Configuration documentation](../reference/configuration.md). Settings are merged from four files. The precedence order for single-value settings (like `theme`) is: @@ -224,8 +224,8 @@ gemini You can significantly enhance security by controlling which tools the Gemini model can use. This is achieved through the `tools.core` setting and the -[Policy Engine](../core/policy-engine.md). For a list of available tools, see -the [Tools documentation](../tools/index.md). +[Policy Engine](../reference/policy-engine.md). For a list of available tools, +see the [Tools documentation](../tools/index.md). ### Allowlisting with `coreTools` @@ -245,8 +245,8 @@ on the approved list. ### Blocklisting with `excludeTools` (Deprecated) -> **Deprecated:** Use the [Policy Engine](../core/policy-engine.md) for more -> robust control. +> **Deprecated:** Use the [Policy Engine](../reference/policy-engine.md) for +> more robust control. Alternatively, you can add specific tools that are considered dangerous in your environment to a blocklist. @@ -289,8 +289,8 @@ unintended tool execution. ## Managing custom tools (MCP servers) If your organization uses custom tools via -[Model-Context Protocol (MCP) servers](../core/tools-api.md), it is crucial to -understand how server configurations are managed to apply security policies +[Model-Context Protocol (MCP) servers](../reference/tools-api.md), it is crucial +to understand how server configurations are managed to apply security policies effectively. ### How MCP server configurations are merged diff --git a/docs/cli/gemini-md.md b/docs/cli/gemini-md.md index 01c99972d9..95f46ae095 100644 --- a/docs/cli/gemini-md.md +++ b/docs/cli/gemini-md.md @@ -88,7 +88,7 @@ More content here. @../shared/style-guide.md ``` -For more details, see the [Memory Import Processor](../core/memport.md) +For more details, see the [Memory Import Processor](../reference/memport.md) documentation. ## Customize the context file name diff --git a/docs/cli/model.md b/docs/cli/model.md index fd0e950bbb..62bfcf5b0b 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -39,7 +39,7 @@ To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable You can also use the `--model` flag to specify a particular Gemini model on startup. For more details, refer to the -[configuration documentation](../get-started/configuration.md). +[configuration documentation](../reference/configuration.md). Changes to these settings will be applied to all subsequent interactions with Gemini CLI. diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 995e693bb2..b59b0c3198 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -225,7 +225,7 @@ Guide]. [MCP tools]: /docs/tools/mcp-server.md [`activate_skill`]: /docs/cli/skills.md [experimental research sub-agents]: /docs/core/subagents.md -[Policy Engine Guide]: /docs/core/policy-engine.md +[Policy Engine Guide]: /docs/reference/policy-engine.md [`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode [`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode [`ask_user`]: /docs/tools/ask-user.md diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 9f632693c7..392c71a176 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -167,6 +167,6 @@ gemini -s -p "run shell command: mount | grep workspace" ## Related documentation -- [Configuration](../get-started/configuration.md): Full configuration options. -- [Commands](./commands.md): Available commands. -- [Troubleshooting](../troubleshooting.md): General troubleshooting. +- [Configuration](../reference/configuration.md): Full configuration options. +- [Commands](../reference/commands.md): Available commands. +- [Troubleshooting](../resources/troubleshooting.md): General troubleshooting. diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index ca44bccaf0..0cda8b4528 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -93,7 +93,7 @@ Environment variables can be used to override the settings in the file. `true` or `1` will enable the feature. Any other value will disable it. For detailed information about all configuration options, see the -[Configuration guide](../get-started/configuration.md). +[Configuration guide](../reference/configuration.md). ## Google Cloud telemetry diff --git a/docs/cli/themes.md b/docs/cli/themes.md index dc3423080f..08564a249a 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -41,8 +41,8 @@ can change the theme using the `/theme` command. ### Theme persistence Selected themes are saved in Gemini CLI's -[configuration](../get-started/configuration.md) so your preference is -remembered across sessions. +[configuration](../reference/configuration.md) so your preference is remembered +across sessions. --- @@ -194,7 +194,7 @@ untrusted sources. - Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`. - Custom themes can be set at the user, project, or system level, and follow the - same [configuration precedence](../get-started/configuration.md) as other + same [configuration precedence](../reference/configuration.md) as other settings. ### Themes from extensions diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index f679719238..829fbecbd4 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -121,6 +121,6 @@ immediately. Force a reload with: - Learn about [Session management](session-management.md) to see how short-term history works. -- Explore the [Command reference](../../cli/commands.md) for more `/memory` - options. +- Explore the [Command reference](../../reference/commands.md) for more + `/memory` options. - Read the technical spec for [Project context](../../cli/gemini-md.md). diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md index 775a1e9b5f..7815aa94d6 100644 --- a/docs/cli/tutorials/session-management.md +++ b/docs/cli/tutorials/session-management.md @@ -101,5 +101,5 @@ This creates a new branch of history without losing your original work. - Learn about [Checkpointing](../../cli/checkpointing.md) to understand the underlying safety mechanism. - Explore [Task planning](task-planning.md) to keep complex sessions organized. -- See the [Command reference](../../cli/commands.md) for all `/chat` and +- See the [Command reference](../../reference/commands.md) for all `/chat` and `/resume` options. diff --git a/docs/core/index.md b/docs/core/index.md index 0cd49ad43e..53aa647dc2 100644 --- a/docs/core/index.md +++ b/docs/core/index.md @@ -9,11 +9,11 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the - **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use specialized sub-agents for complex tasks. -- **[Core tools API](./tools-api.md):** Information on how tools are defined, - registered, and used by the core. -- **[Memory Import Processor](./memport.md):** Documentation for the modular - GEMINI.md import feature using @file.md syntax. -- **[Policy Engine](./policy-engine.md):** Use the Policy Engine for +- **[Core tools API](../reference/tools-api.md):** Information on how tools are + defined, registered, and used by the core. +- **[Memory Import Processor](../reference/memport.md):** Documentation for the + modular GEMINI.md import feature using @file.md syntax. +- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for fine-grained control over tool execution. ## Role of the core @@ -92,8 +92,8 @@ This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information. -You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and -`refresh` the content of loaded `GEMINI.md` files. +You can use the [`/memory` command](../reference/commands.md) to `show`, `add`, +and `refresh` the content of loaded `GEMINI.md` files. ## Citations diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 7e17da94ae..3619609e95 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -17,7 +17,7 @@ the main agent's context or toolset. > ``` > > **Warning:** Subagents currently operate in -> ["YOLO mode"](../get-started/configuration.md#command-line-arguments), meaning +> ["YOLO mode"](../reference/configuration.md#command-line-arguments), meaning > they may execute tools without individual user confirmation for each step. > Proceed with caution when defining agents with powerful tools like > `run_shell_command` or `write_file`. diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index eec5b82025..b4a0df7336 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -130,7 +130,7 @@ The manifest file defines the extension's behavior and configuration. - `description`: A short summary shown in the extension gallery. - `mcpServers`: A map of Model Context Protocol (MCP) servers. Extension servers follow the same format as standard - [CLI configuration](../get-started/configuration.md). + [CLI configuration](../reference/configuration.md). - `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can also be an array of strings to load multiple context files. - `excludeTools`: An array of tools to block from the model. You can restrict diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index 6e2ce5ca05..e8696137cf 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -22,8 +22,8 @@ Select the authentication method that matches your situation in the table below: ### What is my Google account type? - **Individual Google accounts:** Includes all - [free tier accounts](../quota-and-pricing/#free-usage) such as Gemini Code - Assist for individuals, as well as paid subscriptions for + [free tier accounts](../resources/quota-and-pricing.md#free-usage) such as + Gemini Code Assist for individuals, as well as paid subscriptions for [Google AI Pro and Ultra](https://gemini.google/subscriptions/). - **Organization accounts:** Accounts using paid licenses through an @@ -317,5 +317,5 @@ configure authentication using environment variables: Your authentication method affects your quotas, pricing, Terms of Service, and privacy notices. Review the following pages to learn more: -- [Gemini CLI: Quotas and Pricing](../quota-and-pricing.md). -- [Gemini CLI: Terms of Service and Privacy Notice](../tos-privacy.md). +- [Gemini CLI: Quotas and Pricing](../resources/quota-and-pricing.md). +- [Gemini CLI: Terms of Service and Privacy Notice](../resources/tos-privacy.md). diff --git a/docs/get-started/index.md b/docs/get-started/index.md index d5cf891381..4d0158b71f 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -54,7 +54,7 @@ Gemini CLI offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. To explore your configuration options, see -[Gemini CLI Configuration](./configuration.md). +[Gemini CLI Configuration](../reference/configuration.md). ## Use diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index 316aacbc29..fd80fc0b40 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -420,7 +420,7 @@ When you open a project with hooks defined in `.gemini/settings.json`: Hooks inherit the environment of the Gemini CLI process, which may include sensitive API keys. Gemini CLI provides a -[redaction system](/docs/get-started/configuration#environment-variable-redaction) +[redaction system](/docs/reference/configuration.md#environment-variable-redaction) that automatically filters variables matching sensitive patterns (e.g., `KEY`, `TOKEN`). diff --git a/docs/index.md b/docs/index.md index 813cd4047e..81e760fadd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,41 +48,8 @@ User-focused guides and tutorials for daily development workflows. ## Features -Technical reference documentation for each capability of Gemini CLI. +Technical documentation for each capability of Gemini CLI. -- **[/about](./cli/commands.md#about):** About Gemini CLI. -- **[/auth](./get-started/authentication.md):** Authentication. -- **[/bug](./cli/commands.md#bug):** Report a bug. -- **[/chat](./cli/commands.md#chat):** Chat history. -- **[/clear](./cli/commands.md#clear):** Clear screen. -- **[/compress](./cli/commands.md#compress):** Compress context. -- **[/copy](./cli/commands.md#copy):** Copy output. -- **[/directory](./cli/commands.md#directory-or-dir):** Manage workspace. -- **[/docs](./cli/commands.md#docs):** Open documentation. -- **[/editor](./cli/commands.md#editor):** Select editor. -- **[/extensions](./extensions/index.md):** Manage extensions. -- **[/help](./cli/commands.md#help-or):** Show help. -- **[/hooks](./hooks/index.md):** Hooks. -- **[/ide](./ide-integration/index.md):** IDE integration. -- **[/init](./cli/commands.md#init):** Initialize context. -- **[/mcp](./tools/mcp-server.md):** MCP servers. -- **[/memory](./cli/commands.md#memory):** Manage memory. -- **[/model](./cli/model.md):** Model selection. -- **[/policies](./cli/commands.md#policies):** Manage policies. -- **[/privacy](./cli/commands.md#privacy):** Privacy notice. -- **[/quit](./cli/commands.md#quit-or-exit):** Exit CLI. -- **[/restore](./cli/checkpointing.md):** Restore files. -- **[/resume](./cli/commands.md#resume):** Resume session. -- **[/rewind](./cli/rewind.md):** Rewind. -- **[/settings](./cli/settings.md):** Settings. -- **[/setup-github](./cli/commands.md#setup-github):** GitHub setup. -- **[/shells](./cli/commands.md#shells-or-bashes):** Manage processes. -- **[/skills](./cli/skills.md):** Agent skills. -- **[/stats](./cli/commands.md#stats):** Session statistics. -- **[/terminal-setup](./cli/commands.md#terminal-setup):** Terminal keybindings. -- **[/theme](./cli/themes.md):** Themes. -- **[/tools](./cli/commands.md#tools):** List tools. -- **[/vim](./cli/commands.md#vim):** Vim mode. - **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for loading expert procedures. - **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for @@ -97,12 +64,12 @@ Technical reference documentation for each capability of Gemini CLI. - **[Model routing](./cli/model-routing.md):** Automatic fallback resilience. - **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for planning complex changes. +- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific + tasks. - **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using remote agents. - **[Sandboxing](./cli/sandbox.md):** Isolate tool execution. - **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters. -- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific - tasks. - **[Telemetry](./cli/telemetry.md):** Usage and performance metric details. - **[Todo (tool)](./tools/todos.md):** Progress tracking specification. - **[Token caching](./cli/token-caching.md):** Performance optimization. @@ -134,23 +101,29 @@ Settings and customization options for Gemini CLI. Deep technical documentation and API specifications. -- **[Command reference](./cli/commands.md):** Detailed slash command guide. -- **[Configuration reference](./get-started/configuration.md):** Settings and +- **[Command reference](./reference/commands.md):** Detailed slash command + guide. +- **[Configuration reference](./reference/configuration.md):** Settings and environment variables. -- **[Keyboard shortcuts](./cli/keyboard-shortcuts.md):** Productivity tips. -- **[Memory import processor](./core/memport.md):** How Gemini CLI processes - memory from various sources. -- **[Policy engine](./core/policy-engine.md):** Fine-grained execution control. -- **[Tools API](./core/tools-api.md):** The API for defining and using tools. +- **[Keyboard shortcuts](./reference/keyboard-shortcuts.md):** Productivity + tips. +- **[Memory import processor](./reference/memport.md):** How Gemini CLI + processes memory from various sources. +- **[Policy engine](./reference/policy-engine.md):** Fine-grained execution + control. +- **[Tools API](./reference/tools-api.md):** The API for defining and using + tools. ## Resources Support, release history, and legal information. -- **[FAQ](./faq.md):** Answers to frequently asked questions. +- **[FAQ](./resources/faq.md):** Answers to frequently asked questions. - **[Changelogs](./changelogs/index.md):** Highlights and notable changes. -- **[Quota and pricing](./quota-and-pricing.md):** Limits and billing details. -- **[Terms and privacy](./tos-privacy.md):** Official notices and terms. +- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing + details. +- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and + terms. ## Development diff --git a/docs/redirects.json b/docs/redirects.json new file mode 100644 index 0000000000..5183d0d476 --- /dev/null +++ b/docs/redirects.json @@ -0,0 +1,19 @@ +{ + "/docs/architecture": "/docs/cli/index", + "/docs/cli/commands": "/docs/reference/commands", + "/docs/cli": "/docs", + "/docs/cli/index": "/docs", + "/docs/cli/keyboard-shortcuts": "/docs/reference/keyboard-shortcuts", + "/docs/cli/uninstall": "/docs/resources/uninstall", + "/docs/core/concepts": "/docs", + "/docs/core/memport": "/docs/reference/memport", + "/docs/core/policy-engine": "/docs/reference/policy-engine", + "/docs/core/tools-api": "/docs/reference/tools-api", + "/docs/faq": "/docs/resources/faq", + "/docs/get-started/configuration": "/docs/reference/configuration", + "/docs/get-started/configuration-v1": "/docs/reference/configuration", + "/docs/index": "/docs", + "/docs/quota-and-pricing": "/docs/resources/quota-and-pricing", + "/docs/tos-privacy": "/docs/resources/tos-privacy", + "/docs/troubleshooting": "/docs/resources/troubleshooting" +} diff --git a/docs/cli/commands.md b/docs/reference/commands.md similarity index 98% rename from docs/cli/commands.md rename to docs/reference/commands.md index 6d44659404..ee7ac6d581 100644 --- a/docs/cli/commands.md +++ b/docs/reference/commands.md @@ -217,7 +217,7 @@ Slash commands provide meta-level control over the CLI itself. model. - **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the - [CLI Configuration documentation](../get-started/configuration.md). + [CLI Configuration documentation](./configuration.md). ### `/model` @@ -254,7 +254,7 @@ Slash commands provide meta-level control over the CLI itself. checkpoints to restore from. - **Usage:** `/restore [tool_call_id]` - **Note:** Only available if checkpointing is configured via - [settings](../get-started/configuration.md). See + [settings](./configuration.md). See [Checkpointing documentation](../cli/checkpointing.md) for more details. ### `/rewind` @@ -293,7 +293,8 @@ Slash commands provide meta-level control over the CLI itself. settings that control the behavior and appearance of Gemini CLI. It is equivalent to manually editing the `.gemini/settings.json` file, but with validation and guidance to prevent errors. See the - [settings documentation](./settings.md) for a full list of available settings. + [settings documentation](../cli/settings.md) for a full list of available + settings. - **Usage:** Simply run `/settings` and the editor will open. You can then browse or search for specific settings, view their current values, and modify them as desired. Changes to some settings are applied immediately, while @@ -380,7 +381,8 @@ Slash commands provide meta-level control over the CLI itself. Custom commands allow you to create personalized shortcuts for your most-used prompts. For detailed instructions on how to create, manage, and use them, -please see the dedicated [Custom Commands documentation](./custom-commands.md). +please see the dedicated +[Custom Commands documentation](../cli/custom-commands.md). ## Input prompt shortcuts diff --git a/docs/get-started/configuration.md b/docs/reference/configuration.md similarity index 99% rename from docs/get-started/configuration.md rename to docs/reference/configuration.md index ba86442a4d..de639f95cf 100644 --- a/docs/get-started/configuration.md +++ b/docs/reference/configuration.md @@ -1234,8 +1234,8 @@ within your user's home folder. Environment variables are a common way to configure applications, especially for sensitive information like API keys or for settings that might change between environments. For authentication setup, see the -[Authentication documentation](./authentication.md) which covers all available -authentication methods. +[Authentication documentation](../get-started/authentication.md) which covers +all available authentication methods. The CLI automatically loads environment variables from an `.env` file. The loading order is: @@ -1254,7 +1254,8 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`GEMINI_API_KEY`**: - Your API key for the Gemini API. - - One of several available [authentication methods](./authentication.md). + - One of several available + [authentication methods](../get-started/authentication.md). - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. - **`GEMINI_MODEL`**: @@ -1600,15 +1601,15 @@ conventions and context. about the active instructional context. - **Importing content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, - see the [Memory Import Processor documentation](../core/memport.md). + see the [Memory Import Processor documentation](./memport.md). - **Commands for memory management:** - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. - - See the [Commands documentation](../cli/commands.md#memory) for full details - on the `/memory` command and its sub-commands (`show` and `refresh`). + - See the [Commands documentation](./commands.md#memory) for full details on + the `/memory` command and its sub-commands (`show` and `refresh`). By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor diff --git a/docs/cli/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md similarity index 100% rename from docs/cli/keyboard-shortcuts.md rename to docs/reference/keyboard-shortcuts.md diff --git a/docs/core/memport.md b/docs/reference/memport.md similarity index 100% rename from docs/core/memport.md rename to docs/reference/memport.md diff --git a/docs/core/policy-engine.md b/docs/reference/policy-engine.md similarity index 100% rename from docs/core/policy-engine.md rename to docs/reference/policy-engine.md diff --git a/docs/core/tools-api.md b/docs/reference/tools-api.md similarity index 100% rename from docs/core/tools-api.md rename to docs/reference/tools-api.md diff --git a/docs/faq.md b/docs/resources/faq.md similarity index 98% rename from docs/faq.md rename to docs/resources/faq.md index 2e78f3aa34..eeb0396495 100644 --- a/docs/faq.md +++ b/docs/resources/faq.md @@ -104,7 +104,7 @@ The Gemini CLI configuration is stored in two `settings.json` files: 1. In your home directory: `~/.gemini/settings.json`. 2. In your project's root directory: `./.gemini/settings.json`. -Refer to [Gemini CLI Configuration](./get-started/configuration.md) for more +Refer to [Gemini CLI Configuration](../reference/configuration.md) for more details. ## Google AI Pro/Ultra and subscription FAQs diff --git a/docs/quota-and-pricing.md b/docs/resources/quota-and-pricing.md similarity index 100% rename from docs/quota-and-pricing.md rename to docs/resources/quota-and-pricing.md diff --git a/docs/tos-privacy.md b/docs/resources/tos-privacy.md similarity index 96% rename from docs/tos-privacy.md rename to docs/resources/tos-privacy.md index 0c7073e0fb..e653e59d1d 100644 --- a/docs/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -10,8 +10,8 @@ and Privacy Notices applicable to those services apply to such access and use. Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. -**Note:** See [quotas and pricing](/docs/quota-and-pricing.md) for the quota and -pricing details that apply to your usage of the Gemini CLI. +**Note:** See [quotas and pricing](/docs/resources/quota-and-pricing.md) for the +quota and pricing details that apply to your usage of the Gemini CLI. ## Supported authentication methods @@ -93,4 +93,4 @@ backend, these Terms of Service and Privacy Notice documents apply: You may opt-out from sending Gemini CLI Usage Statistics to Google by following the instructions available here: -[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md#usage-statistics). +[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md#usage-statistics). diff --git a/docs/troubleshooting.md b/docs/resources/troubleshooting.md similarity index 99% rename from docs/troubleshooting.md rename to docs/resources/troubleshooting.md index f700d0b74f..9e567652d9 100644 --- a/docs/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -93,7 +93,7 @@ topics on: - **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory. - - **Solution:** Refer to the [Configuration: Sandboxing](./cli/sandbox.md) + - **Solution:** Refer to the [Configuration: Sandboxing](../cli/sandbox.md) documentation for more information, including how to customize your sandbox configuration. diff --git a/docs/cli/uninstall.md b/docs/resources/uninstall.md similarity index 100% rename from docs/cli/uninstall.md rename to docs/resources/uninstall.md diff --git a/docs/sidebar.json b/docs/sidebar.json index 4e95111a13..ef9989884f 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -63,13 +63,13 @@ "slug": "docs/extensions/index" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, - { "label": "Help", "link": "/docs/cli/commands/#help-or" }, + { "label": "Help", "link": "/docs/reference/commands/#help-or" }, { "label": "Hooks", "slug": "docs/hooks" }, { "label": "IDE integration", "slug": "docs/ide-integration" }, { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, { "label": "Memory management", - "link": "/docs/cli/commands/#memory" + "link": "/docs/reference/commands/#memory" }, { "label": "Model routing", "slug": "docs/cli/model-routing" }, { "label": "Model selection", "slug": "docs/cli/model" }, @@ -85,15 +85,15 @@ { "label": "Settings", "slug": "docs/cli/settings" }, { "label": "Shell", - "link": "/docs/cli/commands/#shells-or-bashes" + "link": "/docs/reference/commands/#shells-or-bashes" }, { "label": "Stats", - "link": "/docs/cli/commands/#stats" + "link": "/docs/reference/commands/#stats" }, { "label": "Telemetry", "slug": "docs/cli/telemetry" }, { "label": "Token caching", "slug": "docs/cli/token-caching" }, - { "label": "Tools", "link": "/docs/cli/commands/#tools" } + { "label": "Tools", "link": "/docs/reference/commands/#tools" } ] }, { @@ -148,25 +148,31 @@ { "label": "Reference", "items": [ - { "label": "Command reference", "slug": "docs/cli/commands" }, + { "label": "Command reference", "slug": "docs/reference/commands" }, { "label": "Configuration reference", - "slug": "docs/get-started/configuration" + "slug": "docs/reference/configuration" }, - { "label": "Keyboard shortcuts", "slug": "docs/cli/keyboard-shortcuts" }, - { "label": "Memory import processor", "slug": "docs/core/memport" }, - { "label": "Policy engine", "slug": "docs/core/policy-engine" }, - { "label": "Tools API", "slug": "docs/core/tools-api" } + { + "label": "Keyboard shortcuts", + "slug": "docs/reference/keyboard-shortcuts" + }, + { "label": "Memory import processor", "slug": "docs/reference/memport" }, + { "label": "Policy engine", "slug": "docs/reference/policy-engine" }, + { "label": "Tools API", "slug": "docs/reference/tools-api" } ] }, { "label": "Resources", "items": [ - { "label": "FAQ", "slug": "docs/faq" }, - { "label": "Quota and pricing", "slug": "docs/quota-and-pricing" }, - { "label": "Terms and privacy", "slug": "docs/tos-privacy" }, - { "label": "Troubleshooting", "slug": "docs/troubleshooting" }, - { "label": "Uninstall", "slug": "docs/cli/uninstall" } + { "label": "FAQ", "slug": "docs/resources/faq" }, + { + "label": "Quota and pricing", + "slug": "docs/resources/quota-and-pricing" + }, + { "label": "Terms and privacy", "slug": "docs/resources/tos-privacy" }, + { "label": "Troubleshooting", "slug": "docs/resources/troubleshooting" }, + { "label": "Uninstall", "slug": "docs/resources/uninstall" } ] }, { diff --git a/docs/tools/index.md b/docs/tools/index.md index 42304ef008..f496ad591a 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -98,5 +98,5 @@ Always review confirmation prompts carefully before allowing a tool to execute. ## Next steps - Learn how to [Provide context](../cli/gemini-md.md) to guide tool use. -- Explore the [Command reference](../cli/commands.md) for tool-related slash - commands. +- Explore the [Command reference](../reference/commands.md) for tool-related + slash commands. diff --git a/docs/tools/internal-docs.md b/docs/tools/internal-docs.md index f87099b2f3..0d30655fcd 100644 --- a/docs/tools/internal-docs.md +++ b/docs/tools/internal-docs.md @@ -14,8 +14,8 @@ provides direct access to the Markdown files in the `docs/` directory. `get_internal_docs` takes one optional argument: - `path` (string, optional): The relative path to a specific documentation file - (for example, `cli/commands.md`). If omitted, the tool returns a list of all - available documentation paths. + (for example, `reference/commands.md`). If omitted, the tool returns a list of + all available documentation paths. ## Usage @@ -40,7 +40,7 @@ Gemini CLI uses this tool to ensure technical accuracy: ## Next steps -- Explore the [Command reference](../cli/commands.md) for a detailed guide to - slash commands. -- See the [Configuration guide](../get-started/configuration.md) for settings +- Explore the [Command reference](../reference/commands.md) for a detailed guide + to slash commands. +- See the [Configuration guide](../reference/configuration.md) for settings reference. diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 7d8e407434..34fd7c8490 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -131,9 +131,9 @@ configuration file. commands. Including the generic `run_shell_command` acts as a wildcard, allowing any command not explicitly blocked. - `tools.exclude` [DEPRECATED]: To block specific commands, use the - [Policy Engine](../core/policy-engine.md). Historically, this setting allowed - adding entries to the `exclude` list under the `tools` category in the format - `run_shell_command()`. For example, + [Policy Engine](../reference/policy-engine.md). Historically, this setting + allowed adding entries to the `exclude` list under the `tools` category in the + format `run_shell_command()`. For example, `"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands. The validation logic is designed to be secure and flexible: diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index 600a989936..eea7ef9af3 100644 --- a/scripts/generate-keybindings-doc.ts +++ b/scripts/generate-keybindings-doc.ts @@ -22,7 +22,7 @@ import { const START_MARKER = ''; const END_MARKER = ''; -const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md']; +const OUTPUT_RELATIVE_PATH = ['docs', 'reference', 'keyboard-shortcuts.md']; const KEY_NAME_OVERRIDES: Record = { return: 'Enter', diff --git a/scripts/generate-settings-doc.ts b/scripts/generate-settings-doc.ts index 32d9d47c0b..1d27eb962a 100644 --- a/scripts/generate-settings-doc.ts +++ b/scripts/generate-settings-doc.ts @@ -47,7 +47,7 @@ export async function main(argv = process.argv.slice(2)) { path.dirname(fileURLToPath(import.meta.url)), '..', ); - const docPath = path.join(repoRoot, 'docs/get-started/configuration.md'); + const docPath = path.join(repoRoot, 'docs/reference/configuration.md'); const cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md'); const { getSettingsSchema } = await loadSettingsSchemaModule(); From d8b24e698367b50704377960d6d5c915ed1ffcf9 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Thu, 19 Feb 2026 16:16:03 -0800 Subject: [PATCH 19/26] feat(policy): implement project-level policy support (#18682) --- docs/reference/policy-engine.md | 30 +- packages/cli/src/config/config.ts | 14 +- .../config/policy-engine.integration.test.ts | 24 +- packages/cli/src/config/policy.test.ts | 145 +++++++++ packages/cli/src/config/policy.ts | 72 +++++ .../src/config/workspace-policy-cli.test.ts | 239 +++++++++++++++ packages/cli/src/test-utils/render.tsx | 1 + packages/cli/src/ui/AppContainer.tsx | 14 + .../cli/src/ui/components/DialogManager.tsx | 10 + .../ui/components/PolicyUpdateDialog.test.tsx | 141 +++++++++ .../src/ui/components/PolicyUpdateDialog.tsx | 116 +++++++ .../PolicyUpdateDialog.test.tsx.snap | 15 + .../cli/src/ui/contexts/UIActionsContext.tsx | 3 +- .../cli/src/ui/contexts/UIStateContext.tsx | 3 + packages/core/src/config/config.ts | 50 +++ packages/core/src/config/storage.ts | 8 + packages/core/src/index.ts | 1 + packages/core/src/policy/config.test.ts | 52 ++-- packages/core/src/policy/config.ts | 120 +++++--- packages/core/src/policy/integrity.test.ts | 249 +++++++++++++++ packages/core/src/policy/integrity.ts | 154 ++++++++++ packages/core/src/policy/persistence.test.ts | 2 +- packages/core/src/policy/policies/plan.toml | 21 +- .../core/src/policy/policies/read-only.toml | 21 +- packages/core/src/policy/policies/write.toml | 21 +- packages/core/src/policy/policies/yolo.toml | 21 +- .../core/src/policy/policy-engine.test.ts | 85 +++++ packages/core/src/policy/policy-engine.ts | 20 +- packages/core/src/policy/toml-loader.test.ts | 33 +- packages/core/src/policy/toml-loader.ts | 98 +++--- packages/core/src/policy/types.ts | 8 + .../core/src/policy/workspace-policy.test.ts | 290 ++++++++++++++++++ 32 files changed, 1895 insertions(+), 186 deletions(-) create mode 100644 packages/cli/src/config/policy.test.ts create mode 100644 packages/cli/src/config/workspace-policy-cli.test.ts create mode 100644 packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx create mode 100644 packages/cli/src/ui/components/PolicyUpdateDialog.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap create mode 100644 packages/core/src/policy/integrity.test.ts create mode 100644 packages/core/src/policy/integrity.ts create mode 100644 packages/core/src/policy/workspace-policy.test.ts diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 23e672e4b9..2106b751c9 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -92,11 +92,12 @@ rule with the highest priority wins**. To provide a clear hierarchy, policies are organized into three tiers. Each tier has a designated number that forms the base of the final priority calculation. -| Tier | Base | Description | -| :------ | :--- | :------------------------------------------------------------------------- | -| Default | 1 | Built-in policies that ship with the Gemini CLI. | -| User | 2 | Custom policies defined by the user. | -| Admin | 3 | Policies managed by an administrator (e.g., in an enterprise environment). | +| Tier | Base | Description | +| :-------- | :--- | :------------------------------------------------------------------------- | +| Default | 1 | Built-in policies that ship with the Gemini CLI. | +| Workspace | 2 | Policies defined in the current workspace's configuration directory. | +| User | 3 | Custom policies defined by the user. | +| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: @@ -105,15 +106,17 @@ engine transforms this into a final priority using the following formula: This system guarantees that: -- Admin policies always override User and Default policies. -- User policies always override Default policies. +- Admin policies always override User, Workspace, and Default policies. +- User policies override Workspace and Default policies. +- Workspace policies override Default policies. - You can still order rules within a single tier with fine-grained control. For example: - A `priority: 50` rule in a Default policy file becomes `1.050`. -- A `priority: 100` rule in a User policy file becomes `2.100`. -- A `priority: 20` rule in an Admin policy file becomes `3.020`. +- A `priority: 10` rule in a Workspace policy policy file becomes `2.010`. +- A `priority: 100` rule in a User policy file becomes `3.100`. +- A `priority: 20` rule in an Admin policy file becomes `4.020`. ### Approval modes @@ -156,10 +159,11 @@ User, and (if configured) Admin directories. ### Policy locations -| Tier | Type | Location | -| :-------- | :----- | :-------------------------- | -| **User** | Custom | `~/.gemini/policies/*.toml` | -| **Admin** | System | _See below (OS specific)_ | +| Tier | Type | Location | +| :------------ | :----- | :---------------------------------------- | +| **User** | Custom | `~/.gemini/policies/*.toml` | +| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` | +| **Admin** | System | _See below (OS specific)_ | #### System-wide policies (Admin) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6b7f3460af..27b251139c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -56,7 +56,10 @@ import { resolvePath } from '../utils/resolvePath.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; -import { createPolicyEngineConfig } from './policy.js'; +import { + createPolicyEngineConfig, + resolveWorkspacePolicyState, +} from './policy.js'; import { ExtensionManager } from './extension-manager.js'; import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js'; import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; @@ -692,9 +695,17 @@ export async function loadCliConfig( policyPaths: argv.policy, }; + const { workspacePoliciesDir, policyUpdateConfirmationRequest } = + await resolveWorkspacePolicyState({ + cwd, + trustedFolder, + interactive, + }); + const policyEngineConfig = await createPolicyEngineConfig( effectiveSettings, approvalMode, + workspacePoliciesDir, ); policyEngineConfig.nonInteractive = !interactive; @@ -758,6 +769,7 @@ export async function loadCliConfig( coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, policyEngineConfig, + policyUpdateConfirmationRequest, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, toolCallCommand: settings.tools?.callCommand, diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 2c7ce599da..dbc7f6a415 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -148,13 +148,13 @@ describe('Policy Engine Integration Tests', () => { ); const engine = new PolicyEngine(config); - // MCP server allowed (priority 2.1) provides general allow for server - // MCP server allowed (priority 2.1) provides general allow for server + // MCP server allowed (priority 3.1) provides general allow for server + // MCP server allowed (priority 3.1) provides general allow for server expect( (await engine.check({ name: 'my-server__safe-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); - // But specific tool exclude (priority 2.4) wins over server allow + // But specific tool exclude (priority 3.4) wins over server allow expect( (await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) .decision, @@ -412,25 +412,25 @@ describe('Policy Engine Integration Tests', () => { // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); - expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude + expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude const blockedServerRule = rules.find( (r) => r.toolName === 'blocked-server__*', ); - expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude + expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); - expect(specificToolRule?.priority).toBe(2.3); // Command line allow + expect(specificToolRule?.priority).toBe(3.3); // Command line allow const trustedServerRule = rules.find( (r) => r.toolName === 'trusted-server__*', ); - expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); - expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server + expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) @@ -577,16 +577,16 @@ describe('Policy Engine Integration Tests', () => { // Verify each rule has the expected priority const tool3Rule = rules.find((r) => r.toolName === 'tool3'); - expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier) + expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier) const server2Rule = rules.find((r) => r.toolName === 'server2__*'); - expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier) + expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); - expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier) + expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier) const server1Rule = rules.find((r) => r.toolName === 'server1__*'); - expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier) + expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts new file mode 100644 index 0000000000..a0e687388d --- /dev/null +++ b/packages/cli/src/config/policy.test.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { resolveWorkspacePolicyState } from './policy.js'; +import { writeToStderr } from '@google/gemini-cli-core'; + +// Mock debugLogger to avoid noise in test output +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + writeToStderr: vi.fn(), + }; +}); + +describe('resolveWorkspacePolicyState', () => { + let tempDir: string; + let workspaceDir: string; + let policiesDir: string; + + beforeEach(() => { + // Create a temporary directory for the test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); + // Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage + vi.stubEnv('GEMINI_CLI_HOME', tempDir); + + workspaceDir = path.join(tempDir, 'workspace'); + fs.mkdirSync(workspaceDir); + policiesDir = path.join(workspaceDir, '.gemini', 'policies'); + + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clean up temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it('should return empty state if folder is not trusted', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: false, + interactive: true, + }); + + expect(result).toEqual({ + workspacePoliciesDir: undefined, + policyUpdateConfirmationRequest: undefined, + }); + }); + + it('should return policy directory if integrity matches', async () => { + // Set up policies directory with a file + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + // First call to establish integrity (interactive accept) + const firstResult = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + expect(firstResult.policyUpdateConfirmationRequest).toBeDefined(); + + // Establish integrity manually as if accepted + const { PolicyIntegrityManager } = await import('@google/gemini-cli-core'); + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + 'workspace', + workspaceDir, + firstResult.policyUpdateConfirmationRequest!.newHash, + ); + + // Second call should match + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return undefined if integrity is NEW but fileCount is 0', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return confirmation request if changed in interactive mode', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toEqual({ + scope: 'workspace', + identifier: workspaceDir, + policyDir: policiesDir, + newHash: expect.any(String), + }); + }); + + it('should warn and auto-accept if changed in non-interactive mode', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: false, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).toHaveBeenCalledWith( + expect.stringContaining('Automatically accepting and loading'), + ); + }); +}); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 70536070eb..e689094f94 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -12,12 +12,18 @@ import { type PolicySettings, createPolicyEngineConfig as createCorePolicyEngineConfig, createPolicyUpdater as createCorePolicyUpdater, + PolicyIntegrityManager, + IntegrityStatus, + Storage, + type PolicyUpdateConfirmationRequest, + writeToStderr, } from '@google/gemini-cli-core'; import { type Settings } from './settings.js'; export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, + workspacePoliciesDir?: string, ): Promise { // Explicitly construct PolicySettings from Settings to ensure type safety // and avoid accidental leakage of other settings properties. @@ -26,6 +32,7 @@ export async function createPolicyEngineConfig( tools: settings.tools, mcpServers: settings.mcpServers, policyPaths: settings.policyPaths, + workspacePoliciesDir, }; return createCorePolicyEngineConfig(policySettings, approvalMode); @@ -37,3 +44,68 @@ export function createPolicyUpdater( ) { return createCorePolicyUpdater(policyEngine, messageBus); } + +export interface WorkspacePolicyState { + workspacePoliciesDir?: string; + policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; +} + +/** + * Resolves the workspace policy state by checking folder trust and policy integrity. + */ +export async function resolveWorkspacePolicyState(options: { + cwd: string; + trustedFolder: boolean; + interactive: boolean; +}): Promise { + const { cwd, trustedFolder, interactive } = options; + + let workspacePoliciesDir: string | undefined; + let policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; + + if (trustedFolder) { + const potentialWorkspacePoliciesDir = new Storage( + cwd, + ).getWorkspacePoliciesDir(); + const integrityManager = new PolicyIntegrityManager(); + const integrityResult = await integrityManager.checkIntegrity( + 'workspace', + cwd, + potentialWorkspacePoliciesDir, + ); + + if (integrityResult.status === IntegrityStatus.MATCH) { + workspacePoliciesDir = potentialWorkspacePoliciesDir; + } else if ( + integrityResult.status === IntegrityStatus.NEW && + integrityResult.fileCount === 0 + ) { + // No workspace policies found + workspacePoliciesDir = undefined; + } else if (interactive) { + // Policies changed or are new, and we are in interactive mode + policyUpdateConfirmationRequest = { + scope: 'workspace', + identifier: cwd, + policyDir: potentialWorkspacePoliciesDir, + newHash: integrityResult.hash, + }; + } else { + // Non-interactive mode: warn and automatically accept/load + await integrityManager.acceptIntegrity( + 'workspace', + cwd, + integrityResult.hash, + ); + workspacePoliciesDir = potentialWorkspacePoliciesDir; + // debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console + writeToStderr( + 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n', + ); + } + } + + return { workspacePoliciesDir, policyUpdateConfirmationRequest }; +} diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts new file mode 100644 index 0000000000..98cbe05bce --- /dev/null +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; +import { loadCliConfig, type CliArgs } from './config.js'; +import { createTestMergedSettings } from './settings.js'; +import * as ServerConfig from '@google/gemini-cli-core'; +import { isWorkspaceTrusted } from './trustedFolders.js'; + +// Mock dependencies +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(), +})); + +const mockCheckIntegrity = vi.fn(); +const mockAcceptIntegrity = vi.fn(); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual( + '@google/gemini-cli-core', + ); + return { + ...actual, + loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ + memoryContent: '', + fileCount: 0, + filePaths: [], + }), + createPolicyEngineConfig: vi.fn().mockResolvedValue({ + rules: [], + checkers: [], + }), + getVersion: vi.fn().mockResolvedValue('test-version'), + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + checkIntegrity: mockCheckIntegrity, + acceptIntegrity: mockAcceptIntegrity, + })), + IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' }, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + }, + isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive + }; +}); + +describe('Workspace-Level Policy CLI Integration', () => { + const MOCK_CWD = process.cwd(); + + beforeEach(() => { + vi.clearAllMocks(); + // Default to MATCH for existing tests + mockCheckIntegrity.mockResolvedValue({ + status: 'match', + hash: 'test-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); + }); + + it('should have getWorkspacePoliciesDir on Storage class', () => { + const storage = new ServerConfig.Storage(MOCK_CWD); + expect(storage.getWorkspacePoliciesDir).toBeDefined(); + expect(typeof storage.getWorkspacePoliciesDir).toBe('function'); + }); + + it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + ); + }); + + it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); + + it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'hash', + fileCount: 0, + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); + + it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive + + const settings = createTestMergedSettings(); + const argv = { prompt: 'do something' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + ); + }); + + it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { + query: 'test', + promptInteractive: 'test', + } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'workspace', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + // In interactive mode without accept flag, it waits for user confirmation (handled by UI), + // so it currently DOES NOT pass the directory to createPolicyEngineConfig yet. + // The UI will handle the confirmation and reload/update. + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); + + it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'new-hash', + fileCount: 5, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'workspace', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); +}); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 2375a0fba1..d84c04d01e 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -506,6 +506,7 @@ const mockUIActions: UIActions = { vimHandleInput: vi.fn(), handleIdePromptComplete: vi.fn(), handleFolderTrustSelect: vi.fn(), + setIsPolicyUpdateDialogOpen: vi.fn(), setConstrainHeight: vi.fn(), onEscapePromptChange: vi.fn(), refreshStatic: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 08bae44959..b7945b0e10 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1438,6 +1438,13 @@ Logging in with Google... Restarting Gemini CLI to continue. const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); + + const policyUpdateConfirmationRequest = + config.getPolicyUpdateConfirmationRequest(); + const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState( + !!policyUpdateConfirmationRequest, + ); + const { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, @@ -1910,6 +1917,7 @@ Logging in with Google... Restarting Gemini CLI to continue. (shouldShowRetentionWarning && retentionCheckComplete) || shouldShowIdePrompt || isFolderTrustDialogOpen || + isPolicyUpdateDialogOpen || adminSettingsChanged || !!commandConfirmationRequest || !!authConsentRequest || @@ -2137,6 +2145,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2259,6 +2269,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2356,6 +2368,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, + setIsPolicyUpdateDialogOpen, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, refreshStatic, @@ -2440,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, + setIsPolicyUpdateDialogOpen, setConstrainHeight, handleEscapePromptChange, refreshStatic, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index b28f5de218..9fdd4718a6 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -37,6 +37,7 @@ import { AgentConfigDialog } from './AgentConfigDialog.js'; import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; import { useCallback } from 'react'; import { SettingScope } from '../../config/settings.js'; +import { PolicyUpdateDialog } from './PolicyUpdateDialog.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -166,6 +167,15 @@ export const DialogManager = ({ /> ); } + if (uiState.isPolicyUpdateDialogOpen) { + return ( + uiActions.setIsPolicyUpdateDialogOpen(false)} + /> + ); + } if (uiState.loopDetectionConfirmationRequest) { return ( ({ + mockAcceptIntegrity: vi.fn(), +})); + +// Mock PolicyIntegrityManager +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + acceptIntegrity: mockAcceptIntegrity, + checkIntegrity: vi.fn(), + })), + }; +}); + +describe('PolicyUpdateDialog', () => { + let mockConfig: Config; + let mockRequest: PolicyUpdateConfirmationRequest; + let onClose: () => void; + + beforeEach(() => { + mockConfig = { + loadWorkspacePolicies: vi.fn().mockResolvedValue(undefined), + } as unknown as Config; + + mockRequest = { + scope: 'workspace', + identifier: '/test/workspace/.gemini/policies', + policyDir: '/test/workspace/.gemini/policies', + newHash: 'test-hash', + } as PolicyUpdateConfirmationRequest; + + onClose = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly and matches snapshot', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toMatchSnapshot(); + expect(output).toContain('New or changed workspace policies detected'); + expect(output).toContain('Location: /test/workspace/.gemini/policies'); + expect(output).toContain('Accept and Load'); + expect(output).toContain('Ignore'); + }); + + it('handles ACCEPT correctly', async () => { + const { stdin } = renderWithProviders( + , + ); + + // Accept is the first option, so pressing enter should select it + await act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(PolicyIntegrityManager).toHaveBeenCalled(); + expect(mockConfig.loadWorkspacePolicies).toHaveBeenCalledWith( + mockRequest.policyDir, + ); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('handles IGNORE correctly', async () => { + const { stdin } = renderWithProviders( + , + ); + + // Move down to Ignore option + await act(async () => { + stdin.write('\x1B[B'); // Down arrow + }); + await act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(PolicyIntegrityManager).not.toHaveBeenCalled(); + expect(mockConfig.loadWorkspacePolicies).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('calls onClose when Escape key is pressed', async () => { + const { stdin } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\x1B'); // Escape key (matches Command.ESCAPE default) + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx new file mode 100644 index 0000000000..e6ed75c4db --- /dev/null +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { useCallback, useRef } from 'react'; +import type React from 'react'; +import { + type Config, + type PolicyUpdateConfirmationRequest, + PolicyIntegrityManager, +} from '@google/gemini-cli-core'; +import { theme } from '../semantic-colors.js'; +import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; + +export enum PolicyUpdateChoice { + ACCEPT = 'accept', + IGNORE = 'ignore', +} + +interface PolicyUpdateDialogProps { + config: Config; + request: PolicyUpdateConfirmationRequest; + onClose: () => void; +} + +export const PolicyUpdateDialog: React.FC = ({ + config, + request, + onClose, +}) => { + const isProcessing = useRef(false); + + const handleSelect = useCallback( + async (choice: PolicyUpdateChoice) => { + if (isProcessing.current) { + return; + } + + isProcessing.current = true; + try { + if (choice === PolicyUpdateChoice.ACCEPT) { + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + request.scope, + request.identifier, + request.newHash, + ); + await config.loadWorkspacePolicies(request.policyDir); + } + onClose(); + } finally { + isProcessing.current = false; + } + }, + [config, request, onClose], + ); + + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key)) { + onClose(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const options: Array> = [ + { + label: 'Accept and Load', + value: PolicyUpdateChoice.ACCEPT, + key: 'accept', + }, + { + label: 'Ignore (Use Default Policies)', + value: PolicyUpdateChoice.IGNORE, + key: 'ignore', + }, + ]; + + return ( + + + + + New or changed {request.scope} policies detected + + Location: {request.identifier} + + Do you want to accept and load these policies? + + + + + + + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap new file mode 100644 index 0000000000..5f5b3c9c27 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = ` +" ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ New or changed workspace policies detected │ + │ Location: /test/workspace/.gemini/policies │ + │ Do you want to accept and load these policies? │ + │ │ + │ ● 1. Accept and Load │ + │ 2. Ignore (Use Default Policies) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index af8706cfb1..03780c5068 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -52,6 +52,7 @@ export interface UIActions { vimHandleInput: (key: Key) => boolean; handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void; handleFolderTrustSelect: (choice: FolderTrustChoice) => void; + setIsPolicyUpdateDialogOpen: (value: boolean) => void; setConstrainHeight: (value: boolean) => void; onEscapePromptChange: (show: boolean) => void; refreshStatic: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 2df7473b0c..56d4b83c09 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -27,6 +27,7 @@ import type { FallbackIntent, ValidationIntent, AgentDefinition, + PolicyUpdateConfirmationRequest, } from '@google/gemini-cli-core'; import { type TransientMessageType } from '../../utils/events.js'; import type { DOMElement } from 'ink'; @@ -112,6 +113,8 @@ export interface UIState { isResuming: boolean; shouldShowIdePrompt: boolean; isFolderTrustDialogOpen: boolean; + isPolicyUpdateDialogOpen: boolean; + policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined; isTrustedFolder: boolean | undefined; constrainHeight: boolean; showErrorDetails: boolean; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5b57a81acf..fa32fd4d5f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -126,6 +126,8 @@ import { import { fetchAdminControls } from '../code_assist/admin/admin_controls.js'; import { isSubpath } from '../utils/paths.js'; import { UserHintService } from './userHintService.js'; +import { WORKSPACE_POLICY_TIER } from '../policy/config.js'; +import { loadPoliciesFromToml } from '../policy/toml-loader.js'; export interface AccessibilitySettings { /** @deprecated Use ui.loadingPhrases instead. */ @@ -379,6 +381,13 @@ export interface McpEnablementCallbacks { isFileEnabled: (serverId: string) => Promise; } +export interface PolicyUpdateConfirmationRequest { + scope: string; + identifier: string; + policyDir: string; + newHash: string; +} + export interface ConfigParameters { sessionId: string; clientVersion?: string; @@ -459,6 +468,7 @@ export interface ConfigParameters { eventEmitter?: EventEmitter; useWriteTodos?: boolean; policyEngineConfig?: PolicyEngineConfig; + policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; output?: OutputSettings; disableModelRouterForAuth?: AuthType[]; continueOnFailedApiCall?: boolean; @@ -637,6 +647,9 @@ export class Config { private readonly useWriteTodos: boolean; private readonly messageBus: MessageBus; private readonly policyEngine: PolicyEngine; + private policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; private readonly outputSettings: OutputSettings; private readonly continueOnFailedApiCall: boolean; private readonly retryFetchErrors: boolean; @@ -853,6 +866,8 @@ export class Config { approvalMode: params.approvalMode ?? params.policyEngineConfig?.approvalMode, }); + this.policyUpdateConfirmationRequest = + params.policyUpdateConfirmationRequest; this.messageBus = new MessageBus(this.policyEngine, this.debugMode); this.acknowledgedAgentsService = new AcknowledgedAgentsService(); this.skillManager = new SkillManager(); @@ -1721,6 +1736,41 @@ export class Config { return this.policyEngine.getApprovalMode(); } + getPolicyUpdateConfirmationRequest(): + | PolicyUpdateConfirmationRequest + | undefined { + return this.policyUpdateConfirmationRequest; + } + + /** + * Hot-loads workspace policies from the specified directory into the active policy engine. + * This allows applying newly accepted policies without requiring an application restart. + * + * @param policyDir The directory containing the workspace policy TOML files. + */ + async loadWorkspacePolicies(policyDir: string): Promise { + const { rules, checkers } = await loadPoliciesFromToml( + [policyDir], + () => WORKSPACE_POLICY_TIER, + ); + + // Clear existing workspace policies to prevent duplicates/stale rules + this.policyEngine.removeRulesByTier(WORKSPACE_POLICY_TIER); + this.policyEngine.removeCheckersByTier(WORKSPACE_POLICY_TIER); + + for (const rule of rules) { + this.policyEngine.addRule(rule); + } + + for (const checker of checkers) { + this.policyEngine.addChecker(checker); + } + + this.policyUpdateConfirmationRequest = undefined; + + debugLogger.debug(`Workspace policies loaded from: ${policyDir}`); + } + setApprovalMode(mode: ApprovalMode): void { if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) { throw new Error( diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index bce91f7991..3a079f3b7e 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -103,6 +103,10 @@ export class Storage { ); } + static getPolicyIntegrityStoragePath(): string { + return path.join(Storage.getGlobalGeminiDir(), 'policy_integrity.json'); + } + private static getSystemConfigDir(): string { if (os.platform() === 'darwin') { return '/Library/Application Support/GeminiCli'; @@ -146,6 +150,10 @@ export class Storage { return path.join(tempDir, identifier); } + getWorkspacePoliciesDir(): string { + return path.join(this.getGeminiDir(), 'policies'); + } + ensureProjectTempDirExists(): void { fs.mkdirSync(this.getProjectTempDir(), { recursive: true }); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8f82486173..36d10d3832 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ export * from './policy/types.js'; export * from './policy/policy-engine.js'; export * from './policy/toml-loader.js'; export * from './policy/config.js'; +export * from './policy/integrity.js'; export * from './confirmation-bus/types.js'; export * from './confirmation-bus/message-bus.js'; diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index 32a5287113..a9fae7a1fa 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -169,7 +169,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow }); it('should deny tools in tools.exclude', async () => { @@ -188,7 +188,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(rule?.priority).toBeCloseTo(3.4, 5); // Command line exclude }); it('should allow tools from allowed MCP servers', async () => { @@ -206,7 +206,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(2.1); // MCP allowed server + expect(rule?.priority).toBe(3.1); // MCP allowed server }); it('should deny tools from excluded MCP servers', async () => { @@ -224,7 +224,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(2.9); // MCP excluded server + expect(rule?.priority).toBe(3.9); // MCP excluded server }); it('should allow tools from trusted MCP servers', async () => { @@ -251,7 +251,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedRule?.priority).toBe(3.2); // MCP trusted server // Untrusted server should not have an allow rule const untrustedRule = config.rules?.find( @@ -288,7 +288,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(allowedRule).toBeDefined(); - expect(allowedRule?.priority).toBe(2.1); // MCP allowed server + expect(allowedRule?.priority).toBe(3.1); // MCP allowed server // Check trusted server const trustedRule = config.rules?.find( @@ -297,7 +297,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedRule?.priority).toBe(3.2); // MCP trusted server // Check excluded server const excludedRule = config.rules?.find( @@ -306,7 +306,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(excludedRule).toBeDefined(); - expect(excludedRule?.priority).toBe(2.9); // MCP excluded server + expect(excludedRule?.priority).toBe(3.9); // MCP excluded server }); it('should allow all tools in YOLO mode', async () => { @@ -387,11 +387,11 @@ describe('createPolicyEngineConfig', () => { ); expect(serverDenyRule).toBeDefined(); - expect(serverDenyRule?.priority).toBe(2.9); // MCP excluded server + expect(serverDenyRule?.priority).toBe(3.9); // MCP excluded server expect(toolAllowRule).toBeDefined(); - expect(toolAllowRule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(toolAllowRule?.priority).toBeCloseTo(3.3, 5); // Command line allow - // Server deny (2.9) has higher priority than tool allow (2.3), + // Server deny (3.9) has higher priority than tool allow (3.3), // so server deny wins (this is expected behavior - server-level blocks are security critical) }); @@ -424,7 +424,7 @@ describe('createPolicyEngineConfig', () => { expect(serverAllowRule).toBeDefined(); expect(toolDenyRule).toBeDefined(); - // Command line exclude (2.4) has higher priority than MCP server trust (2.2) + // Command line exclude (3.4) has higher priority than MCP server trust (3.2) // This is the correct behavior - specific exclusions should beat general server trust expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!); }); @@ -432,16 +432,16 @@ describe('createPolicyEngineConfig', () => { it('should handle complex priority scenarios correctly', async () => { const settings: PolicySettings = { tools: { - allowed: ['my-server__tool1', 'other-tool'], // Priority 2.3 - exclude: ['my-server__tool2', 'glob'], // Priority 2.4 + allowed: ['my-server__tool1', 'other-tool'], // Priority 3.3 + exclude: ['my-server__tool2', 'glob'], // Priority 3.4 }, mcp: { - allowed: ['allowed-server'], // Priority 2.1 - excluded: ['excluded-server'], // Priority 2.9 + allowed: ['allowed-server'], // Priority 3.1 + excluded: ['excluded-server'], // Priority 3.9 }, mcpServers: { 'trusted-server': { - trust: true, // Priority 90 -> 2.2 + trust: true, // Priority 90 -> 3.2 }, }, }; @@ -517,7 +517,7 @@ describe('createPolicyEngineConfig', () => { expect(globDenyRule).toBeDefined(); expect(globAllowRule).toBeDefined(); // Deny from settings (user tier) - expect(globDenyRule!.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(globDenyRule!.priority).toBeCloseTo(3.4, 5); // Command line exclude // Allow from default TOML: 1 + 50/1000 = 1.05 expect(globAllowRule!.priority).toBeCloseTo(1.05, 5); @@ -530,11 +530,11 @@ describe('createPolicyEngineConfig', () => { })) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); - // Check that the highest priority items are the excludes (user tier: 2.4 and 2.9) + // Check that the highest priority items are the excludes (user tier: 3.4 and 3.9) const highestPriorityExcludes = priorities?.filter( (p) => - Math.abs(p.priority! - 2.4) < 0.01 || - Math.abs(p.priority! - 2.9) < 0.01, + Math.abs(p.priority! - 3.4) < 0.01 || + Math.abs(p.priority! - 3.9) < 0.01, ); expect( highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY), @@ -626,7 +626,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY, ); expect(excludeRule).toBeDefined(); - expect(excludeRule?.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(excludeRule?.priority).toBeCloseTo(3.4, 5); // Command line exclude }); it('should support argsPattern in policy rules', async () => { @@ -733,8 +733,8 @@ priority = 150 r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - // Priority 150 in user tier → 2.150 - expect(rule?.priority).toBeCloseTo(2.15, 5); + // Priority 150 in user tier → 3.150 + expect(rule?.priority).toBeCloseTo(3.15, 5); expect(rule?.argsPattern).toBeInstanceOf(RegExp); expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true); @@ -1046,7 +1046,7 @@ name = "invalid-name" r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow vi.doUnmock('node:fs/promises'); }); @@ -1188,7 +1188,7 @@ modes = ["plan"] r.modes?.includes(ApprovalMode.PLAN), ); expect(subagentRule).toBeDefined(); - expect(subagentRule?.priority).toBeCloseTo(2.1, 5); + expect(subagentRule?.priority).toBeCloseTo(3.1, 5); vi.doUnmock('node:fs/promises'); }); diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index efa5083504..50fbc0ef2a 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -29,6 +29,7 @@ import { coreEvents } from '../utils/events.js'; import { debugLogger } from '../utils/debugLogger.js'; import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; +import { isNodeError } from '../utils/errors.js'; import { isDirectorySecure } from '../utils/security.js'; @@ -38,47 +39,55 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies'); // Policy tier constants for priority calculation export const DEFAULT_POLICY_TIER = 1; -export const USER_POLICY_TIER = 2; -export const ADMIN_POLICY_TIER = 3; +export const WORKSPACE_POLICY_TIER = 2; +export const USER_POLICY_TIER = 3; +export const ADMIN_POLICY_TIER = 4; /** - * Gets the list of directories to search for policy files, in order of decreasing priority - * (Admin -> User -> Default). + * Gets the list of directories to search for policy files, in order of increasing priority + * (Default -> User -> Project -> Admin). * * @param defaultPoliciesDir Optional path to a directory containing default policies. * @param policyPaths Optional user-provided policy paths (from --policy flag). * When provided, these replace the default user policies directory. + * @param workspacePoliciesDir Optional path to a directory containing workspace policies. */ export function getPolicyDirectories( defaultPoliciesDir?: string, policyPaths?: string[], + workspacePoliciesDir?: string, ): string[] { - const dirs: string[] = []; + const dirs = []; - // Default tier (lowest priority) - dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR); + // Admin tier (highest priority) + dirs.push(Storage.getSystemPoliciesDir()); - // User tier (middle priority) + // User tier (second highest priority) if (policyPaths && policyPaths.length > 0) { dirs.push(...policyPaths); } else { dirs.push(Storage.getUserPoliciesDir()); } - // Admin tier (highest priority) - dirs.push(Storage.getSystemPoliciesDir()); + // Workspace Tier (third highest) + if (workspacePoliciesDir) { + dirs.push(workspacePoliciesDir); + } - // Reverse so highest priority (Admin) is first - return dirs.reverse(); + // Default tier (lowest priority) + dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR); + + return dirs; } /** - * Determines the policy tier (1=default, 2=user, 3=admin) for a given directory. + * Determines the policy tier (1=default, 2=user, 3=workspace, 4=admin) for a given directory. * This is used by the TOML loader to assign priority bands. */ export function getPolicyTier( dir: string, defaultPoliciesDir?: string, + workspacePoliciesDir?: string, ): number { const USER_POLICIES_DIR = Storage.getUserPoliciesDir(); const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir(); @@ -99,6 +108,12 @@ export function getPolicyTier( if (normalizedDir === normalizedUser) { return USER_POLICY_TIER; } + if ( + workspacePoliciesDir && + normalizedDir === path.resolve(workspacePoliciesDir) + ) { + return WORKSPACE_POLICY_TIER; + } if (normalizedDir === normalizedAdmin) { return ADMIN_POLICY_TIER; } @@ -157,8 +172,8 @@ export async function createPolicyEngineConfig( const policyDirs = getPolicyDirectories( defaultPoliciesDir, settings.policyPaths, + settings.workspacePoliciesDir, ); - const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs); const normalizedAdminPoliciesDir = path.resolve( @@ -171,7 +186,11 @@ export async function createPolicyEngineConfig( checkers: tomlCheckers, errors, } = await loadPoliciesFromToml(securePolicyDirs, (p) => { - const tier = getPolicyTier(p, defaultPoliciesDir); + const tier = getPolicyTier( + p, + defaultPoliciesDir, + settings.workspacePoliciesDir, + ); // If it's a user-provided path that isn't already categorized as ADMIN, // treat it as USER tier. @@ -207,19 +226,20 @@ export async function createPolicyEngineConfig( // // Priority bands (tiers): // - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) - // - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) - // - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) + // - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) // - // This ensures Admin > User > Default hierarchy is always preserved, + // This ensures Admin > User > Workspace > Default hierarchy is always preserved, // while allowing user-specified priorities to work within each tier. // - // Settings-based and dynamic rules (all in user tier 2.x): - // 2.95: Tools that the user has selected as "Always Allow" in the interactive UI - // 2.9: MCP servers excluded list (security: persistent server blocks) - // 2.4: Command line flag --exclude-tools (explicit temporary blocks) - // 2.3: Command line flag --allowed-tools (explicit temporary allows) - // 2.2: MCP servers with trust=true (persistent trusted servers) - // 2.1: MCP servers allowed list (persistent general server allows) + // Settings-based and dynamic rules (all in user tier 3.x): + // 3.95: Tools that the user has selected as "Always Allow" in the interactive UI + // 3.9: MCP servers excluded list (security: persistent server blocks) + // 3.4: Command line flag --exclude-tools (explicit temporary blocks) + // 3.3: Command line flag --allowed-tools (explicit temporary allows) + // 3.2: MCP servers with trust=true (persistent trusted servers) + // 3.1: MCP servers allowed list (persistent general server allows) // // TOML policy priorities (before transformation): // 10: Write tools default to ASK_USER (becomes 1.010 in default tier) @@ -230,33 +250,33 @@ export async function createPolicyEngineConfig( // 999: YOLO mode allow-all (becomes 1.999 in default tier) // MCP servers that are explicitly excluded in settings.mcp.excluded - // Priority: 2.9 (highest in user tier for security - persistent server blocks) + // Priority: 3.9 (highest in user tier for security - persistent server blocks) if (settings.mcp?.excluded) { for (const serverName of settings.mcp.excluded) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.DENY, - priority: 2.9, + priority: 3.9, source: 'Settings (MCP Excluded)', }); } } // Tools that are explicitly excluded in the settings. - // Priority: 2.4 (user tier - explicit temporary blocks) + // Priority: 3.4 (user tier - explicit temporary blocks) if (settings.tools?.exclude) { for (const tool of settings.tools.exclude) { rules.push({ toolName: tool, decision: PolicyDecision.DENY, - priority: 2.4, + priority: 3.4, source: 'Settings (Tools Excluded)', }); } } // Tools that are explicitly allowed in the settings. - // Priority: 2.3 (user tier - explicit temporary allows) + // Priority: 3.3 (user tier - explicit temporary allows) if (settings.tools?.allowed) { for (const tool of settings.tools.allowed) { // Check for legacy format: toolName(args) @@ -276,7 +296,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: 3.3, argsPattern: new RegExp(pattern), source: 'Settings (Tools Allowed)', }); @@ -288,7 +308,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: 3.3, source: 'Settings (Tools Allowed)', }); } @@ -300,7 +320,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: 3.3, source: 'Settings (Tools Allowed)', }); } @@ -308,7 +328,7 @@ export async function createPolicyEngineConfig( } // MCP servers that are trusted in the settings. - // Priority: 2.2 (user tier - persistent trusted servers) + // Priority: 3.2 (user tier - persistent trusted servers) if (settings.mcpServers) { for (const [serverName, serverConfig] of Object.entries( settings.mcpServers, @@ -319,7 +339,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 2.2, + priority: 3.2, source: 'Settings (MCP Trusted)', }); } @@ -327,13 +347,13 @@ export async function createPolicyEngineConfig( } // MCP servers that are explicitly allowed in settings.mcp.allowed - // Priority: 2.1 (user tier - persistent general server allows) + // Priority: 3.1 (user tier - persistent general server allows) if (settings.mcp?.allowed) { for (const serverName of settings.mcp.allowed) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 2.1, + priority: 3.1, source: 'Settings (MCP Allowed)', }); } @@ -380,10 +400,10 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (2) + high priority (950/1000) = 2.95 + // User tier (3) + high priority (950/1000) = 3.95 // This ensures user "always allow" selections are high priority - // but still lose to admin policies (3.xxx) and settings excludes (200) - priority: 2.95, + // but still lose to admin policies (4.xxx) and settings excludes (300) + priority: 3.95, argsPattern: new RegExp(pattern), source: 'Dynamic (Confirmed)', }); @@ -405,10 +425,10 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (2) + high priority (950/1000) = 2.95 + // User tier (3) + high priority (950/1000) = 3.95 // This ensures user "always allow" selections are high priority - // but still lose to admin policies (3.xxx) and settings excludes (200) - priority: 2.95, + // but still lose to admin policies (4.xxx) and settings excludes (300) + priority: 3.95, argsPattern, source: 'Dynamic (Confirmed)', }); @@ -425,10 +445,16 @@ export function createPolicyUpdater( let existingData: { rule?: TomlRule[] } = {}; try { const fileContent = await fs.readFile(policyFile, 'utf-8'); - existingData = toml.parse(fileContent) as { rule?: TomlRule[] }; + const parsed = toml.parse(fileContent); + if ( + typeof parsed === 'object' && + parsed !== null && + (!('rule' in parsed) || Array.isArray(parsed['rule'])) + ) { + existingData = parsed as { rule?: TomlRule[] }; + } } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + if (!isNodeError(error) || error.code !== 'ENOENT') { debugLogger.warn( `Failed to parse ${policyFile}, overwriting with new policy.`, error, diff --git a/packages/core/src/policy/integrity.test.ts b/packages/core/src/policy/integrity.test.ts new file mode 100644 index 0000000000..32ebf56058 --- /dev/null +++ b/packages/core/src/policy/integrity.test.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { PolicyIntegrityManager, IntegrityStatus } from './integrity.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { Storage } from '../config/storage.js'; + +describe('PolicyIntegrityManager', () => { + let integrityManager: PolicyIntegrityManager; + let tempDir: string; + let integrityStoragePath: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-')); + integrityStoragePath = path.join(tempDir, 'policy_integrity.json'); + + vi.spyOn(Storage, 'getPolicyIntegrityStoragePath').mockReturnValue( + integrityStoragePath, + ); + + integrityManager = new PolicyIntegrityManager(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('checkIntegrity', () => { + it('should return NEW if no stored hash', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + const result = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + expect(result.status).toBe(IntegrityStatus.NEW); + expect(result.hash).toBeDefined(); + expect(result.hash).toHaveLength(64); + expect(result.fileCount).toBe(1); + }); + + it('should return MATCH if stored hash matches', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + // First run to get the hash + const resultNew = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + const currentHash = resultNew.hash; + + // Save the hash to mock storage + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'workspace:id': currentHash }), + ); + + const result = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + expect(result.status).toBe(IntegrityStatus.MATCH); + expect(result.hash).toBe(currentHash); + }); + + it('should return MISMATCH if stored hash differs', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + const resultNew = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + const currentHash = resultNew.hash; + + // Save a different hash + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'workspace:id': 'different_hash' }), + ); + + const result = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + expect(result.status).toBe(IntegrityStatus.MISMATCH); + expect(result.hash).toBe(currentHash); + }); + + it('should result in different hash if filename changes', async () => { + const policyDir1 = path.join(tempDir, 'policies1'); + await fs.mkdir(policyDir1); + await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA'); + + const result1 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir1, + ); + + const policyDir2 = path.join(tempDir, 'policies2'); + await fs.mkdir(policyDir2); + await fs.writeFile(path.join(policyDir2, 'b.toml'), 'contentA'); + + const result2 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir2, + ); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should result in different hash if content changes', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + const result1 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentB'); + const result2 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should be deterministic (sort order)', async () => { + const policyDir1 = path.join(tempDir, 'policies1'); + await fs.mkdir(policyDir1); + await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA'); + await fs.writeFile(path.join(policyDir1, 'b.toml'), 'contentB'); + + const result1 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir1, + ); + + // Re-read with same files but they might be in different order in readdir + // PolicyIntegrityManager should sort them. + const result2 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir1, + ); + + expect(result1.hash).toBe(result2.hash); + }); + + it('should handle multiple projects correctly', async () => { + const dirA = path.join(tempDir, 'dirA'); + await fs.mkdir(dirA); + await fs.writeFile(path.join(dirA, 'p.toml'), 'contentA'); + + const dirB = path.join(tempDir, 'dirB'); + await fs.mkdir(dirB); + await fs.writeFile(path.join(dirB, 'p.toml'), 'contentB'); + + const { hash: hashA } = await integrityManager.checkIntegrity( + 'workspace', + 'idA', + dirA, + ); + const { hash: hashB } = await integrityManager.checkIntegrity( + 'workspace', + 'idB', + dirB, + ); + + // Save to storage + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ + 'workspace:idA': hashA, + 'workspace:idB': 'oldHashB', + }), + ); + + // Project A should match + const resultA = await integrityManager.checkIntegrity( + 'workspace', + 'idA', + dirA, + ); + expect(resultA.status).toBe(IntegrityStatus.MATCH); + expect(resultA.hash).toBe(hashA); + + // Project B should mismatch + const resultB = await integrityManager.checkIntegrity( + 'workspace', + 'idB', + dirB, + ); + expect(resultB.status).toBe(IntegrityStatus.MISMATCH); + expect(resultB.hash).toBe(hashB); + }); + }); + + describe('acceptIntegrity', () => { + it('should save the hash to storage', async () => { + await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); + + const stored = JSON.parse( + await fs.readFile(integrityStoragePath, 'utf-8'), + ); + expect(stored['workspace:id']).toBe('hash123'); + }); + + it('should update existing hash', async () => { + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'other:id': 'otherhash' }), + ); + + await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); + + const stored = JSON.parse( + await fs.readFile(integrityStoragePath, 'utf-8'), + ); + expect(stored['other:id']).toBe('otherhash'); + expect(stored['workspace:id']).toBe('hash123'); + }); + }); +}); diff --git a/packages/core/src/policy/integrity.ts b/packages/core/src/policy/integrity.ts new file mode 100644 index 0000000000..e8716ed438 --- /dev/null +++ b/packages/core/src/policy/integrity.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { readPolicyFiles } from './toml-loader.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; + +export enum IntegrityStatus { + MATCH = 'MATCH', + MISMATCH = 'MISMATCH', + NEW = 'NEW', +} + +export interface IntegrityResult { + status: IntegrityStatus; + hash: string; + fileCount: number; +} + +interface StoredIntegrityData { + [key: string]: string; // key = scope:identifier, value = hash +} + +export class PolicyIntegrityManager { + /** + * Checks the integrity of policies in a given directory against the stored hash. + * + * @param scope The scope of the policy (e.g., 'project', 'user'). + * @param identifier A unique identifier for the policy scope (e.g., project path). + * @param policyDir The directory containing the policy files. + * @returns IntegrityResult indicating if the current policies match the stored hash. + */ + async checkIntegrity( + scope: string, + identifier: string, + policyDir: string, + ): Promise { + const { hash: currentHash, fileCount } = + await PolicyIntegrityManager.calculateIntegrityHash(policyDir); + const storedData = await this.loadIntegrityData(); + const key = this.getIntegrityKey(scope, identifier); + const storedHash = storedData[key]; + + if (!storedHash) { + return { status: IntegrityStatus.NEW, hash: currentHash, fileCount }; + } + + if (storedHash === currentHash) { + return { status: IntegrityStatus.MATCH, hash: currentHash, fileCount }; + } + + return { status: IntegrityStatus.MISMATCH, hash: currentHash, fileCount }; + } + + /** + * Accepts and persists the current integrity hash for a given policy scope. + * + * @param scope The scope of the policy. + * @param identifier A unique identifier for the policy scope (e.g., project path). + * @param hash The hash to persist. + */ + async acceptIntegrity( + scope: string, + identifier: string, + hash: string, + ): Promise { + const storedData = await this.loadIntegrityData(); + const key = this.getIntegrityKey(scope, identifier); + storedData[key] = hash; + await this.saveIntegrityData(storedData); + } + + /** + * Calculates a SHA-256 hash of all policy files in the directory. + * The hash includes the relative file path and content to detect renames and modifications. + * + * @param policyDir The directory containing the policy files. + * @returns The calculated hash and file count + */ + private static async calculateIntegrityHash( + policyDir: string, + ): Promise<{ hash: string; fileCount: number }> { + try { + const files = await readPolicyFiles(policyDir); + + // Sort files by path to ensure deterministic hashing + files.sort((a, b) => a.path.localeCompare(b.path)); + + const hash = crypto.createHash('sha256'); + + for (const file of files) { + const relativePath = path.relative(policyDir, file.path); + // Include relative path and content in the hash + hash.update(relativePath); + hash.update('\0'); // Separator + hash.update(file.content); + hash.update('\0'); // Separator + } + + return { hash: hash.digest('hex'), fileCount: files.length }; + } catch (error) { + debugLogger.error('Failed to calculate policy integrity hash', error); + // Return a unique hash (random) to force a mismatch if calculation fails? + // Or throw? Throwing is better so we don't accidentally accept/deny corrupted state. + throw error; + } + } + + private getIntegrityKey(scope: string, identifier: string): string { + return `${scope}:${identifier}`; + } + + private async loadIntegrityData(): Promise { + const storagePath = Storage.getPolicyIntegrityStoragePath(); + try { + const content = await fs.readFile(storagePath, 'utf-8'); + const parsed: unknown = JSON.parse(content); + if ( + typeof parsed === 'object' && + parsed !== null && + Object.values(parsed).every((v) => typeof v === 'string') + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return parsed as StoredIntegrityData; + } + debugLogger.warn('Invalid policy integrity data format'); + return {}; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return {}; + } + debugLogger.error('Failed to load policy integrity data', error); + return {}; + } + } + + private async saveIntegrityData(data: StoredIntegrityData): Promise { + const storagePath = Storage.getPolicyIntegrityStoragePath(); + try { + await fs.mkdir(path.dirname(storagePath), { recursive: true }); + await fs.writeFile(storagePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (error) { + debugLogger.error('Failed to save policy integrity data', error); + throw error; + } + } +} diff --git a/packages/core/src/policy/persistence.test.ts b/packages/core/src/policy/persistence.test.ts index 7d80b41893..3acf7c714d 100644 --- a/packages/core/src/policy/persistence.test.ts +++ b/packages/core/src/policy/persistence.test.ts @@ -136,7 +136,7 @@ describe('createPolicyUpdater', () => { const rules = policyEngine.getRules(); const addedRule = rules.find((r) => r.toolName === toolName); expect(addedRule).toBeDefined(); - expect(addedRule?.priority).toBe(2.95); + expect(addedRule?.priority).toBe(3.95); expect(addedRule?.argsPattern).toEqual( new RegExp(`"command":"git\\ status(?:[\\s"]|\\\\")`), ); diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 12648fec5f..e7129208c8 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index b608a87904..1688d5108c 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/write.toml b/packages/core/src/policy/policies/write.toml index 991424cebc..47cd9c98ae 100644 --- a/packages/core/src/policy/policies/write.toml +++ b/packages/core/src/policy/policies/write.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/yolo.toml b/packages/core/src/policy/policies/yolo.toml index 95c3b411f1..332334db7c 100644 --- a/packages/core/src/policy/policies/yolo.toml +++ b/packages/core/src/policy/policies/yolo.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 693ae3a4b2..11e8333f47 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -2373,4 +2373,89 @@ describe('PolicyEngine', () => { ); }); }); + + describe('removeRulesByTier', () => { + it('should remove rules matching a specific tier', () => { + engine.addRule({ + toolName: 'rule1', + decision: PolicyDecision.ALLOW, + priority: 1.1, + }); + engine.addRule({ + toolName: 'rule2', + decision: PolicyDecision.ALLOW, + priority: 1.5, + }); + engine.addRule({ + toolName: 'rule3', + decision: PolicyDecision.ALLOW, + priority: 2.1, + }); + engine.addRule({ + toolName: 'rule4', + decision: PolicyDecision.ALLOW, + priority: 0.5, + }); + engine.addRule({ toolName: 'rule5', decision: PolicyDecision.ALLOW }); // priority undefined -> 0 + + expect(engine.getRules()).toHaveLength(5); + + engine.removeRulesByTier(1); + + const rules = engine.getRules(); + expect(rules).toHaveLength(3); + expect(rules.some((r) => r.toolName === 'rule1')).toBe(false); + expect(rules.some((r) => r.toolName === 'rule2')).toBe(false); + expect(rules.some((r) => r.toolName === 'rule3')).toBe(true); + expect(rules.some((r) => r.toolName === 'rule4')).toBe(true); + expect(rules.some((r) => r.toolName === 'rule5')).toBe(true); + }); + + it('should handle removing tier 0 rules (including undefined priority)', () => { + engine.addRule({ + toolName: 'rule1', + decision: PolicyDecision.ALLOW, + priority: 0.5, + }); + engine.addRule({ toolName: 'rule2', decision: PolicyDecision.ALLOW }); // defaults to 0 + engine.addRule({ + toolName: 'rule3', + decision: PolicyDecision.ALLOW, + priority: 1.5, + }); + + expect(engine.getRules()).toHaveLength(3); + + engine.removeRulesByTier(0); + + const rules = engine.getRules(); + expect(rules).toHaveLength(1); + expect(rules[0].toolName).toBe('rule3'); + }); + }); + + describe('removeCheckersByTier', () => { + it('should remove checkers matching a specific tier', () => { + engine.addChecker({ + checker: { type: 'external', name: 'c1' }, + priority: 1.1, + }); + engine.addChecker({ + checker: { type: 'external', name: 'c2' }, + priority: 1.9, + }); + engine.addChecker({ + checker: { type: 'external', name: 'c3' }, + priority: 2.5, + }); + + expect(engine.getCheckers()).toHaveLength(3); + + engine.removeCheckersByTier(1); + + const checkers = engine.getCheckers(); + expect(checkers).toHaveLength(1); + expect(checkers[0].priority).toBe(2.5); + }); + }); }); diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 3f386edd8f..353cdae9c1 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -475,6 +475,24 @@ export class PolicyEngine { this.checkers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); } + /** + * Remove rules matching a specific tier (priority band). + */ + removeRulesByTier(tier: number): void { + this.rules = this.rules.filter( + (rule) => Math.floor(rule.priority ?? 0) !== tier, + ); + } + + /** + * Remove checkers matching a specific tier (priority band). + */ + removeCheckersByTier(tier: number): void { + this.checkers = this.checkers.filter( + (checker) => Math.floor(checker.priority ?? 0) !== tier, + ); + } + /** * Remove rules for a specific tool. * If source is provided, only rules matching that source are removed. diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index c627f6d049..e706b16bf7 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -228,14 +228,18 @@ modes = ["autoEdit"] `, ); - const getPolicyTier = (_dir: string) => 2; // Tier 2 - const result = await loadPoliciesFromToml([tempDir], getPolicyTier); + const getPolicyTier2 = (_dir: string) => 2; // Tier 2 + const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2); - expect(result.rules).toHaveLength(1); - expect(result.rules[0].toolName).toBe('tier2-tool'); - expect(result.rules[0].modes).toEqual(['autoEdit']); - expect(result.rules[0].source).toBe('User: tier2.toml'); - expect(result.errors).toHaveLength(0); + expect(result2.rules).toHaveLength(1); + expect(result2.rules[0].toolName).toBe('tier2-tool'); + expect(result2.rules[0].modes).toEqual(['autoEdit']); + expect(result2.rules[0].source).toBe('Workspace: tier2.toml'); + + const getPolicyTier3 = (_dir: string) => 3; // Tier 3 + const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3); + expect(result3.rules[0].source).toBe('User: tier2.toml'); + expect(result3.errors).toHaveLength(0); }); it('should handle TOML parse errors', async () => { @@ -359,6 +363,21 @@ priority = -1 expect(result.errors[0].fileName).toBe('invalid.toml'); expect(result.errors[0].errorType).toBe('schema_validation'); }); + + it('should transform safety checker priorities based on tier', async () => { + const result = await runLoadPoliciesFromToml(` +[[safety_checker]] +toolName = "write_file" +priority = 100 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +`); + + expect(result.checkers).toHaveLength(1); + expect(result.checkers[0].priority).toBe(1.1); // tier 1 + 100/1000 + expect(result.checkers[0].source).toBe('Default: test.toml'); + }); }); describe('Negative Tests', () => { diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index a627064d41..7be3fe27dc 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -17,6 +17,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import toml from '@iarna/toml'; import { z, type ZodError } from 'zod'; +import { isNodeError } from '../utils/errors.js'; /** * Schema for a single policy rule in the TOML file (before transformation). @@ -105,7 +106,7 @@ export type PolicyFileErrorType = export interface PolicyFileError { filePath: string; fileName: string; - tier: 'default' | 'user' | 'admin'; + tier: 'default' | 'user' | 'workspace' | 'admin'; ruleIndex?: number; errorType: PolicyFileErrorType; message: string; @@ -122,13 +123,59 @@ export interface PolicyLoadResult { errors: PolicyFileError[]; } +export interface PolicyFile { + path: string; + content: string; +} + +/** + * Reads policy files from a directory or a single file. + * + * @param policyPath Path to a directory or a .toml file. + * @returns Array of PolicyFile objects. + */ +export async function readPolicyFiles( + policyPath: string, +): Promise { + let filesToLoad: string[] = []; + let baseDir = ''; + + try { + const stats = await fs.stat(policyPath); + if (stats.isDirectory()) { + baseDir = policyPath; + const dirEntries = await fs.readdir(policyPath, { withFileTypes: true }); + filesToLoad = dirEntries + .filter((entry) => entry.isFile() && entry.name.endsWith('.toml')) + .map((entry) => entry.name); + } else if (stats.isFile() && policyPath.endsWith('.toml')) { + baseDir = path.dirname(policyPath); + filesToLoad = [path.basename(policyPath)]; + } + } catch (e) { + if (isNodeError(e) && e.code === 'ENOENT') { + return []; + } + throw e; + } + + const results: PolicyFile[] = []; + for (const file of filesToLoad) { + const filePath = path.join(baseDir, file); + const content = await fs.readFile(filePath, 'utf-8'); + results.push({ path: filePath, content }); + } + return results; +} + /** * Converts a tier number to a human-readable tier name. */ -function getTierName(tier: number): 'default' | 'user' | 'admin' { +function getTierName(tier: number): 'default' | 'user' | 'workspace' | 'admin' { if (tier === 1) return 'default'; - if (tier === 2) return 'user'; - if (tier === 3) return 'admin'; + if (tier === 2) return 'workspace'; + if (tier === 3) return 'user'; + if (tier === 4) return 'admin'; return 'default'; } @@ -211,7 +258,7 @@ function transformPriority(priority: number, tier: number): number { * 4. Collects detailed error information for any failures * * @param policyPaths Array of paths (directories or files) to scan for policy files - * @param getPolicyTier Function to determine tier (1-3) for a path + * @param getPolicyTier Function to determine tier (1-4) for a path * @returns Object containing successfully parsed rules and any errors encountered */ export async function loadPoliciesFromToml( @@ -226,48 +273,26 @@ export async function loadPoliciesFromToml( const tier = getPolicyTier(p); const tierName = getTierName(tier); - let filesToLoad: string[] = []; - let baseDir = ''; + let policyFiles: PolicyFile[] = []; try { - const stats = await fs.stat(p); - if (stats.isDirectory()) { - baseDir = p; - const dirEntries = await fs.readdir(p, { withFileTypes: true }); - filesToLoad = dirEntries - .filter((entry) => entry.isFile() && entry.name.endsWith('.toml')) - .map((entry) => entry.name); - } else if (stats.isFile() && p.endsWith('.toml')) { - baseDir = path.dirname(p); - filesToLoad = [path.basename(p)]; - } - // Other file types or non-.toml files are silently ignored - // for consistency with directory scanning behavior. + policyFiles = await readPolicyFiles(p); } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const error = e as NodeJS.ErrnoException; - if (error.code === 'ENOENT') { - // Path doesn't exist, skip it (not an error) - continue; - } errors.push({ filePath: p, fileName: path.basename(p), tier: tierName, errorType: 'file_read', message: `Failed to read policy path`, - details: error.message, + details: isNodeError(e) ? e.message : String(e), }); continue; } - for (const file of filesToLoad) { - const filePath = path.join(baseDir, file); + for (const { path: filePath, content: fileContent } of policyFiles) { + const file = path.basename(filePath); try { - // Read file - const fileContent = await fs.readFile(filePath, 'utf-8'); - // Parse TOML let parsed: unknown; try { @@ -438,10 +463,11 @@ export async function loadPoliciesFromToml( const safetyCheckerRule: SafetyCheckerRule = { toolName: effectiveToolName, - priority: checker.priority, + priority: transformPriority(checker.priority, tier), // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion checker: checker.checker as SafetyCheckerConfig, modes: checker.modes, + source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`, }; if (argsPattern) { @@ -485,17 +511,15 @@ export async function loadPoliciesFromToml( checkers.push(...parsedCheckers); } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const error = e as NodeJS.ErrnoException; // Catch-all for unexpected errors - if (error.code !== 'ENOENT') { + if (!isNodeError(e) || e.code !== 'ENOENT') { errors.push({ filePath, fileName: file, tier: tierName, errorType: 'file_read', message: 'Failed to read policy file', - details: error.message, + details: isNodeError(e) ? e.message : String(e), }); } } diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index 2e672fff26..e8aa0e6dd1 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -182,6 +182,12 @@ export interface SafetyCheckerRule { * If undefined or empty, it applies to all modes. */ modes?: ApprovalMode[]; + + /** + * Source of the rule. + * e.g. "my-policies.toml", "Workspace: project.toml", etc. + */ + source?: string; } export interface HookExecutionContext { @@ -272,7 +278,9 @@ export interface PolicySettings { allowed?: string[]; }; mcpServers?: Record; + // User provided policies that will replace the USER level policies in ~/.gemini/policies policyPaths?: string[]; + workspacePoliciesDir?: string; } export interface CheckResult { diff --git a/packages/core/src/policy/workspace-policy.test.ts b/packages/core/src/policy/workspace-policy.test.ts new file mode 100644 index 0000000000..999dae6f0d --- /dev/null +++ b/packages/core/src/policy/workspace-policy.test.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import nodePath from 'node:path'; +import { ApprovalMode } from './types.js'; +import { isDirectorySecure } from '../utils/security.js'; + +// Mock dependencies +vi.mock('../utils/security.js', () => ({ + isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }), +})); + +describe('Workspace-Level Policies', () => { + beforeEach(async () => { + vi.resetModules(); + const { Storage } = await import('../config/storage.js'); + vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue( + '/mock/user/policies', + ); + vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue( + '/mock/system/policies', + ); + // Ensure security check always returns secure + vi.mocked(isDirectorySecure).mockResolvedValue({ secure: true }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.doUnmock('node:fs/promises'); + }); + + it('should load workspace policies with correct priority (Tier 2)', async () => { + const workspacePoliciesDir = '/mock/workspace/policies'; + const defaultPoliciesDir = '/mock/default/policies'; + + // Mock FS + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + + // Mock readdir to return a policy file for each tier + const mockReaddir = vi.fn(async (path: string) => { + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('default/policies')) + return [ + { + name: 'default.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + if (normalizedPath.endsWith('user/policies')) + return [ + { name: 'user.toml', isFile: () => true, isDirectory: () => false }, + ] as unknown as Awaited>; + if (normalizedPath.endsWith('workspace/policies')) + return [ + { + name: 'workspace.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + if (normalizedPath.endsWith('system/policies')) + return [ + { name: 'admin.toml', isFile: () => true, isDirectory: () => false }, + ] as unknown as Awaited>; + return []; + }); + + // Mock readFile to return content with distinct priorities/decisions + const mockReadFile = vi.fn(async (path: string) => { + if (path.includes('default.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "allow" +priority = 10 +`; // Tier 1 -> 1.010 + } + if (path.includes('user.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "deny" +priority = 10 +`; // Tier 3 -> 3.010 + } + if (path.includes('workspace.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "allow" +priority = 10 +`; // Tier 2 -> 2.010 + } + if (path.includes('admin.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "deny" +priority = 10 +`; // Tier 4 -> 4.010 + } + return ''; + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { + ...actualFs, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + }, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + + // Test 1: Workspace vs User (User should win) + const config = await createPolicyEngineConfig( + { workspacePoliciesDir }, + ApprovalMode.DEFAULT, + defaultPoliciesDir, + ); + + const rules = config.rules?.filter((r) => r.toolName === 'test_tool'); + expect(rules).toBeDefined(); + + // Check for all 4 rules + const defaultRule = rules?.find((r) => r.priority === 1.01); + const workspaceRule = rules?.find((r) => r.priority === 2.01); + const userRule = rules?.find((r) => r.priority === 3.01); + const adminRule = rules?.find((r) => r.priority === 4.01); + + expect(defaultRule).toBeDefined(); + expect(userRule).toBeDefined(); + expect(workspaceRule).toBeDefined(); + expect(adminRule).toBeDefined(); + + // Verify Hierarchy: Admin > User > Workspace > Default + expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!); + expect(userRule!.priority).toBeGreaterThan(workspaceRule!.priority!); + expect(workspaceRule!.priority).toBeGreaterThan(defaultRule!.priority!); + }); + + it('should ignore workspace policies if workspacePoliciesDir is undefined', async () => { + const defaultPoliciesDir = '/mock/default/policies'; + + // Mock FS (simplified) + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + + const mockReaddir = vi.fn(async (path: string) => { + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('default/policies')) + return [ + { + name: 'default.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + return []; + }); + const mockReadFile = vi.fn( + async () => `[[rule]] +toolName="t" +decision="allow" +priority=10`, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { + ...actualFs, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + }, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + + const config = await createPolicyEngineConfig( + { workspacePoliciesDir: undefined }, + ApprovalMode.DEFAULT, + defaultPoliciesDir, + ); + + // Should only have default tier rule (1.01) + const rules = config.rules; + expect(rules).toHaveLength(1); + expect(rules![0].priority).toBe(1.01); + }); + + it('should load workspace policies and correctly transform to Tier 2', async () => { + const workspacePoliciesDir = '/mock/workspace/policies'; + + // Mock FS + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + + const mockReaddir = vi.fn(async (path: string) => { + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('workspace/policies')) + return [ + { + name: 'workspace.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + return []; + }); + const mockReadFile = vi.fn( + async () => `[[rule]] +toolName="p_tool" +decision="allow" +priority=500`, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { + ...actualFs, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + }, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + + const config = await createPolicyEngineConfig( + { workspacePoliciesDir }, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find((r) => r.toolName === 'p_tool'); + expect(rule).toBeDefined(); + // Workspace Tier (2) + 500/1000 = 2.5 + expect(rule?.priority).toBe(2.5); + }); +}); From 2ac39b6acc33354ab30ca828902fabeea4adb30e Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Thu, 19 Feb 2026 16:38:07 -0800 Subject: [PATCH 20/26] chore(skills): adds pr-address-comments skill to work on PR feedback (#19576) --- .gemini/skills/pr-address-comments/SKILL.md | 13 ++ .../scripts/fetch-pr-info.js | 160 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 .gemini/skills/pr-address-comments/SKILL.md create mode 100755 .gemini/skills/pr-address-comments/scripts/fetch-pr-info.js diff --git a/.gemini/skills/pr-address-comments/SKILL.md b/.gemini/skills/pr-address-comments/SKILL.md new file mode 100644 index 0000000000..bd190feffa --- /dev/null +++ b/.gemini/skills/pr-address-comments/SKILL.md @@ -0,0 +1,13 @@ +--- +name: pr-address-comments +description: Use this skill if the user asks you to help them address GitHub PR comments for their current branch of the Gemini CLI. Requires `gh` CLI tool. +--- +You are helping the user address comments on their Pull Request. These comments may have come from an automated review agent or a team member. + +OBJECTIVE: Help the user review and address comments on their PR. + +# Comment Review Procedure + +1. Run the `scripts/fetch-pr-info.js` script to get PR info and state. MAKE SURE you read the entire output of the command, even if it gets truncated. +2. Summarize the review status by analyzing the diff, commit log, and comments to see which still need to be addressed. Pay attention to the current user's comments. For resolved threads, summarize as a single line with a ✅. For open threads, provide a reference number e.g. [1] and the comment content. +3. Present your summary of the feedback and current state and allow the user to guide you as to what to fix/address/skip. DO NOT begin fixing issues automatically. diff --git a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js new file mode 100755 index 0000000000..772f8d18a4 --- /dev/null +++ b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-env node */ +/* global console, process */ + +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +async function run(cmd) { + try { + const { stdout } = await execAsync(cmd, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + return stdout.trim(); + } catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars + return null; + } +} + +const IGNORE_MESSAGES = [ + 'thank you so much for your contribution to Gemini CLI!', + "I'm currently reviewing this pull request and will post my feedback shortly.", + 'This pull request is being closed because it is not currently linked to an issue.', +]; + +const shouldIgnore = (body) => { + if (!body) return false; + return IGNORE_MESSAGES.some((msg) => body.includes(msg)); +}; + +async function main() { + const branch = await run('git branch --show-current'); + if (!branch) { + console.error('❌ Could not determine current git branch.'); + process.exit(1); + } + + const gqlQuery = `query($branch:String!){repository(name:"gemini-cli",owner:"google-gemini"){pullRequests(headRefName:$branch,first:100){nodes{id,number,state,comments(first:100){nodes{createdAt,isMinimized,minimizedReason,author{login},body,url,authorAssociation}},reviews(first:100){nodes{id,author{login},createdAt,isMinimized,minimizedReason,body,state,comments(first:30){nodes{id,replyTo{id},author{login},createdAt,body,isMinimized,minimizedReason,path,line,startLine,originalLine,originalStartLine}}}}}}}}`; + + const [authInfo, diff, commits, rawJson] = await Promise.all([ + run('gh auth status -a'), + run('gh pr diff'), + run( + 'git fetch && git log origin/main..origin/$(git branch --show-current)', + ), + run(`gh api graphql -F branch="${branch}" -f query='${gqlQuery}'`), + ]); + + if (!diff) { + console.error(`⚠️ No active PR found for branch: ${branch}`); + process.exit(1); + } + + console.log(`\n# Current GitHub user info:\n\n${authInfo}\n`); + console.log(`\n# PR diff for current branch: ${branch}\n\n\`\`\``); + console.log(diff); + console.log('```'); + console.log( + `\n# Commit history (origin/main..origin/${branch})\n\n${commits}`, + ); + + const data = JSON.parse(rawJson || '{}'); + const prs = data?.data?.repository?.pullRequests?.nodes || []; + + // Sort PRs by number descending so we check the newest one first + prs.sort((a, b) => b.number - a.number); + + const pr = prs.find((p) => p.state === 'OPEN') || prs[0]; + + if (!pr) { + console.error('❌ No PR data found.'); + process.exit(1); + } + + console.log('\n# PR Feedback\n'); + + // 1. General PR Comments + const general = pr.comments.nodes.filter((c) => !shouldIgnore(c.body)); + if (general.length > 0) { + console.log('\n💬 GENERAL COMMENTS:'); + general.forEach((c) => { + const minimized = c.isMinimized + ? ` (Minimized: ${c.minimizedReason})` + : ''; + console.log( + `[${c.createdAt}] [${c.author.login}]${minimized}: ${c.body}\n`, + ); + }); + } + + // 2. Process ALL Review Comments into a single Thread Map + const allInlineComments = pr.reviews.nodes.flatMap((r) => r.comments.nodes); + const filteredInlines = allInlineComments.filter( + (c) => !shouldIgnore(c.body), + ); + + console.log('🔍 CODE REVIEWS & INLINE THREADS:'); + + // Print Review Summaries First + pr.reviews.nodes.forEach((review) => { + if (review.body && !shouldIgnore(review.body)) { + const icon = review.state === 'APPROVED' ? '✅' : '💬'; + const minimized = review.isMinimized + ? ` (Minimized: ${review.minimizedReason})` + : ''; + console.log( + `\n${icon} ${review.state} by ${review.author.login} at ${review.createdAt}${minimized}: "${review.body}"`, + ); + } + }); + + // Build and Print Threads + const topLevelThreads = filteredInlines.filter((c) => !c.replyTo); + + const printThread = (parentId, depth = 1) => { + const indent = ' '.repeat(depth); + filteredInlines + .filter((c) => c.replyTo?.id === parentId) + .forEach((reply) => { + const minimized = reply.isMinimized + ? ` (Minimized: ${reply.minimizedReason})` + : ''; + console.log( + `${indent}↳ [${reply.createdAt}] ${reply.author.login}${minimized}: ${reply.body}`, + ); + printThread(reply.id, depth + 1); + }); + }; + + topLevelThreads.forEach((c) => { + const start = c.startLine || c.originalStartLine; + const end = c.line || c.originalLine; + const range = start && end && start !== end ? `${start}-${end}` : end || ''; + const fileInfo = c.path + ? `(${c.path}${range ? `:${range}` : ''}) ` + : range + ? `(Line ${range}) ` + : ''; + const minimized = c.isMinimized ? ` (Minimized: ${c.minimizedReason})` : ''; + console.log( + `\n💬 ${minimized}${c.author.login} | ${c.createdAt} ${fileInfo}\n${c.body}`, + ); + printThread(c.id); + }); + + console.log('\n'); +} + +main().catch((err) => { + console.error('❌ Unexpected error:', err); + process.exit(1); +}); From 6351352e54a61b8d9ceac7f6eab4998e57140770 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Thu, 19 Feb 2026 16:38:22 -0800 Subject: [PATCH 21/26] feat(core): Implement parallel FC for read only tools. (#18791) --- integration-tests/parallel-tools.responses | 1 + integration-tests/parallel-tools.test.ts | 77 ++++ .../core/src/agents/subagent-tool.test.ts | 84 ++++ packages/core/src/agents/subagent-tool.ts | 48 +++ packages/core/src/scheduler/scheduler.test.ts | 358 ++++------------ packages/core/src/scheduler/scheduler.ts | 133 +++++- .../src/scheduler/scheduler_parallel.test.ts | 397 ++++++++++++++++++ packages/core/src/scheduler/state-manager.ts | 8 + packages/core/src/tools/mcp-tool.ts | 12 +- packages/core/src/tools/tools.test.ts | 29 ++ packages/core/src/tools/tools.ts | 16 + 11 files changed, 862 insertions(+), 301 deletions(-) create mode 100644 integration-tests/parallel-tools.responses create mode 100644 integration-tests/parallel-tools.test.ts create mode 100644 packages/core/src/scheduler/scheduler_parallel.test.ts diff --git a/integration-tests/parallel-tools.responses b/integration-tests/parallel-tools.responses new file mode 100644 index 0000000000..d7beedc8b2 --- /dev/null +++ b/integration-tests/parallel-tools.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file2.txt"}}},{"functionCall":{"name":"write_file","args":{"file_path":"output.txt","content":"wave2"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file3.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file4.txt"}}}, {"text":"All waves completed successfully."}]},"finishReason":"STOP","index":0}]}]} diff --git a/integration-tests/parallel-tools.test.ts b/integration-tests/parallel-tools.test.ts new file mode 100644 index 0000000000..760f98cd7a --- /dev/null +++ b/integration-tests/parallel-tools.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { join } from 'node:path'; +import fs from 'node:fs'; + +describe('Parallel Tool Execution Integration', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should execute [read, read, write, read, read] in correct waves with user approval', async () => { + rig.setup('parallel-wave-execution', { + fakeResponsesPath: join(import.meta.dirname, 'parallel-tools.responses'), + settings: { + tools: { + core: ['read_file', 'write_file'], + approval: 'ASK', // Disable YOLO mode to show permission prompts + confirmationRequired: ['write_file'], + }, + }, + }); + + rig.createFile('file1.txt', 'c1'); + rig.createFile('file2.txt', 'c2'); + rig.createFile('file3.txt', 'c3'); + rig.createFile('file4.txt', 'c4'); + rig.sync(); + + const run = await rig.runInteractive({ approvalMode: 'default' }); + + // 1. Trigger the wave + await run.type('ok'); + await run.type('\r'); + + // 3. Wait for the write_file prompt. + await run.expectText('Allow', 5000); + + // 4. Press Enter to approve the write_file. + await run.type('y'); + await run.type('\r'); + + // 5. Wait for the final model response + await run.expectText('All waves completed successfully.', 5000); + + // Verify all tool calls were made and succeeded in the logs + await rig.expectToolCallSuccess(['write_file']); + const toolLogs = rig.readToolLogs(); + + const readFiles = toolLogs.filter( + (l) => l.toolRequest.name === 'read_file', + ); + const writeFiles = toolLogs.filter( + (l) => l.toolRequest.name === 'write_file', + ); + + expect(readFiles.length).toBe(4); + expect(writeFiles.length).toBe(1); + expect(toolLogs.every((l) => l.toolRequest.success)).toBe(true); + + // Check that output.txt was actually written + expect(fs.readFileSync(join(rig.testDir!, 'output.txt'), 'utf8')).toBe( + 'wave2', + ); + }); +}); diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts index 135365712d..d6d6bdfd89 100644 --- a/packages/core/src/agents/subagent-tool.test.ts +++ b/packages/core/src/agents/subagent-tool.test.ts @@ -17,10 +17,12 @@ import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { + DeclarativeTool, ToolCallConfirmationDetails, ToolInvocation, ToolResult, } from '../tools/tools.js'; +import type { ToolRegistry } from 'src/tools/tool-registry.js'; vi.mock('./subagent-tool-wrapper.js'); @@ -274,3 +276,85 @@ describe('SubAgentInvocation', () => { }); }); }); + +describe('SubagentTool Read-Only logic', () => { + let mockConfig: Config; + let mockMessageBus: MessageBus; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = makeFakeConfig(); + mockMessageBus = createMockMessageBus(); + }); + + it('should be false for remote agents', () => { + const tool = new SubagentTool( + testRemoteDefinition, + mockConfig, + mockMessageBus, + ); + expect(tool.isReadOnly).toBe(false); + }); + + it('should be true for local agent with only read-only tools', () => { + const readOnlyTool = { + name: 'read', + isReadOnly: true, + } as unknown as DeclarativeTool; + const registry = { + getTool: (name: string) => (name === 'read' ? readOnlyTool : undefined), + }; + vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue( + registry as unknown as ToolRegistry, + ); + + const defWithTools: LocalAgentDefinition = { + ...testDefinition, + toolConfig: { tools: ['read'] }, + }; + const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus); + expect(tool.isReadOnly).toBe(true); + }); + + it('should be false for local agent with at least one non-read-only tool', () => { + const readOnlyTool = { + name: 'read', + isReadOnly: true, + } as unknown as DeclarativeTool; + const mutatorTool = { + name: 'write', + isReadOnly: false, + } as unknown as DeclarativeTool; + const registry = { + getTool: (name: string) => { + if (name === 'read') return readOnlyTool; + if (name === 'write') return mutatorTool; + return undefined; + }, + }; + vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue( + registry as unknown as ToolRegistry, + ); + + const defWithTools: LocalAgentDefinition = { + ...testDefinition, + toolConfig: { tools: ['read', 'write'] }, + }; + const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus); + expect(tool.isReadOnly).toBe(false); + }); + + it('should be true for local agent with no tools', () => { + const registry = { getTool: () => undefined }; + vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue( + registry as unknown as ToolRegistry, + ); + + const defNoTools: LocalAgentDefinition = { + ...testDefinition, + toolConfig: { tools: [] }, + }; + const tool = new SubagentTool(defNoTools, mockConfig, mockMessageBus); + expect(tool.isReadOnly).toBe(true); + }); +}); diff --git a/packages/core/src/agents/subagent-tool.ts b/packages/core/src/agents/subagent-tool.ts index 3a92452c3d..f47b506634 100644 --- a/packages/core/src/agents/subagent-tool.ts +++ b/packages/core/src/agents/subagent-tool.ts @@ -11,6 +11,7 @@ import { type ToolResult, BaseToolInvocation, type ToolCallConfirmationDetails, + isTool, } from '../tools/tools.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { Config } from '../config/config.js'; @@ -48,6 +49,53 @@ export class SubagentTool extends BaseDeclarativeTool { ); } + private _memoizedIsReadOnly: boolean | undefined; + + override get isReadOnly(): boolean { + if (this._memoizedIsReadOnly !== undefined) { + return this._memoizedIsReadOnly; + } + // No try-catch here. If getToolRegistry() throws, we let it throw. + // This is an invariant: you can't check read-only status if the system isn't initialized. + this._memoizedIsReadOnly = SubagentTool.checkIsReadOnly( + this.definition, + this.config, + ); + return this._memoizedIsReadOnly; + } + + private static checkIsReadOnly( + definition: AgentDefinition, + config: Config, + ): boolean { + if (definition.kind === 'remote') { + return false; + } + const tools = definition.toolConfig?.tools ?? []; + const registry = config.getToolRegistry(); + + if (!registry) { + return false; + } + + for (const tool of tools) { + if (typeof tool === 'string') { + const resolvedTool = registry.getTool(tool); + if (!resolvedTool || !resolvedTool.isReadOnly) { + return false; + } + } else if (isTool(tool)) { + if (!tool.isReadOnly) { + return false; + } + } else { + // FunctionDeclaration - we don't know, so assume NOT read-only + return false; + } + } + return true; + } + protected createInvocation( params: AgentInputs, messageBus: MessageBus, diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index dd26ba4c03..61699d07a6 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -27,7 +27,6 @@ vi.mock('../telemetry/trace.js', () => ({ })); import { logToolCall } from '../telemetry/loggers.js'; -import { ToolCallEvent } from '../telemetry/types.js'; vi.mock('../telemetry/loggers.js', () => ({ logToolCall: vi.fn(), })); @@ -76,6 +75,8 @@ import type { CancelledToolCall, CompletedToolCall, ToolCallResponseInfo, + Status, + ToolCall, } from './types.js'; import { CoreToolCallStatus, ROOT_SCHEDULER_ID } from './types.js'; import { ToolErrorType } from '../tools/tool-error.js'; @@ -168,29 +169,55 @@ describe('Scheduler (Orchestrator)', () => { getPreferredEditor = vi.fn().mockReturnValue('vim'); // --- Setup Sub-component Mocks --- + const mockActiveCallsMap = new Map(); + const mockQueue: ToolCall[] = []; + mockStateManager = { - enqueue: vi.fn(), - dequeue: vi.fn(), - getToolCall: vi.fn(), - updateStatus: vi.fn(), - finalizeCall: vi.fn(), + enqueue: vi.fn((calls: ToolCall[]) => { + // Clone to preserve initial state for Phase 1 tests + mockQueue.push(...calls.map((c) => ({ ...c }) as ToolCall)); + }), + dequeue: vi.fn(() => { + const next = mockQueue.shift(); + if (next) mockActiveCallsMap.set(next.request.callId, next); + return next; + }), + peekQueue: vi.fn(() => mockQueue[0]), + getToolCall: vi.fn((id: string) => mockActiveCallsMap.get(id)), + updateStatus: vi.fn((id: string, status: Status) => { + const call = mockActiveCallsMap.get(id); + if (call) (call as unknown as { status: Status }).status = status; + }), + finalizeCall: vi.fn((id: string) => { + const call = mockActiveCallsMap.get(id); + if (call) { + mockActiveCallsMap.delete(id); + capturedTerminalHandler?.(call as CompletedToolCall); + } + }), updateArgs: vi.fn(), setOutcome: vi.fn(), - cancelAllQueued: vi.fn(), + cancelAllQueued: vi.fn(() => { + mockQueue.length = 0; + }), clearBatch: vi.fn(), } as unknown as Mocked; // Define getters for accessors idiomatically Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), + get: vi.fn(() => mockActiveCallsMap.size > 0), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'allActiveCalls', { + get: vi.fn(() => Array.from(mockActiveCallsMap.values())), configurable: true, }); Object.defineProperty(mockStateManager, 'queueLength', { - get: vi.fn().mockReturnValue(0), + get: vi.fn(() => mockQueue.length), configurable: true, }); Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(undefined), + get: vi.fn(() => mockActiveCallsMap.values().next().value), configurable: true, }); Object.defineProperty(mockStateManager, 'completedBatch', { @@ -227,8 +254,9 @@ describe('Scheduler (Orchestrator)', () => { ); mockStateManager.finalizeCall.mockImplementation((callId: string) => { - const call = mockStateManager.getToolCall(callId); + const call = mockActiveCallsMap.get(callId); if (call) { + mockActiveCallsMap.delete(callId); capturedTerminalHandler?.(call as CompletedToolCall); } }); @@ -242,6 +270,13 @@ describe('Scheduler (Orchestrator)', () => { vi.mocked(ToolExecutor).mockReturnValue( mockExecutor as unknown as Mocked, ); + mockExecutor.execute.mockResolvedValue({ + status: 'success', + response: { + callId: 'default', + responseParts: [], + } as unknown as ToolCallResponseInfo, + } as unknown as SuccessfulToolCall); vi.mocked(ToolModificationHandler).mockReturnValue( mockModifier as unknown as Mocked, ); @@ -339,35 +374,6 @@ describe('Scheduler (Orchestrator)', () => { describe('Phase 2: Queue Management', () => { it('should drain the queue if multiple calls are scheduled', async () => { - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // Setup queue simulation: two items - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(2) - .mockReturnValueOnce(1) - .mockReturnValue(0), - configurable: true, - }); - - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - mockStateManager.dequeue.mockReturnValue(validatingCall); - vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(validatingCall), - configurable: true, - }); - // Execute is the end of the loop, stub it mockExecutor.execute.mockResolvedValue({ status: CoreToolCallStatus.Success, @@ -375,56 +381,12 @@ describe('Scheduler (Orchestrator)', () => { await scheduler.schedule(req1, signal); - // Verify loop ran twice - expect(mockStateManager.dequeue).toHaveBeenCalledTimes(2); - expect(mockStateManager.finalizeCall).toHaveBeenCalledTimes(2); + // Verify loop ran once for this schedule call (which had 1 request) + // schedule(req1) enqueues 1 request. + expect(mockExecutor.execute).toHaveBeenCalledTimes(1); }); it('should execute tool calls sequentially (first completes before second starts)', async () => { - // Setup queue simulation: two items - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(2) - .mockReturnValueOnce(1) - .mockReturnValue(0), - configurable: true, - }); - - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValue(undefined); - - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi - .fn() - .mockReturnValueOnce(validatingCall1) // Used in loop check for call 1 - .mockReturnValueOnce(validatingCall1) // Used in _execute for call 1 - .mockReturnValueOnce(validatingCall2) // Used in loop check for call 2 - .mockReturnValueOnce(validatingCall2), // Used in _execute for call 2 - configurable: true, - }); - const executionLog: string[] = []; // Mock executor to push to log with a deterministic microtask delay @@ -452,52 +414,6 @@ describe('Scheduler (Orchestrator)', () => { }); it('should queue and process multiple schedule() calls made synchronously', async () => { - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, // Second request - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // Mock state responses dynamically - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - // Queue state responses for the two batches: - // Batch 1: length 1 -> 0 - // Batch 2: length 1 -> 0 - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(1) - .mockReturnValueOnce(0) - .mockReturnValueOnce(1) - .mockReturnValue(0), - configurable: true, - }); - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi - .fn() - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValueOnce(validatingCall2), - configurable: true, - }); - // Executor succeeds instantly mockExecutor.execute.mockResolvedValue({ status: CoreToolCallStatus.Success, @@ -516,50 +432,6 @@ describe('Scheduler (Orchestrator)', () => { }); it('should queue requests when scheduler is busy (overlapping batches)', async () => { - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, // Second request - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // 1. Setup State Manager for 2 sequential batches - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(1) // Batch 1 - .mockReturnValueOnce(0) - .mockReturnValueOnce(1) // Batch 2 - .mockReturnValue(0), - configurable: true, - }); - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2); - - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi - .fn() - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValueOnce(validatingCall2), - configurable: true, - }); - // 2. Setup Executor with a controllable lock for the first batch const executionLog: string[] = []; let finishFirstBatch: (value: unknown) => void; @@ -635,10 +507,8 @@ describe('Scheduler (Orchestrator)', () => { invocation: mockInvocation as unknown as AnyToolInvocation, }; - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(activeCall), - configurable: true, - }); + mockStateManager.enqueue([activeCall]); + mockStateManager.dequeue(); scheduler.cancelAll(); @@ -676,24 +546,7 @@ describe('Scheduler (Orchestrator)', () => { }); describe('Phase 3: Policy & Confirmation Loop', () => { - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - beforeEach(() => { - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi.fn().mockReturnValueOnce(1).mockReturnValue(0), - configurable: true, - }); - vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(validatingCall), - configurable: true, - }); - }); + beforeEach(() => {}); it('should update state to error with POLICY_VIOLATION if Policy returns DENY', async () => { vi.mocked(checkPolicy).mockResolvedValue({ @@ -854,30 +707,6 @@ describe('Scheduler (Orchestrator)', () => { }); it('should auto-approve remaining identical tools in batch after ProceedAlways', async () => { - // Setup: two identical tools - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValue(undefined); - - vi.spyOn(mockStateManager, 'queueLength', 'get') - .mockReturnValueOnce(2) - .mockReturnValueOnce(1) - .mockReturnValue(0); - // First call requires confirmation, second is auto-approved (simulating policy update) vi.mocked(checkPolicy) .mockResolvedValueOnce({ @@ -1045,21 +874,7 @@ describe('Scheduler (Orchestrator)', () => { }); describe('Phase 4: Execution Outcomes', () => { - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - beforeEach(() => { - vi.spyOn(mockStateManager, 'queueLength', 'get') - .mockReturnValueOnce(1) - .mockReturnValue(0); - mockStateManager.dequeue.mockReturnValue(validatingCall); - vi.spyOn(mockStateManager, 'firstActiveCall', 'get').mockReturnValue( - validatingCall, - ); mockPolicyEngine.check.mockResolvedValue({ decision: PolicyDecision.ALLOW, }); // Bypass confirmation @@ -1132,30 +947,12 @@ describe('Scheduler (Orchestrator)', () => { response: mockResponse, } as unknown as SuccessfulToolCall); - // Mock the state manager to return a SUCCESS state when getToolCall is - // called - const successfulCall: SuccessfulToolCall = { - status: CoreToolCallStatus.Success, - request: req1, - response: mockResponse, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - mockStateManager.getToolCall.mockReturnValue(successfulCall); - Object.defineProperty(mockStateManager, 'completedBatch', { - get: vi.fn().mockReturnValue([successfulCall]), - configurable: true, - }); - await scheduler.schedule(req1, signal); // Verify the finalizer and logger were called expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1'); - expect(ToolCallEvent).toHaveBeenCalledWith(successfulCall); - expect(logToolCall).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining(successfulCall), - ); + // We check that logToolCall was called (it's called via the state manager's terminal handler) + expect(logToolCall).toHaveBeenCalled(); }); it('should not double-report completed tools when concurrent completions occur', async () => { @@ -1182,6 +979,33 @@ describe('Scheduler (Orchestrator)', () => { expect(mockStateManager.finalizeCall).toHaveBeenCalledTimes(1); expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1'); }); + + it('should break the loop if no progress is made (safeguard against stuck states)', async () => { + // Setup: A tool that is 'validating' but stays 'validating' even after processing + // This simulates a bug in state management or a weird edge case. + const stuckCall: ValidatingToolCall = { + status: CoreToolCallStatus.Validating, + request: req1, + tool: mockTool, + invocation: mockInvocation as unknown as AnyToolInvocation, + }; + + // Mock dequeue to keep returning the same stuck call + mockStateManager.dequeue.mockReturnValue(stuckCall); + // Mock isActive to be true + Object.defineProperty(mockStateManager, 'isActive', { + get: vi.fn().mockReturnValue(true), + configurable: true, + }); + + // Mock updateStatus to do NOTHING (simulating no progress) + mockStateManager.updateStatus.mockImplementation(() => {}); + + // This should return false (break loop) instead of hanging indefinitely + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (scheduler as any)._processNextItem(signal); + expect(result).toBe(false); + }); }); describe('Tool Call Context Propagation', () => { @@ -1196,26 +1020,6 @@ describe('Scheduler (Orchestrator)', () => { parentCallId, }); - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // Mock queueLength to run the loop once - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi.fn().mockReturnValueOnce(1).mockReturnValue(0), - configurable: true, - }); - - vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(validatingCall), - configurable: true, - }); - vi.mocked(mockStateManager.getToolCall).mockReturnValue(validatingCall); - mockToolRegistry.getTool.mockReturnValue(mockTool); mockPolicyEngine.check.mockResolvedValue({ decision: PolicyDecision.ALLOW, diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index deb28d33a5..3ee55975f1 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -20,6 +20,7 @@ import { type ValidatingToolCall, type ErroredToolCall, CoreToolCallStatus, + type ScheduledToolCall, } from './types.js'; import { ToolErrorType } from '../tools/tool-error.js'; import type { ApprovalMode } from '../policy/types.js'; @@ -231,14 +232,16 @@ export class Scheduler { next?.reject(new Error('Operation cancelled by user')); } - // Cancel active call - const activeCall = this.state.firstActiveCall; - if (activeCall && !this.isTerminal(activeCall.status)) { - this.state.updateStatus( - activeCall.request.callId, - CoreToolCallStatus.Cancelled, - 'Operation cancelled by user', - ); + // Cancel active calls + const activeCalls = this.state.allActiveCalls; + for (const activeCall of activeCalls) { + if (!this.isTerminal(activeCall.status)) { + this.state.updateStatus( + activeCall.request.callId, + CoreToolCallStatus.Cancelled, + 'Operation cancelled by user', + ); + } } // Clear queue @@ -384,6 +387,10 @@ export class Scheduler { return false; } + const initialStatuses = new Map( + this.state.allActiveCalls.map((c) => [c.request.callId, c.status]), + ); + if (!this.state.isActive) { const next = this.state.dequeue(); if (!next) return false; @@ -397,16 +404,91 @@ export class Scheduler { this.state.finalizeCall(next.request.callId); return true; } + + // If the first tool is read-only, batch all contiguous read-only tools. + if (next.tool?.isReadOnly) { + while (this.state.queueLength > 0) { + const peeked = this.state.peekQueue(); + if (peeked && peeked.tool?.isReadOnly) { + this.state.dequeue(); + } else { + break; + } + } + } } - const active = this.state.firstActiveCall; - if (!active) return false; + // Now we have one or more active calls. Move them through the lifecycle + // as much as possible in this iteration. - if (active.status === CoreToolCallStatus.Validating) { - await this._processValidatingCall(active, signal); + // 1. Process all 'validating' calls (Policy & Confirmation) + let activeCalls = this.state.allActiveCalls; + const validatingCalls = activeCalls.filter( + (c): c is ValidatingToolCall => + c.status === CoreToolCallStatus.Validating, + ); + if (validatingCalls.length > 0) { + await Promise.all( + validatingCalls.map((c) => this._processValidatingCall(c, signal)), + ); } - return true; + // 2. Execute scheduled calls + // Refresh activeCalls as status might have changed to 'scheduled' + activeCalls = this.state.allActiveCalls; + const scheduledCalls = activeCalls.filter( + (c): c is ScheduledToolCall => c.status === CoreToolCallStatus.Scheduled, + ); + + // We only execute if ALL active calls are in a ready state (scheduled or terminal) + const allReady = activeCalls.every( + (c) => + c.status === CoreToolCallStatus.Scheduled || this.isTerminal(c.status), + ); + + if (allReady && scheduledCalls.length > 0) { + await Promise.all(scheduledCalls.map((c) => this._execute(c, signal))); + } + + // 3. Finalize terminal calls + activeCalls = this.state.allActiveCalls; + let madeProgress = false; + for (const call of activeCalls) { + if (this.isTerminal(call.status)) { + this.state.finalizeCall(call.request.callId); + madeProgress = true; + } + } + + // Check if any calls changed status during this iteration (excluding terminal finalization) + const currentStatuses = new Map( + activeCalls.map((c) => [c.request.callId, c.status]), + ); + const anyStatusChanged = Array.from(initialStatuses.entries()).some( + ([id, status]) => currentStatuses.get(id) !== status, + ); + + if (madeProgress || anyStatusChanged) { + return true; + } + + // If we have active calls but NONE of them progressed, check if we are waiting for external events. + // States that are 'waiting' from the loop's perspective: awaiting_approval, executing. + const isWaitingForExternal = activeCalls.some( + (c) => + c.status === CoreToolCallStatus.AwaitingApproval || + c.status === CoreToolCallStatus.Executing, + ); + + if (isWaitingForExternal && this.state.isActive) { + // Yield to the event loop to allow external events (tool completion, user input) to progress. + await new Promise((resolve) => queueMicrotask(() => resolve(true))); + return true; + } + + // If we are here, we have active calls (likely Validating or Scheduled) but none progressed. + // This is a stuck state. + return false; } private async _processValidatingCall( @@ -437,8 +519,6 @@ export class Scheduler { ); } } - - this.state.finalizeCall(active.request.callId); } // --- Phase 3: Single Call Orchestration --- @@ -467,7 +547,6 @@ export class Scheduler { errorType, ), ); - this.state.finalizeCall(callId); return; } @@ -506,13 +585,11 @@ export class Scheduler { CoreToolCallStatus.Cancelled, 'User denied execution.', ); - this.state.finalizeCall(callId); this.state.cancelAllQueued('User cancelled operation'); return; // Skip execution } - // Execution - await this._execute(callId, signal); + this.state.updateStatus(callId, CoreToolCallStatus.Scheduled); } // --- Sub-phase Handlers --- @@ -520,13 +597,23 @@ export class Scheduler { /** * Executes the tool and records the result. */ - private async _execute(callId: string, signal: AbortSignal): Promise { - this.state.updateStatus(callId, CoreToolCallStatus.Scheduled); - if (signal.aborted) throw new Error('Operation cancelled'); + private async _execute( + toolCall: ScheduledToolCall, + signal: AbortSignal, + ): Promise { + const callId = toolCall.request.callId; + if (signal.aborted) { + this.state.updateStatus( + callId, + CoreToolCallStatus.Cancelled, + 'Operation cancelled', + ); + return; + } this.state.updateStatus(callId, CoreToolCallStatus.Executing); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const activeCall = this.state.firstActiveCall as ExecutingToolCall; + const activeCall = this.state.getToolCall(callId) as ExecutingToolCall; const result = await runWithToolCallContext( { diff --git a/packages/core/src/scheduler/scheduler_parallel.test.ts b/packages/core/src/scheduler/scheduler_parallel.test.ts new file mode 100644 index 0000000000..824cdc4a16 --- /dev/null +++ b/packages/core/src/scheduler/scheduler_parallel.test.ts @@ -0,0 +1,397 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type Mocked, +} from 'vitest'; +import { randomUUID } from 'node:crypto'; + +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn(), +})); + +vi.mock('../telemetry/trace.js', () => ({ + runInDevTraceSpan: vi.fn(async (_opts, fn) => + fn({ metadata: { input: {}, output: {} } }), + ), +})); +vi.mock('../telemetry/loggers.js', () => ({ + logToolCall: vi.fn(), +})); +vi.mock('../telemetry/types.js', () => ({ + ToolCallEvent: vi.fn().mockImplementation((call) => ({ ...call })), +})); + +import { + SchedulerStateManager, + type TerminalCallHandler, +} from './state-manager.js'; +import { checkPolicy, updatePolicy } from './policy.js'; +import { ToolExecutor } from './tool-executor.js'; +import { ToolModificationHandler } from './tool-modifier.js'; + +vi.mock('./state-manager.js'); +vi.mock('./confirmation.js'); +vi.mock('./policy.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkPolicy: vi.fn(), + updatePolicy: vi.fn(), + }; +}); +vi.mock('./tool-executor.js'); +vi.mock('./tool-modifier.js'); + +import { Scheduler } from './scheduler.js'; +import type { Config } from '../config/config.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import type { PolicyEngine } from '../policy/policy-engine.js'; +import type { ToolRegistry } from '../tools/tool-registry.js'; +import { ApprovalMode, PolicyDecision } from '../policy/types.js'; +import { + type AnyDeclarativeTool, + type AnyToolInvocation, +} from '../tools/tools.js'; +import type { + ToolCallRequestInfo, + CompletedToolCall, + SuccessfulToolCall, + Status, + ToolCall, +} from './types.js'; +import { ROOT_SCHEDULER_ID } from './types.js'; +import type { EditorType } from '../utils/editor.js'; + +describe('Scheduler Parallel Execution', () => { + let scheduler: Scheduler; + let signal: AbortSignal; + let abortController: AbortController; + + let mockConfig: Mocked; + let mockMessageBus: Mocked; + let mockPolicyEngine: Mocked; + let mockToolRegistry: Mocked; + let getPreferredEditor: Mock<() => EditorType | undefined>; + + let mockStateManager: Mocked; + let mockExecutor: Mocked; + let mockModifier: Mocked; + + const req1: ToolCallRequestInfo = { + callId: 'call-1', + name: 'read-tool-1', + args: { path: 'a.txt' }, + isClientInitiated: false, + prompt_id: 'p1', + schedulerId: ROOT_SCHEDULER_ID, + }; + + const req2: ToolCallRequestInfo = { + callId: 'call-2', + name: 'read-tool-2', + args: { path: 'b.txt' }, + isClientInitiated: false, + prompt_id: 'p1', + schedulerId: ROOT_SCHEDULER_ID, + }; + + const req3: ToolCallRequestInfo = { + callId: 'call-3', + name: 'write-tool', + args: { path: 'c.txt', content: 'hi' }, + isClientInitiated: false, + prompt_id: 'p1', + schedulerId: ROOT_SCHEDULER_ID, + }; + + const readTool1 = { + name: 'read-tool-1', + isReadOnly: true, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const readTool2 = { + name: 'read-tool-2', + isReadOnly: true, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const writeTool = { + name: 'write-tool', + isReadOnly: false, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + + const mockInvocation = { + shouldConfirmExecute: vi.fn().mockResolvedValue(false), + }; + + beforeEach(() => { + vi.mocked(randomUUID).mockReturnValue( + 'uuid' as unknown as `${string}-${string}-${string}-${string}-${string}`, + ); + abortController = new AbortController(); + signal = abortController.signal; + + mockPolicyEngine = { + check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ALLOW }), + } as unknown as Mocked; + + mockToolRegistry = { + getTool: vi.fn((name) => { + if (name === 'read-tool-1') return readTool1; + if (name === 'read-tool-2') return readTool2; + if (name === 'write-tool') return writeTool; + return undefined; + }), + getAllToolNames: vi + .fn() + .mockReturnValue(['read-tool-1', 'read-tool-2', 'write-tool']), + } as unknown as Mocked; + + mockConfig = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + isInteractive: vi.fn().mockReturnValue(true), + getEnableHooks: vi.fn().mockReturnValue(true), + setApprovalMode: vi.fn(), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + } as unknown as Mocked; + + mockMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + } as unknown as Mocked; + getPreferredEditor = vi.fn().mockReturnValue('vim'); + + vi.mocked(checkPolicy).mockReset(); + vi.mocked(checkPolicy).mockResolvedValue({ + decision: PolicyDecision.ALLOW, + rule: undefined, + }); + vi.mocked(updatePolicy).mockReset(); + + const mockActiveCallsMap = new Map(); + const mockQueue: ToolCall[] = []; + let capturedTerminalHandler: TerminalCallHandler | undefined; + + mockStateManager = { + enqueue: vi.fn((calls: ToolCall[]) => { + mockQueue.push(...calls.map((c) => ({ ...c }) as ToolCall)); + }), + dequeue: vi.fn(() => { + const next = mockQueue.shift(); + if (next) mockActiveCallsMap.set(next.request.callId, next); + return next; + }), + peekQueue: vi.fn(() => mockQueue[0]), + getToolCall: vi.fn((id: string) => mockActiveCallsMap.get(id)), + updateStatus: vi.fn((id: string, status: Status) => { + const call = mockActiveCallsMap.get(id); + if (call) (call as unknown as { status: Status }).status = status; + }), + finalizeCall: vi.fn((id: string) => { + const call = mockActiveCallsMap.get(id); + if (call) { + mockActiveCallsMap.delete(id); + capturedTerminalHandler?.(call as CompletedToolCall); + } + }), + updateArgs: vi.fn(), + setOutcome: vi.fn(), + cancelAllQueued: vi.fn(() => { + mockQueue.length = 0; + }), + clearBatch: vi.fn(), + } as unknown as Mocked; + + Object.defineProperty(mockStateManager, 'isActive', { + get: vi.fn(() => mockActiveCallsMap.size > 0), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'allActiveCalls', { + get: vi.fn(() => Array.from(mockActiveCallsMap.values())), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'queueLength', { + get: vi.fn(() => mockQueue.length), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'firstActiveCall', { + get: vi.fn(() => mockActiveCallsMap.values().next().value), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'completedBatch', { + get: vi.fn().mockReturnValue([]), + configurable: true, + }); + + vi.mocked(SchedulerStateManager).mockImplementation( + (_bus, _id, onTerminal) => { + capturedTerminalHandler = onTerminal; + return mockStateManager as unknown as SchedulerStateManager; + }, + ); + + mockExecutor = { execute: vi.fn() } as unknown as Mocked; + vi.mocked(ToolExecutor).mockReturnValue( + mockExecutor as unknown as Mocked, + ); + mockModifier = { + handleModifyWithEditor: vi.fn(), + applyInlineModify: vi.fn(), + } as unknown as Mocked; + vi.mocked(ToolModificationHandler).mockReturnValue( + mockModifier as unknown as Mocked, + ); + + scheduler = new Scheduler({ + config: mockConfig, + messageBus: mockMessageBus, + getPreferredEditor, + schedulerId: 'root', + }); + + vi.mocked(readTool1.build).mockReturnValue( + mockInvocation as unknown as AnyToolInvocation, + ); + vi.mocked(readTool2.build).mockReturnValue( + mockInvocation as unknown as AnyToolInvocation, + ); + vi.mocked(writeTool.build).mockReturnValue( + mockInvocation as unknown as AnyToolInvocation, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should execute contiguous read-only tools in parallel', async () => { + const executionLog: string[] = []; + + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + // Schedule 2 read tools and 1 write tool + await scheduler.schedule([req1, req2, req3], signal); + + // Parallel read tools should start together + expect(executionLog[0]).toBe('start-call-1'); + expect(executionLog[1]).toBe('start-call-2'); + + // They can finish in any order, but both must finish before call-3 starts + expect(executionLog.indexOf('start-call-3')).toBeGreaterThan( + executionLog.indexOf('end-call-1'), + ); + expect(executionLog.indexOf('start-call-3')).toBeGreaterThan( + executionLog.indexOf('end-call-2'), + ); + + expect(executionLog).toContain('end-call-3'); + }); + + it('should execute non-read-only tools sequentially', async () => { + const executionLog: string[] = []; + + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + // req3 is NOT read-only + await scheduler.schedule([req3, req1], signal); + + // Should be strictly sequential + expect(executionLog).toEqual([ + 'start-call-3', + 'end-call-3', + 'start-call-1', + 'end-call-1', + ]); + }); + + it('should execute [WRITE, READ, READ] as [sequential, parallel]', async () => { + const executionLog: string[] = []; + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + // req3 (WRITE), req1 (READ), req2 (READ) + await scheduler.schedule([req3, req1, req2], signal); + + // Order should be: + // 1. write starts and ends + // 2. read1 and read2 start together (parallel) + expect(executionLog[0]).toBe('start-call-3'); + expect(executionLog[1]).toBe('end-call-3'); + expect(executionLog.slice(2, 4)).toContain('start-call-1'); + expect(executionLog.slice(2, 4)).toContain('start-call-2'); + }); + + it('should execute [READ, READ, WRITE, READ, READ] in three waves', async () => { + const executionLog: string[] = []; + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + const req4: ToolCallRequestInfo = { ...req1, callId: 'call-4' }; + const req5: ToolCallRequestInfo = { ...req2, callId: 'call-5' }; + + await scheduler.schedule([req1, req2, req3, req4, req5], signal); + + // Wave 1: call-1, call-2 (parallel) + expect(executionLog.slice(0, 2)).toContain('start-call-1'); + expect(executionLog.slice(0, 2)).toContain('start-call-2'); + + // Wave 2: call-3 (sequential) + // Must start after both call-1 and call-2 end + const start3 = executionLog.indexOf('start-call-3'); + expect(start3).toBeGreaterThan(executionLog.indexOf('end-call-1')); + expect(start3).toBeGreaterThan(executionLog.indexOf('end-call-2')); + const end3 = executionLog.indexOf('end-call-3'); + expect(end3).toBeGreaterThan(start3); + + // Wave 3: call-4, call-5 (parallel) + // Must start after call-3 ends + expect(executionLog.indexOf('start-call-4')).toBeGreaterThan(end3); + expect(executionLog.indexOf('start-call-5')).toBeGreaterThan(end3); + }); +}); diff --git a/packages/core/src/scheduler/state-manager.ts b/packages/core/src/scheduler/state-manager.ts index b282c3eb78..42da1d78ac 100644 --- a/packages/core/src/scheduler/state-manager.ts +++ b/packages/core/src/scheduler/state-manager.ts @@ -78,10 +78,18 @@ export class SchedulerStateManager { return next; } + peekQueue(): ToolCall | undefined { + return this.queue[0]; + } + get isActive(): boolean { return this.activeCalls.size > 0; } + get allActiveCalls(): ToolCall[] { + return Array.from(this.activeCalls.values()); + } + get activeCallCount(): number { return this.activeCalls.size; } diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 280af4589a..1279d0f705 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -247,7 +247,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< override readonly parameterSchema: unknown, messageBus: MessageBus, readonly trust?: boolean, - readonly isReadOnly?: boolean, + isReadOnly?: boolean, nameOverride?: string, private readonly cliConfig?: Config, override readonly extensionName?: string, @@ -265,6 +265,16 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< extensionName, extensionId, ); + this._isReadOnly = isReadOnly; + } + + private readonly _isReadOnly?: boolean; + + override get isReadOnly(): boolean { + if (this._isReadOnly !== undefined) { + return this._isReadOnly; + } + return super.isReadOnly; } getFullyQualifiedPrefix(): string { diff --git a/packages/core/src/tools/tools.test.ts b/packages/core/src/tools/tools.test.ts index 514f4f3455..41edf9f21d 100644 --- a/packages/core/src/tools/tools.test.ts +++ b/packages/core/src/tools/tools.test.ts @@ -9,6 +9,8 @@ import type { ToolInvocation, ToolResult } from './tools.js'; import { DeclarativeTool, hasCycleInSchema, Kind } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { ReadFileTool } from './read-file.js'; +import { makeFakeConfig } from '../test-utils/config.js'; class TestToolInvocation implements ToolInvocation { constructor( @@ -238,3 +240,30 @@ describe('hasCycleInSchema', () => { expect(hasCycleInSchema({})).toBe(false); }); }); + +describe('Tools Read-Only property', () => { + it('should have isReadOnly true for ReadFileTool', () => { + const config = makeFakeConfig(); + const bus = createMockMessageBus(); + const tool = new ReadFileTool(config, bus); + expect(tool.isReadOnly).toBe(true); + }); + + it('should derive isReadOnly from Kind', () => { + const bus = createMockMessageBus(); + class MyTool extends DeclarativeTool { + build(_params: object): ToolInvocation { + throw new Error('Not implemented'); + } + } + + const mutator = new MyTool('m', 'M', 'd', Kind.Edit, {}, bus); + expect(mutator.isReadOnly).toBe(false); + + const reader = new MyTool('r', 'R', 'd', Kind.Read, {}, bus); + expect(reader.isReadOnly).toBe(true); + + const searcher = new MyTool('s', 'S', 'd', Kind.Search, {}, bus); + expect(searcher.isReadOnly).toBe(true); + }); +}); diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 4e9972e37c..acbbd7bfff 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -333,6 +333,11 @@ export interface ToolBuilder< */ canUpdateOutput: boolean; + /** + * Whether the tool is read-only (has no side effects). + */ + isReadOnly: boolean; + /** * Validates raw parameters and builds a ready-to-execute invocation. * @param params The raw, untrusted parameters from the model. @@ -363,6 +368,10 @@ export abstract class DeclarativeTool< readonly extensionId?: string, ) {} + get isReadOnly(): boolean { + return READ_ONLY_KINDS.includes(this.kind); + } + getSchema(_modelId?: string): FunctionDeclaration { return { name: this.name, @@ -819,6 +828,13 @@ export const MUTATOR_KINDS: Kind[] = [ Kind.Execute, ] as const; +// Function kinds that are safe to run in parallel +export const READ_ONLY_KINDS: Kind[] = [ + Kind.Read, + Kind.Search, + Kind.Fetch, +] as const; + export interface ToolLocation { // Absolute path to the file path: string; From f1c0a695f8175dd36544356a81be88601f75b673 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Thu, 19 Feb 2026 16:47:35 -0800 Subject: [PATCH 22/26] refactor(sdk): introduce session-based architecture (#19180) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- eslint.config.js | 1 + packages/core/src/config/storage.test.ts | 80 +++++ packages/core/src/config/storage.ts | 57 ++++ packages/core/src/index.ts | 2 +- packages/core/src/utils/session.ts | 4 + packages/sdk/SDK_DESIGN.md | 18 +- packages/sdk/src/agent.integration.test.ts | 77 ++--- packages/sdk/src/agent.ts | 276 ++++-------------- packages/sdk/src/index.ts | 1 + packages/sdk/src/session.ts | 270 +++++++++++++++++ packages/sdk/src/skills.integration.test.ts | 6 +- packages/sdk/src/tool.integration.test.ts | 9 +- packages/sdk/src/types.ts | 20 ++ .../test-data/agent-dynamic-instructions.json | 24 +- .../sdk/test-data/agent-resume-session.json | 2 + .../test-data/agent-static-instructions.json | 3 + packages/sdk/test-data/skill-dir-success.json | 6 + .../sdk/test-data/skill-root-success.json | 6 + .../sdk/test-data/tool-catchall-error.json | 6 + .../sdk/test-data/tool-error-recovery.json | 6 + packages/sdk/test-data/tool-success.json | 6 + 21 files changed, 612 insertions(+), 268 deletions(-) create mode 100644 packages/sdk/src/session.ts create mode 100644 packages/sdk/test-data/agent-resume-session.json diff --git a/eslint.config.js b/eslint.config.js index 48af3775f2..b05072cfc0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,6 +38,7 @@ export default tseslint.config( 'dist/**', 'evals/**', 'packages/test-utils/**', + '.gemini/skills/**', ], }, eslint.configs.recommended, diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index afb3eaeeeb..15b49d12f1 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -180,6 +180,86 @@ describe('Storage – additional helpers', () => { expect(storageWithSession.getProjectTempPlansDir()).toBe(expected); }); + describe('Session and JSON Loading', () => { + beforeEach(async () => { + await storage.initialize(); + }); + + it('listProjectChatFiles returns sorted sessions from chats directory', async () => { + const readdirSpy = vi + .spyOn(fs.promises, 'readdir') + /* eslint-disable @typescript-eslint/no-explicit-any */ + .mockResolvedValue([ + 'session-1.json', + 'session-2.json', + 'not-a-session.txt', + ] as any); + + const statSpy = vi + .spyOn(fs.promises, 'stat') + .mockImplementation(async (p: any) => { + if (p.toString().endsWith('session-1.json')) { + return { + mtime: new Date('2026-02-01'), + mtimeMs: 1000, + } as any; + } + return { + mtime: new Date('2026-02-02'), + mtimeMs: 2000, + } as any; + }); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + const sessions = await storage.listProjectChatFiles(); + + expect(readdirSpy).toHaveBeenCalledWith(expect.stringContaining('chats')); + expect(sessions).toHaveLength(2); + // Sorted by mtime desc + expect(sessions[0].filePath).toBe(path.join('chats', 'session-2.json')); + expect(sessions[1].filePath).toBe(path.join('chats', 'session-1.json')); + expect(sessions[0].lastUpdated).toBe( + new Date('2026-02-02').toISOString(), + ); + + readdirSpy.mockRestore(); + statSpy.mockRestore(); + }); + + it('loadProjectTempFile loads and parses JSON from relative path', async () => { + const readFileSpy = vi + .spyOn(fs.promises, 'readFile') + .mockResolvedValue(JSON.stringify({ hello: 'world' })); + + const result = await storage.loadProjectTempFile<{ hello: string }>( + 'some/file.json', + ); + + expect(readFileSpy).toHaveBeenCalledWith( + expect.stringContaining(path.join(PROJECT_SLUG, 'some/file.json')), + 'utf8', + ); + expect(result).toEqual({ hello: 'world' }); + + readFileSpy.mockRestore(); + }); + + it('loadProjectTempFile returns null if file does not exist', async () => { + const error = new Error('File not found'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (error as any).code = 'ENOENT'; + const readFileSpy = vi + .spyOn(fs.promises, 'readFile') + .mockRejectedValue(error); + + const result = await storage.loadProjectTempFile('missing.json'); + + expect(result).toBeNull(); + + readFileSpy.mockRestore(); + }); + }); + describe('getPlansDir', () => { interface TestCase { name: string; diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 3a079f3b7e..bd04123c34 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -295,6 +295,63 @@ export class Storage { return path.join(this.getProjectTempDir(), 'tasks'); } + async listProjectChatFiles(): Promise< + Array<{ filePath: string; lastUpdated: string }> + > { + const chatsDir = path.join(this.getProjectTempDir(), 'chats'); + try { + const files = await fs.promises.readdir(chatsDir); + const jsonFiles = files.filter((f) => f.endsWith('.json')); + + const sessions = await Promise.all( + jsonFiles.map(async (file) => { + const absolutePath = path.join(chatsDir, file); + const stats = await fs.promises.stat(absolutePath); + return { + filePath: path.join('chats', file), + lastUpdated: stats.mtime.toISOString(), + mtimeMs: stats.mtimeMs, + }; + }), + ); + + return sessions + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ filePath, lastUpdated }) => ({ filePath, lastUpdated })); + } catch (e) { + // If directory doesn't exist, return empty + if ( + e instanceof Error && + 'code' in e && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (e as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return []; + } + throw e; + } + } + + async loadProjectTempFile(filePath: string): Promise { + const absolutePath = path.join(this.getProjectTempDir(), filePath); + try { + const content = await fs.promises.readFile(absolutePath, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as T; + } catch (e) { + // If file doesn't exist, return null + if ( + e instanceof Error && + 'code' in e && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (e as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return null; + } + throw e; + } + } + getExtensionsDir(): string { return path.join(this.getGeminiDir(), 'extensions'); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 36d10d3832..8ecd8cef7c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -180,7 +180,7 @@ export { OAuthUtils } from './mcp/oauth-utils.js'; // Export telemetry functions export * from './telemetry/index.js'; -export { sessionId } from './utils/session.js'; +export { sessionId, createSessionId } from './utils/session.js'; export * from './utils/compatibility.js'; export * from './utils/browser.js'; export { Storage } from './config/storage.js'; diff --git a/packages/core/src/utils/session.ts b/packages/core/src/utils/session.ts index 96cdbbf48c..2a0ec52115 100644 --- a/packages/core/src/utils/session.ts +++ b/packages/core/src/utils/session.ts @@ -7,3 +7,7 @@ import { randomUUID } from 'node:crypto'; export const sessionId = randomUUID(); + +export function createSessionId(): string { + return randomUUID(); +} diff --git a/packages/sdk/SDK_DESIGN.md b/packages/sdk/SDK_DESIGN.md index d8c8512991..d3031f32b8 100644 --- a/packages/sdk/SDK_DESIGN.md +++ b/packages/sdk/SDK_DESIGN.md @@ -8,7 +8,8 @@ ## `Simple Example` -> **Status:** Implemented. `GeminiCliAgent` supports `cwd` and `sendStream`. +> **Status:** Implemented. `GeminiCliAgent` supports `session()` and +> `resumeSession()`. Equivalent to `gemini -p "what does this project do?"`. Loads all workspace and user settings. @@ -20,10 +21,14 @@ const simpleAgent = new GeminiCliAgent({ cwd: '/path/to/some/dir', }); -for await (const chunk of simpleAgent.sendStream( - 'what does this project do?', -)) { - console.log(chunk); // equivalent to JSON streaming chunks (probably?) for now +// Create a new empty session +const session = simpleAgent.session(); + +// Resume a specific session by ID +// const session = await simpleAgent.resumeSession('some-session-id'); + +for await (const chunk of session.sendStream('what does this project do?')) { + console.log(chunk); // equivalent to JSON streaming chunks } ``` @@ -268,8 +273,9 @@ export interface SessionContext { // helpers to access files and run shell commands while adhering to policies/validation fs: AgentFilesystem; shell: AgentShell; - // the agent itself is passed as context + // the agent and session are passed as context agent: GeminiCliAgent; + session: GeminiCliSession; } export interface AgentFilesystem { diff --git a/packages/sdk/src/agent.integration.test.ts b/packages/sdk/src/agent.integration.test.ts index 5226e30e06..064cd9fad7 100644 --- a/packages/sdk/src/agent.integration.test.ts +++ b/packages/sdk/src/agent.integration.test.ts @@ -30,8 +30,12 @@ describe('GeminiCliAgent Integration', () => { fakeResponses: RECORD_MODE ? undefined : goldenFile, }); + const session = agent.session(); + expect(session.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); const events = []; - const stream = agent.sendStream('Say hello.'); + const stream = session.sendStream('Say hello.'); for await (const event of stream) { events.push(event); @@ -60,9 +64,11 @@ describe('GeminiCliAgent Integration', () => { fakeResponses: RECORD_MODE ? undefined : goldenFile, }); + const session = agent.session(); + // First turn - const stream1 = agent.sendStream('What is the secret number?'); const events1 = []; + const stream1 = session.sendStream('What is the secret number?'); for await (const event of stream1) { events1.push(event); } @@ -72,11 +78,10 @@ describe('GeminiCliAgent Integration', () => { .join(''); expect(responseText1).toContain('1'); - expect(callCount).toBe(1); // Second turn - const stream2 = agent.sendStream('What is the secret number now?'); const events2 = []; + const stream2 = session.sendStream('What is the secret number now?'); for await (const event of stream2) { events2.push(event); } @@ -85,57 +90,60 @@ describe('GeminiCliAgent Integration', () => { .map((e) => (typeof e.value === 'string' ? e.value : '')) .join(''); - // Should still be 1 because instructions are only loaded once per session - expect(responseText2).toContain('1'); - expect(callCount).toBe(1); + expect(responseText2).toContain('2'); }, 30000); - it('handles async dynamic instructions', async () => { - const goldenFile = getGoldenPath('agent-async-instructions'); + it('resumes a session', async () => { + const goldenFile = getGoldenPath('agent-resume-session'); - let callCount = 0; + // Create initial session const agent = new GeminiCliAgent({ - instructions: async (_ctx) => { - await new Promise((resolve) => setTimeout(resolve, 10)); // Simulate async work - callCount++; - return `You are a helpful assistant. The secret number is ${callCount}. Always mention the secret number when asked.`; - }, + instructions: 'You are a memory test. Remember the word "BANANA".', model: 'gemini-2.0-flash', recordResponses: RECORD_MODE ? goldenFile : undefined, fakeResponses: RECORD_MODE ? undefined : goldenFile, }); - // First turn - const stream1 = agent.sendStream('What is the secret number?'); - const events1 = []; - for await (const event of stream1) { - events1.push(event); + const session1 = agent.session({ sessionId: 'resume-test-fixed-id' }); + const sessionId = session1.id; + const stream1 = session1.sendStream('What is the word?'); + for await (const _ of stream1) { + // consume stream } - const responseText1 = events1 - .filter((e) => e.type === 'content') - .map((e) => (typeof e.value === 'string' ? e.value : '')) - .join(''); - expect(responseText1).toContain('1'); - expect(callCount).toBe(1); + // Resume session + // Allow some time for async writes if any + await new Promise((resolve) => setTimeout(resolve, 500)); + + const session2 = await agent.resumeSession(sessionId); + expect(session2.id).toBe(sessionId); - // Second turn - const stream2 = agent.sendStream('What is the secret number now?'); const events2 = []; + const stream2 = session2.sendStream('What is the word again?'); for await (const event of stream2) { events2.push(event); } - const responseText2 = events2 + + const responseText = events2 .filter((e) => e.type === 'content') .map((e) => (typeof e.value === 'string' ? e.value : '')) .join(''); - // Should still be 1 because instructions are only loaded once per session - expect(responseText2).toContain('1'); - expect(callCount).toBe(1); + expect(responseText).toContain('BANANA'); }, 30000); - it('throws when dynamic instructions fail', async () => { + it('throws on invalid instructions', () => { + // Missing instructions should be fine + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => new GeminiCliAgent({} as any).session()).not.toThrow(); + + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new GeminiCliAgent({ instructions: 123 as any }).session(), + ).toThrow('Instructions must be a string or a function.'); + }); + + it('propagates errors from dynamic instructions', async () => { const agent = new GeminiCliAgent({ instructions: () => { throw new Error('Dynamic instruction failure'); @@ -143,7 +151,8 @@ describe('GeminiCliAgent Integration', () => { model: 'gemini-2.0-flash', }); - const stream = agent.sendStream('Say hello.'); + const session = agent.session(); + const stream = session.sendStream('Say hello.'); await expect(async () => { for await (const _event of stream) { diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index 7db03a98f5..6e713c0fe1 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -4,245 +4,81 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'node:path'; import { - Config, - type ConfigParameters, - AuthType, - PREVIEW_GEMINI_MODEL_AUTO, - GeminiEventType, - type ToolCallRequestInfo, - type ServerGeminiStreamEvent, - type GeminiClient, - type Content, - scheduleAgentTools, - getAuthTypeFromEnv, - type ToolRegistry, - loadSkillsFromDir, - ActivateSkillTool, + Storage, + createSessionId, + type ResumedSessionData, + type ConversationRecord, } from '@google/gemini-cli-core'; -import { type Tool, SdkTool } from './tool.js'; -import { SdkAgentFilesystem } from './fs.js'; -import { SdkAgentShell } from './shell.js'; -import type { SessionContext } from './types.js'; -import type { SkillReference } from './skills.js'; - -export type SystemInstructions = - | string - | ((context: SessionContext) => string | Promise); - -export interface GeminiCliAgentOptions { - instructions: SystemInstructions; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tools?: Array>; - skills?: SkillReference[]; - model?: string; - cwd?: string; - debug?: boolean; - recordResponses?: string; - fakeResponses?: string; -} +import { GeminiCliSession } from './session.js'; +import type { GeminiCliAgentOptions } from './types.js'; export class GeminiCliAgent { - private readonly config: Config; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly tools: Array>; - private readonly skillRefs: SkillReference[]; - private readonly instructions: SystemInstructions; - private instructionsLoaded = false; + private options: GeminiCliAgentOptions; constructor(options: GeminiCliAgentOptions) { - this.instructions = options.instructions; - const cwd = options.cwd || process.cwd(); - this.tools = options.tools || []; - this.skillRefs = options.skills || []; - - const initialMemory = - typeof this.instructions === 'string' ? this.instructions : ''; - - const configParams: ConfigParameters = { - sessionId: `sdk-${Date.now()}`, - targetDir: cwd, - cwd, - debugMode: options.debug ?? false, - model: options.model || PREVIEW_GEMINI_MODEL_AUTO, - userMemory: initialMemory, - // Minimal config - enableHooks: false, - mcpEnabled: false, - extensionsEnabled: false, - recordResponses: options.recordResponses, - fakeResponses: options.fakeResponses, - skillsSupport: true, - adminSkillsEnabled: true, - }; - - this.config = new Config(configParams); + this.options = options; } - async *sendStream( - prompt: string, - signal?: AbortSignal, - ): AsyncGenerator { - // Lazy initialization of auth and client - if (!this.config.getContentGenerator()) { - const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC; + session(options?: { sessionId?: string }): GeminiCliSession { + const sessionId = options?.sessionId || createSessionId(); + return new GeminiCliSession(this.options, sessionId, this); + } - await this.config.refreshAuth(authType); - await this.config.initialize(); + async resumeSession(sessionId: string): Promise { + const cwd = this.options.cwd || process.cwd(); + const storage = new Storage(cwd); + await storage.initialize(); - // Load additional skills from options - if (this.skillRefs.length > 0) { - const skillManager = this.config.getSkillManager(); + let conversation: ConversationRecord | undefined; + let filePath: string | undefined; - const loadPromises = this.skillRefs.map(async (ref) => { - try { - if (ref.type === 'dir') { - return await loadSkillsFromDir(ref.path); - } - } catch (e) { - // eslint-disable-next-line no-console - console.error(`Failed to load skills from ${ref.path}:`, e); - } - return []; - }); + const sessions = await storage.listProjectChatFiles(); - const loadedSkills = (await Promise.all(loadPromises)).flat(); - - if (loadedSkills.length > 0) { - skillManager.addSkills(loadedSkills); - } - } - - // Re-register ActivateSkillTool if we have skills (either built-in/workspace or manually loaded) - // This is required because ActivateSkillTool captures the set of available skills at construction time. - const skillManager = this.config.getSkillManager(); - if (skillManager.getSkills().length > 0) { - const registry = this.config.getToolRegistry(); - const toolName = ActivateSkillTool.Name; - // Config.initialize already registers it, but we might have added more skills. - // Re-registering updates the schema with new skills. - if (registry.getTool(toolName)) { - registry.unregisterTool(toolName); - } - registry.registerTool( - new ActivateSkillTool(this.config, this.config.getMessageBus()), - ); - } - - // Register tools now that registry exists - const registry = this.config.getToolRegistry(); - const messageBus = this.config.getMessageBus(); - - for (const toolDef of this.tools) { - const sdkTool = new SdkTool(toolDef, messageBus, this); - registry.registerTool(sdkTool); - } + if (sessions.length === 0) { + throw new Error( + `No sessions found in ${path.join(storage.getProjectTempDir(), 'chats')}`, + ); } - const client = this.config.getGeminiClient(); - const abortSignal = signal ?? new AbortController().signal; - const sessionId = this.config.getSessionId(); + const truncatedId = sessionId.slice(0, 8); + // Optimization: filenames include first 8 chars of sessionId. + // Filter sessions that might match. + const candidates = sessions.filter((s) => s.filePath.includes(truncatedId)); - const fs = new SdkAgentFilesystem(this.config); - const shell = new SdkAgentShell(this.config); + // If optimization fails (e.g. old files), check all? + // Assuming filenames always follow convention if created by this tool. + // But we can fallback to checking all if needed, but let's stick to candidates first. + // If candidates is empty, maybe fallback to all. + const filesToCheck = candidates.length > 0 ? candidates : sessions; - let request: Parameters[0] = [ - { text: prompt }, - ]; - - if (!this.instructionsLoaded && typeof this.instructions === 'function') { - const context: SessionContext = { - sessionId, - transcript: client.getHistory(), - cwd: this.config.getWorkingDir(), - timestamp: new Date().toISOString(), - fs, - shell, - agent: this, - }; - try { - const newInstructions = await this.instructions(context); - this.config.setUserMemory(newInstructions); - client.updateSystemInstruction(); - this.instructionsLoaded = true; - } catch (e) { - const error = - e instanceof Error - ? e - : new Error(`Error resolving dynamic instructions: ${String(e)}`); - throw error; - } - } - - while (true) { - // sendMessageStream returns AsyncGenerator - const stream = client.sendMessageStream(request, abortSignal, sessionId); - - const toolCallsToSchedule: ToolCallRequestInfo[] = []; - - for await (const event of stream) { - yield event; - if (event.type === GeminiEventType.ToolCallRequest) { - const toolCall = event.value; - let args = toolCall.args; - if (typeof args === 'string') { - args = JSON.parse(args); - } - toolCallsToSchedule.push({ - ...toolCall, - args, - isClientInitiated: false, - prompt_id: sessionId, - }); - } - } - - if (toolCallsToSchedule.length === 0) { + for (const sessionFile of filesToCheck) { + const loaded = await storage.loadProjectTempFile( + sessionFile.filePath, + ); + if (loaded && loaded.sessionId === sessionId) { + conversation = loaded; + filePath = path.join(storage.getProjectTempDir(), sessionFile.filePath); break; } - - // Prepare SessionContext - const transcript: Content[] = client.getHistory(); - const context: SessionContext = { - sessionId, - transcript, - cwd: this.config.getWorkingDir(), - timestamp: new Date().toISOString(), - fs, - shell, - agent: this, - }; - - // Create a scoped registry for this turn to bind context safely - const originalRegistry = this.config.getToolRegistry(); - const scopedRegistry: ToolRegistry = Object.create(originalRegistry); - scopedRegistry.getTool = (name: string) => { - const tool = originalRegistry.getTool(name); - if (tool instanceof SdkTool) { - return tool.bindContext(context); - } - return tool; - }; - - const completedCalls = await scheduleAgentTools( - this.config, - toolCallsToSchedule, - { - schedulerId: sessionId, - toolRegistry: scopedRegistry, - signal: abortSignal, - }, - ); - - const functionResponses = completedCalls.flatMap( - (call) => call.response.responseParts, - ); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - request = functionResponses as unknown as Parameters< - GeminiClient['sendMessageStream'] - >[0]; } + + if (!conversation || !filePath) { + throw new Error(`Session with ID ${sessionId} not found`); + } + + const resumedData: ResumedSessionData = { + conversation, + filePath, + }; + + return new GeminiCliSession( + this.options, + conversation.sessionId, + this, + resumedData, + ); } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f1b9e020f5..91e7a080f0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -5,6 +5,7 @@ */ export * from './agent.js'; +export * from './session.js'; export * from './tool.js'; export * from './skills.js'; export * from './types.js'; diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts new file mode 100644 index 0000000000..d5e6b9ee8a --- /dev/null +++ b/packages/sdk/src/session.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Config, + type ConfigParameters, + AuthType, + PREVIEW_GEMINI_MODEL_AUTO, + GeminiEventType, + type ToolCallRequestInfo, + type ServerGeminiStreamEvent, + type GeminiClient, + type Content, + scheduleAgentTools, + getAuthTypeFromEnv, + type ToolRegistry, + loadSkillsFromDir, + ActivateSkillTool, + type ResumedSessionData, + PolicyDecision, +} from '@google/gemini-cli-core'; + +import { type Tool, SdkTool } from './tool.js'; +import { SdkAgentFilesystem } from './fs.js'; +import { SdkAgentShell } from './shell.js'; +import type { + SessionContext, + GeminiCliAgentOptions, + SystemInstructions, +} from './types.js'; +import type { SkillReference } from './skills.js'; +import type { GeminiCliAgent } from './agent.js'; + +export class GeminiCliSession { + private readonly config: Config; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly tools: Array>; + private readonly skillRefs: SkillReference[]; + private readonly instructions: SystemInstructions | undefined; + private client: GeminiClient | undefined; + private initialized = false; + + constructor( + options: GeminiCliAgentOptions, + private readonly sessionId: string, + private readonly agent: GeminiCliAgent, + private readonly resumedData?: ResumedSessionData, + ) { + this.instructions = options.instructions; + const cwd = options.cwd || process.cwd(); + this.tools = options.tools || []; + this.skillRefs = options.skills || []; + + let initialMemory = ''; + if (typeof this.instructions === 'string') { + initialMemory = this.instructions; + } else if (this.instructions && typeof this.instructions !== 'function') { + throw new Error('Instructions must be a string or a function.'); + } + + const configParams: ConfigParameters = { + sessionId: this.sessionId, + targetDir: cwd, + cwd, + debugMode: options.debug ?? false, + model: options.model || PREVIEW_GEMINI_MODEL_AUTO, + userMemory: initialMemory, + // Minimal config + enableHooks: false, + mcpEnabled: false, + extensionsEnabled: false, + recordResponses: options.recordResponses, + fakeResponses: options.fakeResponses, + skillsSupport: true, + adminSkillsEnabled: true, + policyEngineConfig: { + // TODO: Revisit this default when we have a mechanism for wiring up approvals + defaultDecision: PolicyDecision.ALLOW, + }, + }; + + this.config = new Config(configParams); + } + + get id(): string { + return this.sessionId; + } + + async initialize(): Promise { + if (this.initialized) return; + + const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC; + + await this.config.refreshAuth(authType); + await this.config.initialize(); + + // Load additional skills from options + if (this.skillRefs.length > 0) { + const skillManager = this.config.getSkillManager(); + + const loadPromises = this.skillRefs.map(async (ref) => { + try { + if (ref.type === 'dir') { + return await loadSkillsFromDir(ref.path); + } + } catch (e) { + // TODO: refactor this to use a proper logger interface + // eslint-disable-next-line no-console + console.error(`Failed to load skills from ${ref.path}:`, e); + } + return []; + }); + + const loadedSkills = (await Promise.all(loadPromises)).flat(); + + if (loadedSkills.length > 0) { + skillManager.addSkills(loadedSkills); + } + } + + // Re-register ActivateSkillTool if we have skills + const skillManager = this.config.getSkillManager(); + if (skillManager.getSkills().length > 0) { + const registry = this.config.getToolRegistry(); + const toolName = ActivateSkillTool.Name; + if (registry.getTool(toolName)) { + registry.unregisterTool(toolName); + } + registry.registerTool( + new ActivateSkillTool(this.config, this.config.getMessageBus()), + ); + } + + // Register tools + const registry = this.config.getToolRegistry(); + const messageBus = this.config.getMessageBus(); + + for (const toolDef of this.tools) { + const sdkTool = new SdkTool(toolDef, messageBus, this.agent, undefined); + registry.registerTool(sdkTool); + } + + this.client = this.config.getGeminiClient(); + + if (this.resumedData) { + const history: Content[] = this.resumedData.conversation.messages.map( + (m) => { + const role = m.type === 'gemini' ? 'model' : 'user'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let parts: any[] = []; + if (Array.isArray(m.content)) { + parts = m.content; + } else if (m.content) { + parts = [{ text: String(m.content) }]; + } + return { role, parts }; + }, + ); + await this.client.resumeChat(history, this.resumedData); + } + + this.initialized = true; + } + + async *sendStream( + prompt: string, + signal?: AbortSignal, + ): AsyncGenerator { + if (!this.initialized || !this.client) { + await this.initialize(); + } + const client = this.client!; + const abortSignal = signal ?? new AbortController().signal; + const sessionId = this.config.getSessionId(); + + const fs = new SdkAgentFilesystem(this.config); + const shell = new SdkAgentShell(this.config); + + let request: Parameters[0] = [ + { text: prompt }, + ]; + + while (true) { + if (typeof this.instructions === 'function') { + const context: SessionContext = { + sessionId, + transcript: client.getHistory(), + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this.agent, + session: this, + }; + const newInstructions = await this.instructions(context); + this.config.setUserMemory(newInstructions); + client.updateSystemInstruction(); + } + + const stream = client.sendMessageStream(request, abortSignal, sessionId); + + const toolCallsToSchedule: ToolCallRequestInfo[] = []; + + for await (const event of stream) { + yield event; + if (event.type === GeminiEventType.ToolCallRequest) { + const toolCall = event.value; + let args = toolCall.args; + if (typeof args === 'string') { + args = JSON.parse(args); + } + toolCallsToSchedule.push({ + ...toolCall, + args, + isClientInitiated: false, + prompt_id: sessionId, + }); + } + } + + if (toolCallsToSchedule.length === 0) { + break; + } + + const transcript: Content[] = client.getHistory(); + const context: SessionContext = { + sessionId, + transcript, + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this.agent, + session: this, + }; + + const originalRegistry = this.config.getToolRegistry(); + const scopedRegistry: ToolRegistry = Object.create(originalRegistry); + scopedRegistry.getTool = (name: string) => { + const tool = originalRegistry.getTool(name); + if (tool instanceof SdkTool) { + return tool.bindContext(context); + } + return tool; + }; + + const completedCalls = await scheduleAgentTools( + this.config, + toolCallsToSchedule, + { + schedulerId: sessionId, + toolRegistry: scopedRegistry, + signal: abortSignal, + }, + ); + + const functionResponses = completedCalls.flatMap( + (call) => call.response.responseParts, + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + request = functionResponses as unknown as Parameters< + GeminiClient['sendMessageStream'] + >[0]; + } + } +} diff --git a/packages/sdk/src/skills.integration.test.ts b/packages/sdk/src/skills.integration.test.ts index a91480ff30..48f0e0fb06 100644 --- a/packages/sdk/src/skills.integration.test.ts +++ b/packages/sdk/src/skills.integration.test.ts @@ -39,8 +39,9 @@ describe('GeminiCliAgent Skills Integration', () => { // 1. Ask to activate the skill const events = []; + const session = agent.session(); // The prompt explicitly asks to activate the skill by name - const stream = agent.sendStream( + const stream = session.sendStream( 'Activate the pirate-skill and then tell me a joke.', ); @@ -72,7 +73,8 @@ describe('GeminiCliAgent Skills Integration', () => { // 1. Ask to activate the skill const events = []; - const stream = agent.sendStream( + const session = agent.session(); + const stream = session.sendStream( 'Activate the pirate-skill and confirm it is active.', ); diff --git a/packages/sdk/src/tool.integration.test.ts b/packages/sdk/src/tool.integration.test.ts index 1ec9d73abd..cc0a7c2454 100644 --- a/packages/sdk/src/tool.integration.test.ts +++ b/packages/sdk/src/tool.integration.test.ts @@ -45,7 +45,8 @@ describe('GeminiCliAgent Tool Integration', () => { }); const events = []; - const stream = agent.sendStream('What is 5 + 3?'); + const session = agent.session(); + const stream = session.sendStream('What is 5 + 3?'); for await (const event of stream) { events.push(event); @@ -85,9 +86,10 @@ describe('GeminiCliAgent Tool Integration', () => { }); const events = []; + const session = agent.session(); // Force the model to trigger the error first, then hopefully recover or at least acknowledge it. // The prompt is crafted to make the model try 'fail' first. - const stream = agent.sendStream( + const stream = session.sendStream( 'Call the tool with "fail". If it fails, tell me the error message.', ); @@ -128,7 +130,8 @@ describe('GeminiCliAgent Tool Integration', () => { }); const events = []; - const stream = agent.sendStream( + const session = agent.session(); + const stream = session.sendStream( 'Check the system status and report any errors.', ); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index d7e013d66c..9b6bf7093a 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -5,7 +5,26 @@ */ import type { Content } from '@google/gemini-cli-core'; +import type { Tool } from './tool.js'; +import type { SkillReference } from './skills.js'; import type { GeminiCliAgent } from './agent.js'; +import type { GeminiCliSession } from './session.js'; + +export type SystemInstructions = + | string + | ((context: SessionContext) => string | Promise); + +export interface GeminiCliAgentOptions { + instructions: SystemInstructions; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools?: Array>; + skills?: SkillReference[]; + model?: string; + cwd?: string; + debug?: boolean; + recordResponses?: string; + fakeResponses?: string; +} export interface AgentFilesystem { readFile(path: string): Promise; @@ -38,4 +57,5 @@ export interface SessionContext { fs: AgentFilesystem; shell: AgentShell; agent: GeminiCliAgent; + session: GeminiCliSession; } diff --git a/packages/sdk/test-data/agent-dynamic-instructions.json b/packages/sdk/test-data/agent-dynamic-instructions.json index 833467ad84..cbafd27206 100644 --- a/packages/sdk/test-data/agent-dynamic-instructions.json +++ b/packages/sdk/test-data/agent-dynamic-instructions.json @@ -1,4 +1,24 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9831,"totalTokenCount":9831,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9831}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7098,"candidatesTokenCount":8,"totalTokenCount":7106,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7098}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9853,"totalTokenCount":9853,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9853}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7120,"candidatesTokenCount":8,"totalTokenCount":7128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7120}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10028,"totalTokenCount":10028,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10028}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7193,"candidatesTokenCount":7,"totalTokenCount":7200,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7193}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10044,"totalTokenCount":10044,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10044}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7207,"candidatesTokenCount":7,"totalTokenCount":7214,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7207}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":8,"totalTokenCount":7226,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10123,"totalTokenCount":10123,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10123}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7188,"candidatesTokenCount":8,"totalTokenCount":7196,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7188}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10140,"totalTokenCount":10140,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10140}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7203,"candidatesTokenCount":8,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7203}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10123,"totalTokenCount":10123,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10123}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7188,"candidatesTokenCount":8,"totalTokenCount":7196,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7188}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10140,"totalTokenCount":10140,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10140}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7203,"candidatesTokenCount":8,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7203}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10112,"totalTokenCount":10112,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10112}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7177,"candidatesTokenCount":7,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7177}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10128,"totalTokenCount":10128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10128}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7191,"candidatesTokenCount":7,"totalTokenCount":7198,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7191}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} diff --git a/packages/sdk/test-data/agent-resume-session.json b/packages/sdk/test-data/agent-resume-session.json new file mode 100644 index 0000000000..f9d521898e --- /dev/null +++ b/packages/sdk/test-data/agent-resume-session.json @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"BAN"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10103,"totalTokenCount":10103,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10103}]}},{"candidates":[{"content":{"parts":[{"text":"ANA\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7168,"candidatesTokenCount":3,"totalTokenCount":7171,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7168}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"BANANA\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10124,"totalTokenCount":10124,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10124}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":3,"totalTokenCount":7190,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} diff --git a/packages/sdk/test-data/agent-static-instructions.json b/packages/sdk/test-data/agent-static-instructions.json index 733c1915e7..216960da38 100644 --- a/packages/sdk/test-data/agent-static-instructions.json +++ b/packages/sdk/test-data/agent-static-instructions.json @@ -1 +1,4 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9828,"totalTokenCount":9828,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9828}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to chart a course through the code?"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7095,"candidatesTokenCount":15,"totalTokenCount":7110,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7095}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":15}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10109,"totalTokenCount":10109,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10109}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to plunder the codebase, are ye?\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":16,"totalTokenCount":7190,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10109,"totalTokenCount":10109,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10109}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to shiver some timbers and get to work?\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":18,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":18}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10098,"totalTokenCount":10098,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10098}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to chart a course through these here files, are we?\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10098,"totalTokenCount":10098,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10098}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7163,"candidatesTokenCount":20,"totalTokenCount":7183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7163}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":20}]}}]} diff --git a/packages/sdk/test-data/skill-dir-success.json b/packages/sdk/test-data/skill-dir-success.json index f14658426f..63a7267093 100644 --- a/packages/sdk/test-data/skill-dir-success.json +++ b/packages/sdk/test-data/skill-dir-success.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7160,"candidatesTokenCount":8,"totalTokenCount":7168,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7160}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play poker? Because they always have a hidden ace up their sleeves"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":"!\\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7284,"candidatesTokenCount":23,"totalTokenCount":7307,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7284}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7185,"candidatesTokenCount":8,"totalTokenCount":7193,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7185}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":" sorry, I cannot activate the skill at this time because it requires user confirmation."}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":" Would you like me to proceed with telling you a joke without activating the skill?"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":35,"totalTokenCount":7253,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":35}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7185,"candidatesTokenCount":8,"totalTokenCount":7193,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7185}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10275,"totalTokenCount":10275,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10275}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play cards? Because someone's always standin' on the"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10275,"totalTokenCount":10275,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10275}]}},{"candidates":[{"content":{"parts":[{"text":" deck!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7309,"candidatesTokenCount":24,"totalTokenCount":7333,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7309}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":24}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":8,"totalTokenCount":7182,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10264,"totalTokenCount":10264,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10264}]}},{"candidates":[{"content":{"parts":[{"text":"rr, why don't pirates play cards? Because someone always be standin' on the"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10264,"totalTokenCount":10264,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10264}]}},{"candidates":[{"content":{"parts":[{"text":" deck!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7298,"candidatesTokenCount":23,"totalTokenCount":7321,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7298}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]} diff --git a/packages/sdk/test-data/skill-root-success.json b/packages/sdk/test-data/skill-root-success.json index 1048a2c627..4fe1e3fe6e 100644 --- a/packages/sdk/test-data/skill-root-success.json +++ b/packages/sdk/test-data/skill-root-success.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7170,"candidatesTokenCount":8,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7170}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10058,"totalTokenCount":10058,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10058}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate skill be active, matey!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7294,"candidatesTokenCount":12,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7294}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":8,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10178,"totalTokenCount":10178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10178}]}},{"candidates":[{"content":{"parts":[{"text":" am unable to activate the skill without user confirmation. I will await further instructions.\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10178,"totalTokenCount":10178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10178}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7217,"candidatesTokenCount":18,"totalTokenCount":7235,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7217}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":18}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":8,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10274,"totalTokenCount":10274,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10274}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate-skill be active, aye!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7308,"candidatesTokenCount":13,"totalTokenCount":7321,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7308}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":13}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7173,"candidatesTokenCount":8,"totalTokenCount":7181,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7173}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10263,"totalTokenCount":10263,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10263}]}},{"candidates":[{"content":{"parts":[{"text":"rr, pirate skill activated, aye!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7297,"candidatesTokenCount":9,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7297}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":9}]}}]} diff --git a/packages/sdk/test-data/tool-catchall-error.json b/packages/sdk/test-data/tool-catchall-error.json index 43c3b44d8b..8572c8be42 100644 --- a/packages/sdk/test-data/tool-catchall-error.json +++ b/packages/sdk/test-data/tool-catchall-error.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7070,"candidatesTokenCount":3,"totalTokenCount":7073,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7070}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9850,"totalTokenCount":9850,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9850}]}},{"candidates":[{"content":{"parts":[{"text":" returned an error. It says `Error: Standard error caught`."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7082,"candidatesTokenCount":17,"totalTokenCount":7099,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7082}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":17}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am unable to"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10183,"totalTokenCount":10183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10183}]}},{"candidates":[{"content":{"parts":[{"text":" check the system status because it requires user confirmation in interactive mode. Is there anything else I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10183,"totalTokenCount":10183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10183}]}},{"candidates":[{"content":{"parts":[{"text":" can help with?"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7213,"candidatesTokenCount":26,"totalTokenCount":7239,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7213}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":26}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10164,"totalTokenCount":10164,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10164}]}},{"candidates":[{"content":{"parts":[{"text":" reported an error: \"Standard error caught\".\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7194,"candidatesTokenCount":14,"totalTokenCount":7208,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7194}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":14}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"There"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10164,"totalTokenCount":10164,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10164}]}},{"candidates":[{"content":{"parts":[{"text":" is an error reported in the system status.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7194,"candidatesTokenCount":11,"totalTokenCount":7205,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7194}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":11}]}}]} diff --git a/packages/sdk/test-data/tool-error-recovery.json b/packages/sdk/test-data/tool-error-recovery.json index 4e36d24aa7..9b17bf52d3 100644 --- a/packages/sdk/test-data/tool-error-recovery.json +++ b/packages/sdk/test-data/tool-error-recovery.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7073,"candidatesTokenCount":4,"totalTokenCount":7077,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7073}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9867,"totalTokenCount":9867,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9867}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly with the error message: \"Error: Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7085,"candidatesTokenCount":16,"totalTokenCount":7101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7085}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7198,"candidatesTokenCount":4,"totalTokenCount":7202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7198}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10210,"totalTokenCount":10210,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10210}]}},{"candidates":[{"content":{"parts":[{"text":" tool execution for \"failVisible\" requires user confirmation, which is not supported in non-"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10210,"totalTokenCount":10210,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10210}]}},{"candidates":[{"content":{"parts":[{"text":"interactive mode."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7226,"candidatesTokenCount":22,"totalTokenCount":7248,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7226}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":22}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7198,"candidatesTokenCount":4,"totalTokenCount":7202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7198}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10192,"totalTokenCount":10192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10192}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly. The error message is \"Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7208,"candidatesTokenCount":14,"totalTokenCount":7222,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7208}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":14}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":4,"totalTokenCount":7191,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The tool failed with"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10181,"totalTokenCount":10181,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10181}]}},{"candidates":[{"content":{"parts":[{"text":" the error message \"Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7197,"candidatesTokenCount":12,"totalTokenCount":7209,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7197}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]} diff --git a/packages/sdk/test-data/tool-success.json b/packages/sdk/test-data/tool-success.json index 1b17993fe4..d90d09cd0d 100644 --- a/packages/sdk/test-data/tool-success.json +++ b/packages/sdk/test-data/tool-success.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7045,"candidatesTokenCount":5,"totalTokenCount":7050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7045}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9849,"totalTokenCount":9849,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9849}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7053,"candidatesTokenCount":1,"totalTokenCount":7054,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7053}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"b":3,"a":5}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7179,"candidatesTokenCount":5,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7179}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10204,"totalTokenCount":10204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10204}]}},{"candidates":[{"content":{"parts":[{"text":" am unable to execute the add tool in non-interactive mode.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7206,"candidatesTokenCount":15,"totalTokenCount":7221,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7206}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":15}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7179,"candidatesTokenCount":5,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7179}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":1,"totalTokenCount":7188,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7168,"candidatesTokenCount":5,"totalTokenCount":7173,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7168}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10174,"totalTokenCount":10174,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10174}]}},{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7176,"candidatesTokenCount":2,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7176}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":2}]}}]} From 99fa700231b0f003f3019cf9b8a3d2e0c28a8b46 Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Thu, 19 Feb 2026 20:19:01 -0500 Subject: [PATCH 23/26] fix(ci): add fallback JSON extraction to issue triage workflow (#19593) --- .../workflows/gemini-automated-issue-triage.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index 08b97db0a2..64609b5c3b 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -284,8 +284,21 @@ jobs: return; } } else { - core.setFailed(`Output is not valid JSON and does not contain a JSON markdown block.\nRaw output: ${rawOutput}`); - return; + // If no markdown block, try to find a raw JSON object in the output. + // The CLI may include debug/log lines (e.g. telemetry init, YOLO mode) + // before the actual JSON response. + const jsonObjectMatch = rawOutput.match(/(\{[\s\S]*"labels_to_set"[\s\S]*\})/); + if (jsonObjectMatch) { + try { + parsedLabels = JSON.parse(jsonObjectMatch[0]); + } catch (extractError) { + core.setFailed(`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawOutput}`); + return; + } + } else { + core.setFailed(`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawOutput}`); + return; + } } } From fb1b1b451d5165e256976c7fc2074c52681aafc7 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 19 Feb 2026 17:03:10 -0800 Subject: [PATCH 24/26] feat(core): refine Edit and WriteFile tool schemas for Gemini 3 (#19476) --- .../coreToolsModelSnapshots.test.ts.snap | 37 +++---------------- .../definitions/model-family-sets/gemini-3.ts | 37 +++---------------- 2 files changed, 12 insertions(+), 62 deletions(-) diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index 8aa86f60a7..9767829f0e 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -1293,18 +1293,8 @@ Use this tool when the user's query implies needing the content of several files exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: replace 1`] = ` { - "description": "Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the read_file tool to examine the file's current content before attempting a text replacement. - - The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. - - Expectation for required parameters: - 1. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). - 2. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic and that \`old_string\` and \`new_string\` are different. - 3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary. - 4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. - **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. - 5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match. - **Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.", + "description": "Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. +The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.", "name": "replace", "parametersJsonSchema": { "properties": { @@ -1318,29 +1308,15 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > "type": "string", }, "instruction": { - "description": "A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change. - -A good instruction should concisely answer: -1. WHY is the change needed? (e.g., "To fix a bug where users can be null...") -2. WHERE should the change happen? (e.g., "...in the 'renderUserProfile' function...") -3. WHAT is the high-level change? (e.g., "...add a null check for the 'user' object...") -4. WHAT is the desired outcome? (e.g., "...so that it displays a loading spinner instead of crashing.") - -**GOOD Example:** "In the 'calculateTotal' function, correct the sales tax calculation by updating the 'taxRate' constant from 0.05 to 0.075 to reflect the new regional tax laws." - -**BAD Examples:** -- "Change the text." (Too vague) -- "Fix the bug." (Doesn't explain the bug or the fix) -- "Replace the line with this new line." (Brittle, just repeats the other parameters) -", + "description": "A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.", "type": "string", }, "new_string": { - "description": "The exact literal text to replace \`old_string\` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.", + "description": "The exact literal text to replace \`old_string\` with, unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.", "type": "string", }, "old_string": { - "description": "The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.", + "description": "The exact literal text to replace, unescaped. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.", "type": "string", }, }, @@ -1448,8 +1424,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: write_file 1`] = ` { "description": "Writes content to a specified file in the local filesystem. - - The user has the ability to modify \`content\`. If modified, this will be stated in the response.", +The user has the ability to modify \`content\`. If modified, this will be stated in the response.", "name": "write_file", "parametersJsonSchema": { "properties": { diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index a532cac8ba..71e8aaec1c 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -64,8 +64,7 @@ export const GEMINI_3_SET: CoreToolSet = { write_file: { name: WRITE_FILE_TOOL_NAME, description: `Writes content to a specified file in the local filesystem. - - The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, +The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, parametersJsonSchema: { type: 'object', properties: { @@ -291,18 +290,8 @@ export const GEMINI_3_SET: CoreToolSet = { replace: { name: EDIT_TOOL_NAME, - description: `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${READ_FILE_TOOL_NAME} tool to examine the file's current content before attempting a text replacement. - - The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. - - Expectation for required parameters: - 1. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). - 2. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic and that \`old_string\` and \`new_string\` are different. - 3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary. - 4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. - **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. - 5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match. - **Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`, + description: `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. +The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.`, parametersJsonSchema: { type: 'object', properties: { @@ -311,31 +300,17 @@ export const GEMINI_3_SET: CoreToolSet = { type: 'string', }, instruction: { - description: `A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change. - -A good instruction should concisely answer: -1. WHY is the change needed? (e.g., "To fix a bug where users can be null...") -2. WHERE should the change happen? (e.g., "...in the 'renderUserProfile' function...") -3. WHAT is the high-level change? (e.g., "...add a null check for the 'user' object...") -4. WHAT is the desired outcome? (e.g., "...so that it displays a loading spinner instead of crashing.") - -**GOOD Example:** "In the 'calculateTotal' function, correct the sales tax calculation by updating the 'taxRate' constant from 0.05 to 0.075 to reflect the new regional tax laws." - -**BAD Examples:** -- "Change the text." (Too vague) -- "Fix the bug." (Doesn't explain the bug or the fix) -- "Replace the line with this new line." (Brittle, just repeats the other parameters) -`, + description: `A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.`, type: 'string', }, old_string: { description: - 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', + 'The exact literal text to replace, unescaped. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', type: 'string', }, new_string: { description: - 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', + 'The exact literal text to replace `old_string` with, unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', type: 'string', }, expected_replacements: { From cbfb2a4e2646989a5fed30ed3e6c5a7ffba7f0df Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Thu, 19 Feb 2026 20:10:05 -0500 Subject: [PATCH 25/26] Changelog for v0.30.0-preview.3 (#19585) Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com> Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> --- docs/changelogs/preview.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index e8ac4a2dc9..4cb6a3824b 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,4 +1,4 @@ -# Preview release: v0.30.0-preview.1 +# Preview release: v0.30.0-preview.3 Released: February 19, 2026 @@ -311,4 +311,4 @@ npm install -g @google/gemini-cli@preview [#19008](https://github.com/google-gemini/gemini-cli/pull/19008) **Full changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.1 +https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.3 From 5fd557347edf557230baf483a5a8f35e1c3f5e89 Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:53:12 -0500 Subject: [PATCH 26/26] fix(plan): exclude EnterPlanMode tool from YOLO mode (#19570) --- docs/cli/plan-mode.md | 1 + docs/tools/planning.md | 2 ++ packages/core/src/config/config.test.ts | 38 ++++++++++++++++++++++++- packages/core/src/config/config.ts | 15 ++++++++-- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index b59b0c3198..03da2a6ac9 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -69,6 +69,7 @@ You can enter Plan Mode in three ways: 2. **Command:** Type `/plan` in the input box. 3. **Natural Language:** Ask the agent to "start a plan for...". The agent will then call the [`enter_plan_mode`] tool to switch modes. + - **Note:** This tool is not available when the CLI is in YOLO mode. ### The Planning Workflow diff --git a/docs/tools/planning.md b/docs/tools/planning.md index 686b27f058..458b172510 100644 --- a/docs/tools/planning.md +++ b/docs/tools/planning.md @@ -11,6 +11,8 @@ by the agent when you ask it to "start a plan" using natural language. In this mode, the agent is restricted to read-only tools to allow for safe exploration and planning. +> **Note:** This tool is not available when the CLI is in YOLO mode. + - **Tool name:** `enter_plan_mode` - **Display name:** Enter Plan Mode - **File:** `enter-plan-mode.ts` diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1c8820f273..a899ee045f 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1392,7 +1392,22 @@ describe('setApprovalMode with folder trust', () => { expect(updateSpy).toHaveBeenCalled(); }); - it('should not update system instruction when switching between non-Plan modes', () => { + it('should update system instruction when entering YOLO mode', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn(config, 'getToolRegistry').mockReturnValue({ + getTool: vi.fn().mockReturnValue(undefined), + unregisterTool: vi.fn(), + registerTool: vi.fn(), + } as Partial as ToolRegistry); + const updateSpy = vi.spyOn(config, 'updateSystemInstructionIfInitialized'); + + config.setApprovalMode(ApprovalMode.YOLO); + + expect(updateSpy).toHaveBeenCalled(); + }); + + it('should not update system instruction when switching between non-Plan/non-YOLO modes', () => { const config = new Config(baseParams); vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); const updateSpy = vi.spyOn(config, 'updateSystemInstructionIfInitialized'); @@ -2649,6 +2664,27 @@ describe('syncPlanModeTools', () => { expect(registeredTool).toBeUndefined(); }); + it('should NOT register EnterPlanModeTool when in YOLO mode, even if plan is enabled', async () => { + const config = new Config({ + ...baseParams, + approvalMode: ApprovalMode.YOLO, + plan: true, + }); + const registry = new ToolRegistry(config, config.getMessageBus()); + vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); + + const registerSpy = vi.spyOn(registry, 'registerTool'); + vi.spyOn(registry, 'getTool').mockReturnValue(undefined); + + config.syncPlanModeTools(); + + const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js'); + const registeredTool = registerSpy.mock.calls.find( + (call) => call[0] instanceof EnterPlanModeTool, + ); + expect(registeredTool).toBeUndefined(); + }); + it('should call geminiClient.setTools if initialized', async () => { const config = new Config(baseParams); const registry = new ToolRegistry(config, config.getMessageBus()); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fa32fd4d5f..406835310a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1793,7 +1793,11 @@ export class Config { const isPlanModeTransition = currentMode !== mode && (currentMode === ApprovalMode.PLAN || mode === ApprovalMode.PLAN); - if (isPlanModeTransition) { + const isYoloModeTransition = + currentMode !== mode && + (currentMode === ApprovalMode.YOLO || mode === ApprovalMode.YOLO); + + if (isPlanModeTransition || isYoloModeTransition) { this.syncPlanModeTools(); this.updateSystemInstructionIfInitialized(); } @@ -1803,8 +1807,13 @@ export class Config { * Synchronizes enter/exit plan mode tools based on current mode. */ syncPlanModeTools(): void { - const isPlanMode = this.getApprovalMode() === ApprovalMode.PLAN; const registry = this.getToolRegistry(); + if (!registry) { + return; + } + const approvalMode = this.getApprovalMode(); + const isPlanMode = approvalMode === ApprovalMode.PLAN; + const isYoloMode = approvalMode === ApprovalMode.YOLO; if (isPlanMode) { if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) { @@ -1817,7 +1826,7 @@ export class Config { if (registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) { registry.unregisterTool(EXIT_PLAN_MODE_TOOL_NAME); } - if (this.planEnabled) { + if (this.planEnabled && !isYoloMode) { if (!registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) { registry.registerTool(new EnterPlanModeTool(this, this.messageBus)); }