Merge branch 'main' into restart-resume

This commit is contained in:
Jack Wotherspoon
2026-03-09 17:27:28 +01:00
committed by GitHub
47 changed files with 456 additions and 292 deletions
@@ -75,6 +75,14 @@ export function createMockConfig(
validatePathAccess: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(mockConfig as unknown as { config: Config; promptId: string }).config =
mockConfig;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
'test-prompt-id';
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
mockConfig.getHookSystem = vi
.fn()
+2 -2
View File
@@ -172,7 +172,7 @@ describe('GeminiAgent', () => {
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
@@ -650,7 +650,7 @@ describe('Session', () => {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
setModel: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
+6 -1
View File
@@ -92,7 +92,7 @@ describe('GeminiAgent Session Resume', () => {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
@@ -204,6 +204,11 @@ describe('GeminiAgent Session Resume', () => {
name: 'YOLO',
description: 'Auto-approves all tools',
},
{
id: ApprovalMode.PLAN,
name: 'Plan',
description: 'Read-only mode',
},
],
currentModeId: ApprovalMode.DEFAULT,
},
+2 -2
View File
@@ -2622,13 +2622,13 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => {
it('should allow plan approval mode by default when --approval-mode=plan is used', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN);
});
it('should pass planSettings.directory from settings to config', async () => {
@@ -424,12 +424,10 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(false);
expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(true);
expect(setting.description).toBe(
'Enable planning features (Plan Mode and tools).',
);
expect(setting.description).toBe('Enable Plan Mode.');
});
it('should have hooksConfig.notifications setting in schema', () => {
+2 -2
View File
@@ -1823,8 +1823,8 @@ const SETTINGS_SCHEMA = {
label: 'Plan',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable planning features (Plan Mode and tools).',
default: true,
description: 'Enable Plan Mode.',
showInDialog: true,
},
taskTracker: {
@@ -151,7 +151,7 @@ describe('BuiltinCommandLoader', () => {
vi.clearAllMocks();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
getEnableHooksUI: () => false,
@@ -351,7 +351,7 @@ describe('BuiltinCommandLoader profile', () => {
vi.resetModules();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: () => false,
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
@@ -131,4 +131,12 @@ describe('compressCommand', () => {
await compressCommand.action!(context, '');
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
});
describe('metadata', () => {
it('should have the correct name and aliases', () => {
expect(compressCommand.name).toBe('compress');
expect(compressCommand.altNames).toContain('summarize');
expect(compressCommand.altNames).toContain('compact');
});
});
});
@@ -11,7 +11,7 @@ import { CommandKind } from './types.js';
export const compressCommand: SlashCommand = {
name: 'compress',
altNames: ['summarize'],
altNames: ['summarize', 'compact'],
description: 'Compresses the context by replacing it with a summary',
kind: CommandKind.BUILT_IN,
autoExecute: true,
@@ -110,4 +110,28 @@ describe('toolsCommand', () => {
);
expect(message.tools[1].description).toBe('Edits code files.');
});
it('should expose a desc subcommand for TUI discoverability', async () => {
const descSubCommand = toolsCommand.subCommands?.find(
(cmd) => cmd.name === 'desc',
);
expect(descSubCommand).toBeDefined();
expect(descSubCommand?.description).toContain('descriptions');
const mockContext = createMockCommandContext({
services: {
config: {
getToolRegistry: () => ({ getAllTools: () => mockTools }),
},
},
});
if (!descSubCommand?.action) throw new Error('Action not defined');
await descSubCommand.action(mockContext, '');
const [message] = (mockContext.ui.addItem as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(message.type).toBe(MessageType.TOOLS_LIST);
expect(message.showDescriptions).toBe(true);
});
});
+47 -30
View File
@@ -11,43 +11,60 @@ import {
} from './types.js';
import { MessageType, type HistoryItemToolsList } from '../types.js';
async function listTools(
context: CommandContext,
showDescriptions: boolean,
): Promise<void> {
const toolRegistry = context.services.config?.getToolRegistry();
if (!toolRegistry) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Could not retrieve tool registry.',
});
return;
}
const tools = toolRegistry.getAllTools();
// Filter out MCP tools by checking for the absence of a serverName property
const geminiTools = tools.filter((tool) => !('serverName' in tool));
const toolsListItem: HistoryItemToolsList = {
type: MessageType.TOOLS_LIST,
tools: geminiTools.map((tool) => ({
name: tool.name,
displayName: tool.displayName,
description: tool.description,
})),
showDescriptions,
};
context.ui.addItem(toolsListItem);
}
const toolsDescSubCommand: SlashCommand = {
name: 'desc',
altNames: ['descriptions'],
description: 'List available Gemini CLI tools with descriptions.',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext): Promise<void> =>
listTools(context, true),
};
export const toolsCommand: SlashCommand = {
name: 'tools',
description: 'List available Gemini CLI tools. Usage: /tools [desc]',
description:
'List available Gemini CLI tools. Use /tools desc to include descriptions.',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [toolsDescSubCommand],
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();
// Default to NOT showing descriptions. The user must opt in with an argument.
let useShowDescriptions = false;
if (subCommand === 'desc' || subCommand === 'descriptions') {
useShowDescriptions = true;
}
// Keep backward compatibility for typed arguments while exposing desc in TUI via subcommands.
const useShowDescriptions =
subCommand === 'desc' || subCommand === 'descriptions';
const toolRegistry = context.services.config?.getToolRegistry();
if (!toolRegistry) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Could not retrieve tool registry.',
});
return;
}
const tools = toolRegistry.getAllTools();
// Filter out MCP tools by checking for the absence of a serverName property
const geminiTools = tools.filter((tool) => !('serverName' in tool));
const toolsListItem: HistoryItemToolsList = {
type: MessageType.TOOLS_LIST,
tools: geminiTools.map((tool) => ({
name: tool.name,
displayName: tool.displayName,
description: tool.description,
})),
showDescriptions: useShowDescriptions,
};
context.ui.addItem(toolsListItem);
await listTools(context, useShowDescriptions);
},
};
@@ -231,7 +231,7 @@ const createMockConfig = (overrides = {}): Config =>
getDebugMode: vi.fn(() => false),
getAccessibility: vi.fn(() => ({})),
getMcpServers: vi.fn(() => ({})),
isPlanEnabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => true),
getToolRegistry: () => ({
getTool: vi.fn(),
}),
@@ -86,7 +86,7 @@ describe('useApprovalModeIndicator', () => {
(value: ApprovalMode) => void
>,
isYoloModeDisabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
isTrustedFolder: vi.fn().mockReturnValue(true) as Mock<() => boolean>,
getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>,
getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock<
@@ -30,7 +30,7 @@ describe('agent-scheduler', () => {
} as unknown as Mocked<ToolRegistry>;
mockConfig = {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
toolRegistry: mockToolRegistry,
} as unknown as Mocked<Config>;
});
@@ -69,6 +69,6 @@ describe('agent-scheduler', () => {
// Verify that the scheduler's config has the overridden tool registry
const schedulerConfig = vi.mocked(Scheduler).mock.calls[0][0].config;
expect(schedulerConfig.getToolRegistry()).toBe(mockToolRegistry);
expect(schedulerConfig.toolRegistry).toBe(mockToolRegistry);
});
});
@@ -0,0 +1,27 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { GeminiClient } from '../core/client.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
/**
* AgentLoopContext represents the execution-scoped view of the world for a single
* agent turn or sub-agent loop.
*/
export interface AgentLoopContext {
/** The unique ID for the current user turn or agent thought loop. */
readonly promptId: string;
/** The registry of tools available to the agent in this context. */
readonly toolRegistry: ToolRegistry;
/** The bus for user confirmations and messages in this context. */
readonly messageBus: MessageBus;
/** The client used to communicate with the LLM in this context. */
readonly geminiClient: GeminiClient;
}
+2 -2
View File
@@ -2788,9 +2788,9 @@ describe('Config Quota & Preview Model Access', () => {
});
describe('isPlanEnabled', () => {
it('should return false by default', () => {
it('should return true by default', () => {
const config = new Config(baseParams);
expect(config.isPlanEnabled()).toBe(false);
expect(config.isPlanEnabled()).toBe(true);
});
it('should return true when plan is enabled', () => {
+78 -64
View File
@@ -6,7 +6,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { inspect } from 'node:util';
import process from 'node:process';
import {
@@ -97,6 +96,7 @@ import type {
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { ModelRouterService } from '../routing/modelRouterService.js';
import { OutputFormat } from '../output/types.js';
//import { type AgentLoopContext } from './agent-loop-context.js';
import {
ModelConfigService,
type ModelConfig,
@@ -146,7 +146,7 @@ import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath } from '../utils/paths.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { UserHintService } from './userHintService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
import { loadPoliciesFromToml } from '../policy/toml-loader.js';
@@ -155,6 +155,7 @@ import { CheckerRunner } from '../safety/checker-runner.js';
import { ContextBuilder } from '../safety/context-builder.js';
import { CheckerRegistry } from '../safety/registry.js';
import { ConsecaSafetyChecker } from '../safety/conseca/conseca.js';
import type { AgentLoopContext } from './agent-loop-context.js';
export interface AccessibilitySettings {
/** @deprecated Use ui.loadingPhrases instead. */
@@ -599,8 +600,8 @@ export interface ConfigParameters {
};
}
export class Config implements McpContext {
private toolRegistry!: ToolRegistry;
export class Config implements McpContext, AgentLoopContext {
private _toolRegistry!: ToolRegistry;
private mcpClientManager?: McpClientManager;
private allowedMcpServers: string[];
private blockedMcpServers: string[];
@@ -612,7 +613,7 @@ export class Config implements McpContext {
private agentRegistry!: AgentRegistry;
private readonly acknowledgedAgentsService: AcknowledgedAgentsService;
private skillManager!: SkillManager;
private sessionId: string;
private _sessionId: string;
private clientVersion: string;
private fileSystemService: FileSystemService;
private trackerService?: TrackerService;
@@ -646,7 +647,7 @@ export class Config implements McpContext {
private readonly accessibility: AccessibilitySettings;
private readonly telemetrySettings: TelemetrySettings;
private readonly usageStatisticsEnabled: boolean;
private geminiClient!: GeminiClient;
private _geminiClient!: GeminiClient;
private baseLlmClient!: BaseLlmClient;
private localLiteRtLmClient?: LocalLiteRtLmClient;
private modelRouterService: ModelRouterService;
@@ -741,7 +742,7 @@ export class Config implements McpContext {
private readonly fileExclusions: FileExclusions;
private readonly eventEmitter?: EventEmitter;
private readonly useWriteTodos: boolean;
private readonly messageBus: MessageBus;
private readonly _messageBus: MessageBus;
private readonly policyEngine: PolicyEngine;
private policyUpdateConfirmationRequest:
| PolicyUpdateConfirmationRequest
@@ -807,7 +808,7 @@ export class Config implements McpContext {
private approvedPlanPath: string | undefined;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
this._sessionId = params.sessionId;
this.clientVersion = params.clientVersion ?? 'unknown';
this.approvedPlanPath = undefined;
this.embeddingModel =
@@ -885,7 +886,7 @@ export class Config implements McpContext {
this.enableAgents = params.enableAgents ?? false;
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? false;
this.planEnabled = params.plan ?? true;
this.trackerEnabled = params.tracker ?? false;
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
@@ -962,7 +963,7 @@ export class Config implements McpContext {
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
this.extensionManagement = params.extensionManagement ?? true;
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this.sessionId);
this.storage = new Storage(this.targetDir, this._sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory);
this.fakeResponses = params.fakeResponses;
@@ -998,7 +999,7 @@ export class Config implements McpContext {
ConsecaSafetyChecker.getInstance().setConfig(this);
}
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
this._messageBus = new MessageBus(this.policyEngine, this.debugMode);
this.acknowledgedAgentsService = new AcknowledgedAgentsService();
this.skillManager = new SkillManager();
this.outputSettings = {
@@ -1058,7 +1059,7 @@ export class Config implements McpContext {
);
}
}
this.geminiClient = new GeminiClient(this);
this._geminiClient = new GeminiClient(this);
this.modelRouterService = new ModelRouterService(this);
// HACK: The settings loading logic doesn't currently merge the default
@@ -1143,11 +1144,11 @@ export class Config implements McpContext {
coreEvents.on(CoreEvent.AgentsRefreshed, this.onAgentsRefreshed);
this.toolRegistry = await this.createToolRegistry();
this._toolRegistry = await this.createToolRegistry();
discoverToolsHandle?.end();
this.mcpClientManager = new McpClientManager(
this.clientVersion,
this.toolRegistry,
this._toolRegistry,
this,
this.eventEmitter,
);
@@ -1182,7 +1183,7 @@ export class Config implements McpContext {
if (this.getSkillManager().getSkills().length > 0) {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.getToolRegistry().registerTool(
new ActivateSkillTool(this, this.messageBus),
new ActivateSkillTool(this, this._messageBus),
);
}
}
@@ -1199,7 +1200,7 @@ export class Config implements McpContext {
await this.contextManager.refresh();
}
await this.geminiClient.initialize();
await this._geminiClient.initialize();
this.initialized = true;
}
@@ -1223,7 +1224,7 @@ export class Config implements McpContext {
authMethod !== AuthType.USE_GEMINI
) {
// Restore the conversation history to the new client
this.geminiClient.stripThoughtsFromHistory();
this._geminiClient.stripThoughtsFromHistory();
}
// Reset availability status when switching auth (e.g. from limited key to OAuth)
@@ -1344,12 +1345,28 @@ export class Config implements McpContext {
return this.localLiteRtLmClient;
}
get promptId(): string {
return this._sessionId;
}
get toolRegistry(): ToolRegistry {
return this._toolRegistry;
}
get messageBus(): MessageBus {
return this._messageBus;
}
get geminiClient(): GeminiClient {
return this._geminiClient;
}
getSessionId(): string {
return this.sessionId;
return this.promptId;
}
setSessionId(sessionId: string): void {
this.sessionId = sessionId;
this._sessionId = sessionId;
}
setTerminalBackground(terminalBackground: string | undefined): void {
@@ -1614,6 +1631,7 @@ export class Config implements McpContext {
return this.acknowledgedAgentsService;
}
/** @deprecated Use toolRegistry getter */
getToolRegistry(): ToolRegistry {
return this.toolRegistry;
}
@@ -1890,9 +1908,9 @@ export class Config implements McpContext {
);
await refreshServerHierarchicalMemory(this);
}
if (this.geminiClient?.isInitialized()) {
await this.geminiClient.setTools();
this.geminiClient.updateSystemInstruction();
if (this._geminiClient?.isInitialized()) {
await this._geminiClient.setTools();
this._geminiClient.updateSystemInstruction();
}
}
@@ -2046,8 +2064,8 @@ export class Config implements McpContext {
(currentMode === ApprovalMode.YOLO || mode === ApprovalMode.YOLO);
if (isPlanModeTransition || isYoloModeTransition) {
if (this.geminiClient?.isInitialized()) {
this.geminiClient.setTools().catch((err) => {
if (this._geminiClient?.isInitialized()) {
this._geminiClient.setTools().catch((err) => {
debugLogger.error('Failed to update tools', err);
});
}
@@ -2143,6 +2161,7 @@ export class Config implements McpContext {
return this.telemetrySettings.useCliAuth ?? false;
}
/** @deprecated Use geminiClient getter */
getGeminiClient(): GeminiClient {
return this.geminiClient;
}
@@ -2389,17 +2408,7 @@ export class Config implements McpContext {
* @returns true if the path is allowed, false otherwise.
*/
isPathAllowed(absolutePath: string): boolean {
const realpath = (p: string) => {
let resolved: string;
try {
resolved = fs.realpathSync(p);
} catch {
resolved = path.resolve(p);
}
return os.platform() === 'win32' ? resolved.toLowerCase() : resolved;
};
const resolvedPath = realpath(absolutePath);
const resolvedPath = resolveToRealPath(absolutePath);
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(resolvedPath)) {
@@ -2407,7 +2416,7 @@ export class Config implements McpContext {
}
const projectTempDir = this.storage.getProjectTempDir();
const resolvedTempDir = realpath(projectTempDir);
const resolvedTempDir = resolveToRealPath(projectTempDir);
return isSubpath(resolvedTempDir, resolvedPath);
}
@@ -2588,7 +2597,7 @@ export class Config implements McpContext {
if (this.getSkillManager().getSkills().length > 0) {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.getToolRegistry().registerTool(
new ActivateSkillTool(this, this.messageBus),
new ActivateSkillTool(this, this._messageBus),
);
} else {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
@@ -2714,6 +2723,7 @@ export class Config implements McpContext {
return this.fileExclusions;
}
/** @deprecated Use messageBus getter */
getMessageBus(): MessageBus {
return this.messageBus;
}
@@ -2771,7 +2781,7 @@ export class Config implements McpContext {
}
async createToolRegistry(): Promise<ToolRegistry> {
const registry = new ToolRegistry(this, this.messageBus);
const registry = new ToolRegistry(this, this._messageBus);
// helper to create & register core tools that are enabled
const maybeRegister = (
@@ -2801,10 +2811,10 @@ export class Config implements McpContext {
};
maybeRegister(LSTool, () =>
registry.registerTool(new LSTool(this, this.messageBus)),
registry.registerTool(new LSTool(this, this._messageBus)),
);
maybeRegister(ReadFileTool, () =>
registry.registerTool(new ReadFileTool(this, this.messageBus)),
registry.registerTool(new ReadFileTool(this, this._messageBus)),
);
if (this.getUseRipgrep()) {
@@ -2817,81 +2827,85 @@ export class Config implements McpContext {
}
if (useRipgrep) {
maybeRegister(RipGrepTool, () =>
registry.registerTool(new RipGrepTool(this, this.messageBus)),
registry.registerTool(new RipGrepTool(this, this._messageBus)),
);
} else {
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
maybeRegister(GrepTool, () =>
registry.registerTool(new GrepTool(this, this.messageBus)),
registry.registerTool(new GrepTool(this, this._messageBus)),
);
}
} else {
maybeRegister(GrepTool, () =>
registry.registerTool(new GrepTool(this, this.messageBus)),
registry.registerTool(new GrepTool(this, this._messageBus)),
);
}
maybeRegister(GlobTool, () =>
registry.registerTool(new GlobTool(this, this.messageBus)),
registry.registerTool(new GlobTool(this, this._messageBus)),
);
maybeRegister(ActivateSkillTool, () =>
registry.registerTool(new ActivateSkillTool(this, this.messageBus)),
registry.registerTool(new ActivateSkillTool(this, this._messageBus)),
);
maybeRegister(EditTool, () =>
registry.registerTool(new EditTool(this, this.messageBus)),
registry.registerTool(new EditTool(this, this._messageBus)),
);
maybeRegister(WriteFileTool, () =>
registry.registerTool(new WriteFileTool(this, this.messageBus)),
registry.registerTool(new WriteFileTool(this, this._messageBus)),
);
maybeRegister(WebFetchTool, () =>
registry.registerTool(new WebFetchTool(this, this.messageBus)),
registry.registerTool(new WebFetchTool(this, this._messageBus)),
);
maybeRegister(ShellTool, () =>
registry.registerTool(new ShellTool(this, this.messageBus)),
registry.registerTool(new ShellTool(this, this._messageBus)),
);
maybeRegister(MemoryTool, () =>
registry.registerTool(new MemoryTool(this.messageBus)),
registry.registerTool(new MemoryTool(this._messageBus)),
);
maybeRegister(WebSearchTool, () =>
registry.registerTool(new WebSearchTool(this, this.messageBus)),
registry.registerTool(new WebSearchTool(this, this._messageBus)),
);
maybeRegister(AskUserTool, () =>
registry.registerTool(new AskUserTool(this.messageBus)),
registry.registerTool(new AskUserTool(this._messageBus)),
);
if (this.getUseWriteTodos()) {
maybeRegister(WriteTodosTool, () =>
registry.registerTool(new WriteTodosTool(this.messageBus)),
registry.registerTool(new WriteTodosTool(this._messageBus)),
);
}
if (this.isPlanEnabled()) {
maybeRegister(ExitPlanModeTool, () =>
registry.registerTool(new ExitPlanModeTool(this, this.messageBus)),
registry.registerTool(new ExitPlanModeTool(this, this._messageBus)),
);
maybeRegister(EnterPlanModeTool, () =>
registry.registerTool(new EnterPlanModeTool(this, this.messageBus)),
registry.registerTool(new EnterPlanModeTool(this, this._messageBus)),
);
}
if (this.isTrackerEnabled()) {
maybeRegister(TrackerCreateTaskTool, () =>
registry.registerTool(new TrackerCreateTaskTool(this, this.messageBus)),
registry.registerTool(
new TrackerCreateTaskTool(this, this._messageBus),
),
);
maybeRegister(TrackerUpdateTaskTool, () =>
registry.registerTool(new TrackerUpdateTaskTool(this, this.messageBus)),
registry.registerTool(
new TrackerUpdateTaskTool(this, this._messageBus),
),
);
maybeRegister(TrackerGetTaskTool, () =>
registry.registerTool(new TrackerGetTaskTool(this, this.messageBus)),
registry.registerTool(new TrackerGetTaskTool(this, this._messageBus)),
);
maybeRegister(TrackerListTasksTool, () =>
registry.registerTool(new TrackerListTasksTool(this, this.messageBus)),
registry.registerTool(new TrackerListTasksTool(this, this._messageBus)),
);
maybeRegister(TrackerAddDependencyTool, () =>
registry.registerTool(
new TrackerAddDependencyTool(this, this.messageBus),
new TrackerAddDependencyTool(this, this._messageBus),
),
);
maybeRegister(TrackerVisualizeTool, () =>
registry.registerTool(new TrackerVisualizeTool(this, this.messageBus)),
registry.registerTool(new TrackerVisualizeTool(this, this._messageBus)),
);
}
@@ -3018,8 +3032,8 @@ export class Config implements McpContext {
}
private onAgentsRefreshed = async () => {
if (this.toolRegistry) {
this.registerSubAgentTools(this.toolRegistry);
if (this._toolRegistry) {
this.registerSubAgentTools(this._toolRegistry);
}
// Propagate updates to the active chat session
const client = this.getGeminiClient();
@@ -3040,7 +3054,7 @@ export class Config implements McpContext {
this.logCurrentModeDuration(this.getApprovalMode());
coreEvents.off(CoreEvent.AgentsRefreshed, this.onAgentsRefreshed);
this.agentRegistry?.dispose();
this.geminiClient?.dispose();
this._geminiClient?.dispose();
if (this.mcpClientManager) {
await this.mcpClientManager.stop();
}
+3 -5
View File
@@ -24,7 +24,7 @@ vi.mock('fs', async (importOriginal) => {
});
import { Storage } from './storage.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
import { GEMINI_DIR, homedir, resolveToRealPath } from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
@@ -279,8 +279,7 @@ describe('Storage additional helpers', () => {
name: 'custom absolute path outside throws',
customDir: '/absolute/path/to/plans',
expected: '',
expectedError:
"Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '/tmp/project'.",
expectedError: `Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'absolute path that happens to be inside project root',
@@ -306,8 +305,7 @@ describe('Storage additional helpers', () => {
name: 'escaping relative path throws',
customDir: '../escaped-plans',
expected: '',
expectedError:
"Custom plans directory '../escaped-plans' resolves to '/tmp/escaped-plans', which is outside the project root '/tmp/project'.",
expectedError: `Custom plans directory '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'hidden directory starting with ..',
@@ -290,6 +290,8 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
const finalConfig = { ...baseConfig, ...overrides } as Config;
(finalConfig as unknown as { config: Config }).config = finalConfig;
// Patch the policy engine to use the final config if not overridden
if (!overrides.getPolicyEngine) {
finalConfig.getPolicyEngine = () =>
+1 -1
View File
@@ -133,7 +133,7 @@ export class CoreToolScheduler {
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate;
this.getPreferredEditor = options.getPreferredEditor;
this.toolExecutor = new ToolExecutor(this.config);
this.toolExecutor = new ToolExecutor(this.config, this.config);
this.toolModifier = new ToolModificationHandler();
// Subscribe to message bus for ASK_USER policy decisions
+1
View File
@@ -6,6 +6,7 @@
// Export config
export * from './config/config.js';
export * from './config/agent-loop-context.js';
export * from './config/memory.js';
export * from './config/defaultModelConfigs.js';
export * from './config/models.js';
+55 -1
View File
@@ -49,6 +49,9 @@ describe('policy.ts', () => {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const toolCall = {
request: { name: 'test-tool', args: {} },
tool: { name: 'test-tool' },
@@ -72,6 +75,9 @@ describe('policy.ts', () => {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mcpTool = Object.create(DiscoveredMCPTool.prototype);
mcpTool.serverName = 'my-server';
mcpTool._toolAnnotations = { readOnlyHint: true };
@@ -99,6 +105,9 @@ describe('policy.ts', () => {
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const toolCall = {
request: { name: 'test-tool', args: {} },
tool: { name: 'test-tool' },
@@ -118,6 +127,9 @@ describe('policy.ts', () => {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const toolCall = {
request: { name: 'test-tool', args: {} },
tool: { name: 'test-tool' },
@@ -137,6 +149,9 @@ describe('policy.ts', () => {
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const toolCall = {
request: { name: 'test-tool', args: {} },
tool: { name: 'test-tool' },
@@ -152,6 +167,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -175,6 +193,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -200,6 +221,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -225,6 +249,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -256,6 +283,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -290,6 +320,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -308,6 +341,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -325,6 +361,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -344,6 +383,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -378,6 +420,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -410,6 +455,9 @@ describe('policy.ts', () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
@@ -447,6 +495,8 @@ describe('policy.ts', () => {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
} as unknown as Config;
(mockConfig as unknown as { config: Config }).config = mockConfig;
const { errorMessage, errorType } = getPolicyDenialError(mockConfig);
expect(errorMessage).toBe('Tool execution denied by policy.');
@@ -457,6 +507,8 @@ describe('policy.ts', () => {
const mockConfig = {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
} as unknown as Config;
(mockConfig as unknown as { config: Config }).config = mockConfig;
const rule = {
decision: PolicyDecision.DENY,
denyMessage: 'Custom Deny',
@@ -517,7 +569,8 @@ describe('Plan Mode Denial Consistency', () => {
mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
toolRegistry: mockToolRegistry,
getToolRegistry: () => mockToolRegistry,
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
isInteractive: vi.fn().mockReturnValue(true),
getEnableHooks: vi.fn().mockReturnValue(false),
@@ -525,6 +578,7 @@ describe('Plan Mode Denial Consistency', () => {
setApprovalMode: vi.fn(),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config = mockConfig as Config;
});
afterEach(() => {
@@ -169,13 +169,15 @@ describe('Scheduler (Orchestrator)', () => {
mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
toolRegistry: mockToolRegistry,
isInteractive: vi.fn().mockReturnValue(true),
getEnableHooks: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config = mockConfig as Config;
mockMessageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
@@ -1320,6 +1322,8 @@ describe('Scheduler MCP Progress', () => {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config = mockConfig as Config;
mockMessageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
+9 -6
View File
@@ -5,6 +5,7 @@
*/
import type { Config } from '../config/config.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { SchedulerStateManager } from './state-manager.js';
import { resolveConfirmation } from './confirmation.js';
@@ -57,7 +58,7 @@ interface SchedulerQueueItem {
export interface SchedulerOptions {
config: Config;
messageBus: MessageBus;
messageBus?: MessageBus;
getPreferredEditor: () => EditorType | undefined;
schedulerId: string;
parentCallId?: string;
@@ -97,6 +98,7 @@ export class Scheduler {
private readonly executor: ToolExecutor;
private readonly modifier: ToolModificationHandler;
private readonly config: Config;
private readonly context: AgentLoopContext;
private readonly messageBus: MessageBus;
private readonly getPreferredEditor: () => EditorType | undefined;
private readonly schedulerId: string;
@@ -109,7 +111,8 @@ export class Scheduler {
constructor(options: SchedulerOptions) {
this.config = options.config;
this.messageBus = options.messageBus;
this.context = options.config;
this.messageBus = options.messageBus ?? this.context.messageBus;
this.getPreferredEditor = options.getPreferredEditor;
this.schedulerId = options.schedulerId;
this.parentCallId = options.parentCallId;
@@ -119,7 +122,7 @@ export class Scheduler {
this.schedulerId,
(call) => logToolCall(this.config, new ToolCallEvent(call)),
);
this.executor = new ToolExecutor(this.config);
this.executor = new ToolExecutor(this.config, this.context);
this.modifier = new ToolModificationHandler();
this.setupMessageBusListener(this.messageBus);
@@ -294,7 +297,7 @@ export class Scheduler {
const currentApprovalMode = this.config.getApprovalMode();
try {
const toolRegistry = this.config.getToolRegistry();
const toolRegistry = this.context.toolRegistry;
const newCalls: ToolCall[] = requests.map((request) => {
const enrichedRequest: ToolCallRequestInfo = {
...request,
@@ -697,7 +700,7 @@ export class Scheduler {
const originalRequestName =
result.request.originalRequestName || result.request.name;
const newTool = this.config.getToolRegistry().getTool(tailRequest.name);
const newTool = this.context.toolRegistry.getTool(tailRequest.name);
const newRequest: ToolCallRequestInfo = {
callId: originalCallId,
@@ -713,7 +716,7 @@ export class Scheduler {
// Enqueue an errored tool call
const errorCall = this._createToolNotFoundErroredToolCall(
newRequest,
this.config.getToolRegistry().getAllToolNames(),
this.context.toolRegistry.getAllToolNames(),
);
this.state.replaceActiveCallWithTailCall(callId, errorCall);
} else {
@@ -211,13 +211,15 @@ describe('Scheduler Parallel Execution', () => {
mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
toolRegistry: mockToolRegistry,
isInteractive: vi.fn().mockReturnValue(true),
getEnableHooks: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config = mockConfig as Config;
mockMessageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
@@ -36,7 +36,7 @@ describe('Scheduler waiting callback', () => {
mockTool = new MockTool({ name: 'test_tool' });
toolRegistry = new ToolRegistry(mockConfig, messageBus);
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(toolRegistry);
vi.spyOn(mockConfig, 'toolRegistry', 'get').mockReturnValue(toolRegistry);
toolRegistry.registerTool(mockTool);
vi.mocked(checkPolicy).mockResolvedValue({
@@ -64,7 +64,7 @@ describe('ToolExecutor', () => {
beforeEach(() => {
// Use the standard fake config factory
config = makeFakeConfig();
executor = new ToolExecutor(config);
executor = new ToolExecutor(config, config);
// Reset mocks
vi.resetAllMocks();
+7 -3
View File
@@ -13,6 +13,7 @@ import {
type ToolCallResponseInfo,
type ToolResult,
type Config,
type AgentLoopContext,
type ToolLiveOutput,
} from '../index.js';
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
@@ -49,7 +50,10 @@ export interface ToolExecutionContext {
}
export class ToolExecutor {
constructor(private readonly config: Config) {}
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
) {}
async execute(context: ToolExecutionContext): Promise<CompletedToolCall> {
const { call, signal, outputUpdateHandler, onUpdateToolCall } = context;
@@ -202,7 +206,7 @@ export class ToolExecutor {
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.config.getSessionId(),
this.context.promptId,
);
outputFile = savedPath;
const truncatedContent = formatTruncatedToolOutput(
@@ -241,7 +245,7 @@ export class ToolExecutor {
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.config.getSessionId(),
this.context.promptId,
);
outputFile = savedPath;
const truncatedText = formatTruncatedToolOutput(
+23 -2
View File
@@ -510,9 +510,11 @@ describe('resolveToRealPath', () => {
expect(resolveToRealPath(input)).toBe(expected);
});
it('should return decoded path even if fs.realpathSync fails', () => {
it('should return decoded path even if fs.realpathSync fails with ENOENT', () => {
vi.spyOn(fs, 'realpathSync').mockImplementationOnce(() => {
throw new Error('File not found');
const err = new Error('File not found') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
});
const p = path.resolve('path', 'to', 'New Project');
@@ -521,6 +523,25 @@ describe('resolveToRealPath', () => {
expect(resolveToRealPath(input)).toBe(expected);
});
it('should recursively resolve symlinks for non-existent child paths', () => {
const parentPath = path.resolve('/some/parent/path');
const resolvedParentPath = path.resolve('/resolved/parent/path');
const childPath = path.resolve(parentPath, 'child', 'file.txt');
const expectedPath = path.resolve(resolvedParentPath, 'child', 'file.txt');
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
const pStr = p.toString();
if (pStr === parentPath) {
return resolvedParentPath;
}
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
});
expect(resolveToRealPath(childPath)).toBe(expectedPath);
});
});
describe('normalizePath', () => {
+24 -7
View File
@@ -359,8 +359,8 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
* @param pathStr The path string to resolve.
* @returns The resolved real path.
*/
export function resolveToRealPath(path: string): string {
let resolvedPath = path;
export function resolveToRealPath(pathStr: string): string {
let resolvedPath = pathStr;
try {
if (resolvedPath.startsWith('file://')) {
@@ -372,11 +372,28 @@ export function resolveToRealPath(path: string): string {
// Ignore error (e.g. malformed URI), keep path from previous step
}
return robustRealpath(path.resolve(resolvedPath));
}
function robustRealpath(p: string): string {
try {
return fs.realpathSync(resolvedPath);
} catch (_e) {
// If realpathSync fails, it might be because the path doesn't exist.
// In that case, we can fall back to the path processed.
return resolvedPath;
return fs.realpathSync(p);
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') {
try {
const stat = fs.lstatSync(p);
if (stat.isSymbolicLink()) {
const target = fs.readlinkSync(p);
const resolvedTarget = path.resolve(path.dirname(p), target);
return robustRealpath(resolvedTarget);
}
} catch {
// Not a symlink, or lstat failed. Just resolve parent.
}
const parent = path.dirname(p);
if (parent === p) return p;
return path.join(robustRealpath(parent), path.basename(p));
}
throw e;
}
}
+2 -28
View File
@@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { isNodeError } from '../utils/errors.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { debugLogger } from './debugLogger.js';
import { resolveToRealPath } from './paths.js';
export type Unsubscribe = () => void;
@@ -227,22 +227,7 @@ export class WorkspaceContext {
* if it did exist.
*/
private fullyResolvedPath(pathToCheck: string): string {
try {
return fs.realpathSync(path.resolve(this.targetDir, pathToCheck));
} catch (e: unknown) {
if (
isNodeError(e) &&
e.code === 'ENOENT' &&
e.path &&
// realpathSync does not set e.path correctly for symlinks to
// non-existent files.
!this.isFileSymlink(e.path)
) {
// If it doesn't exist, e.path contains the fully resolved path.
return e.path;
}
throw e;
}
return resolveToRealPath(path.resolve(this.targetDir, pathToCheck));
}
/**
@@ -262,15 +247,4 @@ export class WorkspaceContext {
!path.isAbsolute(relative)
);
}
/**
* Checks if a file path is a symbolic link that points to a file.
*/
private isFileSymlink(filePath: string): boolean {
try {
return !fs.readlinkSync(filePath).endsWith('/');
} catch (_error) {
return false;
}
}
}