From dcf5afafdab5fc8da71daf8520aa70611102b965 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:29:38 -0400 Subject: [PATCH 01/21] fix(core): resolve subagent chat recording gaps and directory inheritance (#24368) --- .../core/src/agents/local-executor.test.ts | 53 ++++++++++++++---- packages/core/src/agents/local-executor.ts | 55 +++++++++++++++++-- .../src/services/chatRecordingService.test.ts | 36 ++++++++++++ .../core/src/services/chatRecordingService.ts | 18 +++++- 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index bf3bf52d22..84e552e30c 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -18,6 +18,8 @@ const { mockSendMessageStream, mockScheduleAgentTools, mockSetSystemInstruction, + mockRecordCompletedToolCalls, + mockSaveSummary, mockCompress, mockMaybeDiscoverMcpServer, mockStopMcp, @@ -32,6 +34,8 @@ const { }), mockScheduleAgentTools: vi.fn(), mockSetSystemInstruction: vi.fn(), + mockRecordCompletedToolCalls: vi.fn(), + mockSaveSummary: vi.fn(), mockCompress: vi.fn(), mockMaybeDiscoverMcpServer: vi.fn().mockResolvedValue(undefined), mockStopMcp: vi.fn().mockResolvedValue(undefined), @@ -127,18 +131,21 @@ vi.mock('../context/chatCompressionService.js', () => ({ })), })); -vi.mock('../core/geminiChat.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - GeminiChat: vi.fn().mockImplementation(() => ({ - sendMessageStream: mockSendMessageStream, - getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]), - setHistory: mockSetHistory, - setSystemInstruction: mockSetSystemInstruction, - })), - }; -}); +vi.mock('../core/geminiChat.js', () => ({ + StreamEventType: { + CHUNK: 'chunk', + }, + GeminiChat: vi.fn().mockImplementation(() => ({ + sendMessageStream: mockSendMessageStream, + getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]), + setHistory: mockSetHistory, + setSystemInstruction: mockSetSystemInstruction, + recordCompletedToolCalls: mockRecordCompletedToolCalls, + getChatRecordingService: vi.fn().mockReturnValue({ + saveSummary: mockSaveSummary, + }), + })), +})); vi.mock('./agent-scheduler.js', () => ({ scheduleAgentTools: mockScheduleAgentTools, @@ -337,6 +344,10 @@ describe('LocalAgentExecutor', () => { getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]), getLastPromptTokenCount: vi.fn(() => 100), setHistory: mockSetHistory, + recordCompletedToolCalls: mockRecordCompletedToolCalls, + getChatRecordingService: vi.fn().mockReturnValue({ + saveSummary: mockSaveSummary, + }), }) as unknown as GeminiChat, ); @@ -942,6 +953,20 @@ describe('LocalAgentExecutor', () => { // Context checks expect(mockedPromptIdContext.run).toHaveBeenCalledTimes(2); // Two turns + + // Recording checks + expect(mockRecordCompletedToolCalls).toHaveBeenCalledTimes(1); + expect(mockRecordCompletedToolCalls).toHaveBeenCalledWith( + expect.any(String), // model + expect.arrayContaining([ + expect.objectContaining({ + status: 'success', + request: expect.objectContaining({ name: LS_TOOL_NAME }), + }), + ]), + ); + expect(mockSaveSummary).toHaveBeenCalledTimes(1); + expect(mockSaveSummary).toHaveBeenCalledWith('Found file1.txt'); const agentId = executor['agentId']; expect(mockedPromptIdContext.run).toHaveBeenNthCalledWith( 1, @@ -2450,6 +2475,10 @@ describe('LocalAgentExecutor', () => { expect(recoveryEvent).toBeInstanceOf(RecoveryAttemptEvent); expect(recoveryEvent.success).toBe(true); expect(recoveryEvent.reason).toBe(AgentTerminateMode.MAX_TURNS); + + // Verify that the summary is saved upon successful recovery + expect(mockSaveSummary).toHaveBeenCalledTimes(1); + expect(mockSaveSummary).toHaveBeenCalledWith('Recovered!'); }); describe('Model Steering', () => { diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 4ad9d35934..8168c44610 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -317,8 +317,10 @@ export class LocalAgentExecutor { await this.tryCompressChat(chat, promptId, combinedSignal); - const { functionCalls } = await promptIdContext.run(promptId, async () => - this.callModel(chat, currentMessage, combinedSignal, promptId), + const { functionCalls, modelToUse } = await promptIdContext.run( + promptId, + async () => + this.callModel(chat, currentMessage, combinedSignal, promptId), ); if (combinedSignal.aborted) { @@ -348,6 +350,8 @@ export class LocalAgentExecutor { const { nextMessage, submittedOutput, taskCompleted, aborted } = await this.processFunctionCalls( + chat, + modelToUse, functionCalls, combinedSignal, promptId, @@ -722,8 +726,17 @@ export class LocalAgentExecutor { } } - // === FINAL RETURN LOGIC === if (terminateReason === AgentTerminateMode.GOAL) { + // Save the session summary upon completion + if (finalResult && chat) { + try { + const summary = this.getTruncatedSummary(finalResult); + chat.getChatRecordingService()?.saveSummary(summary); + } catch (error) { + debugLogger.warn('Failed to save subagent session summary.', error); + } + } + return { result: finalResult || 'Task completed.', terminate_reason: terminateReason, @@ -759,6 +772,18 @@ export class LocalAgentExecutor { // Recovery Succeeded terminateReason = AgentTerminateMode.GOAL; finalResult = recoveryResult; + + // Save the session summary upon successful recovery + try { + const summary = this.getTruncatedSummary(finalResult); + chat.getChatRecordingService()?.saveSummary(summary); + } catch (summaryError) { + debugLogger.warn( + 'Failed to save subagent session summary during recovery.', + summaryError, + ); + } + return { result: finalResult, terminate_reason: terminateReason, @@ -846,7 +871,11 @@ export class LocalAgentExecutor { message: Content, signal: AbortSignal, promptId: string, - ): Promise<{ functionCalls: FunctionCall[]; textResponse: string }> { + ): Promise<{ + functionCalls: FunctionCall[]; + textResponse: string; + modelToUse: string; + }> { const modelConfigAlias = getModelConfigAlias(this.definition); // Resolve the model config early to get the concrete model string (which may be `auto`). @@ -931,7 +960,7 @@ export class LocalAgentExecutor { } } - return { functionCalls, textResponse }; + return { functionCalls, textResponse, modelToUse }; } /** Initializes a `GeminiChat` instance for the agent run. */ @@ -985,6 +1014,8 @@ export class LocalAgentExecutor { * @returns A new `Content` object for history, any submitted output, and completion status. */ private async processFunctionCalls( + chat: GeminiChat, + model: string, functionCalls: FunctionCall[], signal: AbortSignal, promptId: string, @@ -1226,6 +1257,9 @@ export class LocalAgentExecutor { }, ); + // Record completed tool calls for persistent chat history + chat.recordCompletedToolCalls(model, completedCalls); + for (const call of completedCalls) { const toolName = toolNameMap.get(call.request.callId) || call.request.name; @@ -1475,4 +1509,15 @@ Important Rules: this.onActivity(event); } } + + /** + * Truncates a string to 200 characters in a Unicode-safe way for session summaries. + */ + private getTruncatedSummary(text: string): string { + const chars = Array.from(text); + if (chars.length <= 200) { + return text; + } + return chars.slice(0, 197).join('') + '...'; + } } diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index b84f387e1f..d542b8c7cb 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -14,6 +14,7 @@ import { type ToolCallRecord, type MessageRecord, } from './chatRecordingService.js'; +import type { WorkspaceContext } from '../utils/workspaceContext.js'; import { CoreToolCallStatus } from '../scheduler/types.js'; import type { Content, Part } from '@google/genai'; import type { Config } from '../config/config.js'; @@ -57,6 +58,9 @@ describe('ChatRecordingService', () => { }, getModel: vi.fn().mockReturnValue('gemini-pro'), getDebugMode: vi.fn().mockReturnValue(false), + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: vi.fn().mockReturnValue([]), + }), getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn().mockReturnValue({ displayName: 'Test Tool', @@ -66,6 +70,13 @@ describe('ChatRecordingService', () => { }), } as unknown as Config; + // Ensure mockConfig.config points to itself for AgentLoopContext parity + Object.defineProperty(mockConfig, 'config', { + get() { + return mockConfig; + }, + }); + vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); chatRecordingService = new ChatRecordingService(mockConfig); }); @@ -132,6 +143,31 @@ describe('ChatRecordingService', () => { expect(files[0]).toBe('test-session-id.json'); }); + it('should inherit workspace directories for subagents during initialization', () => { + const mockDirectories = ['/project/dir1', '/project/dir2']; + vi.mocked(mockConfig.getWorkspaceContext).mockReturnValue({ + getDirectories: vi.fn().mockReturnValue(mockDirectories), + } as unknown as WorkspaceContext); + + // Initialize as a subagent + chatRecordingService.initialize(undefined, 'subagent'); + + // Recording a message triggers the disk write (deferred until then) + chatRecordingService.recordMessage({ + type: 'user', + content: 'ping', + model: 'm', + }); + + const sessionFile = chatRecordingService.getConversationFilePath()!; + const conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), + ) as ConversationRecord; + + expect(conversation.kind).toBe('subagent'); + expect(conversation.directories).toEqual(mockDirectories); + }); + it('should resume from an existing session if provided', () => { const chatsDir = path.join(testTempDir, 'chats'); fs.mkdirSync(chatsDir, { recursive: true }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index f4aea75fd0..c71519f858 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -218,12 +218,22 @@ export class ChatRecordingService { } this.conversationFile = path.join(chatsDir, filename); + const directories = + this.kind === 'subagent' + ? [ + ...(this.context.config + .getWorkspaceContext() + ?.getDirectories() ?? []), + ] + : undefined; + this.writeConversation({ sessionId: this.sessionId, projectHash: this.projectHash, startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messages: [], + directories, kind: this.kind, }); } @@ -518,6 +528,13 @@ export class ChatRecordingService { ): void { try { if (!this.conversationFile) return; + + // Cache the conversation state even if we don't write to disk yet. + // This ensures that subsequent reads (e.g. during recordMessage) + // see the initial state (like directories) instead of trying to + // read a non-existent file from disk. + this.cachedConversation = conversation; + // Don't write the file yet until there's at least one message. if (conversation.messages.length === 0 && !allowEmpty) return; @@ -527,7 +544,6 @@ export class ChatRecordingService { // Compare before updating lastUpdated so the timestamp doesn't // cause a false diff. if (this.cachedLastConvData === newContent) return; - this.cachedConversation = conversation; conversation.lastUpdated = new Date().toISOString(); const contentToWrite = JSON.stringify(conversation, null, 2); this.cachedLastConvData = contentToWrite; From ca43f8c2912d126c26e9d3beb1e1d24bc0641039 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Wed, 1 Apr 2026 11:55:47 -0400 Subject: [PATCH 02/21] feat(core): prioritize discussion before formal plan approval (#24423) --- docs/cli/plan-mode.md | 16 ++++---- docs/tools/planning.md | 10 +++-- evals/plan_mode.eval.ts | 38 ++++++++++++++++++- .../core/__snapshots__/prompts.test.ts.snap | 24 +++++++----- packages/core/src/prompts/snippets.ts | 8 ++-- .../coreToolsModelSnapshots.test.ts.snap | 4 +- .../dynamic-declaration-helpers.ts | 2 +- 7 files changed, 74 insertions(+), 28 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index ad87bc591b..56895e42b6 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -56,19 +56,21 @@ Gemini CLI takes action. 1. **Provide a goal:** Start by describing what you want to achieve. Gemini CLI will then enter Plan Mode (if it's not already) to research the task. -2. **Review research and provide input:** As Gemini CLI analyzes your codebase, - it may ask you questions or present different implementation options using - [`ask_user`](../tools/ask-user.md). Provide your preferences to help guide - the design. -3. **Review the plan:** Once Gemini CLI has a proposed strategy, it creates a - detailed implementation plan as a Markdown file in your plans directory. +2. **Discuss and agree on strategy:** As Gemini CLI analyzes your codebase, it + will discuss its findings and proposed strategy with you to ensure + alignment. It may ask you questions or present different implementation + options using [`ask_user`](../tools/ask-user.md). **Gemini CLI will stop and + wait for your confirmation** before drafting the formal plan. You should + reach an informal agreement on the approach before proceeding. +3. **Review the plan:** Once you've agreed on the strategy, Gemini CLI creates + a detailed implementation plan as a Markdown file in your plans directory. - **View:** You can open and read this file to understand the proposed changes. - **Edit:** Press `Ctrl+X` to open the plan directly in your configured external editor. 4. **Approve or iterate:** Gemini CLI will present the finalized plan for your - approval. + formal approval. - **Approve:** If you're satisfied with the plan, approve it to start the implementation immediately: **Yes, automatically accept edits** or **Yes, manually accept edits**. diff --git a/docs/tools/planning.md b/docs/tools/planning.md index e554e47a34..13e9cd4fd8 100644 --- a/docs/tools/planning.md +++ b/docs/tools/planning.md @@ -32,7 +32,9 @@ and planning. ## 2. `exit_plan_mode` (ExitPlanMode) `exit_plan_mode` signals that the planning phase is complete. It presents the -finalized plan to the user and requests approval to start the implementation. +finalized plan to the user and requests formal approval to start the +implementation. The agent MUST reach an informal agreement with the user in the +chat regarding the proposed strategy BEFORE calling this tool. - **Tool name:** `exit_plan_mode` - **Display name:** Exit Plan Mode @@ -44,7 +46,7 @@ finalized plan to the user and requests approval to start the implementation. - **Behavior:** - Validates that the `plan_path` is within the allowed directory and that the file exists and has content. - - Presents the plan to the user for review. + - Presents the plan to the user for formal review. - If the user approves the plan: - Switches the CLI's approval mode to the user's chosen approval mode ( `DEFAULT` or `AUTO_EDIT`). @@ -56,5 +58,5 @@ finalized plan to the user and requests approval to start the implementation. - On approval: A message indicating the plan was approved and the new approval mode. - On rejection: A message containing the user's feedback. -- **Confirmation:** Yes. Shows the finalized plan and asks for user approval to - proceed with implementation. +- **Confirmation:** Yes. Shows the finalized plan and asks for user formal + approval to proceed with implementation. diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts index 05bce0e6a5..6eea0c62ba 100644 --- a/evals/plan_mode.eval.ts +++ b/evals/plan_mode.eval.ts @@ -174,7 +174,8 @@ describe('plan_mode', () => { params: { settings, }, - prompt: 'Create a plan for a new login feature.', + prompt: + 'I agree with the strategy to use a JWT-based login. Create a plan for a new login feature.', assert: async (rig, result) => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); @@ -211,7 +212,7 @@ describe('plan_mode', () => { 'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));', }, prompt: - 'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.', + 'I want to refactor our math utilities. I agree with the strategy to move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts`. Please create a detailed implementation plan first, then execute it.', assert: async (rig, result) => { const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode'); expect( @@ -326,4 +327,37 @@ describe('plan_mode', () => { assertModelHasOutput(result); }, }); + + evalTest('USUALLY_PASSES', { + name: 'should not exit plan mode or draft before informal agreement', + approvalMode: ApprovalMode.PLAN, + params: { + settings, + }, + prompt: 'I need to build a new login feature. Please plan it.', + assert: async (rig, result) => { + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + const exitPlanCall = toolLogs.find( + (log) => log.toolRequest.name === 'exit_plan_mode', + ); + expect( + exitPlanCall, + 'Should NOT call exit_plan_mode before informal agreement', + ).toBeUndefined(); + + const planWrite = toolLogs.find( + (log) => + log.toolRequest.name === 'write_file' && + log.toolRequest.args.includes('/plans/'), + ); + expect( + planWrite, + 'Should NOT draft the plan file before informal agreement', + ).toBeUndefined(); + + assertModelHasOutput(result); + }, + }); }); diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index b4e8dd4e7e..40a3ee6a52 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -126,11 +126,13 @@ Plan Mode uses an adaptive planning workflow where the research depth, plan stru Analyze requirements and use search/read tools to explore the codebase. Systematically map affected modules, trace data flow, and identify dependencies. ### 2. Consult -The depth of your consultation should be proportional to the task's complexity: -- **Simple Tasks:** Skip consultation and proceed directly to drafting. +The depth of your consultation should be proportional to the task's complexity. Before proceeding to Step 3 (Draft), you MUST discuss your findings and proposed strategy with the user to reach an informal agreement. +- **Simple Tasks:** Briefly describe your proposed strategy in the chat to ensure alignment, then **STOP and wait** for the user to confirm agreement before drafting the plan. - **Standard Tasks:** If multiple viable approaches exist, present a concise summary (including pros/cons and your recommendation) via \`ask_user\` and wait for a decision. - **Complex Tasks:** You MUST present at least two viable approaches with detailed trade-offs via \`ask_user\` and obtain approval before drafting the plan. +**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan. + ### 3. Draft Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task: - **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. @@ -138,7 +140,7 @@ Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to - **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. ### 4. Review & Approval -Use the \`exit_plan_mode\` tool to present the plan and formally request approval. +ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval. # Operational Guidelines @@ -301,11 +303,13 @@ Plan Mode uses an adaptive planning workflow where the research depth, plan stru Analyze requirements and use search/read tools to explore the codebase. Systematically map affected modules, trace data flow, and identify dependencies. ### 2. Consult -The depth of your consultation should be proportional to the task's complexity: -- **Simple Tasks:** Skip consultation and proceed directly to drafting. +The depth of your consultation should be proportional to the task's complexity. Before proceeding to Step 3 (Draft), you MUST discuss your findings and proposed strategy with the user to reach an informal agreement. +- **Simple Tasks:** Briefly describe your proposed strategy in the chat to ensure alignment, then **STOP and wait** for the user to confirm agreement before drafting the plan. - **Standard Tasks:** If multiple viable approaches exist, present a concise summary (including pros/cons and your recommendation) via \`ask_user\` and wait for a decision. - **Complex Tasks:** You MUST present at least two viable approaches with detailed trade-offs via \`ask_user\` and obtain approval before drafting the plan. +**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan. + ### 3. Draft Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task: - **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. @@ -313,7 +317,7 @@ Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to - **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. ### 4. Review & Approval -Use the \`exit_plan_mode\` tool to present the plan and formally request approval. +ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval. ## Approved Plan An approved plan is available for this task at \`/tmp/plans/feature-x.md\`. @@ -595,11 +599,13 @@ Plan Mode uses an adaptive planning workflow where the research depth, plan stru Analyze requirements and use search/read tools to explore the codebase. Systematically map affected modules, trace data flow, and identify dependencies. ### 2. Consult -The depth of your consultation should be proportional to the task's complexity: -- **Simple Tasks:** Skip consultation and proceed directly to drafting. +The depth of your consultation should be proportional to the task's complexity. Before proceeding to Step 3 (Draft), you MUST discuss your findings and proposed strategy with the user to reach an informal agreement. +- **Simple Tasks:** Briefly describe your proposed strategy in the chat to ensure alignment, then **STOP and wait** for the user to confirm agreement before drafting the plan. - **Standard Tasks:** If multiple viable approaches exist, present a concise summary (including pros/cons and your recommendation) via \`ask_user\` and wait for a decision. - **Complex Tasks:** You MUST present at least two viable approaches with detailed trade-offs via \`ask_user\` and obtain approval before drafting the plan. +**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan. + ### 3. Draft Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's structure adapts to the task: - **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. @@ -607,7 +613,7 @@ Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's struct - **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. ### 4. Review & Approval -Use the \`exit_plan_mode\` tool to present the plan and formally request approval. +ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval. # Operational Guidelines diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index d7e95a1f4e..b71fca746d 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -587,11 +587,13 @@ Plan Mode uses an adaptive planning workflow where the research depth, plan stru Analyze requirements and use search/read tools to explore the codebase. Systematically map affected modules, trace data flow, and identify dependencies. ### 2. Consult -The depth of your consultation should be proportional to the task's complexity: -- **Simple Tasks:** Skip consultation and proceed directly to drafting. +The depth of your consultation should be proportional to the task's complexity. Before proceeding to Step 3 (Draft), you MUST discuss your findings and proposed strategy with the user to reach an informal agreement. +- **Simple Tasks:** Briefly describe your proposed strategy in the chat to ensure alignment, then **STOP and wait** for the user to confirm agreement before drafting the plan. - **Standard Tasks:** If multiple viable approaches exist, present a concise summary (including pros/cons and your recommendation) via ${formatToolName(ASK_USER_TOOL_NAME)} and wait for a decision. - **Complex Tasks:** You MUST present at least two viable approaches with detailed trade-offs via ${formatToolName(ASK_USER_TOOL_NAME)} and obtain approval before drafting the plan. +**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan. + ### 3. Draft Write the implementation plan to \`${options.plansDir}/\`. The plan's structure adapts to the task: - **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. @@ -599,7 +601,7 @@ Write the implementation plan to \`${options.plansDir}/\`. The plan's structure - **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. ### 4. Review & Approval -Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'} +ONLY use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'} ${renderApprovedPlanSection(options.approvedPlanPath)}`.trim(); } 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 dbaad2d1f8..ba93e42e62 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -165,7 +165,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snapshot for tool: exit_plan_mode 1`] = ` { - "description": "Finalizes the planning phase and transitions to implementation by presenting the plan for user approval. This tool MUST be used to exit Plan Mode before any source code edits can be performed. Call this whenever a plan is ready or the user requests implementation.", + "description": "Finalizes the planning phase and transitions to implementation by presenting the plan for formal user approval. You MUST reach an informal agreement with the user in the chat regarding the proposed strategy BEFORE calling this tool. This tool MUST be used to exit Plan Mode before any source code edits can be performed.", "name": "exit_plan_mode", "parametersJsonSchema": { "properties": { @@ -991,7 +991,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: exit_plan_mode 1`] = ` { - "description": "Finalizes the planning phase and transitions to implementation by presenting the plan for user approval. This tool MUST be used to exit Plan Mode before any source code edits can be performed. Call this whenever a plan is ready or the user requests implementation.", + "description": "Finalizes the planning phase and transitions to implementation by presenting the plan for formal user approval. You MUST reach an informal agreement with the user in the chat regarding the proposed strategy BEFORE calling this tool. This tool MUST be used to exit Plan Mode before any source code edits can be performed.", "name": "exit_plan_mode", "parametersJsonSchema": { "properties": { diff --git a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts index 59b1bf7479..1e7a36e639 100644 --- a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts +++ b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts @@ -161,7 +161,7 @@ export function getExitPlanModeDeclaration(): FunctionDeclaration { return { name: EXIT_PLAN_MODE_TOOL_NAME, description: - 'Finalizes the planning phase and transitions to implementation by presenting the plan for user approval. This tool MUST be used to exit Plan Mode before any source code edits can be performed. Call this whenever a plan is ready or the user requests implementation.', + 'Finalizes the planning phase and transitions to implementation by presenting the plan for formal user approval. You MUST reach an informal agreement with the user in the chat regarding the proposed strategy BEFORE calling this tool. This tool MUST be used to exit Plan Mode before any source code edits can be performed.', parametersJsonSchema: { type: 'object', required: [EXIT_PLAN_PARAM_PLAN_FILENAME], From eb95e99b3dc2d61d97c216e2699450cae11fc636 Mon Sep 17 00:00:00 2001 From: ruomeng Date: Wed, 1 Apr 2026 11:56:10 -0400 Subject: [PATCH 03/21] feat(plan): conditionally add enter/exit plan mode tools based on current mode (#24378) --- .../src/ui/hooks/atCommandProcessor.test.ts | 2 ++ .../hooks/atCommandProcessor_agents.test.ts | 2 ++ packages/core/src/tools/tool-registry.test.ts | 36 +++++++++++++++++++ packages/core/src/tools/tool-registry.ts | 10 ++++++ 4 files changed, 50 insertions(+) diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index b30e9675cd..ca2ecf7bc1 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -26,6 +26,7 @@ import { ToolRegistry, COMMON_IGNORE_PATTERNS, GEMINI_IGNORE_FILE_NAME, + ApprovalMode, // DEFAULT_FILE_EXCLUDES, CoreToolCallStatus, type Config, @@ -146,6 +147,7 @@ describe('handleAtCommand', () => { getClient: () => undefined, }), getMessageBus: () => mockMessageBus, + getApprovalMode: () => ApprovalMode.DEFAULT, } as unknown as Config; const registry = new ToolRegistry(mockConfig, mockMessageBus); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor_agents.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor_agents.test.ts index 90267e64c0..2dc5104a86 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor_agents.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor_agents.test.ts @@ -18,6 +18,7 @@ import { StandardFileSystemService, ToolRegistry, COMMON_IGNORE_PATTERNS, + ApprovalMode, } from '@google/gemini-cli-core'; import * as os from 'node:os'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -136,6 +137,7 @@ describe('handleAtCommand with Agents', () => { getMessageBus: () => mockMessageBus, interactive: true, getAgentRegistry: () => mockAgentRegistry, + getApprovalMode: () => ApprovalMode.DEFAULT, } as unknown as Config; const registry = new ToolRegistry(mockConfig, mockMessageBus); diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 3d27171ad1..006bfcd894 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -22,6 +22,8 @@ import { ToolRegistry, DiscoveredTool } from './tool-registry.js'; import { DISCOVERED_TOOL_PREFIX, UPDATE_TOPIC_TOOL_NAME, + ENTER_PLAN_MODE_TOOL_NAME, + EXIT_PLAN_MODE_TOOL_NAME, } from './tool-names.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; import { @@ -833,6 +835,40 @@ describe('ToolRegistry', () => { expect(toolRegistry.getAllToolNames()).toContain(UPDATE_TOPIC_TOOL_NAME); expect(toolRegistry.getTool(UPDATE_TOPIC_TOOL_NAME)).toBe(topicTool); }); + + it('should show enter_plan_mode only when NOT in plan mode', () => { + const enterTool = new MockTool({ name: ENTER_PLAN_MODE_TOOL_NAME }); + toolRegistry.registerTool(enterTool); + + // Not in plan mode + vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT); + expect(toolRegistry.getAllToolNames()).toContain( + ENTER_PLAN_MODE_TOOL_NAME, + ); + + // In plan mode + vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.PLAN); + expect(toolRegistry.getAllToolNames()).not.toContain( + ENTER_PLAN_MODE_TOOL_NAME, + ); + }); + + it('should show exit_plan_mode only when in plan mode', () => { + const exitTool = new MockTool({ name: EXIT_PLAN_MODE_TOOL_NAME }); + toolRegistry.registerTool(exitTool); + + // Not in plan mode + vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT); + expect(toolRegistry.getAllToolNames()).not.toContain( + EXIT_PLAN_MODE_TOOL_NAME, + ); + + // In plan mode + vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.PLAN); + expect(toolRegistry.getAllToolNames()).toContain( + EXIT_PLAN_MODE_TOOL_NAME, + ); + }); }); describe('DiscoveredToolInvocation', () => { diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index a059c964d0..f9551d75da 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -31,6 +31,8 @@ import { WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, UPDATE_TOPIC_TOOL_NAME, + ENTER_PLAN_MODE_TOOL_NAME, + EXIT_PLAN_MODE_TOOL_NAME, } from './tool-names.js'; type ToolParams = Record; @@ -583,6 +585,14 @@ export class ToolRegistry { } } + const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; + if ( + (tool.name === ENTER_PLAN_MODE_TOOL_NAME && isPlanMode) || + (tool.name === EXIT_PLAN_MODE_TOOL_NAME && !isPlanMode) + ) { + return false; + } + const normalizedClassName = tool.constructor.name.replace(/^_+/, ''); const possibleNames = [tool.name, normalizedClassName]; if (tool instanceof DiscoveredMCPTool) { From 7d1848d578b644c274fcd1f6d03685aafc19e8ed Mon Sep 17 00:00:00 2001 From: PROTHAM <155388736+ProthamD@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:39:30 +0530 Subject: [PATCH 04/21] fix(cli): cap shell output at 10 MB to prevent RangeError crash (#24168) --- packages/cli/src/ui/constants.ts | 13 ++ .../cli/src/ui/hooks/shellReducer.test.ts | 196 +++++++++++++++++- packages/cli/src/ui/hooks/shellReducer.ts | 45 +++- 3 files changed, 248 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 19df95a621..ebed097637 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -65,3 +65,16 @@ export const SKILLS_DOCS_URL = /** Max lines to show for a compact tool subview (e.g. diff) */ export const COMPACT_TOOL_SUBVIEW_MAX_LINES = 15; + +// Maximum number of UTF-16 code units to retain in a background task's output +// buffer. Beyond this, the oldest output is dropped to keep memory bounded. +// 10 MB is large enough for ~200,000 lines of terminal output and stays well +// below the V8 string length limit (~1 GB) even with multiple concurrent tasks. +export const MAX_SHELL_OUTPUT_SIZE = 10_000_000; // 10 MB + +// Truncation is triggered only once the output exceeds +// MAX_SHELL_OUTPUT_SIZE + SHELL_OUTPUT_TRUNCATION_BUFFER, then sliced back to +// MAX_SHELL_OUTPUT_SIZE. This avoids an O(n) string copy on every appended +// chunk, amortizing the cost to once per SHELL_OUTPUT_TRUNCATION_BUFFER bytes +// of new input (i.e. once per ~1 MB on a busy shell). +export const SHELL_OUTPUT_TRUNCATION_BUFFER = 1_000_000; // 1 MB diff --git a/packages/cli/src/ui/hooks/shellReducer.test.ts b/packages/cli/src/ui/hooks/shellReducer.test.ts index a6df9e61e6..7052fb9708 100644 --- a/packages/cli/src/ui/hooks/shellReducer.test.ts +++ b/packages/cli/src/ui/hooks/shellReducer.test.ts @@ -11,6 +11,10 @@ import { type ShellState, type ShellAction, } from './shellReducer.js'; +import { + MAX_SHELL_OUTPUT_SIZE, + SHELL_OUTPUT_TRUNCATION_BUFFER, +} from '../constants.js'; describe('shellReducer', () => { it('should return the initial state', () => { @@ -188,6 +192,196 @@ describe('shellReducer', () => { const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 }; const state = shellReducer(registeredState, action); expect(state.backgroundTasks.has(1001)).toBe(false); - expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last shell + expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last task + }); + + it('should NOT truncate output when below the size threshold', () => { + const existingOutput = 'x'.repeat(MAX_SHELL_OUTPUT_SIZE - 1); + const chunk = 'y'; + // combined length = MAX_SHELL_OUTPUT_SIZE, well below MAX + BUFFER + const taskState: ShellState = { + ...initialState, + backgroundTasks: new Map([ + [ + 1001, + { + pid: 1001, + command: 'tail -f log', + output: existingOutput, + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }, + ], + ]), + }; + + const action: ShellAction = { + type: 'APPEND_TASK_OUTPUT', + pid: 1001, + chunk, + }; + const state = shellReducer(taskState, action); + const output = state.backgroundTasks.get(1001)?.output; + expect(typeof output).toBe('string'); + expect((output as string).length).toBe(MAX_SHELL_OUTPUT_SIZE); + expect((output as string).endsWith(chunk)).toBe(true); + }); + + it('should truncate output to MAX_SHELL_OUTPUT_SIZE when threshold is exceeded', () => { + // existing output is already at MAX_SHELL_OUTPUT_SIZE + SHELL_OUTPUT_TRUNCATION_BUFFER + const existingOutput = 'a'.repeat( + MAX_SHELL_OUTPUT_SIZE + SHELL_OUTPUT_TRUNCATION_BUFFER, + ); + const chunk = 'b'.repeat(100); + // combined length = MAX + BUFFER + 100, which exceeds the threshold + const taskState: ShellState = { + ...initialState, + backgroundTasks: new Map([ + [ + 1001, + { + pid: 1001, + command: 'tail -f log', + output: existingOutput, + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }, + ], + ]), + }; + + const action: ShellAction = { + type: 'APPEND_TASK_OUTPUT', + pid: 1001, + chunk, + }; + const state = shellReducer(taskState, action); + const output = state.backgroundTasks.get(1001)?.output; + expect(typeof output).toBe('string'); + // After truncation the result should be exactly MAX_SHELL_OUTPUT_SIZE chars + expect((output as string).length).toBe(MAX_SHELL_OUTPUT_SIZE); + // The newest chunk should be preserved at the end + expect((output as string).endsWith(chunk)).toBe(true); + }); + + it('should preserve output when appending empty string', () => { + const originalOutput = 'important data' + 'x'.repeat(5000); + const taskState: ShellState = { + ...initialState, + backgroundTasks: new Map([ + [ + 1001, + { + pid: 1001, + command: 'tail -f log', + output: originalOutput, + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }, + ], + ]), + }; + + const action: ShellAction = { + type: 'APPEND_TASK_OUTPUT', + pid: 1001, + chunk: '', // Empty string should not modify output + }; + + const state = shellReducer(taskState, action); + const output = state.backgroundTasks.get(1001)?.output; + + // Empty string should leave output unchanged + expect(output).toBe(originalOutput); + expect(output).not.toBe(''); + }); + + it('should handle chunks larger than MAX_SHELL_OUTPUT_SIZE', () => { + // Setup: existing output that when combined with large chunk exceeds threshold + const existingOutput = 'a'.repeat(1_500_000); // 1.5 MB + const largeChunk = 'b'.repeat(MAX_SHELL_OUTPUT_SIZE + 10_000); // 10.01 MB + // Combined exceeds MAX (10MB) + BUFFER (1MB) = 11MB threshold + const taskState: ShellState = { + ...initialState, + backgroundTasks: new Map([ + [ + 1001, + { + pid: 1001, + command: 'test', + output: existingOutput, + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }, + ], + ]), + }; + + const action: ShellAction = { + type: 'APPEND_TASK_OUTPUT', + pid: 1001, + chunk: largeChunk, + }; + + const state = shellReducer(taskState, action); + const output = state.backgroundTasks.get(1001)?.output as string; + + expect(typeof output).toBe('string'); + // After truncation, output should be exactly MAX_SHELL_OUTPUT_SIZE + expect(output.length).toBe(MAX_SHELL_OUTPUT_SIZE); + // Because largeChunk > MAX_SHELL_OUTPUT_SIZE, we only preserve its tail + expect(output).toBe('b'.repeat(MAX_SHELL_OUTPUT_SIZE)); + }); + + it('should not produce a broken surrogate pair at the truncation boundary', () => { + // '😀' is U+1F600, encoded as the surrogate pair \uD83D\uDE00 (2 code units). + // If a slice cuts between the high and low surrogate, the result starts with + // a stray low surrogate (\uDE00). The reducer must detect and strip it. + const emoji = '\uD83D\uDE00'; // 😀 — 2 UTF-16 code units + // Fill up to just below the trigger threshold with 'a', then append an emoji + // whose low surrogate lands exactly on the boundary so the slice splits it. + const baseLength = + MAX_SHELL_OUTPUT_SIZE + SHELL_OUTPUT_TRUNCATION_BUFFER - 1; + const existingOutput = 'a'.repeat(baseLength); + // chunk = emoji + padding so combinedLength exceeds the threshold + const chunk = emoji + 'z'; + + const taskState: ShellState = { + ...initialState, + backgroundTasks: new Map([ + [ + 1001, + { + pid: 1001, + command: 'test', + output: existingOutput, + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }, + ], + ]), + }; + + const action: ShellAction = { + type: 'APPEND_TASK_OUTPUT', + pid: 1001, + chunk, + }; + + const state = shellReducer(taskState, action); + const output = state.backgroundTasks.get(1001)?.output as string; + + expect(typeof output).toBe('string'); + // The output must not begin with a lone low surrogate (U+DC00–U+DFFF). + const firstCharCode = output.charCodeAt(0); + const isLowSurrogate = firstCharCode >= 0xdc00 && firstCharCode <= 0xdfff; + expect(isLowSurrogate).toBe(false); + // The final 'z' from the chunk must always be preserved. + expect(output.endsWith('z')).toBe(true); }); }); diff --git a/packages/cli/src/ui/hooks/shellReducer.ts b/packages/cli/src/ui/hooks/shellReducer.ts index 43f40d546c..0e9307259d 100644 --- a/packages/cli/src/ui/hooks/shellReducer.ts +++ b/packages/cli/src/ui/hooks/shellReducer.ts @@ -5,6 +5,10 @@ */ import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core'; +import { + MAX_SHELL_OUTPUT_SIZE, + SHELL_OUTPUT_TRUNCATION_BUFFER, +} from '../constants.js'; export interface BackgroundTask { pid: number; @@ -98,13 +102,44 @@ export function shellReducer( // This is an intentional performance optimization for the CLI. let newOutput = task.output; if (typeof action.chunk === 'string') { - newOutput = - typeof task.output === 'string' - ? task.output + action.chunk - : action.chunk; - } else { + // Check combined length BEFORE concatenation — the + operator itself + // can throw if the resulting string would exceed ~1 GB. + const currentOutput = + typeof task.output === 'string' ? task.output : ''; + const combinedLength = currentOutput.length + action.chunk.length; + + if ( + combinedLength > + MAX_SHELL_OUTPUT_SIZE + SHELL_OUTPUT_TRUNCATION_BUFFER + ) { + if (action.chunk.length >= MAX_SHELL_OUTPUT_SIZE) { + // Incoming chunk alone exceeds the cap — keep its tail. + newOutput = action.chunk.slice(-MAX_SHELL_OUTPUT_SIZE); + } else { + // Keep as much of the existing output as possible, then append. + const keepFromCurrent = MAX_SHELL_OUTPUT_SIZE - action.chunk.length; + newOutput = currentOutput.slice(-keepFromCurrent) + action.chunk; + } + + // Native slice operates on UTF-16 code units, so it may split a + // surrogate pair at the truncation boundary. If the first code unit + // of the result is a low surrogate (\uDC00-\uDFFF), trim it off to + // avoid emitting a broken character. + if (newOutput.length > 0) { + const firstCharCode = newOutput.charCodeAt(0); + if (firstCharCode >= 0xdc00 && firstCharCode <= 0xdfff) { + newOutput = newOutput.slice(1); + } + } + } else { + newOutput = currentOutput + action.chunk; + } + } else if (action.chunk) { + // AnsiOutput replaces the whole buffer. newOutput = action.chunk; } + // If action.chunk is falsy (e.g. empty string already handled above via + // typeof gate), newOutput remains unchanged — no data loss. task.output = newOutput; const nextState = { ...state, lastShellOutputTime: Date.now() }; From 066da2a1d16314a08d0ddc1b2821a1da80741533 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Wed, 1 Apr 2026 12:23:40 -0400 Subject: [PATCH 05/21] fix(ui): add accelerated scrolling on alternate buffer mode (#23940) Co-authored-by: jacob314 --- .../src/ui/contexts/ScrollProvider.test.tsx | 161 ++++++++++++++++++ .../cli/src/ui/contexts/ScrollProvider.tsx | 33 +++- .../utils/terminalCapabilityManager.test.ts | 43 +++++ .../src/ui/utils/terminalCapabilityManager.ts | 12 ++ 4 files changed, 246 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx index c06eada4f0..4455b66919 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx @@ -14,6 +14,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useRef, useImperativeHandle, forwardRef, type RefObject } from 'react'; import { Box, type DOMElement } from 'ink'; import type { MouseEvent } from '../hooks/useMouse.js'; +import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; + +vi.mock('../utils/terminalCapabilityManager.js', () => ({ + terminalCapabilityManager: { + isGhosttyTerminal: vi.fn(() => false), + }, +})); // Mock useMouse hook const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>(); @@ -78,6 +85,7 @@ describe('ScrollProvider', () => { }); afterEach(() => { + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -520,4 +528,157 @@ describe('ScrollProvider', () => { expect(scrollBy).toHaveBeenCalled(); }); + + describe('Scroll Acceleration', () => { + it('accelerates scroll for non-Ghostty terminals during rapid scrolling', async () => { + const scrollBy = vi.fn(); + const getScrollState = vi.fn(() => ({ + scrollTop: 50, + scrollHeight: 1000, + innerHeight: 10, + })); + + vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue( + false, + ); + + await render( + + + , + ); + + const mouseEvent: MouseEvent = { + name: 'scroll-down', + col: 5, + row: 5, + shift: false, + ctrl: false, + meta: false, + button: 'none', + }; + + // Perform 60 rapid scrolls (within 50ms of each other) + for (let i = 0; i < 60; i++) { + for (const callback of mockUseMouseCallbacks) { + callback(mouseEvent); + } + // Advance time by 10ms for each scroll + vi.advanceTimersByTime(10); + } + + await vi.runAllTimersAsync(); + + // We sum all calls to scrollBy as they might have been flushed individually due to advanceTimersByTime + const totalDelta = scrollBy.mock.calls.reduce( + (sum, call) => sum + call[0], + 0, + ); + expect(totalDelta).toBeGreaterThan(60); + expect(totalDelta).toBe(150); + }); + + it('does not accelerate for Ghostty terminals even during rapid scrolling', async () => { + const scrollBy = vi.fn(); + const getScrollState = vi.fn(() => ({ + scrollTop: 50, + scrollHeight: 1000, + innerHeight: 10, + })); + + vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue( + true, + ); + + await render( + + + , + ); + + const mouseEvent: MouseEvent = { + name: 'scroll-down', + col: 5, + row: 5, + shift: false, + ctrl: false, + meta: false, + button: 'none', + }; + + for (let i = 0; i < 60; i++) { + for (const callback of mockUseMouseCallbacks) { + callback(mouseEvent); + } + vi.advanceTimersByTime(10); + } + + await vi.runAllTimersAsync(); + + // No acceleration means 60 scrolls = delta 60 + const totalDelta = scrollBy.mock.calls.reduce( + (sum, call) => sum + call[0], + 0, + ); + expect(totalDelta).toBe(60); + }); + + it('resets acceleration count if scrolling is slow', async () => { + const scrollBy = vi.fn(); + const getScrollState = vi.fn(() => ({ + scrollTop: 50, + scrollHeight: 1000, + innerHeight: 10, + })); + + vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue( + false, + ); + + await render( + + + , + ); + + const mouseEvent: MouseEvent = { + name: 'scroll-down', + col: 5, + row: 5, + shift: false, + ctrl: false, + meta: false, + button: 'none', + }; + + // Perform scrolls with 100ms gap (greater than 50ms threshold) + for (let i = 0; i < 60; i++) { + for (const callback of mockUseMouseCallbacks) { + callback(mouseEvent); + } + vi.advanceTimersByTime(100); + } + + await vi.runAllTimersAsync(); + + // No acceleration because gaps were too large + const totalDelta = scrollBy.mock.calls.reduce( + (sum, call) => sum + call[0], + 0, + ); + expect(totalDelta).toBe(60); + }); + }); }); diff --git a/packages/cli/src/ui/contexts/ScrollProvider.tsx b/packages/cli/src/ui/contexts/ScrollProvider.tsx index a76768de21..16b63416b6 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.tsx @@ -16,6 +16,7 @@ import { } from 'react'; import { getBoundingBox, type DOMElement } from 'ink'; import { useMouse, type MouseEvent } from '../hooks/useMouse.js'; +import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; export interface ScrollState { scrollTop: number; @@ -125,8 +126,33 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ } }, []); + const scrollMomentumRef = useRef({ + count: 0, + lastTime: 0, + }); + const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => { - const delta = direction === 'up' ? -1 : 1; + let multiplier = 1; + const now = Date.now(); + + if (!terminalCapabilityManager.isGhosttyTerminal()) { + const timeSinceLastScroll = now - scrollMomentumRef.current.lastTime; + // 50ms threshold to consider scrolls consecutive + if (timeSinceLastScroll < 50) { + scrollMomentumRef.current.count += 1; + // Accelerate up to 3x, starting after 5 consecutive scrolls. + // Each consecutive scroll increases the multiplier by 0.1. + multiplier = Math.min( + 3, + 1 + Math.max(0, scrollMomentumRef.current.count - 5) * 0.1, + ); + } else { + scrollMomentumRef.current.count = 0; + } + } + scrollMomentumRef.current.lastTime = now; + + const delta = (direction === 'up' ? -1 : 1) * multiplier; const candidates = findScrollableCandidates( mouseEvent, scrollablesRef.current, @@ -142,15 +168,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ const canScrollUp = effectiveScrollTop > 0.001; const canScrollDown = effectiveScrollTop < scrollHeight - innerHeight - 0.001; + const totalDelta = Math.round(pendingDelta + delta); if (direction === 'up' && canScrollUp) { - pendingScrollsRef.current.set(candidate.id, pendingDelta + delta); + pendingScrollsRef.current.set(candidate.id, totalDelta); scheduleFlush(); return true; } if (direction === 'down' && canScrollDown) { - pendingScrollsRef.current.set(candidate.id, pendingDelta + delta); + pendingScrollsRef.current.set(candidate.id, totalDelta); scheduleFlush(); return true; } diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts index 732945ffe8..4cc25586b5 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts @@ -322,6 +322,49 @@ describe('TerminalCapabilityManager', () => { }); }); + describe('isGhosttyTerminal', () => { + const manager = TerminalCapabilityManager.getInstance(); + + it.each([ + { + name: 'Ghostty (terminal name)', + terminalName: 'Ghostty', + env: {}, + expected: true, + }, + { + name: 'ghostty (TERM_PROGRAM)', + terminalName: undefined, + env: { TERM_PROGRAM: 'ghostty' }, + expected: true, + }, + { + name: 'xterm-ghostty (TERM)', + terminalName: undefined, + env: { TERM: 'xterm-ghostty' }, + expected: true, + }, + { + name: 'iTerm.app (TERM_PROGRAM)', + terminalName: undefined, + env: { TERM_PROGRAM: 'iTerm.app' }, + expected: false, + }, + { + name: 'undefined env', + terminalName: undefined, + env: {}, + expected: false, + }, + ])( + 'should return $expected for $name', + ({ terminalName, env, expected }) => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue(terminalName); + expect(manager.isGhosttyTerminal(env)).toBe(expected); + }, + ); + }); + describe('supportsOsc9Notifications', () => { const manager = TerminalCapabilityManager.getInstance(); diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts index 6aeda005dc..ddbbad4ce8 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts @@ -272,6 +272,18 @@ export class TerminalCapabilityManager { return this.kittyEnabled; } + isGhosttyTerminal(env: NodeJS.ProcessEnv = process.env): boolean { + const termProgram = env['TERM_PROGRAM']?.toLowerCase(); + const term = env['TERM']?.toLowerCase(); + const name = this.getTerminalName()?.toLowerCase(); + + return !!( + name?.includes('ghostty') || + termProgram?.includes('ghostty') || + term?.includes('ghostty') + ); + } + supportsOsc9Notifications(env: NodeJS.ProcessEnv = process.env): boolean { if (env['WT_SESSION']) { return false; From 6a8a0d4faac7c3f9382d75e655a4c223f0f55954 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Wed, 1 Apr 2026 12:27:55 -0400 Subject: [PATCH 06/21] feat(core): populate sandbox forbidden paths with project ignore file contents (#24038) --- packages/core/src/config/config.test.ts | 29 ++++ packages/core/src/config/config.ts | 16 +++ .../sandbox/linux/LinuxSandboxManager.test.ts | 22 +-- .../src/sandbox/linux/LinuxSandboxManager.ts | 13 +- .../sandbox/macos/MacOsSandboxManager.test.ts | 10 +- .../src/sandbox/macos/MacOsSandboxManager.ts | 8 +- .../sandbox/macos/seatbeltArgsBuilder.test.ts | 16 ++- .../src/sandbox/macos/seatbeltArgsBuilder.ts | 14 +- .../windows/WindowsSandboxManager.test.ts | 19 ++- .../sandbox/windows/WindowsSandboxManager.ts | 13 +- .../sandboxManager.integration.test.ts | 30 ++++- .../core/src/services/sandboxManager.test.ts | 127 +++++++++++++++++- packages/core/src/services/sandboxManager.ts | 71 +++++++--- 13 files changed, 309 insertions(+), 79 deletions(-) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1e5a5d5267..d79f218744 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -253,6 +253,10 @@ vi.mock('../core/tokenLimits.js', () => ({ vi.mock('../code_assist/codeAssist.js'); vi.mock('../code_assist/experiments/experiments.js'); +afterEach(() => { + vi.clearAllMocks(); +}); + describe('Server Config (config.ts)', () => { const MODEL = DEFAULT_GEMINI_MODEL; const SANDBOX: SandboxConfig = createMockSandboxConfig({ @@ -1613,6 +1617,31 @@ describe('Server Config (config.ts)', () => { expect(config.getSandboxAllowedPaths()).toEqual(['/only/this']); expect(config.getSandboxNetworkAccess()).toBe(false); }); + + it('lazily resolves forbidden paths when first accessed', async () => { + const config = new Config({ + ...baseParams, + sandbox: { enabled: true, command: 'docker' }, + }); + + const fileService = config.getFileService(); + vi.spyOn(fileService, 'getIgnoredPaths').mockResolvedValue([ + '/tmp/forbidden', + ]); + + await config.initialize(); + expect(fileService.getIgnoredPaths).not.toHaveBeenCalled(); + + // Access resolved paths via the internal resolver + const resolved = await ( + config as unknown as { + getSandboxForbiddenPaths: () => Promise; + } + ).getSandboxForbiddenPaths(); + + expect(fileService.getIgnoredPaths).toHaveBeenCalled(); + expect(resolved).toEqual(['/tmp/forbidden']); + }); }); it('should have independent TopicState across instances', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b2d6e32d85..5a70e2e7ff 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -758,6 +758,7 @@ export class Config implements McpContext, AgentLoopContext { readonly modelConfigService: ModelConfigService; private readonly embeddingModel: string; private readonly sandbox: SandboxConfig | undefined; + private _sandboxForbiddenPaths: string[] | undefined; private readonly targetDir: string; private workspaceContext: WorkspaceContext; private readonly debugMode: boolean; @@ -997,6 +998,7 @@ export class Config implements McpContext, AgentLoopContext { this.sandbox, { workspace: this.targetDir, + forbiddenPaths: this.getSandboxForbiddenPaths.bind(this), includeDirectories: this.pendingIncludeDirectories, policyManager: this._sandboxPolicyManager, }, @@ -1678,11 +1680,25 @@ export class Config implements McpContext, AgentLoopContext { return this._geminiClient; } + private async getSandboxForbiddenPaths(): Promise { + if (this._sandboxForbiddenPaths) { + return this._sandboxForbiddenPaths; + } + + this._sandboxForbiddenPaths = await this.getFileService().getIgnoredPaths({ + respectGitIgnore: false, + respectGeminiIgnore: true, + }); + + return this._sandboxForbiddenPaths; + } + private refreshSandboxManager(): void { this._sandboxManager = createSandboxManager( this.sandbox, { workspace: this.targetDir, + forbiddenPaths: this.getSandboxForbiddenPaths.bind(this), policyManager: this._sandboxPolicyManager, }, this.getApprovalMode(), diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts index 1a687b9154..55d11e0ce6 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts @@ -420,7 +420,7 @@ describe('LinuxSandboxManager', () => { const customManager = new LinuxSandboxManager({ workspace, - forbiddenPaths: ['/tmp/cache', '/opt/secret.txt'], + forbiddenPaths: async () => ['/tmp/cache', '/opt/secret.txt'], }); const bwrapArgs = await getBwrapArgs( @@ -452,7 +452,7 @@ describe('LinuxSandboxManager', () => { const customManager = new LinuxSandboxManager({ workspace, - forbiddenPaths: ['/tmp/forbidden-symlink'], + forbiddenPaths: async () => ['/tmp/forbidden-symlink'], }); const bwrapArgs = await getBwrapArgs( @@ -480,7 +480,7 @@ describe('LinuxSandboxManager', () => { const customManager = new LinuxSandboxManager({ workspace, - forbiddenPaths: ['/tmp/not-here.txt'], + forbiddenPaths: async () => ['/tmp/not-here.txt'], }); const bwrapArgs = await getBwrapArgs( @@ -509,7 +509,7 @@ describe('LinuxSandboxManager', () => { const customManager = new LinuxSandboxManager({ workspace, - forbiddenPaths: ['/tmp/dir-link'], + forbiddenPaths: async () => ['/tmp/dir-link'], }); const bwrapArgs = await getBwrapArgs( @@ -534,7 +534,7 @@ describe('LinuxSandboxManager', () => { const customManager = new LinuxSandboxManager({ workspace, - forbiddenPaths: ['/tmp/conflict'], + forbiddenPaths: async () => ['/tmp/conflict'], }); const bwrapArgs = await getBwrapArgs( @@ -550,12 +550,14 @@ describe('LinuxSandboxManager', () => { customManager, ); - const bindTryIdx = bwrapArgs.indexOf('--bind-try'); - const tmpfsIdx = bwrapArgs.lastIndexOf('--tmpfs'); + // Conflict should have been filtered out of allow list (--bind-try) + expect(bwrapArgs).not.toContain('--bind-try'); + expect(bwrapArgs).not.toContain('--bind-try-ro'); - expect(bwrapArgs[bindTryIdx + 1]).toBe('/tmp/conflict'); - expect(bwrapArgs[tmpfsIdx + 1]).toBe('/tmp/conflict'); - expect(tmpfsIdx).toBeGreaterThan(bindTryIdx); + // It should only appear as a forbidden path (via --tmpfs) + const conflictIdx = bwrapArgs.indexOf('/tmp/conflict'); + expect(conflictIdx).toBeGreaterThan(0); + expect(bwrapArgs[conflictIdx - 1]).toBe('--tmpfs'); }); }); }); diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index 71d42c90e5..44c3e69647 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -17,6 +17,7 @@ import { getSecretFileFindArgs, sanitizePaths, type ParsedSandboxDenial, + resolveSandboxPaths, } from '../../services/sandboxManager.js'; import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; import { @@ -294,7 +295,7 @@ export class LinuxSandboxManager implements SandboxManager { bwrapArgs.push(bindFlag, mainGitDir, mainGitDir); } - const includeDirs = sanitizePaths(this.options.includeDirectories) || []; + const includeDirs = sanitizePaths(this.options.includeDirectories); for (const includeDir of includeDirs) { try { const resolved = tryRealpath(includeDir); @@ -304,7 +305,8 @@ export class LinuxSandboxManager implements SandboxManager { } } - const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || []; + const { allowed: allowedPaths, forbidden: forbiddenPaths } = + await resolveSandboxPaths(this.options, req); const normalizedWorkspace = normalize(workspacePath).replace(/\/$/, ''); for (const allowedPath of allowedPaths) { @@ -330,8 +332,7 @@ export class LinuxSandboxManager implements SandboxManager { } } - const additionalReads = - sanitizePaths(mergedAdditional.fileSystem?.read) || []; + const additionalReads = sanitizePaths(mergedAdditional.fileSystem?.read); for (const p of additionalReads) { try { const safeResolvedPath = tryRealpath(p); @@ -341,8 +342,7 @@ export class LinuxSandboxManager implements SandboxManager { } } - const additionalWrites = - sanitizePaths(mergedAdditional.fileSystem?.write) || []; + const additionalWrites = sanitizePaths(mergedAdditional.fileSystem?.write); for (const p of additionalWrites) { try { const safeResolvedPath = tryRealpath(p); @@ -362,7 +362,6 @@ export class LinuxSandboxManager implements SandboxManager { } } - const forbiddenPaths = sanitizePaths(this.options.forbiddenPaths) || []; for (const p of forbiddenPaths) { let resolved: string; try { diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts index e49a062d15..c0fdcbab63 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -67,7 +67,7 @@ describe('MacOsSandboxManager', () => { expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith({ workspace: mockWorkspace, allowedPaths: mockAllowedPaths, - forbiddenPaths: undefined, + forbiddenPaths: [], networkAccess: mockNetworkAccess, workspaceWrite: false, additionalPermissions: { @@ -218,7 +218,7 @@ describe('MacOsSandboxManager', () => { it('should parameterize forbidden paths and explicitly deny them', async () => { const customManager = new MacOsSandboxManager({ workspace: mockWorkspace, - forbiddenPaths: ['/tmp/forbidden1'], + forbiddenPaths: async () => ['/tmp/forbidden1'], }); await customManager.prepareCommand({ command: 'echo', @@ -238,7 +238,7 @@ describe('MacOsSandboxManager', () => { it('explicitly denies non-existent forbidden paths to prevent creation', async () => { const customManager = new MacOsSandboxManager({ workspace: mockWorkspace, - forbiddenPaths: ['/tmp/does-not-exist'], + forbiddenPaths: async () => ['/tmp/does-not-exist'], }); await customManager.prepareCommand({ command: 'echo', @@ -258,7 +258,7 @@ describe('MacOsSandboxManager', () => { it('should override allowed paths if a path is also in forbidden paths', async () => { const customManager = new MacOsSandboxManager({ workspace: mockWorkspace, - forbiddenPaths: ['/tmp/conflict'], + forbiddenPaths: async () => ['/tmp/conflict'], }); await customManager.prepareCommand({ command: 'echo', @@ -273,7 +273,7 @@ describe('MacOsSandboxManager', () => { expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith( expect.objectContaining({ - allowedPaths: ['/tmp/conflict'], + allowedPaths: [], forbiddenPaths: ['/tmp/conflict'], }), ); diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index b6332be6d2..51a2651c47 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -14,6 +14,7 @@ import { type SandboxPermissions, type GlobalSandboxOptions, type ParsedSandboxDenial, + resolveSandboxPaths, } from '../../services/sandboxManager.js'; import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; import { @@ -93,6 +94,9 @@ export class MacOsSandboxManager implements SandboxManager { const defaultNetwork = this.options.modeConfig?.network || req.policy?.networkAccess || false; + const { allowed: allowedPaths, forbidden: forbiddenPaths } = + await resolveSandboxPaths(this.options, req); + // Fetch persistent approvals for this command const commandName = await getFullCommandName(currentReq); const persistentPermissions = allowOverrides @@ -128,10 +132,10 @@ export class MacOsSandboxManager implements SandboxManager { const sandboxArgs = buildSeatbeltProfile({ workspace: this.options.workspace, allowedPaths: [ - ...(req.policy?.allowedPaths || []), + ...allowedPaths, ...(this.options.includeDirectories || []), ], - forbiddenPaths: this.options.forbiddenPaths, + forbiddenPaths, networkAccess: mergedAdditional.network, workspaceWrite, additionalPermissions: mergedAdditional, diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts index 647b7cb859..45f1c67728 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts @@ -38,6 +38,8 @@ describe('seatbeltArgsBuilder', () => { const profile = buildSeatbeltProfile({ workspace: '/Users/test/workspace', + allowedPaths: [], + forbiddenPaths: [], }); expect(profile).toContain('(version 1)'); @@ -51,6 +53,8 @@ describe('seatbeltArgsBuilder', () => { vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); const profile = buildSeatbeltProfile({ workspace: '/test', + allowedPaths: [], + forbiddenPaths: [], networkAccess: true, }); expect(profile).toContain('(allow network-outbound)'); @@ -70,6 +74,8 @@ describe('seatbeltArgsBuilder', () => { const profile = buildSeatbeltProfile({ workspace: '/test/workspace', + allowedPaths: [], + forbiddenPaths: [], }); expect(profile).toContain( @@ -96,7 +102,11 @@ describe('seatbeltArgsBuilder', () => { }) as unknown as fs.Stats, ); - const profile = buildSeatbeltProfile({ workspace: '/test/workspace' }); + const profile = buildSeatbeltProfile({ + workspace: '/test/workspace', + allowedPaths: [], + forbiddenPaths: [], + }); expect(profile).toContain( `(deny file-write* (literal "/test/workspace/.gitignore"))`, @@ -117,6 +127,7 @@ describe('seatbeltArgsBuilder', () => { const profile = buildSeatbeltProfile({ workspace: '/test', allowedPaths: ['/custom/path1', '/test/symlink'], + forbiddenPaths: [], }); expect(profile).toContain(`(subpath "/custom/path1")`); @@ -130,6 +141,7 @@ describe('seatbeltArgsBuilder', () => { const profile = buildSeatbeltProfile({ workspace: '/test', + allowedPaths: [], forbiddenPaths: ['/secret/path'], }); @@ -148,6 +160,7 @@ describe('seatbeltArgsBuilder', () => { const profile = buildSeatbeltProfile({ workspace: '/test', + allowedPaths: [], forbiddenPaths: ['/test/symlink'], }); @@ -161,6 +174,7 @@ describe('seatbeltArgsBuilder', () => { const profile = buildSeatbeltProfile({ workspace: '/test', + allowedPaths: [], forbiddenPaths: ['/test/missing-dir/missing-file.txt'], }); diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index 084704b69f..c229632daa 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -13,7 +13,6 @@ import { } from './baseProfile.js'; import { type SandboxPermissions, - sanitizePaths, GOVERNANCE_FILES, SECRET_FILES, } from '../../services/sandboxManager.js'; @@ -26,9 +25,9 @@ export interface SeatbeltArgsOptions { /** The primary workspace path to allow access to. */ workspace: string; /** Additional paths to allow access to. */ - allowedPaths?: string[]; + allowedPaths: string[]; /** Absolute paths to explicitly deny read/write access to (overrides allowlists). */ - forbiddenPaths?: string[]; + forbiddenPaths: string[]; /** Whether to allow network access. */ networkAccess?: boolean; /** Granular additional permissions. */ @@ -92,10 +91,7 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { // Add explicit deny rules for secret files (.env, .env.*) in the workspace and allowed paths. // We use regex rules to avoid expensive file discovery scans. // Anchoring to workspace/allowed paths to avoid over-blocking. - const searchPaths = sanitizePaths([ - options.workspace, - ...(options.allowedPaths || []), - ]) || [options.workspace]; + const searchPaths = [options.workspace, ...options.allowedPaths]; for (const basePath of searchPaths) { const resolvedBase = tryRealpath(basePath); @@ -159,7 +155,7 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { } // Handle allowedPaths - const allowedPaths = sanitizePaths(options.allowedPaths) || []; + const allowedPaths = options.allowedPaths; for (let i = 0; i < allowedPaths.length; i++) { const allowedPath = tryRealpath(allowedPaths[i]); profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(allowedPath)}"))\n`; @@ -203,7 +199,7 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { } // Handle forbiddenPaths - const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || []; + const forbiddenPaths = options.forbiddenPaths; for (let i = 0; i < forbiddenPaths.length; i++) { const forbiddenPath = tryRealpath(forbiddenPaths[i]); profile += `(deny file-read* file-write* (subpath "${escapeSchemeString(forbiddenPath)}"))\n`; diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 1279c1708e..fe1d59550b 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -38,7 +38,7 @@ describe('WindowsSandboxManager', () => { manager = new WindowsSandboxManager({ workspace: testCwd, modeConfig: { readonly: false, allowOverrides: true }, - forbiddenPaths: [], + forbiddenPaths: async () => [], }); }); @@ -107,7 +107,7 @@ describe('WindowsSandboxManager', () => { const planManager = new WindowsSandboxManager({ workspace: testCwd, modeConfig: { readonly: true, allowOverrides: false }, - forbiddenPaths: [], + forbiddenPaths: async () => [], }); const req: SandboxRequest = { command: 'curl', @@ -139,7 +139,7 @@ describe('WindowsSandboxManager', () => { workspace: testCwd, modeConfig: { allowOverrides: true, network: false }, policyManager: mockPolicyManager, - forbiddenPaths: [], + forbiddenPaths: async () => [], }); const req: SandboxRequest = { @@ -369,7 +369,7 @@ describe('WindowsSandboxManager', () => { const managerWithForbidden = new WindowsSandboxManager({ workspace: testCwd, - forbiddenPaths: [missingPath], + forbiddenPaths: async () => [missingPath], }); const req: SandboxRequest = { @@ -397,7 +397,7 @@ describe('WindowsSandboxManager', () => { try { const managerWithForbidden = new WindowsSandboxManager({ workspace: testCwd, - forbiddenPaths: [forbiddenPath], + forbiddenPaths: async () => [forbiddenPath], }); const req: SandboxRequest = { @@ -427,7 +427,7 @@ describe('WindowsSandboxManager', () => { try { const managerWithForbidden = new WindowsSandboxManager({ workspace: testCwd, - forbiddenPaths: [conflictPath], + forbiddenPaths: async () => [conflictPath], }); const req: SandboxRequest = { @@ -458,12 +458,9 @@ describe('WindowsSandboxManager', () => { call[1][0] === path.resolve(conflictPath), ); - // Both should have been called - expect(allowCallIndex).toBeGreaterThan(-1); + // Conflict should have been filtered out of allow calls + expect(allowCallIndex).toBe(-1); expect(denyCallIndex).toBeGreaterThan(-1); - - // Verify order: explicitly denying must happen after the explicit allow - expect(allowCallIndex).toBeLessThan(denyCallIndex); } finally { fs.rmSync(conflictPath, { recursive: true, force: true }); } diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index 1085921959..c828d46fa7 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -19,6 +19,7 @@ import { tryRealpath, type SandboxPermissions, type ParsedSandboxDenial, + resolveSandboxPaths, } from '../../services/sandboxManager.js'; import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; import { @@ -288,14 +289,16 @@ export class WindowsSandboxManager implements SandboxManager { await this.grantLowIntegrityAccess(this.options.workspace); } + const { allowed: allowedPaths, forbidden: forbiddenPaths } = + await resolveSandboxPaths(this.options, req); + // Grant "Low Mandatory Level" access to includeDirectories. - const includeDirs = sanitizePaths(this.options.includeDirectories) || []; + const includeDirs = sanitizePaths(this.options.includeDirectories); for (const includeDir of includeDirs) { await this.grantLowIntegrityAccess(includeDir); } // Grant "Low Mandatory Level" read/write access to allowedPaths. - const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || []; for (const allowedPath of allowedPaths) { const resolved = await tryRealpath(allowedPath); if (!fs.existsSync(resolved)) { @@ -308,8 +311,9 @@ export class WindowsSandboxManager implements SandboxManager { } // Grant "Low Mandatory Level" write access to additional permissions write paths. - const additionalWritePaths = - sanitizePaths(mergedAdditional.fileSystem?.write) || []; + const additionalWritePaths = sanitizePaths( + mergedAdditional.fileSystem?.write, + ); for (const writePath of additionalWritePaths) { const resolved = await tryRealpath(writePath); if (!fs.existsSync(resolved)) { @@ -358,7 +362,6 @@ export class WindowsSandboxManager implements SandboxManager { // is restricted to avoid host corruption. External commands rely on // Low Integrity read/write restrictions, while internal commands // use the manifest for enforcement. - const forbiddenPaths = sanitizePaths(this.options.forbiddenPaths) || []; for (const forbiddenPath of forbiddenPaths) { try { await this.denyLowIntegrityAccess(forbiddenPath); diff --git a/packages/core/src/services/sandboxManager.integration.test.ts b/packages/core/src/services/sandboxManager.integration.test.ts index cf68f255b9..7dab4edd2f 100644 --- a/packages/core/src/services/sandboxManager.integration.test.ts +++ b/packages/core/src/services/sandboxManager.integration.test.ts @@ -274,7 +274,10 @@ describe('SandboxManager Integration', () => { try { const osManager = createSandboxManager( { enabled: true }, - { workspace: tempWorkspace, forbiddenPaths: [forbiddenDir] }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [forbiddenDir], + }, ); const { command, args } = Platform.touch(testFile); @@ -306,7 +309,10 @@ describe('SandboxManager Integration', () => { try { const osManager = createSandboxManager( { enabled: true }, - { workspace: tempWorkspace, forbiddenPaths: [forbiddenDir] }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [forbiddenDir], + }, ); const { command, args } = Platform.cat(nestedFile); @@ -335,7 +341,10 @@ describe('SandboxManager Integration', () => { try { const osManager = createSandboxManager( { enabled: true }, - { workspace: tempWorkspace, forbiddenPaths: [conflictDir] }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [conflictDir], + }, ); const { command, args } = Platform.touch(testFile); @@ -365,7 +374,10 @@ describe('SandboxManager Integration', () => { try { const osManager = createSandboxManager( { enabled: true }, - { workspace: tempWorkspace, forbiddenPaths: [nonExistentPath] }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [nonExistentPath], + }, ); const { command, args } = Platform.echo('survived'); const sandboxed = await osManager.prepareCommand({ @@ -397,7 +409,10 @@ describe('SandboxManager Integration', () => { try { const osManager = createSandboxManager( { enabled: true }, - { workspace: tempWorkspace, forbiddenPaths: [nonExistentFile] }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [nonExistentFile], + }, ); // We use touch to attempt creation of the file @@ -436,7 +451,10 @@ describe('SandboxManager Integration', () => { try { const osManager = createSandboxManager( { enabled: true }, - { workspace: tempWorkspace, forbiddenPaths: [symlinkFile] }, + { + workspace: tempWorkspace, + forbiddenPaths: async () => [symlinkFile], + }, ); // Attempt to read the target file directly diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 0454581aac..8b2da94b63 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -14,6 +14,9 @@ import { findSecretFiles, isSecretFile, tryRealpath, + resolveSandboxPaths, + getPathIdentity, + type SandboxRequest, } from './sandboxManager.js'; import { createSandboxManager } from './sandboxManagerFactory.js'; import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; @@ -121,8 +124,10 @@ describe('SandboxManager', () => { afterEach(() => vi.restoreAllMocks()); describe('sanitizePaths', () => { - it('should return undefined if no paths are provided', () => { - expect(sanitizePaths(undefined)).toBeUndefined(); + it('should return an empty array if no paths are provided', () => { + expect(sanitizePaths(undefined)).toEqual([]); + expect(sanitizePaths(null)).toEqual([]); + expect(sanitizePaths([])).toEqual([]); }); it('should deduplicate paths and return them', () => { @@ -133,6 +138,20 @@ describe('SandboxManager', () => { ]); }); + it('should deduplicate case-insensitively on Windows and macOS', () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + const paths = ['/workspace/foo', '/WORKSPACE/FOO']; + expect(sanitizePaths(paths)).toEqual(['/workspace/foo']); + + vi.spyOn(os, 'platform').mockReturnValue('darwin'); + const macPaths = ['/tmp/foo', '/tmp/FOO']; + expect(sanitizePaths(macPaths)).toEqual(['/tmp/foo']); + + vi.spyOn(os, 'platform').mockReturnValue('linux'); + const linuxPaths = ['/tmp/foo', '/tmp/FOO']; + expect(sanitizePaths(linuxPaths)).toEqual(['/tmp/foo', '/tmp/FOO']); + }); + it('should throw an error if a path is not absolute', () => { const paths = ['/workspace/foo', 'relative/path']; expect(() => sanitizePaths(paths)).toThrow( @@ -141,6 +160,110 @@ describe('SandboxManager', () => { }); }); + describe('getPathIdentity', () => { + it('should normalize slashes and strip trailing slashes', () => { + expect(getPathIdentity('/foo/bar//baz/')).toBe( + path.normalize('/foo/bar/baz'), + ); + }); + + it('should handle case sensitivity correctly per platform', () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + expect(getPathIdentity('/Workspace/Foo')).toBe('/workspace/foo'); + + vi.spyOn(os, 'platform').mockReturnValue('darwin'); + expect(getPathIdentity('/Tmp/Foo')).toBe('/tmp/foo'); + + vi.spyOn(os, 'platform').mockReturnValue('linux'); + expect(getPathIdentity('/Tmp/Foo')).toBe('/Tmp/Foo'); + }); + }); + + describe('resolveSandboxPaths', () => { + it('should resolve allowed and forbidden paths', async () => { + const options = { + workspace: '/workspace', + forbiddenPaths: async () => ['/workspace/forbidden'], + }; + const req = { + command: 'ls', + args: [], + cwd: '/workspace', + env: {}, + policy: { + allowedPaths: ['/workspace/allowed'], + }, + }; + + const result = await resolveSandboxPaths(options, req as SandboxRequest); + + expect(result.allowed).toEqual(['/workspace/allowed']); + expect(result.forbidden).toEqual(['/workspace/forbidden']); + }); + + it('should filter out workspace from allowed paths', async () => { + const options = { + workspace: '/workspace', + }; + const req = { + command: 'ls', + args: [], + cwd: '/workspace', + env: {}, + policy: { + allowedPaths: ['/workspace', '/workspace/', '/other/path'], + }, + }; + + const result = await resolveSandboxPaths(options, req as SandboxRequest); + + expect(result.allowed).toEqual(['/other/path']); + }); + + it('should prioritize forbidden paths over allowed paths', async () => { + const options = { + workspace: '/workspace', + forbiddenPaths: async () => ['/workspace/secret'], + }; + const req = { + command: 'ls', + args: [], + cwd: '/workspace', + env: {}, + policy: { + allowedPaths: ['/workspace/secret', '/workspace/normal'], + }, + }; + + const result = await resolveSandboxPaths(options, req as SandboxRequest); + + expect(result.allowed).toEqual(['/workspace/normal']); + expect(result.forbidden).toEqual(['/workspace/secret']); + }); + + it('should handle case-insensitive conflicts on supported platforms', async () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin'); + const options = { + workspace: '/workspace', + forbiddenPaths: async () => ['/workspace/SECRET'], + }; + const req = { + command: 'ls', + args: [], + cwd: '/workspace', + env: {}, + policy: { + allowedPaths: ['/workspace/secret'], + }, + }; + + const result = await resolveSandboxPaths(options, req as SandboxRequest); + + expect(result.allowed).toEqual([]); + expect(result.forbidden).toEqual(['/workspace/SECRET']); + }); + }); + describe('tryRealpath', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index f359eb5cf1..6313c09eeb 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -63,15 +63,12 @@ export interface SandboxModeConfig { * Global configuration options used to initialize a SandboxManager. */ export interface GlobalSandboxOptions { - /** - * The primary workspace path the sandbox is anchored to. - * This directory is granted full read and write access. - */ + /** The absolute path to the primary workspace directory, granted full read/write access. */ workspace: string; /** Absolute paths to explicitly include in the workspace context. */ includeDirectories?: string[]; - /** Absolute paths to explicitly deny read/write access to (overrides allowlists). */ - forbiddenPaths?: string[]; + /** An optional asynchronous resolver function for paths that should be explicitly denied. */ + forbiddenPaths?: () => Promise; /** The current sandbox mode behavior from config. */ modeConfig?: SandboxModeConfig; /** The policy manager for persistent approvals. */ @@ -298,29 +295,47 @@ export class LocalSandboxManager implements SandboxManager { } /** - * Sanitizes an array of paths by deduplicating them and ensuring they are absolute. + * Resolves sanitized allowed and forbidden paths for a request. + * Filters the workspace from allowed paths and ensures forbidden paths take precedence. */ -export function sanitizePaths(paths?: string[]): string[] | undefined { - if (!paths) return undefined; +export async function resolveSandboxPaths( + options: GlobalSandboxOptions, + req: SandboxRequest, +): Promise<{ + allowed: string[]; + forbidden: string[]; +}> { + const forbidden = sanitizePaths(await options.forbiddenPaths?.()); + const allowed = sanitizePaths(req.policy?.allowedPaths); + + const workspaceIdentity = getPathIdentity(options.workspace); + const forbiddenIdentities = new Set(forbidden.map(getPathIdentity)); + + const filteredAllowed = allowed.filter((p) => { + const identity = getPathIdentity(p); + return identity !== workspaceIdentity && !forbiddenIdentities.has(identity); + }); + + return { + allowed: filteredAllowed, + forbidden, + }; +} + +/** + * Sanitizes an array of paths by deduplicating them and ensuring they are absolute. + * Always returns an array (empty if input is null/undefined). + */ +export function sanitizePaths(paths?: string[] | null): string[] { + if (!paths || paths.length === 0) return []; - // We use a Map to deduplicate paths based on their normalized, - // platform-specific identity e.g. handling case-insensitivity on Windows) - // while preserving the original string casing. const uniquePathsMap = new Map(); for (const p of paths) { if (!path.isAbsolute(p)) { throw new Error(`Sandbox path must be absolute: ${p}`); } - // Normalize the path (resolves slashes and redundant components) - let key = path.normalize(p); - - // Windows file systems are case-insensitive, so we lowercase the key for - // deduplication - if (os.platform() === 'win32') { - key = key.toLowerCase(); - } - + const key = getPathIdentity(p); if (!uniquePathsMap.has(key)) { uniquePathsMap.set(key, p); } @@ -329,6 +344,20 @@ export function sanitizePaths(paths?: string[]): string[] | undefined { return Array.from(uniquePathsMap.values()); } +/** Returns a normalized identity for a path, stripping trailing slashes and handling case sensitivity. */ +export function getPathIdentity(p: string): string { + let norm = path.normalize(p); + + // Strip trailing slashes (except for root paths) + if (norm.length > 1 && (norm.endsWith('/') || norm.endsWith('\\'))) { + norm = norm.slice(0, -1); + } + + const platform = os.platform(); + const isCaseInsensitive = platform === 'win32' || platform === 'darwin'; + return isCaseInsensitive ? norm.toLowerCase() : norm; +} + /** * Resolves symlinks for a given path to prevent sandbox escapes. * If a file does not exist (ENOENT), it recursively resolves the parent directory. From c61506bbc157cfa64810849a091848ee86150476 Mon Sep 17 00:00:00 2001 From: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:35:44 -0700 Subject: [PATCH 07/21] fix(core): ensure blue border overlay and input blocker to act correctly depending on browser agent activities (#24385) --- .../src/agents/browser/automationOverlay.ts | 8 -- .../browser/browserAgentInvocation.test.ts | 82 +++++++++++++++++++ .../agents/browser/browserAgentInvocation.ts | 36 +++++++- .../src/agents/browser/browserManager.test.ts | 22 +++++ .../core/src/agents/browser/browserManager.ts | 47 ++--------- .../core/src/agents/browser/inputBlocker.ts | 2 - 6 files changed, 144 insertions(+), 53 deletions(-) diff --git a/packages/core/src/agents/browser/automationOverlay.ts b/packages/core/src/agents/browser/automationOverlay.ts index e87a70b6da..11d821c95c 100644 --- a/packages/core/src/agents/browser/automationOverlay.ts +++ b/packages/core/src/agents/browser/automationOverlay.ts @@ -88,8 +88,6 @@ export async function injectAutomationOverlay( signal?: AbortSignal, ): Promise { try { - debugLogger.log('Injecting automation overlay...'); - const result = await browserManager.callTool( 'evaluate_script', { function: buildInjectionScript() }, @@ -99,8 +97,6 @@ export async function injectAutomationOverlay( if (result.isError) { debugLogger.warn('Failed to inject automation overlay:', result); - } else { - debugLogger.log('Automation overlay injected successfully'); } } catch (error) { debugLogger.warn('Error injecting automation overlay:', error); @@ -115,8 +111,6 @@ export async function removeAutomationOverlay( signal?: AbortSignal, ): Promise { try { - debugLogger.log('Removing automation overlay...'); - const result = await browserManager.callTool( 'evaluate_script', { function: buildRemovalScript() }, @@ -126,8 +120,6 @@ export async function removeAutomationOverlay( if (result.isError) { debugLogger.warn('Failed to remove automation overlay:', result); - } else { - debugLogger.log('Automation overlay removed successfully'); } } catch (error) { debugLogger.warn('Error removing automation overlay:', error); diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index 200f04e67b..d8dbc69b43 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -32,6 +32,10 @@ vi.mock('./inputBlocker.js', () => ({ removeInputBlocker: vi.fn(), })); +vi.mock('./automationOverlay.js', () => ({ + removeAutomationOverlay: vi.fn(), +})); + vi.mock('../local-executor.js', () => ({ LocalAgentExecutor: { create: vi.fn(), @@ -40,6 +44,7 @@ vi.mock('../local-executor.js', () => ({ import { createBrowserAgentDefinition } from './browserAgentFactory.js'; import { removeInputBlocker } from './inputBlocker.js'; +import { removeAutomationOverlay } from './automationOverlay.js'; import { LocalAgentExecutor } from '../local-executor.js'; import type { ToolLiveOutput } from '../../tools/tools.js'; @@ -677,4 +682,81 @@ describe('BrowserAgentInvocation', () => { expect(toolB?.status).toBe('error'); }); }); + + describe('cleanup', () => { + it('should clean up all pages on finally', async () => { + const mockBrowserManager = { + callTool: vi.fn().mockImplementation(async (toolName: string) => { + if (toolName === 'list_pages') { + return { + content: [{ type: 'text', text: '0: Page 1\n1: Page 2\n' }], + isError: false, + }; + } + return { isError: false }; + }), + }; + + vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ + definition: { + name: 'browser_agent', + description: 'mock definition', + kind: 'local', + inputConfig: {} as never, + outputConfig: {} as never, + processOutput: () => '', + modelConfig: { model: 'test' }, + runConfig: {}, + promptConfig: { query: '', systemPrompt: '' }, + toolConfig: { tools: [] }, + }, + browserManager: mockBrowserManager as never, + }); + + const mockExecutor = { + run: vi.fn().mockResolvedValue({ + result: JSON.stringify({ success: true }), + terminate_reason: 'GOAL', + }), + }; + + vi.mocked(LocalAgentExecutor.create).mockResolvedValue( + mockExecutor as never, + ); + + const invocation = new BrowserAgentInvocation( + mockConfig, + { task: 'test' }, + mockMessageBus, + ); + + await invocation.execute(new AbortController().signal); + + // Verify list_pages was called + expect(mockBrowserManager.callTool).toHaveBeenCalledWith( + 'list_pages', + expect.anything(), + expect.anything(), + true, + ); + + // Verify select_page was called for each page + expect(mockBrowserManager.callTool).toHaveBeenCalledWith( + 'select_page', + { pageId: 0, bringToFront: false }, + expect.anything(), + true, + ); + expect(mockBrowserManager.callTool).toHaveBeenCalledWith( + 'select_page', + { pageId: 1, bringToFront: false }, + expect.anything(), + true, + ); + + // Verify removeInputBlocker and removeAutomationOverlay were called for each page + initial cleanup + expect(removeInputBlocker).toHaveBeenCalledTimes(3); + expect(removeAutomationOverlay).toHaveBeenCalledTimes(3); + }); + }); }); diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 86191c7cc3..17912e5354 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -40,6 +40,7 @@ import { sanitizeToolArgs, sanitizeErrorMessage, } from '../../utils/agent-sanitization-utils.js'; +import { removeAutomationOverlay } from './automationOverlay.js'; const INPUT_PREVIEW_MAX_LENGTH = 50; const DESCRIPTION_MAX_LENGTH = 200; @@ -377,7 +378,40 @@ ${output.result}`; } finally { // Clean up input blocker, but keep browserManager alive for persistent sessions if (browserManager) { - await removeInputBlocker(browserManager); + await removeInputBlocker(browserManager, signal); + await removeAutomationOverlay(browserManager, signal); + + // try cleaning up overlays in previous opened pages if any + try { + const listResult = await browserManager.callTool( + 'list_pages', + {}, + signal, + true, + ); + const pagesText = + listResult.content?.find((c) => c.type === 'text')?.text || ''; + const pageMatches = Array.from(pagesText.matchAll(/^(\d+):/gm)); + const pageIds = pageMatches.map((m) => parseInt(m[1], 10)); + if (pageIds.length > 1) { + for (const pageId of pageIds) { + try { + await browserManager.callTool( + 'select_page', + { pageId, bringToFront: false }, + signal, + true, + ); + await removeInputBlocker(browserManager, signal); + await removeAutomationOverlay(browserManager, signal); + } catch (_err) { + // Ignore errors for individual pages + } + } + } + } catch (_) { + // Ignore errors for removing the overlays. + } } } } diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts index 0be3d6da82..0b3571eea2 100644 --- a/packages/core/src/agents/browser/browserManager.test.ts +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -996,6 +996,28 @@ describe('BrowserManager', () => { const manager = new BrowserManager(mockConfig); await manager.callTool('click', { uid: 'bad' }); }); + + it('should NOT re-inject overlay if select_page is called with bringToFront: false', async () => { + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: false, + disableUserInput: true, + }, + }, + }); + + const manager = new BrowserManager(mockConfig); + await manager.callTool('select_page', { pageId: 1, bringToFront: false }); + + expect(injectAutomationOverlay).not.toHaveBeenCalled(); + expect(injectInputBlocker).not.toHaveBeenCalled(); + }); }); describe('Rate limiting', () => { diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts index d95327a40d..904905cf75 100644 --- a/packages/core/src/agents/browser/browserManager.ts +++ b/packages/core/src/agents/browser/browserManager.ts @@ -302,6 +302,11 @@ export class BrowserManager { POTENTIALLY_NAVIGATING_TOOLS.has(toolName) && !signal?.aborted ) { + // Don't re-inject if explicitly switching to a page in the background + if (toolName === 'select_page' && args['bringToFront'] === false) { + return result; + } + try { if (this.shouldInjectOverlay) { await injectAutomationOverlay(this, signal); @@ -601,7 +606,6 @@ export class BrowserManager { await this.rawMcpClient!.connect(this.mcpTransport!); debugLogger.log('MCP client connected to chrome-devtools-mcp'); await this.discoverTools(); - this.registerInputBlockerHandler(); // clear the action counter for each connection this.actionCounter = 0; })(), @@ -805,45 +809,4 @@ export class BrowserManager { // If none matched, then deny return false; } - - /** - * Registers a fallback notification handler on the MCP client to - * automatically re-inject the input blocker after any server-side - * notification (e.g. page navigation, resource updates). - * - * This covers ALL navigation types (link clicks, form submissions, - * history navigation) — not just explicit navigate_page tool calls. - */ - private registerInputBlockerHandler(): void { - if (!this.rawMcpClient) { - return; - } - - if (!this.config.shouldDisableBrowserUserInput()) { - return; - } - - const existingHandler = this.rawMcpClient.fallbackNotificationHandler; - this.rawMcpClient.fallbackNotificationHandler = async (notification: { - method: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - params?: any; - }) => { - // Chain with any existing handler first. - if (existingHandler) { - await existingHandler(notification); - } - - // Only re-inject on resource update notifications which indicate - // page content has changed (navigation, new page, etc.) - if (notification.method === 'notifications/resources/updated') { - debugLogger.log('Page content changed, re-injecting input blocker...'); - void injectInputBlocker(this); - } - }; - - debugLogger.log( - 'Registered global notification handler for input blocker re-injection', - ); - } } diff --git a/packages/core/src/agents/browser/inputBlocker.ts b/packages/core/src/agents/browser/inputBlocker.ts index d7c6d8ce16..28fc8328f4 100644 --- a/packages/core/src/agents/browser/inputBlocker.ts +++ b/packages/core/src/agents/browser/inputBlocker.ts @@ -207,7 +207,6 @@ export async function injectInputBlocker( signal, true, ); - debugLogger.log('Input blocker injected successfully'); } catch (error) { // Log but don't throw - input blocker is a UX enhancement, not critical functionality debugLogger.warn( @@ -235,7 +234,6 @@ export async function removeInputBlocker( signal, true, ); - debugLogger.log('Input blocker removed successfully'); } catch (error) { // Log but don't throw - removal failure is not critical debugLogger.warn( From f938a3f51dc51854385033a7ff978dc22dffd142 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Wed, 1 Apr 2026 12:39:44 -0400 Subject: [PATCH 08/21] fix(build): upload full bundle directory archive to GitHub releases (#24403) --- .github/actions/publish-release/action.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index 9a31142598..4d33edffee 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -291,8 +291,25 @@ runs: INPUTS_PREVIOUS_TAG: '${{ inputs.previous-tag }}' shell: 'bash' run: | + rm -f gemini-cli-bundle.zip + (cd bundle && chmod +x gemini.js && zip -r ../gemini-cli-bundle.zip .) + + echo "Testing the generated bundle archive..." + rm -rf test-bundle + mkdir -p test-bundle + unzip -q gemini-cli-bundle.zip -d test-bundle + + # Verify it runs and outputs a version + BUNDLE_VERSION=$(node test-bundle/gemini.js --version | xargs) + echo "Bundle version output: ${BUNDLE_VERSION}" + if [[ -z "${BUNDLE_VERSION}" ]]; then + echo "Error: Bundle failed to execute or return version." + exit 1 + fi + rm -rf test-bundle + gh release create "${INPUTS_RELEASE_TAG}" \ - bundle/gemini.js \ + gemini-cli-bundle.zip \ --target "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" \ --title "Release ${INPUTS_RELEASE_TAG}" \ --notes-start-tag "${INPUTS_PREVIOUS_TAG}" \ From 4c4d8bc411280569157d91fad188fb9f2eed3f8f Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Wed, 1 Apr 2026 12:46:38 -0400 Subject: [PATCH 09/21] fix(ui): removed additional vertical padding for tables (#24381) --- packages/cli/src/ui/utils/TableRenderer.tsx | 2 +- .../MarkdownDisplay.test.tsx.snap | 6 +- ...lates-column-widths-based-on-ren-.snap.svg | 50 ++-- ...lates-width-correctly-for-conten-.snap.svg | 50 ++-- ...not-parse-markdown-inside-code-s-.snap.svg | 50 ++-- ...es-nested-markdown-styles-recurs-.snap.svg | 50 ++-- ...dles-non-ASCII-characters-emojis-.snap.svg | 44 +-- ...d-headers-without-showing-markers.snap.svg | 50 ++-- ...rer-renders-a-3x3-table-correctly.snap.svg | 50 ++-- ...h-mixed-content-lengths-correctly.snap.svg | 268 +++++++++--------- ...g-headers-and-4-columns-correctly.snap.svg | 74 ++--- ...ers-a-table-with-mixed-emojis-As-.snap.svg | 44 +-- ...rs-a-table-with-only-Asian-chara-.snap.svg | 44 +-- ...ers-a-table-with-only-emojis-and-.snap.svg | 44 +-- ...ers-complex-markdown-in-rows-and-.snap.svg | 54 ++-- ...rs-correctly-when-headers-are-em-.snap.svg | 26 +- ...rs-correctly-when-there-are-more-.snap.svg | 36 +-- ...eaders-and-renders-them-correctly.snap.svg | 38 +-- ...-wraps-all-long-columns-correctly.snap.svg | 60 ++-- ...olumns-with-punctuation-correctly.snap.svg | 58 ++-- ...wraps-long-cell-content-correctly.snap.svg | 42 +-- ...-long-and-short-columns-correctly.snap.svg | 44 +-- .../__snapshots__/TableRenderer.test.tsx.snap | 120 +++----- 23 files changed, 631 insertions(+), 673 deletions(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 986a3e61e0..fcd760ff16 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -309,7 +309,7 @@ export const TableRenderer: React.FC = ({ }; return ( - + {/* Top border */} {renderBorder('top')} diff --git a/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap index 94685b95fb..5bbf668848 100644 --- a/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap +++ b/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap @@ -88,8 +88,7 @@ exports[` > with 'Unix' line endings > renders ordered lists `; exports[` > with 'Unix' line endings > renders tables correctly 1`] = ` -" -┌──────────┬──────────┐ +"┌──────────┬──────────┐ │ Header 1 │ Header 2 │ ├──────────┼──────────┤ │ Cell 1 │ Cell 2 │ @@ -191,8 +190,7 @@ exports[` > with 'Windows' line endings > renders ordered lis `; exports[` > with 'Windows' line endings > renders tables correctly 1`] = ` -" -┌──────────┬──────────┐ +"┌──────────┬──────────┐ │ Header 1 │ Header 2 │ ├──────────┼──────────┤ │ Cell 1 │ Cell 2 │ diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg index 6a97dd54e7..0b4816c045 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg @@ -1,39 +1,39 @@ - + - + - ┌────────┬────────┬────────┐ - - Col 1 - - Col 2 - - Col 3 - - ├────────┼────────┼────────┤ + ┌────────┬────────┬────────┐ + + Col 1 + + Col 2 + + Col 3 + + ├────────┼────────┼────────┤ + + 123456 + + Normal + + Short + - 123456 + Short - Normal + 123456 - Short + Normal - Short + Normal - 123456 + Short - Normal + 123456 - - Normal - - Short - - 123456 - - └────────┴────────┴────────┘ + └────────┴────────┴────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg index 688b197308..56b8db6511 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg @@ -1,39 +1,39 @@ - + - + - ┌───────────────────────────────────┬───────────────────────────────┬─────────────────────────────────┐ - - Col 1 - - Col 2 - - Col 3 - - ├───────────────────────────────────┼───────────────────────────────┼─────────────────────────────────┤ + ┌───────────────────────────────────┬───────────────────────────────┬─────────────────────────────────┐ + + Col 1 + + Col 2 + + Col 3 + + ├───────────────────────────────────┼───────────────────────────────┼─────────────────────────────────┤ + + Visit Google (https://google.com) + + Plain Text + + More Info + - Visit Google (https://google.com) + Info Here - Plain Text + Visit Bing (https://bing.com) - More Info + Links - Info Here + Check This - Visit Bing (https://bing.com) + Search - Links + Visit Yahoo (https://yahoo.com) - - Check This - - Search - - Visit Yahoo (https://yahoo.com) - - └───────────────────────────────────┴───────────────────────────────┴─────────────────────────────────┘ + └───────────────────────────────────┴───────────────────────────────┴─────────────────────────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg index 6cec80aff1..b90ffb4390 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg @@ -1,39 +1,39 @@ - + - + - ┌─────────────────┬──────────────────────┬──────────────────┐ - - Col 1 - - Col 2 - - Col 3 - - ├─────────────────┼──────────────────────┼──────────────────┤ + ┌─────────────────┬──────────────────────┬──────────────────┐ + + Col 1 + + Col 2 + + Col 3 + + ├─────────────────┼──────────────────────┼──────────────────┤ + + **not bold** + + _not italic_ + + ~~not strike~~ + - **not bold** + [not link](url) - _not italic_ + <u>not underline</u> - ~~not strike~~ + https://not.link - [not link](url) + Normal Text - <u>not underline</u> + More Code: *test* - https://not.link + ***nested*** - - Normal Text - - More Code: *test* - - ***nested*** - - └─────────────────┴──────────────────────┴──────────────────┘ + └─────────────────┴──────────────────────┴──────────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg index 2ac94409d3..76f32914e3 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg @@ -1,39 +1,39 @@ - + - + - ┌─────────────────────────────┬─────────────────────────────┬─────────────────────────────┐ - - Header 1 - - Header 2 - - Header 3 - - ├─────────────────────────────┼─────────────────────────────┼─────────────────────────────┤ + ┌─────────────────────────────┬─────────────────────────────┬─────────────────────────────┐ + + Header 1 + + Header 2 + + Header 3 + + ├─────────────────────────────┼─────────────────────────────┼─────────────────────────────┤ + + Bold with Italic and Strike + + Normal + + Short + - Bold with Italic and Strike + Short - Normal + Bold with Italic and Strike - Short + Normal - Short + Normal - Bold with Italic and Strike + Short - Normal + Bold with Italic and Strike - - Normal - - Short - - Bold with Italic and Strike - - └─────────────────────────────┴─────────────────────────────┴─────────────────────────────┘ + └─────────────────────────────┴─────────────────────────────┴─────────────────────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg index 2526b9c7d3..ac1826110c 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg @@ -1,32 +1,32 @@ - + - + - ┌──────────────┬────────────┬───────────────┐ - - Emoji 😃 - - Asian 汉字 - - Mixed 🚀 Text - - ├──────────────┼────────────┼───────────────┤ + ┌──────────────┬────────────┬───────────────┐ + + Emoji 😃 + + Asian 汉字 + + Mixed 🚀 Text + + ├──────────────┼────────────┼───────────────┤ + + Start 🌟 End + + 你好世界 + + Rocket 🚀 Man + - Start 🌟 End + Thumbs 👍 Up - 你好世界 + こんにちは - Rocket 🚀 Man + Fire 🔥 - - Thumbs 👍 Up - - こんにちは - - Fire 🔥 - - └──────────────┴────────────┴───────────────┘ + └──────────────┴────────────┴───────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg index 3ba3b1176c..ef32c59622 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg @@ -1,47 +1,47 @@ - + - + - ┌─────────────┬───────┬─────────┐ + ┌─────────────┬───────┬─────────┐ + + Very Long + + Short + + Another + - Very Long + Bold Header - Short - Another + Long - Bold Header + That Will - Long + Header - That Will + Wrap - Header - - Wrap - - - - ├─────────────┼───────┼─────────┤ + ├─────────────┼───────┼─────────┤ + + Data 1 + + Data + + Data 3 + - Data 1 - Data + 2 - Data 3 - - - 2 - - - └─────────────┴───────┴─────────┘ + └─────────────┴───────┴─────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg index 59e54e66de..26f82dcd56 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg @@ -1,39 +1,39 @@ - + - + - ┌──────────────┬──────────────┬──────────────┐ - - Header 1 - - Header 2 - - Header 3 - - ├──────────────┼──────────────┼──────────────┤ + ┌──────────────┬──────────────┬──────────────┐ + + Header 1 + + Header 2 + + Header 3 + + ├──────────────┼──────────────┼──────────────┤ + + Row 1, Col 1 + + Row 1, Col 2 + + Row 1, Col 3 + - Row 1, Col 1 + Row 2, Col 1 - Row 1, Col 2 + Row 2, Col 2 - Row 1, Col 3 + Row 2, Col 3 - Row 2, Col 1 + Row 3, Col 1 - Row 2, Col 2 + Row 3, Col 2 - Row 2, Col 3 + Row 3, Col 3 - - Row 3, Col 1 - - Row 3, Col 2 - - Row 3, Col 3 - - └──────────────┴──────────────┴──────────────┘ + └──────────────┴──────────────┴──────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg index 73c93ab257..1e06378fd5 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg @@ -1,61 +1,71 @@ - + - + - ┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐ + ┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐ + + Comprehensive Architectural + + Implementation Details for + + Longitudinal Performance + + Strategic Security Framework + + Key + + Status + + Version + + Owner + - Comprehensive Architectural + Specification for the - Implementation Details for + the High-Throughput - Longitudinal Performance + Analysis Across - Strategic Security Framework + for Mitigating Sophisticated - Key - Status - Version - Owner - Specification for the + Distributed Infrastructure - the High-Throughput + Asynchronous Message - Analysis Across + Multi-Regional Cloud - for Mitigating Sophisticated + Cross-Site Scripting - Distributed Infrastructure + Layer - Asynchronous Message + Processing Pipeline with - Multi-Regional Cloud + Deployment Clusters - Cross-Site Scripting + Vulnerabilities - Layer - Processing Pipeline with + Extended Scalability - Deployment Clusters - Vulnerabilities @@ -63,7 +73,7 @@ - Extended Scalability + Features and Redundancy @@ -73,7 +83,7 @@ - Features and Redundancy + Protocols @@ -81,304 +91,304 @@ - - - Protocols - - - - - - - - ├─────────────────────────────┼──────────────────────────────┼─────────────────────────────┼──────────────────────────────┼─────┼────────┼─────────┼───────┤ + ├─────────────────────────────┼──────────────────────────────┼─────────────────────────────┼──────────────────────────────┼─────┼────────┼─────────┼───────┤ + + The primary architecture + + Each message is processed + + Historical data indicates a + + A multi-layered defense + + INF + + Active + + v2.4 + + J. + - The primary architecture + utilizes a decoupled - Each message is processed + through a series of - Historical data indicates a + significant reduction in - A multi-layered defense + strategy incorporates - INF - Active - v2.4 - J. + Doe - utilizes a decoupled + microservices approach, - through a series of + specialized workers that - significant reduction in + tail latency when utilizing - strategy incorporates + content security policies, - Doe - microservices approach, + leveraging container - specialized workers that + handle data transformation, - tail latency when utilizing + edge computing nodes closer - content security policies, + input sanitization - leveraging container + orchestration for - handle data transformation, + validation, and persistent - edge computing nodes closer + to the geographic location - input sanitization + libraries, and regular - orchestration for + scalability and fault - validation, and persistent + storage using a persistent - to the geographic location + of the end-user base. - libraries, and regular + automated penetration - scalability and fault + tolerance in high-load - storage using a persistent + queue. - of the end-user base. - automated penetration + testing routines. - tolerance in high-load + scenarios. - queue. + Monitoring tools have - testing routines. - scenarios. + The pipeline features - Monitoring tools have + captured a steady increase + Developers are required to + This layer provides the - The pipeline features + built-in retry mechanisms - captured a steady increase + in throughput efficiency - Developers are required to + undergo mandatory security - This layer provides the + fundamental building blocks - built-in retry mechanisms + with exponential backoff to - in throughput efficiency + since the introduction of - undergo mandatory security + training focusing on the - fundamental building blocks + for service discovery, load - with exponential backoff to + ensure message delivery - since the introduction of + the vectorized query engine - training focusing on the + OWASP Top Ten to ensure that - for service discovery, load + balancing, and - ensure message delivery + integrity even during - the vectorized query engine + in the primary data - OWASP Top Ten to ensure that + security is integrated into - balancing, and + inter-service communication - integrity even during + transient network or service - in the primary data + warehouse. - security is integrated into + the initial design phase. - inter-service communication + via highly efficient - transient network or service + failures. - warehouse. - the initial design phase. - via highly efficient + protocol buffers. - failures. + Resource utilization + The implementation of a - protocol buffers. + Horizontal autoscaling is - Resource utilization + metrics demonstrate that - The implementation of a + robust Identity and Access + Advanced telemetry and - Horizontal autoscaling is + triggered automatically - metrics demonstrate that + the transition to - robust Identity and Access + Management system ensures - Advanced telemetry and + logging integrations allow - triggered automatically + based on the depth of the - the transition to + serverless compute for - Management system ensures + that the principle of least - logging integrations allow + for real-time monitoring of - based on the depth of the + processing queue, ensuring - serverless compute for + intermittent tasks has - that the principle of least + privilege is strictly - for real-time monitoring of + system health and rapid - processing queue, ensuring + consistent performance - intermittent tasks has + resulted in a thirty - privilege is strictly + enforced across all - system health and rapid + identification of - consistent performance + during unexpected traffic - resulted in a thirty + percent cost optimization. - enforced across all + environments. - identification of + bottlenecks within the - during unexpected traffic + spikes. - percent cost optimization. - environments. - bottlenecks within the + service mesh. - spikes. @@ -386,16 +396,6 @@ - - service mesh. - - - - - - - - - └─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘ + └─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg index b6a4cbe442..33e14dc880 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg @@ -1,63 +1,63 @@ - + - + - ┌───────────────┬───────────────┬──────────────────┬──────────────────┐ + ┌───────────────┬───────────────┬──────────────────┬──────────────────┐ + + Very Long + + Very Long + + Very Long Column + + Very Long Column + - Very Long + Column Header - Very Long + Column Header - Very Long Column + Header Three - Very Long Column + Header Four - Column Header + One - Column Header + Two - Header Three - Header Four - - One - - Two - - - - ├───────────────┼───────────────┼──────────────────┼──────────────────┤ + ├───────────────┼───────────────┼──────────────────┼──────────────────┤ + + Data 1.1 + + Data 1.2 + + Data 1.3 + + Data 1.4 + - Data 1.1 + Data 2.1 - Data 1.2 + Data 2.2 - Data 1.3 + Data 2.3 - Data 1.4 + Data 2.4 - Data 2.1 + Data 3.1 - Data 2.2 + Data 3.2 - Data 2.3 + Data 3.3 - Data 2.4 + Data 3.4 - - Data 3.1 - - Data 3.2 - - Data 3.3 - - Data 3.4 - - └───────────────┴───────────────┴──────────────────┴──────────────────┘ + └───────────────┴───────────────┴──────────────────┴──────────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg index fbbc9070d3..21bcf698fc 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg @@ -1,32 +1,32 @@ - + - + - ┌───────────────┬───────────────────┬────────────────┐ - - Mixed 😃 中文 - - Complex 🚀 日本語 - - Text 📝 한국어 - - ├───────────────┼───────────────────┼────────────────┤ + ┌───────────────┬───────────────────┬────────────────┐ + + Mixed 😃 中文 + + Complex 🚀 日本語 + + Text 📝 한국어 + + ├───────────────┼───────────────────┼────────────────┤ + + 你好 😃 + + こんにちは 🚀 + + 안녕하세요 📝 + - 你好 😃 + World 🌍 - こんにちは 🚀 + Code 💻 - 안녕하세요 📝 + Pizza 🍕 - - World 🌍 - - Code 💻 - - Pizza 🍕 - - └───────────────┴───────────────────┴────────────────┘ + └───────────────┴───────────────────┴────────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg index 7970df8065..0bea22343f 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg @@ -1,32 +1,32 @@ - + - + - ┌──────────────┬─────────────────┬───────────────┐ - - Chinese 中文 - - Japanese 日本語 - - Korean 한국어 - - ├──────────────┼─────────────────┼───────────────┤ + ┌──────────────┬─────────────────┬───────────────┐ + + Chinese 中文 + + Japanese 日本語 + + Korean 한국어 + + ├──────────────┼─────────────────┼───────────────┤ + + 你好 + + こんにちは + + 안녕하세요 + - 你好 + 世界 - こんにちは + 世界 - 안녕하세요 + 세계 - - 世界 - - 世界 - - 세계 - - └──────────────┴─────────────────┴───────────────┘ + └──────────────┴─────────────────┴───────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg index dc5cc190f7..524fb8db03 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg @@ -1,32 +1,32 @@ - + - + - ┌──────────┬───────────┬──────────┐ - - Happy 😀 - - Rocket 🚀 - - Heart ❤️ - - ├──────────┼───────────┼──────────┤ + ┌──────────┬───────────┬──────────┐ + + Happy 😀 + + Rocket 🚀 + + Heart ❤️ + + ├──────────┼───────────┼──────────┤ + + Smile 😃 + + Fire 🔥 + + Love 💖 + - Smile 😃 + Cool 😎 - Fire 🔥 + Star ⭐ - Love 💖 + Blue 💙 - - Cool 😎 - - Star ⭐ - - Blue 💙 - - └──────────┴───────────┴──────────┘ + └──────────┴───────────┴──────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg index 8b3cc61d04..2499c44621 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg @@ -1,51 +1,51 @@ - + - + - ┌───────────────┬─────────────────────────────┐ - - Feature - - Markdown - - ├───────────────┼─────────────────────────────┤ + ┌───────────────┬─────────────────────────────┐ + + Feature + + Markdown + + ├───────────────┼─────────────────────────────┤ + + Bold + + Bold Text + - Bold + Italic - Bold Text + Italic Text - Italic + Combined - Italic Text + Bold and Italic - Combined + Link - Bold and Italic + Google (https://google.com) - Link + Code - Google (https://google.com) + const x = 1 - Code + Strikethrough - const x = 1 + Strike - Strikethrough + Underline - Strike + Underline - - Underline - - Underline - - └───────────────┴─────────────────────────────┘ + └───────────────┴─────────────────────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg index b2523badcd..a7b94a6077 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg @@ -1,19 +1,19 @@ - + - + - ┌────────┬────────┐ - - - - ├────────┼────────┤ - - Data 1 - - Data 2 - - └────────┴────────┘ + ┌────────┬────────┐ + + + + ├────────┼────────┤ + + Data 1 + + Data 2 + + └────────┴────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg index 8e2f17383b..8d6982e5a6 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg @@ -1,24 +1,24 @@ - + - + - ┌──────────┬──────────┬──────────┐ - - Header 1 - - Header 2 - - Header 3 - - ├──────────┼──────────┼──────────┤ - - Data 1 - - Data 2 - - - └──────────┴──────────┴──────────┘ + ┌──────────┬──────────┬──────────┐ + + Header 1 + + Header 2 + + Header 3 + + ├──────────┼──────────┼──────────┤ + + Data 1 + + Data 2 + + + └──────────┴──────────┴──────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg index af6fd33fc5..0511a8558a 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg @@ -1,25 +1,25 @@ - + - + - ┌─────────────┬───────────────┬──────────────┐ - - Bold Header - - Normal Header - - Another Bold - - ├─────────────┼───────────────┼──────────────┤ - - Data 1 - - Data 2 - - Data 3 - - └─────────────┴───────────────┴──────────────┘ + ┌─────────────┬───────────────┬──────────────┐ + + Bold Header + + Normal Header + + Another Bold + + ├─────────────┼───────────────┼──────────────┤ + + Data 1 + + Data 2 + + Data 3 + + └─────────────┴───────────────┴──────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg index 94f5cff65a..18fa02f781 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg @@ -1,52 +1,52 @@ - + - + - ┌────────────────┬────────────────┬─────────────────┐ - - Col 1 - - Col 2 - - Col 3 - - ├────────────────┼────────────────┼─────────────────┤ + ┌────────────────┬────────────────┬─────────────────┐ + + Col 1 + + Col 2 + + Col 3 + + ├────────────────┼────────────────┼─────────────────┤ + + This is a very + + This is also a + + And this is the + - This is a very + long text that - This is also a + very long text - And this is the + third long text - long text that + needs wrapping - very long text + that needs - third long text + that needs - needs wrapping + in column 1 - that needs + wrapping in - that needs + wrapping in - in column 1 - wrapping in + column 2 - wrapping in + column 3 - - - column 2 - - column 3 - - └────────────────┴────────────────┴─────────────────┘ + └────────────────┴────────────────┴─────────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg index 1b801041d0..0344e555ef 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg @@ -1,51 +1,51 @@ - + - + - ┌───────────────────┬───────────────┬─────────────────┐ - - Punctuation 1 - - Punctuation 2 - - Punctuation 3 - - ├───────────────────┼───────────────┼─────────────────┤ + ┌───────────────────┬───────────────┬─────────────────┐ + + Punctuation 1 + + Punctuation 2 + + Punctuation 3 + + ├───────────────────┼───────────────┼─────────────────┤ + + Start. Stop. + + Semi; colon: + + At@ Hash# + - Start. Stop. + Comma, separated. - Semi; colon: + Pipe| Slash/ - At@ Hash# + Dollar$ - Comma, separated. + Exclamation! - Pipe| Slash/ + Backslash\ - Dollar$ + Percent% Caret^ - Exclamation! + Question? - Backslash\ - Percent% Caret^ + Ampersand& - Question? + hyphen-ated - Ampersand& + Asterisk* - - hyphen-ated - - - Asterisk* - - └───────────────────┴───────────────┴─────────────────┘ + └───────────────────┴───────────────┴─────────────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg index 3e99ee7f52..c64e611a7a 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg @@ -1,35 +1,35 @@ - + - + - ┌───────┬─────────────────────────────┬───────┐ - - Col 1 - - Col 2 - - Col 3 - - ├───────┼─────────────────────────────┼───────┤ + ┌───────┬─────────────────────────────┬───────┐ + + Col 1 + + Col 2 + + Col 3 + + ├───────┼─────────────────────────────┼───────┤ + + Short + + This is a very long cell + + Short + - Short - This is a very long cell + content that should wrap to - Short - content that should wrap to + multiple lines - - - multiple lines - - - └───────┴─────────────────────────────┴───────┘ + └───────┴─────────────────────────────┴───────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg index 0a352660ea..4e0860e323 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg @@ -1,36 +1,36 @@ - + - + - ┌───────┬──────────────────────────┬────────┐ - - Short - - Long - - Medium - - ├───────┼──────────────────────────┼────────┤ + ┌───────┬──────────────────────────┬────────┐ + + Short + + Long + + Medium + + ├───────┼──────────────────────────┼────────┤ + + Tiny + + This is a very long text + + Not so + - Tiny - This is a very long text + that definitely needs to - Not so + long - that definitely needs to + wrap to the next line - long - - - wrap to the next line - - - └───────┴──────────────────────────┴────────┘ + └───────┴──────────────────────────┴────────┘ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/TableRenderer.test.tsx.snap index 9b5c1e875a..b3737888ec 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer.test.tsx.snap +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer.test.tsx.snap @@ -1,100 +1,83 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`TableRenderer > 'calculates column widths based on ren…' 1`] = ` -" -┌────────┬────────┬────────┐ +"┌────────┬────────┬────────┐ │ Col 1 │ Col 2 │ Col 3 │ ├────────┼────────┼────────┤ │ 123456 │ Normal │ Short │ │ Short │ 123456 │ Normal │ │ Normal │ Short │ 123456 │ -└────────┴────────┴────────┘ -" +└────────┴────────┴────────┘" `; exports[`TableRenderer > 'calculates width correctly for conten…' 1`] = ` -" -┌───────────────────────────────────┬───────────────────────────────┬─────────────────────────────────┐ +"┌───────────────────────────────────┬───────────────────────────────┬─────────────────────────────────┐ │ Col 1 │ Col 2 │ Col 3 │ ├───────────────────────────────────┼───────────────────────────────┼─────────────────────────────────┤ │ Visit Google (https://google.com) │ Plain Text │ More Info │ │ Info Here │ Visit Bing (https://bing.com) │ Links │ │ Check This │ Search │ Visit Yahoo (https://yahoo.com) │ -└───────────────────────────────────┴───────────────────────────────┴─────────────────────────────────┘ -" +└───────────────────────────────────┴───────────────────────────────┴─────────────────────────────────┘" `; exports[`TableRenderer > 'does not parse markdown inside code s…' 1`] = ` -" -┌─────────────────┬──────────────────────┬──────────────────┐ +"┌─────────────────┬──────────────────────┬──────────────────┐ │ Col 1 │ Col 2 │ Col 3 │ ├─────────────────┼──────────────────────┼──────────────────┤ │ **not bold** │ _not italic_ │ ~~not strike~~ │ │ [not link](url) │ not underline │ https://not.link │ │ Normal Text │ More Code: *test* │ ***nested*** │ -└─────────────────┴──────────────────────┴──────────────────┘ -" +└─────────────────┴──────────────────────┴──────────────────┘" `; exports[`TableRenderer > 'handles nested markdown styles recurs…' 1`] = ` -" -┌─────────────────────────────┬─────────────────────────────┬─────────────────────────────┐ +"┌─────────────────────────────┬─────────────────────────────┬─────────────────────────────┐ │ Header 1 │ Header 2 │ Header 3 │ ├─────────────────────────────┼─────────────────────────────┼─────────────────────────────┤ │ Bold with Italic and Strike │ Normal │ Short │ │ Short │ Bold with Italic and Strike │ Normal │ │ Normal │ Short │ Bold with Italic and Strike │ -└─────────────────────────────┴─────────────────────────────┴─────────────────────────────┘ -" +└─────────────────────────────┴─────────────────────────────┴─────────────────────────────┘" `; exports[`TableRenderer > 'handles non-ASCII characters (emojis …' 1`] = ` -" -┌──────────────┬────────────┬───────────────┐ +"┌──────────────┬────────────┬───────────────┐ │ Emoji 😃 │ Asian 汉字 │ Mixed 🚀 Text │ ├──────────────┼────────────┼───────────────┤ │ Start 🌟 End │ 你好世界 │ Rocket 🚀 Man │ │ Thumbs 👍 Up │ こんにちは │ Fire 🔥 │ -└──────────────┴────────────┴───────────────┘ -" +└──────────────┴────────────┴───────────────┘" `; exports[`TableRenderer > 'renders a table with mixed emojis, As…' 1`] = ` -" -┌───────────────┬───────────────────┬────────────────┐ +"┌───────────────┬───────────────────┬────────────────┐ │ Mixed 😃 中文 │ Complex 🚀 日本語 │ Text 📝 한국어 │ ├───────────────┼───────────────────┼────────────────┤ │ 你好 😃 │ こんにちは 🚀 │ 안녕하세요 📝 │ │ World 🌍 │ Code 💻 │ Pizza 🍕 │ -└───────────────┴───────────────────┴────────────────┘ -" +└───────────────┴───────────────────┴────────────────┘" `; exports[`TableRenderer > 'renders a table with only Asian chara…' 1`] = ` -" -┌──────────────┬─────────────────┬───────────────┐ +"┌──────────────┬─────────────────┬───────────────┐ │ Chinese 中文 │ Japanese 日本語 │ Korean 한국어 │ ├──────────────┼─────────────────┼───────────────┤ │ 你好 │ こんにちは │ 안녕하세요 │ │ 世界 │ 世界 │ 세계 │ -└──────────────┴─────────────────┴───────────────┘ -" +└──────────────┴─────────────────┴───────────────┘" `; exports[`TableRenderer > 'renders a table with only emojis and …' 1`] = ` -" -┌──────────┬───────────┬──────────┐ +"┌──────────┬───────────┬──────────┐ │ Happy 😀 │ Rocket 🚀 │ Heart ❤️ │ ├──────────┼───────────┼──────────┤ │ Smile 😃 │ Fire 🔥 │ Love 💖 │ │ Cool 😎 │ Star ⭐ │ Blue 💙 │ -└──────────┴───────────┴──────────┘ -" +└──────────┴───────────┴──────────┘" `; exports[`TableRenderer > 'renders complex markdown in rows and …' 1`] = ` -" -┌───────────────┬─────────────────────────────┐ +"┌───────────────┬─────────────────────────────┐ │ Feature │ Markdown │ ├───────────────┼─────────────────────────────┤ │ Bold │ Bold Text │ @@ -104,33 +87,27 @@ exports[`TableRenderer > 'renders complex markdown in rows and …' 1`] = ` │ Code │ const x = 1 │ │ Strikethrough │ Strike │ │ Underline │ Underline │ -└───────────────┴─────────────────────────────┘ -" +└───────────────┴─────────────────────────────┘" `; exports[`TableRenderer > 'renders correctly when headers are em…' 1`] = ` -" -┌────────┬────────┐ +"┌────────┬────────┐ │ │ │ ├────────┼────────┤ │ Data 1 │ Data 2 │ -└────────┴────────┘ -" +└────────┴────────┘" `; exports[`TableRenderer > 'renders correctly when there are more…' 1`] = ` -" -┌──────────┬──────────┬──────────┐ +"┌──────────┬──────────┬──────────┐ │ Header 1 │ Header 2 │ Header 3 │ ├──────────┼──────────┼──────────┤ │ Data 1 │ Data 2 │ │ -└──────────┴──────────┴──────────┘ -" +└──────────┴──────────┴──────────┘" `; exports[`TableRenderer > handles wrapped bold headers without showing markers 1`] = ` -" -┌─────────────┬───────┬─────────┐ +"┌─────────────┬───────┬─────────┐ │ Very Long │ Short │ Another │ │ Bold Header │ │ Long │ │ That Will │ │ Header │ @@ -138,25 +115,21 @@ exports[`TableRenderer > handles wrapped bold headers without showing markers 1` ├─────────────┼───────┼─────────┤ │ Data 1 │ Data │ Data 3 │ │ │ 2 │ │ -└─────────────┴───────┴─────────┘ -" +└─────────────┴───────┴─────────┘" `; exports[`TableRenderer > renders a 3x3 table correctly 1`] = ` -" -┌──────────────┬──────────────┬──────────────┐ +"┌──────────────┬──────────────┬──────────────┐ │ Header 1 │ Header 2 │ Header 3 │ ├──────────────┼──────────────┼──────────────┤ │ Row 1, Col 1 │ Row 1, Col 2 │ Row 1, Col 3 │ │ Row 2, Col 1 │ Row 2, Col 2 │ Row 2, Col 3 │ │ Row 3, Col 1 │ Row 3, Col 2 │ Row 3, Col 3 │ -└──────────────┴──────────────┴──────────────┘ -" +└──────────────┴──────────────┴──────────────┘" `; exports[`TableRenderer > renders a complex table with mixed content lengths correctly 1`] = ` -" -┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐ +"┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐ │ Comprehensive Architectural │ Implementation Details for │ Longitudinal Performance │ Strategic Security Framework │ Key │ Status │ Version │ Owner │ │ Specification for the │ the High-Throughput │ Analysis Across │ for Mitigating Sophisticated │ │ │ │ │ │ Distributed Infrastructure │ Asynchronous Message │ Multi-Regional Cloud │ Cross-Site Scripting │ │ │ │ │ @@ -189,13 +162,11 @@ exports[`TableRenderer > renders a complex table with mixed content lengths corr │ identification of │ during unexpected traffic │ percent cost optimization. │ environments. │ │ │ │ │ │ bottlenecks within the │ spikes. │ │ │ │ │ │ │ │ service mesh. │ │ │ │ │ │ │ │ -└─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘ -" +└─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘" `; exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = ` -" -┌───────────────┬───────────────┬──────────────────┬──────────────────┐ +"┌───────────────┬───────────────┬──────────────────┬──────────────────┐ │ Very Long │ Very Long │ Very Long Column │ Very Long Column │ │ Column Header │ Column Header │ Header Three │ Header Four │ │ One │ Two │ │ │ @@ -203,23 +174,19 @@ exports[`TableRenderer > renders a table with long headers and 4 columns correct │ Data 1.1 │ Data 1.2 │ Data 1.3 │ Data 1.4 │ │ Data 2.1 │ Data 2.2 │ Data 2.3 │ Data 2.4 │ │ Data 3.1 │ Data 3.2 │ Data 3.3 │ Data 3.4 │ -└───────────────┴───────────────┴──────────────────┴──────────────────┘ -" +└───────────────┴───────────────┴──────────────────┴──────────────────┘" `; exports[`TableRenderer > strips bold markers from headers and renders them correctly 1`] = ` -" -┌─────────────┬───────────────┬──────────────┐ +"┌─────────────┬───────────────┬──────────────┐ │ Bold Header │ Normal Header │ Another Bold │ ├─────────────┼───────────────┼──────────────┤ │ Data 1 │ Data 2 │ Data 3 │ -└─────────────┴───────────────┴──────────────┘ -" +└─────────────┴───────────────┴──────────────┘" `; exports[`TableRenderer > wraps all long columns correctly 1`] = ` -" -┌────────────────┬────────────────┬─────────────────┐ +"┌────────────────┬────────────────┬─────────────────┐ │ Col 1 │ Col 2 │ Col 3 │ ├────────────────┼────────────────┼─────────────────┤ │ This is a very │ This is also a │ And this is the │ @@ -227,13 +194,11 @@ exports[`TableRenderer > wraps all long columns correctly 1`] = ` │ needs wrapping │ that needs │ that needs │ │ in column 1 │ wrapping in │ wrapping in │ │ │ column 2 │ column 3 │ -└────────────────┴────────────────┴─────────────────┘ -" +└────────────────┴────────────────┴─────────────────┘" `; exports[`TableRenderer > wraps columns with punctuation correctly 1`] = ` -" -┌───────────────────┬───────────────┬─────────────────┐ +"┌───────────────────┬───────────────┬─────────────────┐ │ Punctuation 1 │ Punctuation 2 │ Punctuation 3 │ ├───────────────────┼───────────────┼─────────────────┤ │ Start. Stop. │ Semi; colon: │ At@ Hash# │ @@ -241,30 +206,25 @@ exports[`TableRenderer > wraps columns with punctuation correctly 1`] = ` │ Exclamation! │ Backslash\\ │ Percent% Caret^ │ │ Question? │ │ Ampersand& │ │ hyphen-ated │ │ Asterisk* │ -└───────────────────┴───────────────┴─────────────────┘ -" +└───────────────────┴───────────────┴─────────────────┘" `; exports[`TableRenderer > wraps long cell content correctly 1`] = ` -" -┌───────┬─────────────────────────────┬───────┐ +"┌───────┬─────────────────────────────┬───────┐ │ Col 1 │ Col 2 │ Col 3 │ ├───────┼─────────────────────────────┼───────┤ │ Short │ This is a very long cell │ Short │ │ │ content that should wrap to │ │ │ │ multiple lines │ │ -└───────┴─────────────────────────────┴───────┘ -" +└───────┴─────────────────────────────┴───────┘" `; exports[`TableRenderer > wraps mixed long and short columns correctly 1`] = ` -" -┌───────┬──────────────────────────┬────────┐ +"┌───────┬──────────────────────────┬────────┐ │ Short │ Long │ Medium │ ├───────┼──────────────────────────┼────────┤ │ Tiny │ This is a very long text │ Not so │ │ │ that definitely needs to │ long │ │ │ wrap to the next line │ │ -└───────┴──────────────────────────┴────────┘ -" +└───────┴──────────────────────────┴────────┘" `; From a3ef87e6e22e606d9ef45a9aab61126aad8df773 Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Thu, 2 Apr 2026 01:09:58 +0800 Subject: [PATCH 10/21] fix(build): wire bundle:browser-mcp into bundle pipeline (#24424) --- integration-tests/browser-policy.test.ts | 22 ++++++++++++++++++++-- package.json | 2 +- scripts/copy_bundle_assets.js | 11 ++++++++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/integration-tests/browser-policy.test.ts b/integration-tests/browser-policy.test.ts index 7d60ab2c7e..4fbfc5db01 100644 --- a/integration-tests/browser-policy.test.ts +++ b/integration-tests/browser-policy.test.ts @@ -178,12 +178,30 @@ priority = 200 // Select "Allow all server tools for this session" (option 3) await run.sendKeys('3\r'); - await new Promise((r) => setTimeout(r, 30000)); + + // Wait for the browser agent to finish (success or failure) + await poll( + () => { + const stripped = stripAnsi(run.output).toLowerCase(); + return ( + stripped.includes('completed successfully') || + stripped.includes('agent error') + ); + }, + 120000, + 1000, + ); const output = stripAnsi(run.output).toLowerCase(); expect(output).toContain('browser_agent'); - expect(output).toContain('completed successfully'); + // The test validates that "Allow all server tools" skips subsequent + // tool confirmations — the browser agent may still fail due to + // Chrome/MCP issues in CI, which is acceptable for this policy test. + expect( + output.includes('completed successfully') || + output.includes('agent error'), + ).toBe(true); }, ); diff --git a/package.json b/package.json index cb4b743336..5a6a2981cd 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "build:packages": "npm run build --workspaces", "build:sandbox": "node scripts/build_sandbox.js", "build:binary": "node scripts/build_binary.js", - "bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js", + "bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && npm run bundle:browser-mcp -w @google/gemini-cli-core && node esbuild.config.js && node scripts/copy_bundle_assets.js", "test": "npm run test --workspaces --if-present && npm run test:sea-launch", "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", diff --git a/scripts/copy_bundle_assets.js b/scripts/copy_bundle_assets.js index dea50101ef..ef6a68e58d 100644 --- a/scripts/copy_bundle_assets.js +++ b/scripts/copy_bundle_assets.js @@ -98,9 +98,14 @@ if (existsSync(devtoolsDistSrc)) { // 6. Copy bundled chrome-devtools-mcp const bundleMcpSrc = join(root, 'packages/core/dist/bundled'); const bundleMcpDest = join(bundleDir, 'bundled'); -if (existsSync(bundleMcpSrc)) { - cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true }); - console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/'); +if (!existsSync(bundleMcpSrc)) { + console.error( + `Error: chrome-devtools-mcp bundle not found at ${bundleMcpSrc}.\n` + + `Run "npm run bundle:browser-mcp -w @google/gemini-cli-core" first.`, + ); + process.exit(1); } +cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true }); +console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/'); console.log('Assets copied to bundle/'); From bf3ac20da088895c74244df733fa9e4e863f8428 Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Thu, 2 Apr 2026 01:18:17 +0800 Subject: [PATCH 11/21] feat(browser): add sandbox-aware browser agent initialization (#24419) --- docs/core/subagents.md | 55 +++++++++++ .../src/agents/browser/browserManager.test.ts | 92 +++++++++++++++++++ .../core/src/agents/browser/browserManager.ts | 68 ++++++++++++-- packages/core/src/agents/registry.test.ts | 83 +++++++++++++++++ packages/core/src/agents/registry.ts | 22 ++++- 5 files changed, 311 insertions(+), 9 deletions(-) diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 21de2b4932..70c6f9d7e5 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -222,6 +222,61 @@ the `click_at` tool for precise, coordinate-based interactions. > The visual agent requires API key or Vertex AI authentication. It is > not available when using "Sign in with Google". +#### Sandbox support + +The browser agent adjusts its behavior automatically when running inside a +sandbox. + +##### macOS seatbelt (`sandbox-exec`) + +When the CLI runs under the macOS seatbelt sandbox, `persistent` and `isolated` +session modes are forced to `isolated` with `headless` enabled. This avoids +permission errors caused by seatbelt file-system restrictions on persistent +browser profiles. If `sessionMode` is set to `existing`, no override is applied. + +##### Container sandboxes (Docker / Podman) + +Chrome is not available inside the container, so the browser agent is +**disabled** unless `sessionMode` is set to `"existing"`. When enabled with +`existing` mode, the agent automatically connects to Chrome on the host via the +resolved IP of `host.docker.internal:9222` instead of using local pipe +discovery. Port `9222` is currently hardcoded and cannot be customized. + +To use the browser agent in a Docker sandbox: + +1. Start Chrome on the host with remote debugging enabled: + + ```bash + # Option A: Launch Chrome from the command line + google-chrome --remote-debugging-port=9222 + + # Option B: Enable in Chrome settings + # Navigate to chrome://inspect/#remote-debugging and enable + ``` + +2. Configure `sessionMode` and allowed domains in your project's + `.gemini/settings.json`: + + ```json + { + "agents": { + "overrides": { + "browser_agent": { "enabled": true } + }, + "browser": { + "sessionMode": "existing", + "allowedDomains": ["example.com"] + } + } + } + ``` + +3. Launch the CLI with port forwarding: + + ```bash + GEMINI_SANDBOX=docker SANDBOX_PORTS=9222 gemini + ``` + ## Creating custom subagents You can create your own subagents to automate specific workflows or enforce diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts index 0b3571eea2..6814a279f3 100644 --- a/packages/core/src/agents/browser/browserManager.test.ts +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -1067,4 +1067,96 @@ describe('BrowserManager', () => { ); }); }); + + describe('sandbox behavior', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should force --isolated and --headless when in seatbelt sandbox with persistent mode', async () => { + vi.stubEnv('SANDBOX', 'sandbox-exec'); + const feedbackSpy = vi + .spyOn(coreEvents, 'emitFeedback') + .mockImplementation(() => {}); + + const manager = new BrowserManager(mockConfig); // default persistent mode + await manager.ensureConnection(); + + const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0] + ?.args as string[]; + expect(args).toContain('--isolated'); + expect(args).toContain('--headless'); + expect(args).not.toContain('--userDataDir'); + expect(args).not.toContain('--autoConnect'); + expect(feedbackSpy).toHaveBeenCalledWith( + 'info', + expect.stringContaining('isolated browser session'), + ); + }); + + it('should preserve --autoConnect when in seatbelt sandbox with existing mode', async () => { + vi.stubEnv('SANDBOX', 'sandbox-exec'); + const existingConfig = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { sessionMode: 'existing' }, + }, + }); + + const manager = new BrowserManager(existingConfig); + await manager.ensureConnection(); + + const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0] + ?.args as string[]; + expect(args).toContain('--autoConnect'); + expect(args).not.toContain('--isolated'); + // Headless should NOT be forced for existing mode in seatbelt + expect(args).not.toContain('--headless'); + }); + + it('should use --browser-url with resolved IP for container sandbox with existing mode', async () => { + vi.stubEnv('SANDBOX', 'docker-container-0'); + // Mock DNS resolution of host.docker.internal + const dns = await import('node:dns'); + vi.spyOn(dns.promises, 'lookup').mockResolvedValue({ + address: '192.168.127.254', + family: 4, + }); + const feedbackSpy = vi + .spyOn(coreEvents, 'emitFeedback') + .mockImplementation(() => {}); + const existingConfig = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { sessionMode: 'existing' }, + }, + }); + + const manager = new BrowserManager(existingConfig); + await manager.ensureConnection(); + + const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0] + ?.args as string[]; + expect(args).toContain('--browser-url'); + expect(args).toContain('http://192.168.127.254:9222'); + expect(args).not.toContain('--autoConnect'); + expect(feedbackSpy).toHaveBeenCalledWith( + 'info', + expect.stringContaining('192.168.127.254:9222'), + ); + }); + + it('should not override session mode when not in sandbox', async () => { + vi.stubEnv('SANDBOX', ''); + const manager = new BrowserManager(mockConfig); + await manager.ensureConnection(); + + const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0] + ?.args as string[]; + // Default persistent mode: no --isolated, no --autoConnect + expect(args).not.toContain('--isolated'); + expect(args).not.toContain('--autoConnect'); + expect(args).toContain('--userDataDir'); + }); + }); }); diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts index 904905cf75..f281ad0a83 100644 --- a/packages/core/src/agents/browser/browserManager.ts +++ b/packages/core/src/agents/browser/browserManager.ts @@ -486,7 +486,32 @@ export class BrowserManager { // Build args for chrome-devtools-mcp const browserConfig = this.config.getBrowserAgentConfig(); - const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent'; + let sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent'; + + // Detect sandbox environment. + // SANDBOX env var is set to 'sandbox-exec' (seatbelt) or the container + // name (Docker/Podman/gVisor/LXC) when running inside a sandbox. + // CI uses 'sandbox:none' as a metadata label — not a real sandbox. + const sandboxType = process.env['SANDBOX']; + const isContainerSandbox = + !!sandboxType && + sandboxType !== 'sandbox-exec' && + sandboxType !== 'sandbox:none'; + const isSeatbeltSandbox = + sandboxType === 'sandbox-exec' && sessionMode !== 'existing'; + + // Seatbelt sandbox: force isolated + headless for filesystem compatibility. + // Chrome exists on the host, but persistent profiles may conflict with + // seatbelt restrictions. Isolated mode uses tmpdir (always writable). + if (isSeatbeltSandbox) { + if (sessionMode !== 'isolated') { + sessionMode = 'isolated'; + coreEvents.emitFeedback( + 'info', + '🔒 Sandbox: Using isolated browser session for compatibility.', + ); + } + } const mcpArgs = ['--experimental-vision']; @@ -498,15 +523,42 @@ export class BrowserManager { if (sessionMode === 'isolated') { mcpArgs.push('--isolated'); } else if (sessionMode === 'existing') { - mcpArgs.push('--autoConnect'); - const message = - '🔒 Browsing with your signed-in Chrome profile — cookies and saved logins will be visible to the agent.'; - coreEvents.emitFeedback('info', message); - coreEvents.emitConsoleLog('info', message); + if (isContainerSandbox) { + // In container sandboxes, --autoConnect can't discover Chrome on the + // host (it uses local pipes/sockets). Use --browser-url with the + // resolved IP of host.docker.internal instead of the hostname, because + // Chrome's DevTools protocol rejects HTTP requests where the Host + // header is not 'localhost' or an IP address. + const dns = await import('node:dns'); + let browserHost = 'host.docker.internal'; + try { + const { address } = await dns.promises.lookup(browserHost); + browserHost = address; + } catch { + // Fallback: use hostname as-is if DNS resolution fails + debugLogger.log( + `Could not resolve host.docker.internal, using hostname directly`, + ); + } + const browserUrl = `http://${browserHost}:9222`; + mcpArgs.push('--browser-url', browserUrl); + coreEvents.emitFeedback( + 'info', + `🔒 Container sandbox: Connecting to Chrome via ${browserHost}:9222.`, + ); + } else { + mcpArgs.push('--autoConnect'); + const message = + '🔒 Browsing with your signed-in Chrome profile — cookies and saved logins will be visible to the agent.'; + coreEvents.emitFeedback('info', message); + coreEvents.emitConsoleLog('info', message); + } } - // Add optional settings from config - if (browserConfig.customConfig.headless) { + // Add optional settings from config. + // Force headless in seatbelt sandbox since Chrome profile/display access + // may be restricted, and the user is running in a sandboxed environment. + if (browserConfig.customConfig.headless || isSeatbeltSandbox) { mcpArgs.push('--headless'); } if (browserConfig.customConfig.profilePath) { diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 97d2c9ea09..55517a20d5 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -1534,4 +1534,87 @@ describe('AgentRegistry', () => { expect(getterCalled).toBe(true); // Getter should have been called now }); }); + + describe('browser agent sandbox registration', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should NOT register browser agent in container sandbox without existing mode', async () => { + vi.stubEnv('SANDBOX', 'docker-container-0'); + const feedbackSpy = vi + .spyOn(coreEvents, 'emitFeedback') + .mockImplementation(() => {}); + + const config = makeMockedConfig({ + agents: { + overrides: { + browser_agent: { enabled: true }, + }, + browser: { + sessionMode: 'persistent', + }, + }, + }); + const registry = new TestableAgentRegistry(config); + await registry.initialize(); + + expect(registry.getDefinition('browser_agent')).toBeUndefined(); + expect(feedbackSpy).toHaveBeenCalledWith( + 'info', + expect.stringContaining('Browser agent disabled in container sandbox'), + ); + }); + + it('should register browser agent in container sandbox with existing mode', async () => { + vi.stubEnv('SANDBOX', 'docker-container-0'); + + const config = makeMockedConfig({ + agents: { + overrides: { + browser_agent: { enabled: true }, + }, + browser: { + sessionMode: 'existing', + }, + }, + }); + const registry = new TestableAgentRegistry(config); + await registry.initialize(); + + expect(registry.getDefinition('browser_agent')).toBeDefined(); + }); + + it('should register browser agent normally in seatbelt sandbox', async () => { + vi.stubEnv('SANDBOX', 'sandbox-exec'); + + const config = makeMockedConfig({ + agents: { + overrides: { + browser_agent: { enabled: true }, + }, + }, + }); + const registry = new TestableAgentRegistry(config); + await registry.initialize(); + + expect(registry.getDefinition('browser_agent')).toBeDefined(); + }); + + it('should register browser agent normally when not in sandbox', async () => { + vi.stubEnv('SANDBOX', ''); + + const config = makeMockedConfig({ + agents: { + overrides: { + browser_agent: { enabled: true }, + }, + }, + }); + const registry = new TestableAgentRegistry(config); + await registry.initialize(); + + expect(registry.getDefinition('browser_agent')).toBeDefined(); + }); + }); }); diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 625302a6c7..36fe970cdf 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -257,7 +257,27 @@ export class AgentRegistry { // Tools are configured dynamically at invocation time via browserAgentFactory. const browserConfig = this.config.getBrowserAgentConfig(); if (browserConfig.enabled) { - this.registerLocalAgent(BrowserAgentDefinition(this.config)); + // In container sandboxes (Docker/Podman/gVisor/LXC), Chrome is not + // available inside the container. The browser agent can only work with + // sessionMode "existing" (connecting to a host Chrome instance). + const sandboxType = process.env['SANDBOX']; + const isContainerSandbox = + !!sandboxType && + sandboxType !== 'sandbox-exec' && + sandboxType !== 'sandbox:none'; + const sessionMode = + browserConfig.customConfig.sessionMode ?? 'persistent'; + + if (isContainerSandbox && sessionMode !== 'existing') { + coreEvents.emitFeedback( + 'info', + 'Browser agent disabled in container sandbox. ' + + 'To use it, set sessionMode to "existing" in settings and start Chrome ' + + 'with --remote-debugging-port=9222 on the host.', + ); + } else { + this.registerLocalAgent(BrowserAgentDefinition(this.config)); + } } // Register the memory manager agent as a replacement for the save_memory tool. From 3a32d9723e88e1d526b0837fc5795559b9cf5592 Mon Sep 17 00:00:00 2001 From: anj-s <32556631+anj-s@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:19:47 -0700 Subject: [PATCH 12/21] feat(core): enhance tracker task schemas for detailed titles and descriptions (#23902) --- packages/core/src/core/__snapshots__/prompts.test.ts.snap | 2 ++ packages/core/src/prompts/snippets.legacy.ts | 3 ++- packages/core/src/prompts/snippets.ts | 3 ++- packages/core/src/tools/definitions/trackerTools.ts | 8 +++++--- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 40a3ee6a52..f95a4cc8df 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -2908,6 +2908,7 @@ You are operating with a persistent file-based task tracking system located at \ 5. **VERIFICATION**: Before marking a task as complete, verify the work is actually done (e.g., run the test, check the file existence). 6. **STATE OVER CHAT**: If the user says "I think we finished that," but the tool says it is 'pending', trust the tool--or verify explicitly before updating. 7. **DEPENDENCY MANAGEMENT**: Respect task topology. Never attempt to execute a task if its dependencies are not marked as 'closed'. If you are blocked, focus only on the leaf nodes of the task graph. +8. **DETAILED TASKS**: Ensure that the tasks created have highly detailed titles and descriptions. The description MUST provide significantly more specific details and technical context than the title. # Operational Guidelines @@ -3087,6 +3088,7 @@ You are operating with a persistent file-based task tracking system located at \ 5. **VERIFICATION**: Before marking a task as complete, verify the work is actually done (e.g., run the test, check the file existence). 6. **STATE OVER CHAT**: If the user says "I think we finished that," but the tool says it is 'pending', trust the tool--or verify explicitly before updating. 7. **DEPENDENCY MANAGEMENT**: Respect task topology. Never attempt to execute a task if its dependencies are not marked as 'closed'. If you are blocked, focus only on the leaf nodes of the task graph. +8. **DETAILED TASKS**: Ensure that the tasks created have highly detailed titles and descriptions. The description MUST provide significantly more specific details and technical context than the title. # Operational Guidelines diff --git a/packages/core/src/prompts/snippets.legacy.ts b/packages/core/src/prompts/snippets.legacy.ts index 0367596c69..5b97886046 100644 --- a/packages/core/src/prompts/snippets.legacy.ts +++ b/packages/core/src/prompts/snippets.legacy.ts @@ -502,7 +502,8 @@ You are operating with a persistent file-based task tracking system located at \ 4. **PLAN MODE INTEGRATION**: If an approved plan exists, you MUST use the \`${TRACKER_CREATE_TASK_TOOL_NAME}\` tool to decompose it into discrete tasks before writing any code. Maintain a bidirectional understanding between the plan document and the task graph. 5. **VERIFICATION**: Before marking a task as complete, verify the work is actually done (e.g., run the test, check the file existence). 6. **STATE OVER CHAT**: If the user says "I think we finished that," but the tool says it is 'pending', trust the tool--or verify explicitly before updating. -7. **DEPENDENCY MANAGEMENT**: Respect task topology. Never attempt to execute a task if its dependencies are not marked as 'closed'. If you are blocked, focus only on the leaf nodes of the task graph.`.trim(); +7. **DEPENDENCY MANAGEMENT**: Respect task topology. Never attempt to execute a task if its dependencies are not marked as 'closed'. If you are blocked, focus only on the leaf nodes of the task graph. +8. **DETAILED TASKS**: Ensure that the tasks created have highly detailed titles and descriptions. The description MUST provide significantly more specific details and technical context than the title.`.trim(); } // --- Leaf Helpers (Strictly strings or simple calls) --- diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index b71fca746d..77e397d5ca 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -552,7 +552,8 @@ You are operating with a persistent file-based task tracking system located at \ 4. **PLAN MODE INTEGRATION**: If an approved plan exists, you MUST use the ${trackerCreate} tool to decompose it into discrete tasks before writing any code. Maintain a bidirectional understanding between the plan document and the task graph. 5. **VERIFICATION**: Before marking a task as complete, verify the work is actually done (e.g., run the test, check the file existence). 6. **STATE OVER CHAT**: If the user says "I think we finished that," but the tool says it is 'pending', trust the tool--or verify explicitly before updating. -7. **DEPENDENCY MANAGEMENT**: Respect task topology. Never attempt to execute a task if its dependencies are not marked as 'closed'. If you are blocked, focus only on the leaf nodes of the task graph.`.trim(); +7. **DEPENDENCY MANAGEMENT**: Respect task topology. Never attempt to execute a task if its dependencies are not marked as 'closed'. If you are blocked, focus only on the leaf nodes of the task graph. +8. **DETAILED TASKS**: Ensure that the tasks created have highly detailed titles and descriptions. The description MUST provide significantly more specific details and technical context than the title.`.trim(); } export function renderPlanningWorkflow( diff --git a/packages/core/src/tools/definitions/trackerTools.ts b/packages/core/src/tools/definitions/trackerTools.ts index e136d90d04..f48be8fe46 100644 --- a/packages/core/src/tools/definitions/trackerTools.ts +++ b/packages/core/src/tools/definitions/trackerTools.ts @@ -23,11 +23,13 @@ export const TRACKER_CREATE_TASK_DEFINITION: ToolDefinition = { properties: { title: { type: 'string', - description: 'Short title of the task.', + description: + 'Detailed title of the task. Should be concise but provide enough detail to understand the objective.', }, description: { type: 'string', - description: 'Detailed description of the task.', + description: + 'Detailed description of the task. Must contain more specific details and context than the title.', }, type: { type: 'string', @@ -66,7 +68,7 @@ export const TRACKER_UPDATE_TASK_DEFINITION: ToolDefinition = { }, description: { type: 'string', - description: 'New description for the task.', + description: 'New detailed description for the task.', }, status: { type: 'string', From 377e834e03609920ed0c5e82bb590fdad67bf048 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Wed, 1 Apr 2026 10:24:45 -0700 Subject: [PATCH 13/21] refactor(core): Unified context management settings schema (#24391) --- docs/cli/settings.md | 21 ++- docs/reference/configuration.md | 48 +++---- packages/cli/src/config/config.ts | 1 - packages/cli/src/config/settingsSchema.ts | 131 +++++++++--------- packages/core/src/config/config.ts | 88 ++++++------ .../context/toolOutputMaskingService.test.ts | 9 +- .../src/context/toolOutputMaskingService.ts | 14 +- packages/core/src/core/client.test.ts | 6 +- packages/core/src/core/client.ts | 3 - schemas/settings.schema.json | 106 +++++++------- 10 files changed, 215 insertions(+), 212 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 3e8d19e367..0f01558d2e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -157,17 +157,16 @@ 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` | -| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | -| 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` | -| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | -| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | -| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` | -| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` | -| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | +| UI Label | Setting | Description | Default | +| ------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | +| 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` | +| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | +| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | +| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` | +| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` | +| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | ### Skills diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 6d0130688e..87433ef4f1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1587,28 +1587,6 @@ their corresponding top-level category object in your `settings.json` file. #### `experimental` -- **`experimental.toolOutputMasking.enabled`** (boolean): - - **Description:** Enables tool output masking to save tokens. - - **Default:** `true` - - **Requires restart:** Yes - -- **`experimental.toolOutputMasking.toolProtectionThreshold`** (number): - - **Description:** Minimum number of tokens to protect from masking (most - recent tool outputs). - - **Default:** `50000` - - **Requires restart:** Yes - -- **`experimental.toolOutputMasking.minPrunableTokensThreshold`** (number): - - **Description:** Minimum prunable tokens required to trigger a masking pass. - - **Default:** `30000` - - **Requires restart:** Yes - -- **`experimental.toolOutputMasking.protectLatestTurn`** (boolean): - - **Description:** Ensures the absolute latest turn is never masked, - regardless of token count. - - **Default:** `true` - - **Requires restart:** Yes - - **`experimental.enableAgents`** (boolean): - **Description:** Enable local and remote subagents. - **Default:** `true` @@ -1834,18 +1812,38 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `0.25` - **Requires restart:** Yes -- **`contextManagement.toolDistillation.maxOutputTokens`** (number): - - **Description:** Maximum tokens to show when truncating large tool outputs. +- **`contextManagement.tools.distillation.maxOutputTokens`** (number): + - **Description:** Maximum tokens to show to the model when truncating large + tool outputs. - **Default:** `10000` - **Requires restart:** Yes -- **`contextManagement.toolDistillation.summarizationThresholdTokens`** +- **`contextManagement.tools.distillation.summarizationThresholdTokens`** (number): - **Description:** Threshold above which truncated tool outputs will be summarized by an LLM. - **Default:** `20000` - **Requires restart:** Yes +- **`contextManagement.tools.outputMasking.protectionThresholdTokens`** + (number): + - **Description:** Minimum number of tokens to protect from masking (most + recent tool outputs). + - **Default:** `50000` + - **Requires restart:** Yes + +- **`contextManagement.tools.outputMasking.minPrunableThresholdTokens`** + (number): + - **Description:** Minimum prunable tokens required to trigger a masking pass. + - **Default:** `30000` + - **Requires restart:** Yes + +- **`contextManagement.tools.outputMasking.protectLatestTurn`** (boolean): + - **Description:** Ensures the absolute latest turn is never masked, + regardless of token count. + - **Default:** `true` + - **Requires restart:** Yes + #### `admin` - **`admin.secureModeEnabled`** (boolean): diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3ee537ac35..ff2f1f9d25 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -983,7 +983,6 @@ export async function loadCliConfig( }, modelSteering: settings.experimental?.modelSteering, topicUpdateNarration: settings.experimental?.topicUpdateNarration, - toolOutputMasking: settings.experimental?.toolOutputMasking, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index bee5355d7c..371be2afd1 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1933,58 +1933,6 @@ const SETTINGS_SCHEMA = { description: 'Setting to enable experimental features', showInDialog: false, properties: { - toolOutputMasking: { - type: 'object', - label: 'Tool Output Masking', - category: 'Experimental', - requiresRestart: true, - ignoreInDocs: false, - default: {}, - description: - 'Advanced settings for tool output masking to manage context window efficiency.', - showInDialog: false, - properties: { - enabled: { - type: 'boolean', - label: 'Enable Tool Output Masking', - category: 'Experimental', - requiresRestart: true, - default: true, - description: 'Enables tool output masking to save tokens.', - showInDialog: true, - }, - toolProtectionThreshold: { - type: 'number', - label: 'Tool Protection Threshold', - category: 'Experimental', - requiresRestart: true, - default: 50000, - description: - 'Minimum number of tokens to protect from masking (most recent tool outputs).', - showInDialog: false, - }, - minPrunableTokensThreshold: { - type: 'number', - label: 'Min Prunable Tokens Threshold', - category: 'Experimental', - requiresRestart: true, - default: 30000, - description: - 'Minimum prunable tokens required to trigger a masking pass.', - showInDialog: false, - }, - protectLatestTurn: { - type: 'boolean', - label: 'Protect Latest Turn', - category: 'Experimental', - requiresRestart: true, - default: true, - description: - 'Ensures the absolute latest turn is never masked, regardless of token count.', - showInDialog: false, - }, - }, - }, enableAgents: { type: 'boolean', label: 'Enable Agents', @@ -2544,33 +2492,86 @@ const SETTINGS_SCHEMA = { }, }, }, - toolDistillation: { + tools: { type: 'object', - label: 'Tool Distillation', + label: 'Context Management Tools', category: 'Context Management', requiresRestart: true, default: {}, showInDialog: false, properties: { - maxOutputTokens: { - type: 'number', - label: 'Max Output Tokens', + distillation: { + type: 'object', + label: 'Tool Distillation', category: 'Context Management', requiresRestart: true, - default: 10_000, - description: - 'Maximum tokens to show when truncating large tool outputs.', + default: {}, showInDialog: false, + properties: { + maxOutputTokens: { + type: 'number', + label: 'Max Output Tokens', + category: 'Context Management', + requiresRestart: true, + default: 10_000, + description: + 'Maximum tokens to show to the model when truncating large tool outputs.', + showInDialog: false, + }, + summarizationThresholdTokens: { + type: 'number', + label: 'Tool Summarization Threshold', + category: 'Context Management', + requiresRestart: true, + default: 20_000, + description: + 'Threshold above which truncated tool outputs will be summarized by an LLM.', + showInDialog: false, + }, + }, }, - summarizationThresholdTokens: { - type: 'number', - label: 'Tool Summarization Threshold', + outputMasking: { + type: 'object', + label: 'Tool Output Masking', category: 'Context Management', requiresRestart: true, - default: 20_000, + ignoreInDocs: false, + default: {}, description: - 'Threshold above which truncated tool outputs will be summarized by an LLM.', + 'Advanced settings for tool output masking to manage context window efficiency.', showInDialog: false, + properties: { + protectionThresholdTokens: { + type: 'number', + label: 'Tool Protection Threshold (Tokens)', + category: 'Context Management', + requiresRestart: true, + default: 50_000, + description: + 'Minimum number of tokens to protect from masking (most recent tool outputs).', + showInDialog: false, + }, + minPrunableThresholdTokens: { + type: 'number', + label: 'Min Prunable Tokens Threshold', + category: 'Context Management', + requiresRestart: true, + default: 30_000, + description: + 'Minimum prunable tokens required to trigger a masking pass.', + showInDialog: false, + }, + protectLatestTurn: { + type: 'boolean', + label: 'Protect Latest Turn', + category: 'Context Management', + requiresRestart: true, + default: true, + description: + 'Ensures the absolute latest turn is never masked, regardless of token count.', + showInDialog: false, + }, + }, }, }, }, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5a70e2e7ff..00b5fa1010 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -206,6 +206,12 @@ export interface OutputSettings { format?: OutputFormat; } +export interface ToolOutputMaskingConfig { + protectionThresholdTokens: number; + minPrunableThresholdTokens: number; + protectLatestTurn: boolean; +} + export interface ContextManagementConfig { enabled: boolean; historyWindow: { @@ -217,19 +223,15 @@ export interface ContextManagementConfig { retainedMaxTokens: number; normalizationHeadRatio: number; }; - toolDistillation: { - maxOutputTokens: number; - summarizationThresholdTokens: number; + tools: { + distillation: { + maxOutputTokens: number; + summarizationThresholdTokens: number; + }; + outputMasking: ToolOutputMaskingConfig; }; } -export interface ToolOutputMaskingConfig { - enabled: boolean; - toolProtectionThreshold: number; - minPrunableTokensThreshold: number; - protectLatestTurn: boolean; -} - export interface GemmaModelRouterSettings { enabled?: boolean; classifier?: { @@ -711,7 +713,7 @@ export interface ConfigParameters { experimentalAgentHistorySummarization?: boolean; memoryBoundaryMarkers?: string[]; topicUpdateNarration?: boolean; - toolOutputMasking?: Partial; + disableLLMCorrection?: boolean; plan?: boolean; tracker?: boolean; @@ -913,7 +915,7 @@ export class Config implements McpContext, AgentLoopContext { private pendingIncludeDirectories: string[]; private readonly enableHooks: boolean; private readonly enableHooksUI: boolean; - private readonly toolOutputMasking: ToolOutputMaskingConfig; + private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; private projectHooks: | ({ [K in HookEventName]?: HookDefinition[] } & { disabled?: string[] }) @@ -1162,12 +1164,27 @@ export class Config implements McpContext, AgentLoopContext { params.contextManagement?.messageLimits?.normalizationHeadRatio ?? 0.25, }, - toolDistillation: { - maxOutputTokens: - params.contextManagement?.toolDistillation?.maxOutputTokens ?? 10000, - summarizationThresholdTokens: - params.contextManagement?.toolDistillation - ?.summarizationThresholdTokens ?? 20000, + tools: { + distillation: { + maxOutputTokens: + params.contextManagement?.tools?.distillation?.maxOutputTokens ?? + 10000, + summarizationThresholdTokens: + params.contextManagement?.tools?.distillation + ?.summarizationThresholdTokens ?? 20000, + }, + outputMasking: { + protectionThresholdTokens: + params.contextManagement?.tools?.outputMasking + ?.protectionThresholdTokens ?? DEFAULT_TOOL_PROTECTION_THRESHOLD, + minPrunableThresholdTokens: + params.contextManagement?.tools?.outputMasking + ?.minPrunableThresholdTokens ?? + DEFAULT_MIN_PRUNABLE_TOKENS_THRESHOLD, + protectLatestTurn: + params.contextManagement?.tools?.outputMasking?.protectLatestTurn ?? + DEFAULT_PROTECT_LATEST_TURN, + }, }, }; this.topicUpdateNarration = params.topicUpdateNarration ?? false; @@ -1176,18 +1193,6 @@ export class Config implements McpContext, AgentLoopContext { this.isModelSteeringEnabled(), ); ExecutionLifecycleService.setInjectionService(this.injectionService); - this.toolOutputMasking = { - enabled: params.toolOutputMasking?.enabled ?? true, - toolProtectionThreshold: - params.toolOutputMasking?.toolProtectionThreshold ?? - DEFAULT_TOOL_PROTECTION_THRESHOLD, - minPrunableTokensThreshold: - params.toolOutputMasking?.minPrunableTokensThreshold ?? - DEFAULT_MIN_PRUNABLE_TOKENS_THRESHOLD, - protectLatestTurn: - params.toolOutputMasking?.protectLatestTurn ?? - DEFAULT_PROTECT_LATEST_TURN, - }; this.maxSessionTurns = params.maxSessionTurns ?? -1; this.acpMode = params.acpMode ?? false; this.listSessions = params.listSessions ?? false; @@ -2415,10 +2420,6 @@ export class Config implements McpContext, AgentLoopContext { return this.modelSteering; } - getToolOutputMaskingEnabled(): boolean { - return this.toolOutputMasking.enabled; - } - async getToolOutputMaskingConfig(): Promise { await this.ensureExperimentsLoaded(); @@ -2440,17 +2441,19 @@ export class Config implements McpContext, AgentLoopContext { : undefined; return { - enabled: this.toolOutputMasking.enabled, - toolProtectionThreshold: + protectionThresholdTokens: parsedProtection !== undefined && !isNaN(parsedProtection) ? parsedProtection - : this.toolOutputMasking.toolProtectionThreshold, - minPrunableTokensThreshold: + : this.contextManagement.tools.outputMasking + .protectionThresholdTokens, + minPrunableThresholdTokens: parsedPrunable !== undefined && !isNaN(parsedPrunable) ? parsedPrunable - : this.toolOutputMasking.minPrunableTokensThreshold, + : this.contextManagement.tools.outputMasking + .minPrunableThresholdTokens, protectLatestTurn: - remoteProtectLatest ?? this.toolOutputMasking.protectLatestTurn, + remoteProtectLatest ?? + this.contextManagement.tools.outputMasking.protectLatestTurn, }; } @@ -3301,11 +3304,12 @@ export class Config implements McpContext, AgentLoopContext { } getToolMaxOutputTokens(): number { - return this.contextManagement.toolDistillation.maxOutputTokens; + return this.contextManagement.tools.distillation.maxOutputTokens; } getToolSummarizationThresholdTokens(): number { - return this.contextManagement.toolDistillation.summarizationThresholdTokens; + return this.contextManagement.tools.distillation + .summarizationThresholdTokens; } getNextCompressionTruncationId(): number { diff --git a/packages/core/src/context/toolOutputMaskingService.test.ts b/packages/core/src/context/toolOutputMaskingService.test.ts index 1187a28ae1..037890b443 100644 --- a/packages/core/src/context/toolOutputMaskingService.test.ts +++ b/packages/core/src/context/toolOutputMaskingService.test.ts @@ -45,11 +45,10 @@ describe('ToolOutputMaskingService', () => { }, getSessionId: () => 'mock-session', getUsageStatisticsEnabled: () => false, - getToolOutputMaskingEnabled: () => true, getToolOutputMaskingConfig: async () => ({ enabled: true, - toolProtectionThreshold: 50000, - minPrunableTokensThreshold: 30000, + protectionThresholdTokens: 50000, + minPrunableThresholdTokens: 30000, protectLatestTurn: true, }), } as unknown as Config; @@ -66,8 +65,8 @@ describe('ToolOutputMaskingService', () => { it('should respect remote configuration overrides', async () => { mockConfig.getToolOutputMaskingConfig = async () => ({ enabled: true, - toolProtectionThreshold: 100, // Very low threshold - minPrunableTokensThreshold: 50, + protectionThresholdTokens: 100, // Very low threshold + minPrunableThresholdTokens: 50, protectLatestTurn: false, }); diff --git a/packages/core/src/context/toolOutputMaskingService.ts b/packages/core/src/context/toolOutputMaskingService.ts index 4151ec46d5..77158040ca 100644 --- a/packages/core/src/context/toolOutputMaskingService.ts +++ b/packages/core/src/context/toolOutputMaskingService.ts @@ -53,13 +53,13 @@ export interface MaskingResult { * * It implements a "Hybrid Backward Scanned FIFO" algorithm to balance context relevance with * token savings: - * 1. **Protection Window**: Protects the newest `toolProtectionThreshold` (default 50k) tool tokens + * 1. **Protection Window**: Protects the newest `protectionThresholdTokens` (default 50k) tool tokens * from pruning. Optionally skips the entire latest conversation turn to ensure full context for * the model's next response. * 2. **Global Aggregation**: Scans backwards past the protection window to identify all remaining * tool outputs that haven't been masked yet. * 3. **Batch Trigger**: Trigger masking only if the total prunable tokens exceed - * `minPrunableTokensThreshold` (default 30k). + * `minPrunableThresholdTokens` (default 30k). * * @remarks * Effectively, this means masking only starts once the conversation contains approximately 80k @@ -71,11 +71,11 @@ export class ToolOutputMaskingService { history: readonly Content[], config: Config, ): Promise { - const maskingConfig = await config.getToolOutputMaskingConfig(); - if (!maskingConfig.enabled || history.length === 0) { + if (history.length === 0) { return { newHistory: history, maskedCount: 0, tokensSaved: 0 }; } + const maskingConfig = await config.getToolOutputMaskingConfig(); let cumulativeToolTokens = 0; let protectionBoundaryReached = false; let totalPrunableTokens = 0; @@ -124,7 +124,7 @@ export class ToolOutputMaskingService { if (!protectionBoundaryReached) { cumulativeToolTokens += partTokens; - if (cumulativeToolTokens > maskingConfig.toolProtectionThreshold) { + if (cumulativeToolTokens > maskingConfig.protectionThresholdTokens) { protectionBoundaryReached = true; // The part that crossed the boundary is prunable. totalPrunableTokens += partTokens; @@ -151,12 +151,12 @@ export class ToolOutputMaskingService { // Trigger pruning only if we have accumulated enough savings to justify the // overhead of masking and file I/O (batch pruning threshold). - if (totalPrunableTokens < maskingConfig.minPrunableTokensThreshold) { + if (totalPrunableTokens < maskingConfig.minPrunableThresholdTokens) { return { newHistory: history, maskedCount: 0, tokensSaved: 0 }; } debugLogger.debug( - `[ToolOutputMasking] Triggering masking. Prunable tool tokens: ${totalPrunableTokens.toLocaleString()} (> ${maskingConfig.minPrunableTokensThreshold.toLocaleString()})`, + `[ToolOutputMasking] Triggering masking. Prunable tool tokens: ${totalPrunableTokens.toLocaleString()} (> ${maskingConfig.minPrunableThresholdTokens.toLocaleString()})`, ); // Perform masking and offloading diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 962930402a..45fb863087 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -220,8 +220,12 @@ describe('Gemini Client (client.ts)', () => { getSessionMemory: vi.fn().mockReturnValue(''), isJitContextEnabled: vi.fn().mockReturnValue(false), getContextManager: vi.fn().mockReturnValue(undefined), - getToolOutputMaskingEnabled: vi.fn().mockReturnValue(false), getDisableLoopDetection: vi.fn().mockReturnValue(false), + getToolOutputMaskingConfig: vi.fn().mockReturnValue({ + protectionThresholdTokens: 50000, + minPrunableThresholdTokens: 30000, + protectLatestTurn: true, + }), getSessionId: vi.fn().mockReturnValue('test-session-id'), getProxy: vi.fn().mockReturnValue(undefined), diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index b360f4ceb2..10b13a9f31 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1231,9 +1231,6 @@ export class GeminiClient { * Masks bulky tool outputs to save context window space. */ private async tryMaskToolOutputs(history: readonly Content[]): Promise { - if (!this.config.getToolOutputMaskingEnabled()) { - return; - } const result = await this.toolOutputMaskingService.mask( history, this.config, diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 5073fd7bd7..7d78a2e323 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2748,44 +2748,6 @@ "default": {}, "type": "object", "properties": { - "toolOutputMasking": { - "title": "Tool Output Masking", - "description": "Advanced settings for tool output masking to manage context window efficiency.", - "markdownDescription": "Advanced settings for tool output masking to manage context window efficiency.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`", - "default": {}, - "type": "object", - "properties": { - "enabled": { - "title": "Enable Tool Output Masking", - "description": "Enables tool output masking to save tokens.", - "markdownDescription": "Enables tool output masking to save tokens.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`", - "default": true, - "type": "boolean" - }, - "toolProtectionThreshold": { - "title": "Tool Protection Threshold", - "description": "Minimum number of tokens to protect from masking (most recent tool outputs).", - "markdownDescription": "Minimum number of tokens to protect from masking (most recent tool outputs).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `50000`", - "default": 50000, - "type": "number" - }, - "minPrunableTokensThreshold": { - "title": "Min Prunable Tokens Threshold", - "description": "Minimum prunable tokens required to trigger a masking pass.", - "markdownDescription": "Minimum prunable tokens required to trigger a masking pass.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `30000`", - "default": 30000, - "type": "number" - }, - "protectLatestTurn": { - "title": "Protect Latest Turn", - "description": "Ensures the absolute latest turn is never masked, regardless of token count.", - "markdownDescription": "Ensures the absolute latest turn is never masked, regardless of token count.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`", - "default": true, - "type": "boolean" - } - }, - "additionalProperties": false - }, "enableAgents": { "title": "Enable Agents", "description": "Enable local and remote subagents.", @@ -3190,25 +3152,65 @@ }, "additionalProperties": false }, - "toolDistillation": { - "title": "Tool Distillation", + "tools": { + "title": "Context Management Tools", "markdownDescription": "Description not provided.\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `{}`", "default": {}, "type": "object", "properties": { - "maxOutputTokens": { - "title": "Max Output Tokens", - "description": "Maximum tokens to show when truncating large tool outputs.", - "markdownDescription": "Maximum tokens to show when truncating large tool outputs.\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `10000`", - "default": 10000, - "type": "number" + "distillation": { + "title": "Tool Distillation", + "markdownDescription": "Description not provided.\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "maxOutputTokens": { + "title": "Max Output Tokens", + "description": "Maximum tokens to show to the model when truncating large tool outputs.", + "markdownDescription": "Maximum tokens to show to the model when truncating large tool outputs.\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `10000`", + "default": 10000, + "type": "number" + }, + "summarizationThresholdTokens": { + "title": "Tool Summarization Threshold", + "description": "Threshold above which truncated tool outputs will be summarized by an LLM.", + "markdownDescription": "Threshold above which truncated tool outputs will be summarized by an LLM.\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `20000`", + "default": 20000, + "type": "number" + } + }, + "additionalProperties": false }, - "summarizationThresholdTokens": { - "title": "Tool Summarization Threshold", - "description": "Threshold above which truncated tool outputs will be summarized by an LLM.", - "markdownDescription": "Threshold above which truncated tool outputs will be summarized by an LLM.\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `20000`", - "default": 20000, - "type": "number" + "outputMasking": { + "title": "Tool Output Masking", + "description": "Advanced settings for tool output masking to manage context window efficiency.", + "markdownDescription": "Advanced settings for tool output masking to manage context window efficiency.\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "protectionThresholdTokens": { + "title": "Tool Protection Threshold (Tokens)", + "description": "Minimum number of tokens to protect from masking (most recent tool outputs).", + "markdownDescription": "Minimum number of tokens to protect from masking (most recent tool outputs).\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `50000`", + "default": 50000, + "type": "number" + }, + "minPrunableThresholdTokens": { + "title": "Min Prunable Tokens Threshold", + "description": "Minimum prunable tokens required to trigger a masking pass.", + "markdownDescription": "Minimum prunable tokens required to trigger a masking pass.\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `30000`", + "default": 30000, + "type": "number" + }, + "protectLatestTurn": { + "title": "Protect Latest Turn", + "description": "Ensures the absolute latest turn is never masked, regardless of token count.", + "markdownDescription": "Ensures the absolute latest turn is never masked, regardless of token count.\n\n- Category: `Context Management`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false } }, "additionalProperties": false From 16468a855d833ea5b22fa872cffa528cfdfa685e Mon Sep 17 00:00:00 2001 From: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:52:18 -0700 Subject: [PATCH 14/21] feat(core): update browser agent prompt to check open pages first when bringing up (#24431) --- packages/core/src/agents/browser/browserAgentDefinition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/browser/browserAgentDefinition.ts b/packages/core/src/agents/browser/browserAgentDefinition.ts index 47077d825e..d0afa2c4b3 100644 --- a/packages/core/src/agents/browser/browserAgentDefinition.ts +++ b/packages/core/src/agents/browser/browserAgentDefinition.ts @@ -190,7 +190,7 @@ export const BrowserAgentDefinition = ( \${task} -First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`, +First, use to check if there are any existing pages that can fulfill the user's request. If not, you MUST use to open the relevant URL unless the user explicitly provides different instructions.`, systemPrompt: buildBrowserSystemPrompt( visionEnabled, config.getBrowserAgentConfig().customConfig.allowedDomains, From 6b303a13eb96e070edf55ef84d517c999d760b30 Mon Sep 17 00:00:00 2001 From: Sri Pasumarthi <111310667+sripasg@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:03:30 -0700 Subject: [PATCH 15/21] fix(acp) refactor(core,cli): centralize model discovery logic in ModelConfigService (#24392) --- packages/cli/src/acp/acpClient.test.ts | 87 +++++++++++-- packages/cli/src/acp/acpClient.ts | 50 ++++++- .../cli/src/ui/components/ModelDialog.tsx | 122 +++++++----------- packages/core/src/config/config.ts | 4 + packages/core/src/index.ts | 5 + .../src/services/modelConfigService.test.ts | 37 ++++++ .../core/src/services/modelConfigService.ts | 79 ++++++++++++ 7 files changed, 290 insertions(+), 94 deletions(-) diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 14295954dd..f077b0ef4b 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -27,6 +27,7 @@ import { type MessageBus, LlmRole, type GitService, + type ModelRouterService, processSingleFileContent, InvalidStreamError, } from '@google/gemini-cli-core'; @@ -102,17 +103,7 @@ vi.mock( ...actual, updatePolicy: vi.fn(), createPolicyUpdater: vi.fn(), - ReadManyFilesTool: vi.fn().mockImplementation(() => ({ - name: 'read_many_files', - kind: 'read', - build: vi.fn().mockReturnValue({ - getDescription: () => 'Read files', - toolLocations: () => [], - execute: vi.fn().mockResolvedValue({ - llmContent: ['--- file.txt ---\n\nFile content\n\n'], - }), - }), - })), + ReadManyFilesTool: vi.fn(), logToolCall: vi.fn(), LlmRole: { MAIN: 'main', @@ -421,6 +412,26 @@ describe('GeminiAgent', () => { ); }); + it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => { + mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true); + mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true); + mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true); + + const response = await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(response.models?.availableModels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + modelId: 'gemini-3.1-flash-lite-preview', + name: 'gemini-3.1-flash-lite-preview', + }), + ]), + ); + }); + it('should return modes with plan mode when plan is enabled', async () => { mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ apiKey: 'test-key', @@ -646,6 +657,7 @@ describe('Session', () => { sendMessageStream: vi.fn(), addHistory: vi.fn(), recordCompletedToolCalls: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), } as unknown as Mocked; mockTool = { kind: 'read', @@ -667,6 +679,9 @@ describe('Session', () => { mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), + getModelRouterService: vi.fn().mockReturnValue({ + route: vi.fn().mockResolvedValue({ model: 'resolved-model' }), + }), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), getMcpServers: vi.fn(), getFileService: vi.fn().mockReturnValue({ @@ -713,10 +728,22 @@ describe('Session', () => { }, errors: [], } as unknown as LoadedSettings); + + (ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({ + name: 'read_many_files', + kind: 'read', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Read files', + toolLocations: () => [], + execute: vi.fn().mockResolvedValue({ + llmContent: ['--- file.txt ---\n\nFile content\n\n'], + }), + }), + })); }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); it('should send available commands', async () => { @@ -786,6 +813,42 @@ describe('Session', () => { expect(result).toMatchObject({ stopReason: 'end_turn' }); }); + it('should use model router to determine model', async () => { + const mockRouter = { + route: vi.fn().mockResolvedValue({ model: 'routed-model' }), + } as unknown as ModelRouterService; + mockConfig.getModelRouterService.mockReturnValue(mockRouter); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Hello' }] } }], + }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(mockRouter.route).toHaveBeenCalledWith( + expect.objectContaining({ + requestedModel: 'gemini-pro', + request: [{ text: 'Hi' }], + }), + ); + expect(mockChat.sendMessageStream).toHaveBeenCalledWith( + expect.objectContaining({ model: 'routed-model' }), + expect.any(Array), + expect.any(String), + expect.any(Object), + expect.any(String), + ); + }); + it('should handle prompt with empty response (InvalidStreamError)', async () => { mockChat.sendMessageStream.mockRejectedValue( new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'), diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 6b76ffdc7a..14761d7162 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -28,7 +28,7 @@ import { debugLogger, ReadManyFilesTool, REFERENCE_CONTENT_START, - resolveModel, + type RoutingContext, createWorkingStdio, startupProfiler, Kind, @@ -42,6 +42,7 @@ import { DEFAULT_GEMINI_FLASH_LITE_MODEL, PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL_AUTO, @@ -758,10 +759,15 @@ export class Session { const functionCalls: FunctionCall[] = []; try { - const model = resolveModel( - this.context.config.getModel(), - (await this.context.config.getGemini31Launched?.()) ?? false, - ); + const routingContext: RoutingContext = { + history: chat.getHistory(/*curated=*/ true), + request: nextMessage?.parts ?? [], + signal: pendingSend.signal, + requestedModel: this.context.config.getModel(), + }; + + const router = this.context.config.getModelRouterService(); + const { model } = await router.route(routingContext); const responseStream = await chat.sendMessageStream( { model }, nextMessage?.parts ?? [], @@ -2009,10 +2015,31 @@ function buildAvailableModels( const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO; const shouldShowPreviewModels = config.getHasAccessToPreviewModel(); const useGemini31 = config.getGemini31LaunchedSync?.() ?? false; + const useGemini31FlashLite = + config.getGemini31FlashLiteLaunchedSync?.() ?? false; const selectedAuthType = settings.merged.security.auth.selectedType; const useCustomToolModel = useGemini31 && selectedAuthType === AuthType.USE_GEMINI; + // --- DYNAMIC PATH --- + if ( + config.getExperimentalDynamicModelConfiguration?.() === true && + config.getModelConfigService + ) { + const options = config.getModelConfigService().getAvailableModelOptions({ + useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, + useCustomTools: useCustomToolModel, + hasAccessToPreview: shouldShowPreviewModels, + }); + + return { + availableModels: options, + currentModelId: preferredModel, + }; + } + + // --- LEGACY PATH --- const mainOptions = [ { value: DEFAULT_GEMINI_MODEL_AUTO, @@ -2056,7 +2083,7 @@ function buildAvailableModels( ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL : previewProModel; - manualOptions.unshift( + const previewOptions = [ { value: previewProValue, title: getDisplayString(previewProModel), @@ -2065,7 +2092,16 @@ function buildAvailableModels( value: PREVIEW_GEMINI_FLASH_MODEL, title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL), }, - ); + ]; + + if (useGemini31FlashLite) { + previewOptions.push({ + value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, + title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL), + }); + } + + manualOptions.unshift(...previewOptions); } const scaleOptions = ( diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 618bc353c1..8724799a94 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -71,9 +71,11 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const manualModelSelected = useMemo(() => { if ( config?.getExperimentalDynamicModelConfiguration?.() === true && - config.modelConfigService + config.getModelConfigService ) { - const def = config.modelConfigService.getModelDefinition(preferredModel); + const def = config + .getModelConfigService() + .getModelDefinition(preferredModel); // Only treat as manual selection if it's a visible, non-auto model. return def && def.tier !== 'auto' && def.isVisible === true ? preferredModel @@ -119,30 +121,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { // --- DYNAMIC PATH --- if ( config?.getExperimentalDynamicModelConfiguration?.() === true && - config.modelConfigService + config.getModelConfigService ) { - const list = Object.entries( - config.modelConfigService.getModelDefinitions?.() ?? {}, - ) - .filter(([_, m]) => { - // Basic visibility and Preview access - if (m.isVisible !== true) return false; - if (m.isPreview && !shouldShowPreviewModels) return false; - // Only auto models are shown on the main menu - if (m.tier !== 'auto') return false; - return true; - }) - .map(([id, m]) => ({ - value: id, - title: m.displayName ?? getDisplayString(id, config ?? undefined), - description: - id === 'auto-gemini-3' && useGemini31 - ? (m.dialogDescription ?? '').replace( - 'gemini-3-pro', - 'gemini-3.1-pro', - ) - : (m.dialogDescription ?? ''), - key: id, + const allOptions = config + .getModelConfigService() + .getAvailableModelOptions({ + useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, + useCustomTools: useCustomToolModel, + hasAccessToPreview: shouldShowPreviewModels, + hasAccessToProModel, + }); + + const list = allOptions + .filter((o) => o.tier === 'auto') + .map((o) => ({ + value: o.modelId, + title: o.name, + description: o.description, + key: o.modelId, })); list.push({ @@ -186,64 +183,39 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { }); } return list; - }, [config, shouldShowPreviewModels, manualModelSelected, useGemini31]); + }, [ + config, + shouldShowPreviewModels, + manualModelSelected, + useGemini31, + useGemini31FlashLite, + useCustomToolModel, + hasAccessToProModel, + ]); const manualOptions = useMemo(() => { // --- DYNAMIC PATH --- if ( config?.getExperimentalDynamicModelConfiguration?.() === true && - config.modelConfigService + config.getModelConfigService ) { - const list = Object.entries( - config.modelConfigService.getModelDefinitions?.() ?? {}, - ) - .filter(([id, m]) => { - // Basic visibility and Preview access - if (m.isVisible !== true) return false; - if (m.isPreview && !shouldShowPreviewModels) return false; - // Auto models are for main menu only - if (m.tier === 'auto') return false; - // Pro models are shown for users with pro access - if (!hasAccessToProModel && m.tier === 'pro') return false; - - // Flag Guard: Versioned models only show if their flag is active. - if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false; - if ( - id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && - !useGemini31FlashLite - ) - return false; - - return true; - }) - .map(([id, m]) => { - const resolvedId = config.modelConfigService.resolveModelId(id, { - useGemini3_1: useGemini31, - useGemini3_1FlashLite: useGemini31FlashLite, - useCustomTools: useCustomToolModel, - }); - // Title ID is the resolved ID without custom tools flag - const titleId = config.modelConfigService.resolveModelId(id, { - useGemini3_1: useGemini31, - useGemini3_1FlashLite: useGemini31FlashLite, - }); - return { - value: resolvedId, - title: - m.displayName ?? getDisplayString(titleId, config ?? undefined), - key: id, - }; + const allOptions = config + .getModelConfigService() + .getAvailableModelOptions({ + useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, + useCustomTools: useCustomToolModel, + hasAccessToPreview: shouldShowPreviewModels, + hasAccessToProModel, }); - // Deduplicate: only show one entry per unique resolved model value. - // This is needed because 3 pro and 3.1 pro models can resolve to the same - // value, depending on the useGemini31 flag. - const seen = new Set(); - return list.filter((option) => { - if (seen.has(option.value)) return false; - seen.add(option.value); - return true; - }); + return allOptions + .filter((o) => o.tier !== 'auto') + .map((o) => ({ + value: o.modelId, + title: o.name, + key: o.modelId, + })); } // --- LEGACY PATH --- diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 00b5fa1010..f01b4bbd93 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2683,6 +2683,10 @@ export class Config implements McpContext, AgentLoopContext { return this.modelRouterService; } + getModelConfigService(): ModelConfigService { + return this.modelConfigService; + } + getModelAvailabilityService(): ModelAvailabilityService { return this.modelAvailabilityService; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 87feca53e7..5361397386 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,6 +49,10 @@ export * from './scheduler/tool-executor.js'; export * from './scheduler/policy.js'; export * from './core/recordingContentGenerator.js'; +// Export Routing +export * from './routing/routingStrategy.js'; +export * from './routing/modelRouterService.js'; + export * from './fallback/types.js'; export * from './fallback/handler.js'; @@ -132,6 +136,7 @@ export * from './services/FolderTrustDiscoveryService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sandboxedFileSystemService.js'; +export * from './services/modelConfigService.js'; export * from './sandbox/windows/WindowsSandboxManager.js'; export * from './services/sessionSummaryUtils.js'; export * from './context/contextManager.js'; diff --git a/packages/core/src/services/modelConfigService.test.ts b/packages/core/src/services/modelConfigService.test.ts index 2bc69bbfe2..70df1aa7b0 100644 --- a/packages/core/src/services/modelConfigService.test.ts +++ b/packages/core/src/services/modelConfigService.test.ts @@ -1018,4 +1018,41 @@ describe('ModelConfigService', () => { expect(retry.generateContentConfig.temperature).toBe(1.0); }); }); + + describe('getAvailableModelOptions', () => { + it('should filter out Pro models when hasAccessToProModel is false', () => { + const config: ModelConfigServiceConfig = { + modelDefinitions: { + 'gemini-3-pro': { isVisible: true, tier: 'pro' }, + 'gemini-3-flash': { isVisible: true, tier: 'flash' }, + }, + }; + const service = new ModelConfigService(config); + const options = service.getAvailableModelOptions({ + hasAccessToProModel: false, + }); + + expect(options.map((o) => o.modelId)).not.toContain('gemini-3-pro'); + expect(options.map((o) => o.modelId)).toContain('gemini-3-flash'); + }); + + it('should include Pro models when hasAccessToProModel is true or undefined', () => { + const config: ModelConfigServiceConfig = { + modelDefinitions: { + 'gemini-3-pro': { isVisible: true, tier: 'pro' }, + }, + }; + const service = new ModelConfigService(config); + + const optionsWithTrue = service.getAvailableModelOptions({ + hasAccessToProModel: true, + }); + expect(optionsWithTrue.map((o) => o.modelId)).toContain('gemini-3-pro'); + + const optionsWithUndefined = service.getAvailableModelOptions({}); + expect(optionsWithUndefined.map((o) => o.modelId)).toContain( + 'gemini-3-pro', + ); + }); + }); }); diff --git a/packages/core/src/services/modelConfigService.ts b/packages/core/src/services/modelConfigService.ts index d92532fd3a..a6d59365d7 100644 --- a/packages/core/src/services/modelConfigService.ts +++ b/packages/core/src/services/modelConfigService.ts @@ -6,6 +6,12 @@ import type { GenerateContentConfig } from '@google/genai'; import type { ModelPolicy } from '../availability/modelPolicy.js'; +import { + getDisplayString, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, + isProModel, +} from '../config/models.js'; // The primary key for the ModelConfig is the model string. However, we also // support a secondary key to limit the override scope, typically an agent name. @@ -93,6 +99,7 @@ export interface ResolutionContext { useGemini3_1FlashLite?: boolean; useCustomTools?: boolean; hasAccessToPreview?: boolean; + hasAccessToProModel?: boolean; requestedModel?: string; } @@ -135,6 +142,78 @@ export class ModelConfigService { // TODO(12597): Process config to build a typed alias hierarchy. constructor(private readonly config: ModelConfigServiceConfig) {} + /** + * Returns a standardized list of available model options based on the resolution context. + * This logic is shared across the TUI and ACP mode. + */ + getAvailableModelOptions(context: ResolutionContext): Array<{ + modelId: string; + name: string; + description: string; + tier: string; + }> { + const definitions = this.config.modelDefinitions ?? {}; + const shouldShowPreviewModels = context.hasAccessToPreview ?? false; + const useGemini31 = context.useGemini3_1 ?? false; + const useGemini31FlashLite = context.useGemini3_1FlashLite ?? false; + + const mainOptions = Object.entries(definitions) + .filter(([_, m]) => { + if (m.isVisible !== true) return false; + if (m.isPreview && !shouldShowPreviewModels) return false; + if (m.tier !== 'auto') return false; + return true; + }) + .map(([id, m]) => ({ + modelId: id, + name: m.displayName ?? getDisplayString(id), + description: + id === 'auto-gemini-3' && useGemini31 + ? (m.dialogDescription ?? '').replace( + 'gemini-3-pro', + 'gemini-3.1-pro', + ) + : (m.dialogDescription ?? ''), + tier: m.tier ?? 'auto', + })); + + const manualOptions = Object.entries(definitions) + .filter(([id, m]) => { + if (m.isVisible !== true) return false; + if (m.isPreview && !shouldShowPreviewModels) return false; + if (m.tier === 'auto') return false; + if (context.hasAccessToProModel === false && isProModel(id)) + return false; + if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false; + if (id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && !useGemini31FlashLite) + return false; + return true; + }) + .map(([id, m]) => { + const resolvedId = this.resolveModelId(id, context); + const titleId = this.resolveModelId(id, { + useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, + }); + return { + modelId: resolvedId, + name: m.displayName ?? getDisplayString(titleId), + description: m.dialogDescription ?? '', + tier: m.tier ?? 'custom', + }; + }); + + // Deduplicate manual options + const seen = new Set(); + const uniqueManualOptions = manualOptions.filter((option) => { + if (seen.has(option.modelId)) return false; + seen.add(option.modelId); + return true; + }); + + return [...mainOptions, ...uniqueManualOptions]; + } + getModelDefinition(modelId: string): ModelDefinition | undefined { const definition = this.config.modelDefinitions?.[modelId]; if (definition) { From bda44916166162007aa39dcf8a588ab1e5fea243 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Wed, 1 Apr 2026 11:23:28 -0700 Subject: [PATCH 16/21] Changelog for v0.36.0-preview.7 (#24346) Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com> --- docs/changelogs/preview.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 5568191d73..da2233cb90 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.36.0-preview.6 +# Preview release: v0.36.0-preview.7 -Released: March 28, 2026 +Released: March 31, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -390,4 +390,4 @@ npm install -g @google/gemini-cli@preview [#23666](https://github.com/google-gemini/gemini-cli/pull/23666) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.6 +https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.7 From 43cf63e1892ec1932669c3c3af180cb11aee9d81 Mon Sep 17 00:00:00 2001 From: anj-s <32556631+anj-s@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:29:09 -0700 Subject: [PATCH 17/21] fix: update task tracker storage location in system prompt (#24034) --- evals/tracker.eval.ts | 17 ++++++++++ .../core/__snapshots__/prompts.test.ts.snap | 4 +-- packages/core/src/core/prompts.test.ts | 3 ++ .../core/src/prompts/promptProvider.test.ts | 33 +++++++++++++++++++ packages/core/src/prompts/promptProvider.ts | 15 +++++++-- packages/core/src/prompts/snippets.legacy.ts | 11 +++---- packages/core/src/prompts/snippets.ts | 11 +++---- 7 files changed, 77 insertions(+), 17 deletions(-) diff --git a/evals/tracker.eval.ts b/evals/tracker.eval.ts index 7afb41dbec..49bc903b0a 100644 --- a/evals/tracker.eval.ts +++ b/evals/tracker.eval.ts @@ -113,4 +113,21 @@ describe('tracker_mode', () => { assertModelHasOutput(result); }, }); + + evalTest('USUALLY_PASSES', { + name: 'should correctly identify the task tracker storage location from the system prompt', + params: { + settings: { experimental: { taskTracker: true } }, + }, + prompt: + 'Where is my task tracker storage located? Please provide the absolute path in your response.', + assert: async (rig, result) => { + // The rig sets GEMINI_CLI_HOME to rig.homeDir + const homeDir = rig.homeDir!; + // The response should contain the dynamic path which includes the home directory + // and follows the .gemini/tmp/.../tracker structure. + expect(result).toContain(homeDir); + expect(result).toMatch(/\.gemini\/tmp\/.*\/tracker/); + }, + }); }); diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index f95a4cc8df..91e2573e62 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -2899,7 +2899,7 @@ Use 'read_file' to understand context and validate any assumptions you may have. 6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype. # TASK MANAGEMENT PROTOCOL -You are operating with a persistent file-based task tracking system located at \`.tracker/tasks/\`. You must adhere to the following rules: +You are operating with a persistent file-based task tracking system located at \`/mock/.gemini/tmp/session/tracker\`. You must adhere to the following rules: 1. **NO IN-MEMORY LISTS**: Do not maintain a mental list of tasks or write markdown checkboxes in the chat. Use the provided tools (\`tracker_create_task\`, \`tracker_list_tasks\`, \`tracker_update_task\`) for all state management. 2. **IMMEDIATE DECOMPOSITION**: Upon receiving a task, evaluate its functional complexity and scope. If the request involves more than a single atomic modification, or necessitates research before execution, you MUST immediately decompose it into discrete entries using \`tracker_create_task\`. @@ -3079,7 +3079,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi 5. **Solicit Feedback:** Provide instructions on how to start the application and request user feedback on the prototype. # TASK MANAGEMENT PROTOCOL -You are operating with a persistent file-based task tracking system located at \`.tracker/tasks/\`. You must adhere to the following rules: +You are operating with a persistent file-based task tracking system located at \`/mock/.gemini/tmp/session/tracker\`. You must adhere to the following rules: 1. **NO IN-MEMORY LISTS**: Do not maintain a mental list of tasks or write markdown checkboxes in the chat. Use the provided tools (\`tracker_create_task\`, \`tracker_list_tasks\`, \`tracker_update_task\`) for all state management. 2. **IMMEDIATE DECOMPOSITION**: Upon receiving a task, evaluate its functional complexity and scope. If the request involves more than a single atomic modification, or necessitates research before execution, you MUST immediately decompose it into discrete entries using \`tracker_create_task\`. diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 6e505dfa2b..c8f5fe6cc7 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -93,6 +93,9 @@ describe('Core System Prompt (prompts.ts)', () => { storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), + getProjectTempTrackerDir: vi + .fn() + .mockReturnValue('/mock/.gemini/tmp/session/tracker'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index 554bad2003..2f82ae56a4 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -64,6 +64,9 @@ describe('PromptProvider', () => { storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), + getProjectTempTrackerDir: vi + .fn() + .mockReturnValue('/tmp/project-temp/tracker'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), @@ -104,6 +107,36 @@ describe('PromptProvider', () => { ); }); + it('should include the task tracker storage location in the system prompt', () => { + vi.mocked(mockConfig.isTrackerEnabled).mockReturnValue(true); + const mockTrackerDir = '/mock/tracker/path'; + vi.mocked(mockConfig.storage.getProjectTempTrackerDir).mockReturnValue( + mockTrackerDir, + ); + + const provider = new PromptProvider(); + const prompt = provider.getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain('# TASK MANAGEMENT PROTOCOL'); + expect(prompt).toContain(`located at \`${mockTrackerDir}\``); + }); + + it('should sanitize the task tracker storage location in the system prompt', () => { + vi.mocked(mockConfig.isTrackerEnabled).mockReturnValue(true); + const mockTrackerDir = '/mock/tracker/path\nwith-newline]and-bracket'; + vi.mocked(mockConfig.storage.getProjectTempTrackerDir).mockReturnValue( + mockTrackerDir, + ); + + const provider = new PromptProvider(); + const prompt = provider.getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain('# TASK MANAGEMENT PROTOCOL'); + expect(prompt).toContain( + 'located at `/mock/tracker/path with-newlineand-bracket`', + ); + }); + it('should handle multiple context filenames in user memory section', () => { vi.mocked(getAllGeminiMdFilenames).mockReturnValue([ DEFAULT_CONTEXT_FILENAME, diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 3425809583..0036dae560 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -72,6 +72,16 @@ export class PromptProvider { const activeSnippets = isModernModel ? snippets : legacySnippets; const contextFilenames = getAllGeminiMdFilenames(); + let trackerDir = context.config.isTrackerEnabled() + ? context.config.storage.getProjectTempTrackerDir() + : undefined; + + if (trackerDir) { + // Sanitize path to prevent prompt injection + trackerDir = trackerDir.replace(/\n/g, ' ').replace(/\]/g, ''); + } + + // --- Context Gathering --- let planModeToolsList = ''; if (isPlanMode) { const allTools = context.toolRegistry.getAllTools(); @@ -149,7 +159,7 @@ export class PromptProvider { })), skills.length > 0, ), - taskTracker: context.config.isTrackerEnabled(), + taskTracker: trackerDir, hookContext: isSectionEnabled('hookContext') || undefined, primaryWorkflows: this.withSection( 'primaryWorkflows', @@ -167,7 +177,7 @@ export class PromptProvider { approvedPlan: approvedPlanPath ? { path: approvedPlanPath } : undefined, - taskTracker: context.config.isTrackerEnabled(), + taskTracker: trackerDir, topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(), }), @@ -180,7 +190,6 @@ export class PromptProvider { planModeToolsList, plansDir: context.config.storage.getPlansDir(), approvedPlanPath: context.config.getApprovedPlanPath(), - taskTracker: context.config.isTrackerEnabled(), }), isPlanMode, ), diff --git a/packages/core/src/prompts/snippets.legacy.ts b/packages/core/src/prompts/snippets.legacy.ts index 5b97886046..4fea88937b 100644 --- a/packages/core/src/prompts/snippets.legacy.ts +++ b/packages/core/src/prompts/snippets.legacy.ts @@ -37,7 +37,7 @@ export interface SystemPromptOptions { hookContext?: boolean; primaryWorkflows?: PrimaryWorkflowsOptions; planningWorkflow?: PlanningWorkflowOptions; - taskTracker?: boolean; + taskTracker?: string; operationalGuidelines?: OperationalGuidelinesOptions; sandbox?: SandboxOptions; interactiveYoloMode?: boolean; @@ -63,7 +63,7 @@ export interface PrimaryWorkflowsOptions { enableWriteTodosTool: boolean; enableEnterPlanModeTool: boolean; approvedPlan?: { path: string }; - taskTracker?: boolean; + taskTracker?: string; topicUpdateNarration?: boolean; } @@ -95,7 +95,6 @@ export interface PlanningWorkflowOptions { planModeToolsList: string; plansDir: string; approvedPlanPath?: string; - taskTracker?: boolean; } export interface AgentSkillOptions { @@ -132,7 +131,7 @@ ${ : renderPrimaryWorkflows(options.primaryWorkflows) } -${options.taskTracker ? renderTaskTracker() : ''} +${options.taskTracker ? renderTaskTracker(options.taskTracker) : ''} ${renderOperationalGuidelines(options.operationalGuidelines)} @@ -491,10 +490,10 @@ An approved plan is available for this task. `; } -export function renderTaskTracker(): string { +export function renderTaskTracker(trackerDir: string): string { return ` # TASK MANAGEMENT PROTOCOL -You are operating with a persistent file-based task tracking system located at \`.tracker/tasks/\`. You must adhere to the following rules: +You are operating with a persistent file-based task tracking system located at \`${trackerDir}\`. You must adhere to the following rules: 1. **NO IN-MEMORY LISTS**: Do not maintain a mental list of tasks or write markdown checkboxes in the chat. Use the provided tools (\`${TRACKER_CREATE_TASK_TOOL_NAME}\`, \`${TRACKER_LIST_TASKS_TOOL_NAME}\`, \`${TRACKER_UPDATE_TASK_TOOL_NAME}\`) for all state management. 2. **IMMEDIATE DECOMPOSITION**: Upon receiving a task, evaluate its functional complexity and scope. If the request involves more than a single atomic modification, or necessitates research before execution, you MUST immediately decompose it into discrete entries using \`${TRACKER_CREATE_TASK_TOOL_NAME}\`. diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 77e397d5ca..5440583419 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -47,7 +47,7 @@ export interface SystemPromptOptions { hookContext?: boolean; primaryWorkflows?: PrimaryWorkflowsOptions; planningWorkflow?: PlanningWorkflowOptions; - taskTracker?: boolean; + taskTracker?: string; operationalGuidelines?: OperationalGuidelinesOptions; sandbox?: SandboxOptions; interactiveYoloMode?: boolean; @@ -74,7 +74,7 @@ export interface PrimaryWorkflowsOptions { enableGrep: boolean; enableGlob: boolean; approvedPlan?: { path: string }; - taskTracker?: boolean; + taskTracker?: string; topicUpdateNarration: boolean; } @@ -101,7 +101,6 @@ export interface PlanningWorkflowOptions { planModeToolsList: string; plansDir: string; approvedPlanPath?: string; - taskTracker?: boolean; } export interface AgentSkillOptions { @@ -139,7 +138,7 @@ ${ : renderPrimaryWorkflows(options.primaryWorkflows) } -${options.taskTracker ? renderTaskTracker() : ''} +${options.taskTracker ? renderTaskTracker(options.taskTracker) : ''} ${renderOperationalGuidelines(options.operationalGuidelines)} @@ -537,14 +536,14 @@ ${trimmed} return `\n---\n\n\n${sections.join('\n')}\n`; } -export function renderTaskTracker(): string { +export function renderTaskTracker(trackerDir: string): string { const trackerCreate = formatToolName(TRACKER_CREATE_TASK_TOOL_NAME); const trackerList = formatToolName(TRACKER_LIST_TASKS_TOOL_NAME); const trackerUpdate = formatToolName(TRACKER_UPDATE_TASK_TOOL_NAME); return ` # TASK MANAGEMENT PROTOCOL -You are operating with a persistent file-based task tracking system located at \`.tracker/tasks/\`. You must adhere to the following rules: +You are operating with a persistent file-based task tracking system located at \`${trackerDir}\`. You must adhere to the following rules: 1. **NO IN-MEMORY LISTS**: Do not maintain a mental list of tasks or write markdown checkboxes in the chat. Use the provided tools (${trackerCreate}, ${trackerList}, ${trackerUpdate}) for all state management. 2. **IMMEDIATE DECOMPOSITION**: Upon receiving a task, evaluate its functional complexity and scope. If the request involves more than a single atomic modification, or necessitates research before execution, you MUST immediately decompose it into discrete entries using ${trackerCreate}. From aed85725b6e80fcf188f9998ce5c296934be0c33 Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:41:39 +0800 Subject: [PATCH 18/21] feat(browser): supersede stale snapshots to reclaim context-window tokens (#24440) --- .../agents/browser/browserAgentDefinition.ts | 6 + .../agents/browser/snapshotSuperseder.test.ts | 214 ++++++++++++++++++ .../src/agents/browser/snapshotSuperseder.ts | 149 ++++++++++++ packages/core/src/agents/local-executor.ts | 4 + packages/core/src/agents/types.ts | 13 ++ 5 files changed, 386 insertions(+) create mode 100644 packages/core/src/agents/browser/snapshotSuperseder.test.ts create mode 100644 packages/core/src/agents/browser/snapshotSuperseder.ts diff --git a/packages/core/src/agents/browser/browserAgentDefinition.ts b/packages/core/src/agents/browser/browserAgentDefinition.ts index d0afa2c4b3..f7bf3258ec 100644 --- a/packages/core/src/agents/browser/browserAgentDefinition.ts +++ b/packages/core/src/agents/browser/browserAgentDefinition.ts @@ -14,6 +14,7 @@ */ import type { LocalAgentDefinition } from '../types.js'; +import { supersedeStaleSnapshots } from './snapshotSuperseder.js'; import type { Config } from '../../config/config.js'; import { z } from 'zod'; import { @@ -184,6 +185,11 @@ export const BrowserAgentDefinition = ( // This is undefined here and will be set at invocation time toolConfig: undefined, + // Supersede stale take_snapshot outputs to reclaim context-window tokens. + // Each snapshot contains the full accessibility tree; only the most recent + // one is meaningful, so prior snapshots are replaced with a placeholder. + onBeforeTurn: (chat) => supersedeStaleSnapshots(chat), + promptConfig: { query: `Your task is: diff --git a/packages/core/src/agents/browser/snapshotSuperseder.test.ts b/packages/core/src/agents/browser/snapshotSuperseder.test.ts new file mode 100644 index 0000000000..773d0216e0 --- /dev/null +++ b/packages/core/src/agents/browser/snapshotSuperseder.test.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + supersedeStaleSnapshots, + SNAPSHOT_SUPERSEDED_PLACEHOLDER, +} from './snapshotSuperseder.js'; +import type { GeminiChat } from '../../core/geminiChat.js'; +import type { Content } from '@google/genai'; + +/** Builds a minimal mock GeminiChat around a mutable history array. */ +function createMockChat(history: Content[]): GeminiChat { + return { + getHistory: vi.fn(() => [...history]), + setHistory: vi.fn((newHistory: readonly Content[]) => { + history.length = 0; + history.push(...newHistory); + }), + } as unknown as GeminiChat; +} + +/** Helper: creates a take_snapshot functionResponse part. */ +function snapshotResponse(output: string) { + return { + functionResponse: { + name: 'take_snapshot', + response: { output }, + }, + }; +} + +/** Helper: creates a non-snapshot functionResponse part. */ +function otherToolResponse(name: string, output: string) { + return { + functionResponse: { + name, + response: { output }, + }, + }; +} + +describe('supersedeStaleSnapshots', () => { + let history: Content[]; + let chat: GeminiChat; + + beforeEach(() => { + history = []; + }); + + it('should no-op when history has no snapshots', () => { + history.push( + { role: 'user', parts: [{ text: 'Click the button' }] }, + { + role: 'user', + parts: [otherToolResponse('click', 'Clicked element')], + }, + ); + chat = createMockChat(history); + + supersedeStaleSnapshots(chat); + + expect(chat.setHistory).not.toHaveBeenCalled(); + }); + + it('should no-op when history has exactly 1 snapshot', () => { + history.push( + { role: 'user', parts: [{ text: 'Navigate to page' }] }, + { + role: 'user', + parts: [snapshotResponse('big accessibility tree')], + }, + ); + chat = createMockChat(history); + + supersedeStaleSnapshots(chat); + + expect(chat.setHistory).not.toHaveBeenCalled(); + }); + + it('should replace all but the last snapshot when there are 2+', () => { + history.push( + { + role: 'user', + parts: [snapshotResponse('snapshot 1')], + }, + { + role: 'user', + parts: [otherToolResponse('click', 'Clicked OK')], + }, + { + role: 'user', + parts: [snapshotResponse('snapshot 2')], + }, + { + role: 'user', + parts: [otherToolResponse('type_text', 'Typed hello')], + }, + { + role: 'user', + parts: [snapshotResponse('snapshot 3 (latest)')], + }, + ); + chat = createMockChat(history); + + supersedeStaleSnapshots(chat); + + expect(chat.setHistory).toHaveBeenCalledTimes(1); + + // First two snapshots should be replaced + const part0 = history[0].parts![0]; + expect(part0.functionResponse?.response).toEqual({ + output: SNAPSHOT_SUPERSEDED_PLACEHOLDER, + }); + + const part2 = history[2].parts![0]; + expect(part2.functionResponse?.response).toEqual({ + output: SNAPSHOT_SUPERSEDED_PLACEHOLDER, + }); + + // Last snapshot should be untouched + const part4 = history[4].parts![0]; + expect(part4.functionResponse?.response).toEqual({ + output: 'snapshot 3 (latest)', + }); + }); + + it('should leave non-snapshot tool responses untouched', () => { + history.push( + { + role: 'user', + parts: [snapshotResponse('snapshot A')], + }, + { + role: 'user', + parts: [otherToolResponse('click', 'Clicked button')], + }, + { + role: 'user', + parts: [snapshotResponse('snapshot B (latest)')], + }, + ); + chat = createMockChat(history); + + supersedeStaleSnapshots(chat); + + // click response should be untouched + const clickPart = history[1].parts![0]; + expect(clickPart.functionResponse?.response).toEqual({ + output: 'Clicked button', + }); + }); + + it('should no-op when all stale snapshots are already superseded', () => { + history.push( + { + role: 'user', + parts: [snapshotResponse(SNAPSHOT_SUPERSEDED_PLACEHOLDER)], + }, + { + role: 'user', + parts: [snapshotResponse('current snapshot')], + }, + ); + chat = createMockChat(history); + + supersedeStaleSnapshots(chat); + + // Should not call setHistory since nothing changed + expect(chat.setHistory).not.toHaveBeenCalled(); + }); + + it('should handle snapshots in Content entries with multiple parts', () => { + history.push( + { + role: 'user', + parts: [ + otherToolResponse('click', 'Clicked'), + snapshotResponse('snapshot in multi-part'), + ], + }, + { + role: 'user', + parts: [snapshotResponse('latest snapshot')], + }, + ); + chat = createMockChat(history); + + supersedeStaleSnapshots(chat); + + expect(chat.setHistory).toHaveBeenCalledTimes(1); + + // The click response (index 0 of parts) should be untouched + const clickPart = history[0].parts![0]; + expect(clickPart.functionResponse?.response).toEqual({ + output: 'Clicked', + }); + + // The snapshot (index 1 of parts) should be replaced + const snapshotPart = history[0].parts![1]; + expect(snapshotPart.functionResponse?.response).toEqual({ + output: SNAPSHOT_SUPERSEDED_PLACEHOLDER, + }); + + // Latest snapshot untouched + const latestPart = history[1].parts![0]; + expect(latestPart.functionResponse?.response).toEqual({ + output: 'latest snapshot', + }); + }); +}); diff --git a/packages/core/src/agents/browser/snapshotSuperseder.ts b/packages/core/src/agents/browser/snapshotSuperseder.ts new file mode 100644 index 0000000000..e8a5068dd9 --- /dev/null +++ b/packages/core/src/agents/browser/snapshotSuperseder.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Supersedes stale `take_snapshot` outputs in the browser + * subagent's conversation history. Each snapshot contains the full + * accessibility tree and is only meaningful as the "current" page state; + * prior snapshots are stale and waste context-window tokens. + * + * Called via the {@link LocalAgentDefinition.onBeforeTurn} hook before each + * model call so the model only ever sees the most recent snapshot in full. + */ + +import type { GeminiChat } from '../../core/geminiChat.js'; +import type { Content, Part } from '@google/genai'; +import { debugLogger } from '../../utils/debugLogger.js'; + +const TAKE_SNAPSHOT_TOOL_NAME = 'take_snapshot'; + +/** + * Placeholder that replaces superseded snapshot outputs. + * Kept short to minimise token cost while still being informative. + */ +export const SNAPSHOT_SUPERSEDED_PLACEHOLDER = + '[Snapshot superseded — a newer snapshot exists later in this conversation. ' + + 'Call take_snapshot for current page state.]'; + +/** + * Scans the chat history and replaces all but the most recent + * `take_snapshot` `functionResponse` with a compact placeholder. + * + * No-ops when: + * - There are fewer than 2 snapshots (nothing to supersede). + * - All prior snapshots have already been superseded. + * + * Uses {@link GeminiChat.setHistory} to apply the modified history. + */ +export function supersedeStaleSnapshots(chat: GeminiChat): void { + const history = chat.getHistory(); + + // Locate all (contentIndex, partIndex) tuples for take_snapshot responses. + const snapshotLocations: Array<{ + contentIdx: number; + partIdx: number; + }> = []; + + for (let i = 0; i < history.length; i++) { + const parts = history[i].parts; + if (!parts) continue; + for (let j = 0; j < parts.length; j++) { + const part = parts[j]; + if ( + part.functionResponse && + part.functionResponse.name === TAKE_SNAPSHOT_TOOL_NAME + ) { + snapshotLocations.push({ contentIdx: i, partIdx: j }); + } + } + } + + // Nothing to do if there are 0 or 1 snapshots. + if (snapshotLocations.length < 2) { + return; + } + + // Check whether any stale snapshot actually needs replacement. + // (Skip the last entry — that's the one we keep.) + const staleLocations = snapshotLocations.slice(0, -1); + const needsUpdate = staleLocations.some(({ contentIdx, partIdx }) => { + const output = getResponseOutput( + history[contentIdx].parts![partIdx].functionResponse?.response, + ); + return !output.includes(SNAPSHOT_SUPERSEDED_PLACEHOLDER); + }); + + if (!needsUpdate) { + return; + } + + // Shallow-copy the history and replace stale snapshots. + const newHistory: Content[] = history.map((content) => ({ + ...content, + parts: content.parts ? [...content.parts] : undefined, + })); + + let replacedCount = 0; + + for (const { contentIdx, partIdx } of staleLocations) { + const originalPart = newHistory[contentIdx].parts![partIdx]; + if (!originalPart.functionResponse) continue; + + // Check if already superseded + const output = getResponseOutput(originalPart.functionResponse.response); + if (output.includes(SNAPSHOT_SUPERSEDED_PLACEHOLDER)) { + continue; + } + + const replacementPart: Part = { + functionResponse: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...originalPart.functionResponse, + response: { output: SNAPSHOT_SUPERSEDED_PLACEHOLDER }, + }, + }; + + newHistory[contentIdx].parts![partIdx] = replacementPart; + replacedCount++; + } + + if (replacedCount > 0) { + chat.setHistory(newHistory); + debugLogger.log( + `[SnapshotSuperseder] Replaced ${replacedCount} stale take_snapshot output(s).`, + ); + } +} + +/** + * Shape of a functionResponse.response that contains an `output` string. + */ +interface ResponseWithOutput { + output: string; +} + +function isResponseWithOutput( + response: object | undefined, +): response is ResponseWithOutput { + return ( + response !== null && + response !== undefined && + 'output' in response && + typeof response.output === 'string' + ); +} + +/** + * Safely extracts the `output` string from a functionResponse.response object. + * The GenAI SDK types `response` as `object | undefined`, so we need runtime + * checks to access the `output` field. + */ +function getResponseOutput(response: object | undefined): string { + if (isResponseWithOutput(response)) { + return response.output; + } + return ''; +} diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 8168c44610..af7312c231 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -317,6 +317,10 @@ export class LocalAgentExecutor { await this.tryCompressChat(chat, promptId, combinedSignal); + // Allow the agent definition to modify history before the model call + // (e.g., superseding stale tool outputs to reclaim context tokens). + await this.definition.onBeforeTurn?.(chat, combinedSignal); + const { functionCalls, modelToUse } = await promptIdContext.run( promptId, async () => diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 456f4cfdb3..a7d921453b 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -16,6 +16,7 @@ import type { AnySchema } from 'ajv'; import type { AgentCard } from '@a2a-js/sdk'; import type { A2AAuthConfig } from './auth-provider/types.js'; import type { MCPServerConfig } from '../config/config.js'; +import type { GeminiChat } from '../core/geminiChat.js'; /** * Describes the possible termination modes for an agent. @@ -227,6 +228,18 @@ export interface LocalAgentDefinition< * @returns A string representation of the final output. */ processOutput?: (output: z.infer) => string; + + /** + * Optional hook invoked before each model call. Receives the active + * {@link GeminiChat} instance and may modify chat history (e.g., to + * supersede stale tool outputs and reclaim context-window tokens). + * + * Runs immediately after chat compression in the agent loop. + */ + onBeforeTurn?: ( + chat: GeminiChat, + signal?: AbortSignal, + ) => Promise | void; } export interface BaseRemoteAgentDefinition< From d9d51ba15b3c0d61d51278f66696335d79e744b7 Mon Sep 17 00:00:00 2001 From: AK Date: Wed, 1 Apr 2026 11:45:21 -0700 Subject: [PATCH 19/21] docs(core): add subagent tool isolation draft doc (#23275) Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com> --- docs/core/subagents.md | 73 +++++++++++++++++++++++++++++++++ docs/reference/policy-engine.md | 32 +++++++-------- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 70c6f9d7e5..a789e0f741 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -332,6 +332,7 @@ it yourself; just report it. | `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. | | `kind` | string | No | `local` (default) or `remote`. | | `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** | +| `mcpServers` | object | No | Configuration for inline Model Context Protocol (MCP) servers isolated to this specific agent. | | `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). | | `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. | | `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. | @@ -359,6 +360,78 @@ Each subagent runs in its own isolated context loop. This means: subagents **cannot** call other subagents. If a subagent is granted the `*` tool wildcard, it will still be unable to see or invoke other agents. +## Subagent tool isolation + +Subagent tool isolation moves Gemini CLI away from a single global tool +registry. By providing isolated execution environments, you can ensure that +subagents only interact with the parts of the system they are designed for. This +prevents unintended side effects, improves reliability by avoiding state +contamination, and enables fine-grained permission control. + +With this feature, you can: + +- **Specify tool access:** Define exactly which tools an agent can access using + a `tools` list in the agent definition. +- **Define inline MCP servers:** Configure Model Context Protocol (MCP) servers + (which provide a standardized way to connect AI models to external tools and + data sources) directly in the subagent's markdown frontmatter, isolating them + to that specific agent. +- **Maintain state isolation:** Ensure that subagents only interact with their + own set of tools and servers, preventing side effects and state contamination. +- **Apply subagent-specific policies:** Enforce granular rules in your + [Policy Engine](../reference/policy-engine.md) TOML configuration based on the + executing subagent's name. + +### Configuring isolated tools and servers + +You can configure tool isolation for a subagent by updating its markdown +frontmatter. This allows you to explicitly state which tools the subagent can +use, rather than relying on the global registry. + +Add an `mcpServers` object to define inline MCP servers that are unique to the +agent. + +**Example:** + +```yaml +--- +name: my-isolated-agent +tools: + - grep_search + - read_file +mcpServers: + my-custom-server: + command: 'node' + args: ['path/to/server.js'] +--- +``` + +### Subagent-specific policies + +You can enforce fine-grained control over subagents using the +[Policy Engine's](../reference/policy-engine.md) TOML configuration. This allows +you to grant or restrict permissions specifically for an agent, without +affecting the rest of your CLI session. + +To restrict a policy rule to a specific subagent, add the `subagent` property to +the `[[rules]]` block in your `policy.toml` file. + +**Example:** + +```toml +[[rules]] +name = "Allow pr-creator to push code" +subagent = "pr-creator" +description = "Permit pr-creator to push branches automatically." +action = "allow" +toolName = "run_shell_command" +commandPrefix = "git push" +``` + +In this configuration, the policy rule only triggers if the executing subagent's +name matches `pr-creator`. Rules without the `subagent` property apply +universally to all agents. + ## Managing subagents You can manage subagents interactively using the `/agents` command or diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index bb00f30f77..597e74f111 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -29,13 +29,12 @@ To create your first policy: ```toml [[rule]] toolName = "run_shell_command" - commandPrefix = "git status" - decision = "allow" + commandPrefix = "rm -rf" + decision = "deny" priority = 100 ``` 3. **Run a command** that triggers the policy (e.g., ask Gemini CLI to - `git status`). The tool will now execute automatically without prompting for - confirmation. + `rm -rf /`). The tool will now be blocked automatically. ## Core concepts @@ -143,25 +142,26 @@ engine transforms this into a final priority using the following formula: This system guarantees that: -- Admin policies always override User, Workspace, and Default policies. +- Admin policies always override User, Workspace, and Default policies (defined + in policy TOML files). - 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: 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`. +- A `priority: 50` rule in a Default policy TOML becomes `1.050`. +- A `priority: 10` rule in a Workspace policy TOML becomes `2.010`. +- A `priority: 100` rule in a User policy TOML becomes `3.100`. +- A `priority: 20` rule in an Admin policy TOML becomes `4.020`. ### Approval modes Approval modes allow the policy engine to apply different sets of rules based on -the CLI's operational mode. A rule can be associated with one or more modes -(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is -running in one of its specified modes. If a rule has no modes specified, it is -always active. +the CLI's operational mode. A rule in a TOML policy file can be associated with +one or more modes (e.g., `yolo`, `autoEdit`, `plan`). The rule will only be +active if the CLI is running in one of its specified modes. If a rule has no +modes specified, it is always active. - `default`: The standard interactive mode where most write tools require confirmation. @@ -179,8 +179,8 @@ outcome. A rule matches a tool call if all of its conditions are met: -1. **Tool name**: The `toolName` in the rule must match the name of the tool - being called. +1. **Tool name**: The `toolName` in the TOML rule must match the name of the + tool being called. - **Wildcards**: You can use wildcards like `*`, `mcp_server_*`, or `mcp_*_toolName` to match multiple tools. See [Tool Name](#tool-name) for details. @@ -264,7 +264,7 @@ toolName = "run_shell_command" # (Optional) The name of a subagent. If provided, the rule only applies to tool # calls made by this specific subagent. -subagent = "generalist" +subagent = "codebase_investigator" # (Optional) The name of an MCP server. Can be combined with toolName # to form a composite FQN internally like "mcp_mcpName_toolName". From 4e21e5b8a3471afd7513b0e0baefab8b17985cb7 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Wed, 1 Apr 2026 12:15:27 -0700 Subject: [PATCH 20/21] fix(cli): refresh slash command list after /skills reload (#24454) --- packages/cli/src/test-utils/mockCommandContext.ts | 1 + packages/cli/src/ui/commands/skillsCommand.test.ts | 1 + packages/cli/src/ui/commands/skillsCommand.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 6eda7f3109..9a1156e5cb 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -61,6 +61,7 @@ export const createMockCommandContext = ( toggleCorgiMode: vi.fn(), toggleShortcutsHelp: vi.fn(), toggleVimEnabled: vi.fn(), + reloadCommands: vi.fn(), openAgentConfigDialog: vi.fn(), closeAgentConfigDialog: vi.fn(), extensionsUpdateState: new Map(), diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 120ba01ed7..438f09b182 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -528,6 +528,7 @@ describe('skillsCommand', () => { await actionPromise; expect(reloadSkillsMock).toHaveBeenCalled(); + expect(context.ui.reloadCommands).toHaveBeenCalled(); expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 8c8db2fca5..ea1888db40 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -285,6 +285,8 @@ async function reloadAction( context.ui.setPendingItem(null); } + context.ui.reloadCommands(); + const afterSkills = skillManager.getSkills(); const afterNames = new Set(afterSkills.map((s) => s.name)); From 597778e55f14d1f5d9da7ea78783dc57309197d3 Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:15:44 -0700 Subject: [PATCH 21/21] Update README.md for links. (#22759) --- README.md | 108 +++++++++++++++++++++++++++++------------------------- 1 file changed, 58 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 03a7be1296..10458b2126 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/). ## 📦 Installation See -[Gemini CLI installation, execution, and releases](./docs/get-started/installation.md) +[Gemini CLI installation, execution, and releases](https://www.geminicli.com/docs/get-started/installation) for recommended system specifications and a detailed installation guide. ### Quick Install @@ -71,9 +71,9 @@ conda activate gemini_env npm install -g @google/gemini-cli ``` -## Release Cadence and Tags +## Release Channels -See [Releases](./docs/releases.md) for more details. +See [Releases](https://www.geminicli.com/docs/changelogs) for more details. ### Preview @@ -209,7 +209,7 @@ gemini ``` For Google Workspace accounts and other authentication methods, see the -[authentication guide](./docs/get-started/authentication.md). +[authentication guide](https://www.geminicli.com/docs/get-started/authentication). ## 🚀 Getting Started @@ -278,59 +278,64 @@ gemini ### Getting Started -- [**Quickstart Guide**](./docs/get-started/index.md) - Get up and running - quickly. -- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed - auth configuration. -- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and - customization. -- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) - +- [**Quickstart Guide**](https://www.geminicli.com/docs/get-started) - Get up + and running quickly. +- [**Authentication Setup**](https://www.geminicli.com/docs/get-started/authentication) - + Detailed auth configuration. +- [**Configuration Guide**](https://www.geminicli.com/docs/reference/configuration) - + Settings and customization. +- [**Keyboard Shortcuts**](https://www.geminicli.com/docs/reference/keyboard-shortcuts) - Productivity tips. ### Core Features -- [**Commands Reference**](./docs/reference/commands.md) - All slash commands - (`/help`, `/chat`, etc). -- [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own - reusable commands. -- [**Context Files (GEMINI.md)**](./docs/cli/gemini-md.md) - Provide persistent - context to Gemini CLI. -- [**Checkpointing**](./docs/cli/checkpointing.md) - Save and resume - conversations. -- [**Token Caching**](./docs/cli/token-caching.md) - Optimize token usage. +- [**Commands Reference**](https://www.geminicli.com/docs/reference/commands) - + All slash commands (`/help`, `/chat`, etc). +- [**Custom Commands**](https://www.geminicli.com/docs/cli/custom-commands) - + Create your own reusable commands. +- [**Context Files (GEMINI.md)**](https://www.geminicli.com/docs/cli/gemini-md) - + Provide persistent context to Gemini CLI. +- [**Checkpointing**](https://www.geminicli.com/docs/cli/checkpointing) - Save + and resume conversations. +- [**Token Caching**](https://www.geminicli.com/docs/cli/token-caching) - + Optimize token usage. ### Tools & Extensions -- [**Built-in Tools Overview**](./docs/reference/tools.md) - - [File System Operations](./docs/tools/file-system.md) - - [Shell Commands](./docs/tools/shell.md) - - [Web Fetch & Search](./docs/tools/web-fetch.md) -- [**MCP Server Integration**](./docs/tools/mcp-server.md) - Extend with custom - tools. -- [**Custom Extensions**](./docs/extensions/index.md) - Build and share your own - commands. +- [**Built-in Tools Overview**](https://www.geminicli.com/docs/reference/tools) + - [File System Operations](https://www.geminicli.com/docs/tools/file-system) + - [Shell Commands](https://www.geminicli.com/docs/tools/shell) + - [Web Fetch & Search](https://www.geminicli.com/docs/tools/web-fetch) +- [**MCP Server Integration**](https://www.geminicli.com/docs/tools/mcp-server) - + Extend with custom tools. +- [**Custom Extensions**](https://geminicli.com/docs/extensions/writing-extensions) - + Build and share your own commands. ### Advanced Topics -- [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in - automated workflows. -- [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion. -- [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution - environments. -- [**Trusted Folders**](./docs/cli/trusted-folders.md) - Control execution - policies by folder. -- [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a - corporate environment. -- [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking. -- [**Tools reference**](./docs/reference/tools.md) - Built-in tools overview. -- [**Local development**](./docs/local-development.md) - Local development - tooling. +- [**Headless Mode (Scripting)**](https://www.geminicli.com/docs/cli/headless) - + Use Gemini CLI in automated workflows. +- [**IDE Integration**](https://www.geminicli.com/docs/ide-integration) - VS + Code companion. +- [**Sandboxing & Security**](https://www.geminicli.com/docs/cli/sandbox) - Safe + execution environments. +- [**Trusted Folders**](https://www.geminicli.com/docs/cli/trusted-folders) - + Control execution policies by folder. +- [**Enterprise Guide**](https://www.geminicli.com/docs/cli/enterprise) - Deploy + and manage in a corporate environment. +- [**Telemetry & Monitoring**](https://www.geminicli.com/docs/cli/telemetry) - + Usage tracking. +- [**Tools reference**](https://www.geminicli.com/docs/reference/tools) - + Built-in tools overview. +- [**Local development**](https://www.geminicli.com/docs/local-development) - + Local development tooling. ### Troubleshooting & Support -- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common - issues and solutions. -- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions. +- [**Troubleshooting Guide**](https://www.geminicli.com/docs/resources/troubleshooting) - + Common issues and solutions. +- [**FAQ**](https://www.geminicli.com/docs/resources/faq) - Frequently asked + questions. - Use `/bug` command to report issues directly from the CLI. ### Using MCP Servers @@ -344,8 +349,9 @@ custom tools: > @database Run a query to find inactive users ``` -See the [MCP Server Integration guide](./docs/tools/mcp-server.md) for setup -instructions. +See the +[MCP Server Integration guide](https://www.geminicli.com/docs/tools/mcp-server) +for setup instructions. ## 🤝 Contributing @@ -366,7 +372,8 @@ for planned features and priorities. ## 📖 Resources - **[Official Roadmap](./ROADMAP.md)** - See what's coming next. -- **[Changelog](./docs/changelogs/index.md)** - See recent notable updates. +- **[Changelog](https://www.geminicli.com/docs/changelogs)** - See recent + notable updates. - **[NPM Package](https://www.npmjs.com/package/@google/gemini-cli)** - Package registry. - **[GitHub Issues](https://github.com/google-gemini/gemini-cli/issues)** - @@ -376,13 +383,14 @@ for planned features and priorities. ### Uninstall -See the [Uninstall Guide](./docs/resources/uninstall.md) for removal -instructions. +See the [Uninstall Guide](https://www.geminicli.com/docs/resources/uninstall) +for removal instructions. ## 📄 Legal - **License**: [Apache License 2.0](LICENSE) -- **Terms of Service**: [Terms & Privacy](./docs/resources/tos-privacy.md) +- **Terms of Service**: + [Terms & Privacy](https://www.geminicli.com/docs/resources/tos-privacy) - **Security**: [Security Policy](SECURITY.md) ---