Compare commits

..

20 Commits

Author SHA1 Message Date
AK bbdfa50983 Merge branch 'main' into feat/enhance-command 2026-04-28 15:13:27 -07:00
Akhilesh Kumar 385fbbd9fb test(core): increase event loop yield timeout in SimulationHarness to prevent flakiness 2026-04-28 22:12:11 +00:00
Akhilesh Kumar c741ce328b docs(settings): regenerate settings documentation and schema 2026-04-28 22:01:14 +00:00
Abhi fbd8aaad57 fix(core): add missing oauth fields support in subagent parsing (#26141) 2026-04-28 21:57:30 +00:00
AK 2128019e70 Merge branch 'main' into feat/enhance-command 2026-04-28 14:47:03 -07:00
AK de8c2d8589 Merge branch 'main' into feat/enhance-command 2026-04-28 14:30:26 -07:00
ifitisit 9e7c924f7b docs(cli): point plan-mode session retention to actual /settings labels (#25978)
Co-authored-by: Spencer <spencertang@google.com>
2026-04-28 21:27:42 +00:00
Akhilesh Kumar 7c3b3ce219 feat(settings): enable enhanceCommand in local settings 2026-04-28 21:25:10 +00:00
Akhilesh Kumar 652c1e2f5e feat(cli): release enhance command behind an experimental flag and address review comments 2026-04-28 21:22:02 +00:00
Anas Khalid 4edd7c745c fix(cli): handle DECKPAM keypad Enter sequences in terminal (#26092)
Co-authored-by: Gitanaskhan26 <Gitanaskhan26@users.noreply.github.com>
Co-authored-by: Spencer <spencertang@google.com>
2026-04-28 21:17:31 +00:00
Coco Sheng 12a77da45c fix(cli): pass node arguments via NODE_OPTIONS during relaunch to support SEA (#26130) 2026-04-28 21:15:23 +00:00
AK b6f9f07289 Merge branch 'main' into feat/enhance-command 2026-04-28 14:13:53 -07:00
gemini-cli-robot 8cfebb9e31 chore(release): bump version to 0.42.0-nightly.20260428.g59b2dea0e (#26142) 2026-04-28 13:28:48 -07:00
Adib234 f8603e990b fix(cli): prevent automatic updates from switching to less stable channels (#26132) 2026-04-28 18:03:08 +00:00
Akhilesh Kumar fcb859d9ec fix(cli): address code review comments for /enhance command
This commit addresses feedback by setting autoExecute to false for the enhance command, filtering out 'thought' parts from model responses, and sanitizing the output to prevent prompt injection.
2026-04-13 19:08:41 +00:00
Akhilesh Kumar 8a8590c516 fix: resolve snapshot merge conflicts
This commit resolves the merge conflicts that were present in several SVG snapshot files by regenerating them via 'vitest run -u'.
2026-04-13 18:59:47 +00:00
AK 30b2af34c8 Merge branch 'main' into feat/enhance-command 2026-04-11 09:42:32 -07:00
Akhilesh Kumar 03da89ed63 Merge branch 'main' into feat/enhance-command 2026-04-11 02:24:11 +00:00
Akhilesh Kumar 48d7a7262b feat(cli): add Alt+E keyboard shortcut for prompt enhancement
This adds the Alt+E keyboard shortcut to quickly trigger the prompt
enhancement functionality alongside the existing /enhance slash command.
2026-04-10 18:37:40 +00:00
Akhilesh Kumar 04f05459f8 feat: add /enhance command to improve user prompts
This adds the /enhance command which uses an LLM to refine user prompts
based on conversation history.

Closes #25133
2026-04-10 17:47:53 +00:00
63 changed files with 1234 additions and 1417 deletions
+9 -2
View File
@@ -6,9 +6,16 @@
"gemma": true,
"memoryManager": true,
"topicUpdateNarration": true,
"voiceMode": true
"voiceMode": true,
"enhanceCommand": true
},
"general": {
"devtools": true
"devtools": true,
"plan": {
"enabled": true
}
},
"agents": {
"overrides": {}
}
}
+2 -1
View File
@@ -470,7 +470,8 @@ associated plan files and task trackers.
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
- **Configuration:** You can customize this behavior via the `/settings` command
(search for **Session Retention**) or in your `settings.json` file. See
(search for **Enable Session Cleanup** or **Keep chat history**) or in your
`settings.json` file. See
[session retention](../cli/session-management.md#session-retention) for more
details.
+1
View File
@@ -180,6 +180,7 @@ they appear in the UI.
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
| Enhance Command | `experimental.enhanceCommand` | Enable the experimental /enhance slash command. | `false` |
### Skills
+5
View File
@@ -1872,6 +1872,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Deprecated: Use general.topicUpdateNarration instead.
- **Default:** `false`
- **`experimental.enhanceCommand`** (boolean):
- **Description:** Enable the experimental /enhance slash command.
- **Default:** `false`
- **Requires restart:** Yes
#### `skills`
- **`skills.enabled`** (boolean):
+1
View File
@@ -94,6 +94,7 @@ available combinations.
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+G`<br />`Ctrl+Shift+G` |
| `input.deprecatedOpenExternalEditor` | Deprecated command to open external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
| `input.enhancePrompt` | Enhance the current prompt using an LLM. | `Alt+E` |
#### App Controls
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"workspaces": [
"packages/*"
],
@@ -18077,7 +18077,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -18206,7 +18206,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18354,7 +18354,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18665,7 +18665,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18680,7 +18680,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18711,7 +18711,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18743,7 +18743,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.2"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -1,173 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Task } from './task.js';
import {
MessageBusType,
CoreToolCallStatus,
type Config,
type MessageBus,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { RequestContext } from '@a2a-js/sdk/server';
describe('Task Race Condition', () => {
let mockConfig: Config;
let messageBus: MessageBus;
beforeEach(() => {
messageBus = {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
publish: vi.fn(),
} as unknown as MessageBus;
mockConfig = createMockConfig({
messageBus,
}) as Config;
});
it('should not hang when multiple tool confirmations are processed while waiting', async () => {
// @ts-expect-error - private constructor
const task = new Task('task-id', 'context-id', mockConfig);
// 1. Register two tools as scheduled
task['_registerToolCall']('tool-1', 'scheduled');
task['_registerToolCall']('tool-2', 'scheduled');
// 2. Both transition to awaiting_approval
const updateHandler = (messageBus.subscribe as Mock).mock.calls.find(
(c: unknown[]) => c[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
updateHandler({
type: MessageBusType.TOOL_CALLS_UPDATE,
schedulerId: 'task-id',
toolCalls: [
{
request: { callId: 'tool-1', name: 't1' },
status: CoreToolCallStatus.AwaitingApproval,
correlationId: 'corr-1',
confirmationDetails: { type: 'info' },
},
{
request: { callId: 'tool-2', name: 't2' },
status: CoreToolCallStatus.AwaitingApproval,
correlationId: 'corr-2',
confirmationDetails: { type: 'info' },
},
],
});
// 3. Confirm Tool 1. This makes isAwaitingApprovalOnly() return false.
for await (const _ of task.acceptUserMessage(
{
userMessage: {
parts: [
{
kind: 'data',
data: { callId: 'tool-1', outcome: 'proceed_once' },
},
],
},
} as unknown as RequestContext,
new AbortController().signal,
)) {
// consume generator
}
// 4. Start waiting. This should now block because Tool 1 is confirmed (so we are waiting for its execution).
const waitPromise = task.waitForPendingTools();
// 5. Confirm Tool 2 while waiting.
for await (const _ of task.acceptUserMessage(
{
userMessage: {
parts: [
{
kind: 'data',
data: { callId: 'tool-2', outcome: 'proceed_once' },
},
],
},
} as unknown as RequestContext,
new AbortController().signal,
)) {
// consume generator
}
// 6. Both tools complete successfully
updateHandler({
type: MessageBusType.TOOL_CALLS_UPDATE,
schedulerId: 'task-id',
toolCalls: [
{
request: { callId: 'tool-1', name: 't1' },
status: CoreToolCallStatus.Success,
response: { responseParts: [] },
},
{
request: { callId: 'tool-2', name: 't2' },
status: CoreToolCallStatus.Success,
response: { responseParts: [] },
},
],
});
// 7. Verify that the original waitPromise resolves.
await expect(waitPromise).resolves.toBeUndefined();
});
it('should reject waitForPendingTools when tools are cancelled', async () => {
// @ts-expect-error - private constructor
const task = new Task('task-id', 'context-id', mockConfig);
// 1. Register a tool
task['_registerToolCall']('tool-1', 'scheduled');
// 2. Start waiting
const waitPromise = task.waitForPendingTools();
// 3. Cancel pending tools
task.cancelPendingTools('User requested cancellation');
// 4. Verify waitPromise rejects with the reason
await expect(waitPromise).rejects.toThrow('User requested cancellation');
});
it('should handle concurrent tool scheduling correctly', async () => {
// @ts-expect-error - private constructor
const task = new Task('task-id', 'context-id', mockConfig);
// 1. Register a tool and start waiting
task['_registerToolCall']('tool-1', 'scheduled');
const waitPromise = task.waitForPendingTools();
// 2. Schedule another tool concurrently (e.g. from a secondary user message)
// This should NOT resolve the current waitPromise until both are done
await task.scheduleToolCalls(
[{ callId: 'tool-2', name: 't2', args: {} }],
new AbortController().signal,
);
expect(task['pendingToolCalls'].size).toBe(2);
// 3. Resolve tool 1
task['_resolveToolCall']('tool-1');
// 4. Verify waitPromise is still pending
let resolved = false;
waitPromise.then(() => (resolved = true));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(resolved).toBe(false);
// 5. Resolve tool 2
task['_resolveToolCall']('tool-2');
// 6. Now it should resolve
await expect(waitPromise).resolves.toBeUndefined();
});
});
@@ -12,7 +12,6 @@ import {
ApprovalMode,
Scheduler,
type MessageBus,
type ToolLiveOutput,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
@@ -67,7 +66,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
expect(mockEventBus.publish).toHaveBeenCalledWith(
@@ -108,7 +106,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Simulate A2A client confirmation
@@ -151,11 +148,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Simulate Rejection (Cancel)
const handled = await (
@@ -181,11 +174,7 @@ describe('Task Event-Driven Scheduler', () => {
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall2],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall2] });
// Simulate ModifyWithEditor
const handled2 = await (
@@ -226,11 +215,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Simulate ProceedOnce for MCP
const handled = await (
@@ -270,11 +255,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
@@ -313,11 +294,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
@@ -356,11 +333,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
@@ -403,11 +376,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
@@ -450,7 +419,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Should publish artifact update for output
@@ -485,11 +453,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// The tool should be complete and registered appropriately, eventually
// triggering the toolCompletionPromise resolution when all clear.
@@ -569,7 +533,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
schedulerId: 'task-id',
});
// Confirm first tool call
@@ -609,74 +572,6 @@ describe('Task Event-Driven Scheduler', () => {
);
});
it('should handle multi-turn tool resolution correctly', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig);
task['_registerToolCall']('1', 'scheduled');
task['_registerToolCall']('2', 'scheduled');
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
// Turn 1: Resolve tool 1
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{
request: { callId: '1', name: 't1' },
status: 'success',
response: { responseParts: [] },
},
],
schedulerId: 'task-id',
});
expect(task['pendingToolCalls'].size).toBe(1);
expect(task['pendingToolCalls'].has('2')).toBe(true);
// Turn 2: Resolve tool 2
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{
request: { callId: '2', name: 't2' },
status: 'success',
response: { responseParts: [] },
},
],
schedulerId: 'task-id',
});
expect(task['pendingToolCalls'].size).toBe(0);
});
it('should handle subagent progress events from the scheduler', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
// Trigger _schedulerOutputUpdate with subagent progress
task['_schedulerOutputUpdate']('tool-1', {
isSubagentProgress: true,
agentName: 'researcher',
recentActivity: [],
} as ToolLiveOutput);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'artifact-update',
artifact: expect.objectContaining({
parts: [
expect.objectContaining({
text: expect.stringContaining('researcher'),
}),
],
}),
}),
);
});
it('should wait for executing tools before transitioning to input-required state', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
@@ -705,7 +600,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
schedulerId: 'task-id',
});
// Should NOT transition to input-required yet
@@ -727,7 +621,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1Complete, toolCall2],
schedulerId: 'task-id',
});
// Now it should transition
-203
View File
@@ -12,9 +12,6 @@ import {
type ToolCallRequestInfo,
type GitService,
type CompletedToolCall,
type ToolCall,
type ToolCallsUpdateMessage,
MessageBusType,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
@@ -463,204 +460,4 @@ describe('Task', () => {
expect(task.currentPromptId).toBe(expectedPromptId2);
});
});
describe('Race Condition Fix', () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should NOT transition to input-required if a tool is still validating', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
// Manually register two tool calls
task['_registerToolCall']('tool-1', 'awaiting_approval');
task['_registerToolCall']('tool-2', 'validating');
// Call checkInputRequiredState (private)
task['checkInputRequiredState']();
// Verify task state did NOT change to input-required
expect(task.taskState).not.toBe('input-required');
expect(mockEventBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({ state: 'input-required' }),
}),
);
});
it('should transition to input-required if all active tools are awaiting approval', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
// Transition from submitted to working first to simulate normal flow
task.taskState = 'working';
// Manually register tool calls
task['_registerToolCall']('tool-1', 'awaiting_approval');
// Call checkInputRequiredState
task['checkInputRequiredState']();
// Verify task state changed to input-required
expect(task.taskState).toBe('input-required');
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({ state: 'input-required' }),
}),
);
});
it('handleEventDrivenToolCallsUpdate should ignore events for other schedulers', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const handleEventDrivenToolCallSpy = vi.spyOn(
task as unknown as {
handleEventDrivenToolCall: Task['handleEventDrivenToolCall'];
},
'handleEventDrivenToolCall',
);
const otherEvent: ToolCallsUpdateMessage = {
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
],
schedulerId: 'other-task-id',
};
task['handleEventDrivenToolCallsUpdate'](otherEvent);
expect(handleEventDrivenToolCallSpy).not.toHaveBeenCalled();
const ownEvent: ToolCallsUpdateMessage = {
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
],
schedulerId: 'task-id',
};
task['handleEventDrivenToolCallsUpdate'](ownEvent);
expect(handleEventDrivenToolCallSpy).toHaveBeenCalled();
});
});
describe('Serialization and Mapping', () => {
it('should map internal "validating" status to "scheduled" for the client and include outcome', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const mockToolCall = {
request: { callId: 'tool-1' },
status: 'validating',
outcome: 'accepted',
tool: { name: 'test-tool' },
};
const message = task['toolStatusMessage'](
mockToolCall as unknown as ToolCall,
'task-id',
'context-id',
);
const serialized = (
message.parts![0] as {
data: { status: string; outcome: string };
}
).data;
expect(serialized.status).toBe('scheduled');
expect(serialized.outcome).toBe('accepted');
});
it('should correctly detect changes when status or outcome changes', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const toolCall1 = {
request: { callId: 'tool-1' },
status: 'awaiting_approval',
};
// First update - should trigger change
const changed1 = task['handleEventDrivenToolCall'](
toolCall1 as unknown as ToolCall,
);
expect(changed1).toBe(true);
// Second update with same status - should NOT trigger change
const changed2 = task['handleEventDrivenToolCall'](
toolCall1 as unknown as ToolCall,
);
expect(changed2).toBe(false);
// Update with new outcome - SHOULD trigger change
const toolCall2 = {
request: { callId: 'tool-1' },
status: 'awaiting_approval',
outcome: 'accepted',
};
const changed3 = task['handleEventDrivenToolCall'](
toolCall2 as unknown as ToolCall,
);
expect(changed3).toBe(true);
});
});
});
+48 -73
View File
@@ -11,7 +11,6 @@ import {
GeminiEventType,
ToolConfirmationOutcome,
ApprovalMode,
CoreToolCallStatus,
getAllMCPServerStatuses,
MCPServerStatus,
isNodeError,
@@ -52,7 +51,6 @@ import type {
Artifact,
} from '@a2a-js/sdk';
import { v4 as uuidv4 } from 'uuid';
import { EventEmitter } from 'node:events';
import { logger } from '../utils/logger.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
@@ -97,11 +95,12 @@ export class Task {
// For tool waiting logic
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
private pendingOutcomes: Map<string, ToolConfirmationOutcome | undefined> =
new Map(); // toolCallId --> outcome
private toolsAlreadyConfirmed: Set<string> = new Set();
private toolUpdateEmitter = new EventEmitter();
private cancellationError?: Error;
private toolCompletionPromise?: Promise<void>;
private toolCompletionNotifier?: {
resolve: () => void;
reject: (reason?: Error) => void;
};
private constructor(
id: string,
@@ -122,6 +121,7 @@ export class Task {
this.taskState = 'submitted';
this.eventBus = eventBus;
this.completedToolCalls = [];
this._resetToolCompletionPromise();
this.autoExecute = autoExecute;
this.config.setFallbackModelHandler(
// For a2a-server, we want to automatically switch to the fallback model
@@ -176,9 +176,22 @@ export class Task {
return metadata;
}
private _resetToolCompletionPromise(): void {
this.toolCompletionPromise = new Promise((resolve, reject) => {
this.toolCompletionNotifier = { resolve, reject };
});
// If there are no pending calls when reset, resolve immediately.
if (this.pendingToolCalls.size === 0 && this.toolCompletionNotifier) {
this.toolCompletionNotifier.resolve();
}
}
private _registerToolCall(toolCallId: string, status: string): void {
const wasEmpty = this.pendingToolCalls.size === 0;
this.pendingToolCalls.set(toolCallId, status);
this.toolUpdateEmitter.emit('update');
if (wasEmpty) {
this._resetToolCompletionPromise();
}
logger.info(
`[Task] Registered tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`,
);
@@ -187,47 +200,23 @@ export class Task {
private _resolveToolCall(toolCallId: string): void {
if (this.pendingToolCalls.has(toolCallId)) {
this.pendingToolCalls.delete(toolCallId);
this.toolUpdateEmitter.emit('update');
logger.info(
`[Task] Resolved tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`,
);
}
}
private isAwaitingApprovalOnly(): boolean {
if (this.pendingToolCalls.size === 0) {
return false;
}
for (const [callId, status] of this.pendingToolCalls.entries()) {
if (
status !== CoreToolCallStatus.AwaitingApproval ||
this.toolsAlreadyConfirmed.has(callId)
) {
return false;
if (this.pendingToolCalls.size === 0 && this.toolCompletionNotifier) {
this.toolCompletionNotifier.resolve();
}
}
return true;
}
async waitForPendingTools(): Promise<void> {
while (this.pendingToolCalls.size > 0 && !this.isAwaitingApprovalOnly()) {
if (this.cancellationError) {
const error = this.cancellationError;
this.cancellationError = undefined;
throw error;
}
logger.info(
`[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`,
);
await new Promise((resolve) =>
this.toolUpdateEmitter.once('update', resolve),
);
}
if (this.cancellationError) {
const error = this.cancellationError;
this.cancellationError = undefined;
throw error;
if (this.pendingToolCalls.size === 0) {
return Promise.resolve();
}
logger.info(
`[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`,
);
await this.toolCompletionPromise;
}
cancelPendingTools(reason: string): void {
@@ -236,13 +225,15 @@ export class Task {
`[Task] Cancelling all ${this.pendingToolCalls.size} pending tool calls. Reason: ${reason}`,
);
}
this.cancellationError = new Error(reason);
if (this.toolCompletionNotifier) {
this.toolCompletionNotifier.reject(new Error(reason));
}
this.pendingToolCalls.clear();
this.pendingCorrelationIds.clear();
this.toolsAlreadyConfirmed.clear();
this.scheduler.cancelAll();
this.toolUpdateEmitter.emit('update');
// Reset the promise for any future operations, ensuring it's in a clean state.
this._resetToolCompletionPromise();
}
private _createTextMessage(
@@ -422,10 +413,7 @@ export class Task {
private handleEventDrivenToolCallsUpdate(
event: ToolCallsUpdateMessage,
): void {
if (
event.type !== MessageBusType.TOOL_CALLS_UPDATE ||
event.schedulerId !== this.id
) {
if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) {
return;
}
@@ -438,7 +426,7 @@ export class Task {
this.checkInputRequiredState();
}
private handleEventDrivenToolCall(tc: ToolCall): boolean {
private handleEventDrivenToolCall(tc: ToolCall): void {
const callId = tc.request.callId;
// Do not process events for tools that have already been finalized.
@@ -448,16 +436,11 @@ export class Task {
this.processedToolCallIds.has(callId) ||
this.completedToolCalls.some((c) => c.request.callId === callId)
) {
return false;
return;
}
const previousStatus = this.pendingToolCalls.get(callId);
const previousOutcome = this.pendingOutcomes.get(callId);
const hasChanged =
previousStatus !== tc.status || previousOutcome !== tc.outcome;
// Update outcome tracking
this.pendingOutcomes.set(callId, tc.outcome);
const hasChanged = previousStatus !== tc.status;
// 1. Handle Output
if (tc.status === 'executing' && tc.liveOutput) {
@@ -471,7 +454,6 @@ export class Task {
tc.status === 'cancelled'
) {
this.toolsAlreadyConfirmed.delete(callId);
this.pendingOutcomes.delete(callId);
if (hasChanged) {
logger.info(
`[Task] Tool call ${callId} completed with status: ${tc.status}`,
@@ -514,8 +496,6 @@ export class Task {
);
this.eventBus?.publish(statusUpdate);
}
return hasChanged;
}
private checkInputRequiredState(): void {
@@ -528,14 +508,12 @@ export class Task {
let isExecuting = false;
for (const [callId, status] of this.pendingToolCalls.entries()) {
if (
status === CoreToolCallStatus.Executing ||
status === CoreToolCallStatus.Scheduled ||
status === CoreToolCallStatus.Validating ||
this.toolsAlreadyConfirmed.has(callId)
) {
if (status === 'executing' || status === 'scheduled') {
isExecuting = true;
} else if (status === CoreToolCallStatus.AwaitingApproval) {
} else if (
status === 'awaiting_approval' &&
!this.toolsAlreadyConfirmed.has(callId)
) {
isAwaitingApproval = true;
}
}
@@ -558,8 +536,8 @@ export class Task {
// Unblock waitForPendingTools to correctly end the executor loop and release the HTTP response stream.
// The IDE client will open a new stream with the confirmation reply.
if (!wasAlreadyInputRequired) {
this.toolUpdateEmitter.emit('update');
if (!wasAlreadyInputRequired && this.toolCompletionNotifier) {
this.toolCompletionNotifier.resolve();
}
}
}
@@ -596,14 +574,8 @@ export class Task {
'confirmationDetails',
'liveOutput',
'response',
'outcome',
);
// Map internal 'validating' status to 'scheduled' for the client
if (serializableToolCall.status === CoreToolCallStatus.Validating) {
serializableToolCall.status = CoreToolCallStatus.Scheduled;
}
if (tc.tool) {
const toolFields = this._pickFields(
tc.tool,
@@ -923,7 +895,6 @@ export class Task {
const outcomeString = part.data['outcome'];
this.toolsAlreadyConfirmed.add(callId);
this.toolUpdateEmitter.emit('update');
let confirmationOutcome: ToolConfirmationOutcome | undefined;
@@ -1137,6 +1108,10 @@ export class Task {
if (confirmationHandled) {
anyConfirmationHandled = true;
// If a confirmation was handled, the scheduler will now run the tool (or cancel it).
// We resolve the toolCompletionPromise manually in checkInputRequiredState
// to break the original execution loop, so we must reset it here so the
// new loop correctly awaits the tool's final execution.
this._resetToolCompletionPromise();
// We don't send anything to the LLM for this part.
// The subsequent tool execution will eventually lead to resolveToolCall.
continue;
+37 -79
View File
@@ -228,7 +228,7 @@ describe('E2E Tests', () => {
expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
status: 'validating',
request: { callId: 'test-call-id' },
},
},
@@ -330,7 +330,7 @@ describe('E2E Tests', () => {
expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
status: 'validating',
request: { callId: 'test-call-id-1' },
},
},
@@ -352,7 +352,7 @@ describe('E2E Tests', () => {
kind: 'state-change',
});
// 4. Tool 1 is scheduled.
// 4. Tool 1 is validating.
const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent;
expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
@@ -361,12 +361,12 @@ describe('E2E Tests', () => {
{
data: {
request: { callId: 'test-call-id-1' },
status: 'scheduled',
status: 'validating',
},
},
]);
// 5. Tool 2 is scheduled.
// 5. Tool 2 is validating.
const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent;
expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
@@ -375,17 +375,17 @@ describe('E2E Tests', () => {
{
data: {
request: { callId: 'test-call-id-2' },
status: 'scheduled',
status: 'validating',
},
},
]);
// 6. Tool 1 is awaiting approval.
const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent;
expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({
const toolCallAwaitEvent = events[5].result as TaskStatusUpdateEvent;
expect(toolCallAwaitEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-confirmation',
});
expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([
expect(toolCallAwaitEvent.status.message?.parts).toMatchObject([
{
data: {
request: { callId: 'test-call-id-1' },
@@ -394,28 +394,14 @@ describe('E2E Tests', () => {
},
]);
// 7. Tool 2 is awaiting approval.
const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent;
expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-confirmation',
});
expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([
{
data: {
request: { callId: 'test-call-id-2' },
status: 'awaiting_approval',
},
},
]);
// 8. The final event is "input-required".
const finalEvent = events[7].result as TaskStatusUpdateEvent;
// 7. The final event is "input-required".
const finalEvent = events[6].result as TaskStatusUpdateEvent;
expect(finalEvent.final).toBe(true);
expect(finalEvent.status.state).toBe('input-required');
// The scheduler now waits for approval, so no more events are sent.
assertUniqueFinalEventIsLast(events);
expect(events.length).toBe(8);
expect(events.length).toBe(7);
});
it('should handle multiple tool calls sequentially in YOLO mode', async () => {
@@ -513,7 +499,7 @@ describe('E2E Tests', () => {
// Tool 1 Lifecycle
{
kind: 'tool-call-update',
status: 'scheduled',
status: 'validating',
callId: 'test-call-id-1',
},
{
@@ -534,7 +520,7 @@ describe('E2E Tests', () => {
// Tool 2 Lifecycle
{
kind: 'tool-call-update',
status: 'scheduled',
status: 'validating',
callId: 'test-call-id-2',
},
{
@@ -617,40 +603,26 @@ describe('E2E Tests', () => {
expect(workingEvent2.kind).toBe('status-update');
expect(workingEvent2.status.state).toBe('working');
// Status update: tool-call-update (scheduled)
const scheduledEvent1 = events[3].result as TaskStatusUpdateEvent;
expect(scheduledEvent1.metadata?.['coderAgent']).toMatchObject({
// Status update: tool-call-update (validating)
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent1.status.message?.parts).toMatchObject([
expect(validatingEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
status: 'validating',
request: { callId: 'test-call-id-no-approval' },
},
},
]);
// Status update: tool-call-update (scheduled)
const scheduledEvent2 = events[4].result as TaskStatusUpdateEvent;
expect(scheduledEvent2.metadata?.['coderAgent']).toMatchObject({
const scheduledEvent = events[4].result as TaskStatusUpdateEvent;
expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent2.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
request: { callId: 'test-call-id-no-approval' },
},
},
]);
// Status update: tool-call-update (scheduled)
const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent3.status.message?.parts).toMatchObject([
expect(scheduledEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
@@ -660,7 +632,7 @@ describe('E2E Tests', () => {
]);
// Status update: tool-call-update (executing)
const executingEvent = events[6].result as TaskStatusUpdateEvent;
const executingEvent = events[5].result as TaskStatusUpdateEvent;
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
@@ -674,7 +646,7 @@ describe('E2E Tests', () => {
]);
// Status update: tool-call-update (success)
const successEvent = events[7].result as TaskStatusUpdateEvent;
const successEvent = events[6].result as TaskStatusUpdateEvent;
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
@@ -688,12 +660,12 @@ describe('E2E Tests', () => {
]);
// Status update: working (before sending tool result to LLM)
const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
const workingEvent3 = events[7].result as TaskStatusUpdateEvent;
expect(workingEvent3.kind).toBe('status-update');
expect(workingEvent3.status.state).toBe('working');
// Status update: text-content (final LLM response)
const textContentEvent = events[9].result as TaskStatusUpdateEvent;
const textContentEvent = events[8].result as TaskStatusUpdateEvent;
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'text-content',
});
@@ -702,7 +674,7 @@ describe('E2E Tests', () => {
]);
assertUniqueFinalEventIsLast(events);
expect(events.length).toBe(11);
expect(events.length).toBe(10);
});
it('should bypass tool approval in YOLO mode', async () => {
@@ -762,15 +734,15 @@ describe('E2E Tests', () => {
expect(workingEvent2.kind).toBe('status-update');
expect(workingEvent2.status.state).toBe('working');
// Status update: tool-call-update (scheduled)
const scheduledEvent = events[3].result as TaskStatusUpdateEvent;
expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
// Status update: tool-call-update (validating)
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent.status.message?.parts).toMatchObject([
expect(validatingEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
status: 'validating',
request: { callId: 'test-call-id-yolo' },
},
},
@@ -790,22 +762,8 @@ describe('E2E Tests', () => {
},
]);
// Status update: tool-call-update (scheduled)
const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent3.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
request: { callId: 'test-call-id-yolo' },
},
},
]);
// Status update: tool-call-update (executing)
const executingEvent = events[6].result as TaskStatusUpdateEvent;
const executingEvent = events[5].result as TaskStatusUpdateEvent;
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
@@ -819,7 +777,7 @@ describe('E2E Tests', () => {
]);
// Status update: tool-call-update (success)
const successEvent = events[7].result as TaskStatusUpdateEvent;
const successEvent = events[6].result as TaskStatusUpdateEvent;
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
@@ -833,12 +791,12 @@ describe('E2E Tests', () => {
]);
// Status update: working (before sending tool result to LLM)
const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
const workingEvent3 = events[7].result as TaskStatusUpdateEvent;
expect(workingEvent3.kind).toBe('status-update');
expect(workingEvent3.status.state).toBe('working');
// Status update: text-content (final LLM response)
const textContentEvent = events[9].result as TaskStatusUpdateEvent;
const textContentEvent = events[8].result as TaskStatusUpdateEvent;
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'text-content',
});
@@ -847,7 +805,7 @@ describe('E2E Tests', () => {
]);
assertUniqueFinalEventIsLast(events);
expect(events.length).toBe(11);
expect(events.length).toBe(10);
});
it('should include traceId in status updates when available', async () => {
+8 -11
View File
@@ -9,6 +9,11 @@
import { spawn } from 'node:child_process';
import os from 'node:os';
import v8 from 'node:v8';
import {
RELAUNCH_EXIT_CODE,
getSpawnConfig,
getScriptArgs,
} from './src/utils/processUtils.js';
// --- Global Entry Point ---
@@ -74,18 +79,10 @@ async function run() {
// --- Lightweight Parent Process / Daemon ---
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
const nodeArgs: string[] = [...process.execArgv];
const scriptArgs = process.argv.slice(2);
const scriptArgs = getScriptArgs();
const memoryArgs = await getMemoryNodeArgs();
nodeArgs.push(...memoryArgs);
const { spawnArgs, env: newEnv } = getSpawnConfig(memoryArgs, scriptArgs);
const script = process.argv[1];
nodeArgs.push(script);
nodeArgs.push(...scriptArgs);
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
const RELAUNCH_EXIT_CODE = 199;
let latestAdminSettings: unknown = undefined;
// Prevent the parent process from exiting prematurely on signals.
@@ -97,7 +94,7 @@ async function run() {
const runner = () => {
process.stdin.pause();
const child = spawn(process.execPath, nodeArgs, {
const child = spawn(process.execPath, spawnArgs, {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: newEnv,
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.2"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+1
View File
@@ -1035,6 +1035,7 @@ export async function loadCliConfig(
extensionRegistryURI,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
enableEnhanceCommand: settings.experimental?.enhanceCommand ?? false,
plan: settings.general?.plan?.enabled ?? true,
voiceMode: settings.experimental?.voiceMode,
tracker: settings.experimental?.taskTracker,
@@ -2437,6 +2437,15 @@ const SETTINGS_SCHEMA = {
description: 'Deprecated: Use general.topicUpdateNarration instead.',
showInDialog: false,
},
enhanceCommand: {
type: 'boolean',
label: 'Enhance Command',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable the experimental /enhance slash command.',
showInDialog: true,
},
},
},
extensions: {
@@ -171,6 +171,7 @@ describe('BuiltinCommandLoader', () => {
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
isEnhanceCommandEnabled: vi.fn().mockReturnValue(true),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
@@ -310,6 +311,22 @@ describe('BuiltinCommandLoader', () => {
expect(agentsCmd).toBeUndefined();
});
it('should include enhance command when experimental enhance command is enabled', async () => {
(mockConfig.isEnhanceCommandEnabled as Mock).mockReturnValue(true);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const enhanceCmd = commands.find((c) => c.name === 'enhance');
expect(enhanceCmd).toBeDefined();
});
it('should exclude enhance command when experimental enhance command is disabled', async () => {
(mockConfig.isEnhanceCommandEnabled as Mock).mockReturnValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const enhanceCmd = commands.find((c) => c.name === 'enhance');
expect(enhanceCmd).toBeUndefined();
});
describe('chat debug command', () => {
it('should NOT add debug subcommand to chat/resume commands if not a nightly build', async () => {
vi.mocked(isNightly).mockResolvedValue(false);
@@ -398,6 +415,7 @@ describe('BuiltinCommandLoader profile', () => {
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
isEnhanceCommandEnabled: vi.fn().mockReturnValue(true),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
@@ -31,6 +31,7 @@ import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { enhanceCommand } from '../ui/commands/enhanceCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { footerCommand } from '../ui/commands/footerCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
@@ -135,6 +136,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
docsCommand,
directoryCommand,
editorCommand,
...(this.config?.isEnhanceCommandEnabled() ? [enhanceCommand] : []),
...(this.config?.getExtensionsEnabled() === false
? [
{
@@ -58,6 +58,7 @@ export const createMockCommandContext = (
pendingItem: null,
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
setInput: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleShortcutsHelp: vi.fn(),
toggleVimEnabled: vi.fn(),
@@ -0,0 +1,201 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { enhanceCommand } from './enhanceCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { LlmRole } from '@google/gemini-cli-core';
describe('enhanceCommand', () => {
let mockContext: CommandContext;
let mockGenerateContent: Mock;
beforeEach(() => {
mockGenerateContent = vi.fn();
mockContext = createMockCommandContext({
services: {
agentContext: {
promptId: 'test-prompt-id',
geminiClient: {
getHistory: vi.fn().mockReturnValue([
{ role: 'user', parts: [{ text: 'previous user msg' }] },
{ role: 'model', parts: [{ text: 'previous model msg' }] },
]),
},
config: {
getModel: vi.fn().mockReturnValue('test-model'),
getContentGenerator: vi.fn().mockReturnValue({
generateContent: mockGenerateContent,
}),
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
},
},
},
ui: {
addItem: vi.fn(),
setDebugMessage: vi.fn(),
},
} as unknown as CommandContext);
});
it('should have the correct name and description', () => {
expect(enhanceCommand.name).toBe('enhance');
expect(enhanceCommand.description).toBe(
'Enhance a prompt with additional context and rephrasing',
);
});
it('should show error if no prompt is provided', async () => {
if (!enhanceCommand.action) throw new Error('Action must be defined');
await enhanceCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: expect.stringContaining('Please provide a prompt'),
}),
);
});
it('should call generateContent with correct parameters and show enhanced prompt', async () => {
if (!enhanceCommand.action) throw new Error('Action must be defined');
mockGenerateContent.mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Enhanced: do something' }],
},
},
],
});
await enhanceCommand.action(mockContext, 'do something');
expect(mockGenerateContent).toHaveBeenCalledWith(
expect.objectContaining({
model: 'test-model',
contents: [
{ role: 'user', parts: [{ text: 'previous user msg' }] },
{ role: 'model', parts: [{ text: 'previous model msg' }] },
{ role: 'user', parts: [{ text: 'do something' }] },
],
config: {
systemInstruction: {
role: 'system',
parts: [
{
text: expect.stringContaining(
"Generate an enhanced version of the user's prompt",
),
},
],
},
},
}),
'test-prompt-id',
LlmRole.UTILITY_TOOL,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining(
'Enhanced prompt:\n\nEnhanced: do something',
),
}),
);
expect(mockContext.ui.setInput).toHaveBeenCalledWith(
'Enhanced: do something',
);
});
it('should clean the response from markdown and quotes', async () => {
if (!enhanceCommand.action) throw new Error('Action must be defined');
mockGenerateContent.mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: '```markdown\n"Clean me"\n```' }],
},
},
],
});
await enhanceCommand.action(mockContext, 'dirty prompt');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Enhanced prompt:\n\nClean me'),
}),
);
expect(mockContext.ui.setInput).toHaveBeenCalledWith('Clean me');
});
it('should handle API errors gracefully', async () => {
if (!enhanceCommand.action) throw new Error('Action must be defined');
mockGenerateContent.mockRejectedValue(new Error('API Error'));
await enhanceCommand.action(mockContext, 'test prompt');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: expect.stringContaining('Failed to enhance prompt: API Error'),
}),
);
});
it('should handle empty response from model', async () => {
if (!enhanceCommand.action) throw new Error('Action must be defined');
mockGenerateContent.mockResolvedValue({
candidates: [],
});
await enhanceCommand.action(mockContext, 'test prompt');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: expect.stringContaining('Empty response from model'),
}),
);
});
it('should ignore thought parts and sanitize the output', async () => {
if (!enhanceCommand.action) throw new Error('Action must be defined');
mockGenerateContent.mockResolvedValue({
candidates: [
{
content: {
parts: [
{ thought: true, text: 'This is a thought.' },
{ text: 'Sanitized\nPrompt]' },
],
},
},
],
});
await enhanceCommand.action(mockContext, 'dirty prompt');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Enhanced prompt:\n\nSanitizedPrompt'),
}),
);
expect(mockContext.ui.setInput).toHaveBeenCalledWith('SanitizedPrompt');
});
});
@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
import { LlmRole, resolveModel } from '@google/gemini-cli-core';
import type { Content } from '@google/genai';
const INSTRUCTION =
"Generate an enhanced version of the user's prompt, using the preceding conversation as context. Reply with ONLY the enhanced prompt - no conversation, explanations, lead-in, bullet points, placeholders, or surrounding quotes.";
function clean(text: string) {
const stripped = text.replace(/^```\w*\n?|```$/g, '').trim();
return stripped.replace(/^(['"])([\s\S]*)\1$/, '$2').trim();
}
export const enhanceCommand: SlashCommand = {
name: 'enhance',
description: 'Enhance a prompt with additional context and rephrasing',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context, args) => {
const draft = args.trim();
if (!draft) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Please provide a prompt to enhance. Usage: /enhance <prompt>',
});
return;
}
const agentContext = context.services.agentContext;
if (!agentContext) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Agent context not available.',
});
return;
}
const config = agentContext.config;
const contentGenerator = config.getContentGenerator();
const promptId = agentContext.promptId;
const model = resolveModel(
config.getModel(),
config.getGemini31LaunchedSync?.() ?? false,
false,
false,
config.getHasAccessToPreviewModel?.() ?? true,
config,
);
context.ui.setDebugMessage('Enhancing prompt...');
try {
const history = agentContext.geminiClient?.getHistory() ?? [];
const contents: Content[] = [
...history,
{ role: 'user', parts: [{ text: draft }] },
];
const response = await contentGenerator.generateContent(
{
model,
contents,
config: {
systemInstruction: {
role: 'system',
parts: [{ text: INSTRUCTION }],
},
},
},
promptId,
LlmRole.UTILITY_TOOL,
);
const parts = response.candidates?.[0]?.content?.parts;
const enhancedText = parts
?.find((part) => 'text' in part && !('thought' in part))
?.text?.replace(/\n/g, '')
?.replace(/]/g, '');
if (enhancedText) {
const cleanedText = clean(enhancedText);
context.ui.addItem({
type: MessageType.INFO,
text: `Enhanced prompt:\n\n${cleanedText}`,
});
context.ui.setInput(cleanedText);
} else {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Failed to enhance prompt: Empty response from model.',
});
}
} catch (error) {
context.ui.addItem({
type: MessageType.ERROR,
text: `Failed to enhance prompt: ${error instanceof Error ? error.message : String(error)}`,
});
} finally {
context.ui.setDebugMessage('');
}
},
};
+6
View File
@@ -70,6 +70,12 @@ export interface CommandContext {
* @param postLoadInput Optional text to set in the input buffer after loading history.
*/
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
/**
* Sets the text in the input buffer.
*
* @param text The text to set.
*/
setInput: (text: string) => void;
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVoiceMode: () => void;
+6
View File
@@ -153,6 +153,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '}
- Open input in external editor
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{formatCommand(Command.ENHANCE_PROMPT)}
</Text>{' '}
- Enhance the current prompt using an LLM
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{formatCommand(Command.TOGGLE_YOLO)}
@@ -1287,6 +1287,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
// Alt+E for enhance prompt
if (keyMatchers[Command.ENHANCE_PROMPT](key)) {
const enhanceCmd = slashCommands.find((cmd) => cmd.name === 'enhance');
if (enhanceCmd && enhanceCmd.action) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
enhanceCmd.action(commandContext, buffer.text);
}
return true;
}
if (keyMatchers[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR](key)) {
const cmdKey = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
appEvents.emit(AppEvent.TransientMessage, {
@@ -1352,6 +1362,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
inputHistory,
handleSubmit,
shellHistory,
slashCommands,
commandContext,
reverseSearchCompletion,
handleClipboardPaste,
resetCompletionState,
@@ -326,36 +326,6 @@ describe('SettingsDialog', () => {
});
unmount();
});
it('should render the bottom border correctly when height is constrained', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const constrainedHeight = 15;
const renderResult = await renderDialog(settings, onSelect, {
availableTerminalHeight: constrainedHeight,
});
await renderResult.waitUntilReady();
await waitFor(() => {
const output = renderResult.lastFrame();
const lines = output.trim().split('\n');
// Verify height constraint
expect(lines.length).toBeLessThanOrEqual(constrainedHeight);
// Verify bottom border existence in the last line of the output
const lastLine = lines[lines.length - 1];
// 'round' border characters: ─, ╰, ╯
expect(lastLine).toMatch(/[─╰╯]/);
});
// SVG snapshot ensures visual layout and border rendering are preserved
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
describe('Setting Descriptions', () => {
@@ -44,6 +44,10 @@ const buildShortcutItems = (): ShortcutItem[] => [
key: formatCommand(Command.OPEN_EXTERNAL_EDITOR),
description: 'open external editor',
},
{
key: formatCommand(Command.ENHANCE_PROMPT),
description: 'enhance prompt',
},
];
const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => (
@@ -1,63 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="275" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#878787" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="19" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs" font-weight="bold">&gt; Settings </text>
<text x="891" y="36" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="53" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#d7ffd7" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="891" y="70" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="87" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">╰─</text>
<rect x="36" y="85" width="9" height="17" fill="#ffffff" />
<text x="36" y="87" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">S</text>
<text x="45" y="87" fill="#afafaf" textLength="135" lengthAdjust="spacingAndGlyphs">earch to filter</text>
<text x="180" y="87" fill="#d7ffd7" textLength="702" lengthAdjust="spacingAndGlyphs">─────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="87" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="104" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="104" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="119" width="9" height="17" fill="#005f00" />
<text x="27" y="121" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="119" width="9" height="17" fill="#005f00" />
<rect x="45" y="119" width="72" height="17" fill="#005f00" />
<text x="45" y="121" fill="#d7ffd7" textLength="72" lengthAdjust="spacingAndGlyphs">Vim Mode</text>
<rect x="117" y="119" width="711" height="17" fill="#005f00" />
<rect x="828" y="119" width="45" height="17" fill="#005f00" />
<text x="828" y="121" fill="#d7ffd7" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="121" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="138" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="136" width="198" height="17" fill="#005f00" />
<text x="45" y="138" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">Enable Vim keybindings</text>
<text x="891" y="138" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="155" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="172" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> Apply To </text>
<text x="891" y="172" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="187" width="9" height="17" fill="#005f00" />
<text x="27" y="189" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="187" width="9" height="17" fill="#005f00" />
<rect x="45" y="187" width="117" height="17" fill="#005f00" />
<text x="45" y="189" fill="#d7ffd7" textLength="117" lengthAdjust="spacingAndGlyphs">User Settings</text>
<rect x="162" y="187" width="711" height="17" fill="#005f00" />
<text x="891" y="189" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="206" fill="#afafaf" textLength="657" lengthAdjust="spacingAndGlyphs">(Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)</text>
<text x="891" y="206" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="223" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#878787" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

@@ -46,24 +46,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Initial Rendering > should render the bottom border correctly when height is constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ ╰─Search to filter─────────────────────────────────────────────────────────────────────────────╯ │
│ ▲ │
│ ● Vim Mode false │
│ ▼ Enable Vim keybindings │
│ │
│ Apply To │
│ ● User Settings │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -13,6 +13,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
Alt+M raw markdown mode
Ctrl+R reverse-search history
Ctrl+G open external editor
Alt+E enhance prompt
"
`;
@@ -29,6 +30,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
Option+M raw markdown mode
Ctrl+R reverse-search history
Ctrl+G open external editor
Option+E enhance prompt
"
`;
@@ -425,7 +425,7 @@ export function BaseSettingsDialog({
flexDirection="row"
padding={1}
width="100%"
maxHeight={availableHeight}
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
{/* Title */}
@@ -41,6 +41,7 @@ const KEY_INFO_MAP: Record<
string,
{ name: string; shift?: boolean; ctrl?: boolean }
> = {
OM: { name: 'enter' },
'[200~': { name: 'paste-start' },
'[201~': { name: 'paste-end' },
'[[A': { name: 'f1' },
@@ -230,6 +230,7 @@ export const useSlashCommandProcessor = (
actions.setText(postLoadInput);
}
},
setInput: (text) => actions.setText(text),
setDebugMessage: actions.setDebugMessage,
pendingItem,
setPendingItem,
+4
View File
@@ -79,6 +79,7 @@ export enum Command {
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
DEPRECATED_OPEN_EXTERNAL_EDITOR = 'input.deprecatedOpenExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
ENHANCE_PROMPT = 'input.enhancePrompt',
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
@@ -382,6 +383,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[new KeyBinding('ctrl+g'), new KeyBinding('ctrl+shift+g')],
],
[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+x')]],
[Command.ENHANCE_PROMPT, [new KeyBinding('alt+e')]],
[
Command.PASTE_CLIPBOARD,
[
@@ -520,6 +522,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.OPEN_EXTERNAL_EDITOR,
Command.DEPRECATED_OPEN_EXTERNAL_EDITOR,
Command.PASTE_CLIPBOARD,
Command.ENHANCE_PROMPT,
],
},
{
@@ -639,6 +642,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR]:
'Deprecated command to open external editor.',
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
[Command.ENHANCE_PROMPT]: 'Enhance the current prompt using an LLM.',
// App Controls
[Command.SHOW_ERROR_DETAILS]:
@@ -29,6 +29,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
clear: () => {},
setDebugMessage: (_message) => {},
loadHistory: (_newHistory) => {},
setInput: (_text) => {},
pendingItem: null,
setPendingItem: (_item) => {},
toggleCorgiMode: () => {},
@@ -165,45 +165,6 @@ exports[`TableRenderer > renders a complex table with mixed content lengths corr
└─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘"
`;
exports[`TableRenderer > renders a complex table with mixed content lengths correctly 2`] = `
"
┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐
│ 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 │ │ │ │ │
│ Layer │ Processing Pipeline with │ Deployment Clusters │ Vulnerabilities │ │ │ │ │
│ │ Extended Scalability │ │ │ │ │ │ │
│ │ Features and Redundancy │ │ │ │ │ │ │
│ │ Protocols │ │ │ │ │ │ │
├─────────────────────────────┼──────────────────────────────┼─────────────────────────────┼──────────────────────────────┼─────┼────────┼─────────┼───────┤
│ The primary architecture │ Each message is processed │ Historical data indicates a │ A multi-layered defense │ INF │ Active │ v2.4 │ J. │
│ utilizes a decoupled │ through a series of │ significant reduction in │ strategy incorporates │ │ │ │ Doe │
│ microservices approach, │ specialized workers that │ tail latency when utilizing │ content security policies, │ │ │ │ │
│ leveraging container │ handle data transformation, │ edge computing nodes closer │ input sanitization │ │ │ │ │
│ orchestration for │ validation, and persistent │ to the geographic location │ libraries, and regular │ │ │ │ │
│ scalability and fault │ storage using a persistent │ of the end-user base. │ automated penetration │ │ │ │ │
│ tolerance in high-load │ queue. │ │ testing routines. │ │ │ │ │
│ scenarios. │ │ Monitoring tools have │ │ │ │ │ │
│ │ The pipeline features │ captured a steady increase │ Developers are required to │ │ │ │ │
│ This layer provides the │ built-in retry mechanisms │ in throughput efficiency │ undergo mandatory security │ │ │ │ │
│ fundamental building blocks │ with exponential backoff to │ since the introduction of │ training focusing on the │ │ │ │ │
│ for service discovery, load │ ensure message delivery │ the vectorized query engine │ OWASP Top Ten to ensure that │ │ │ │ │
│ balancing, and │ integrity even during │ in the primary data │ security is integrated into │ │ │ │ │
│ inter-service communication │ transient network or service │ warehouse. │ the initial design phase. │ │ │ │ │
│ via highly efficient │ failures. │ │ │ │ │ │ │
│ protocol buffers. │ │ Resource utilization │ The implementation of a │ │ │ │ │
│ │ Horizontal autoscaling is │ metrics demonstrate that │ robust Identity and Access │ │ │ │ │
│ Advanced telemetry and │ triggered automatically │ the transition to │ Management system ensures │ │ │ │ │
│ logging integrations allow │ based on the depth of the │ serverless compute for │ that the principle of least │ │ │ │ │
│ for real-time monitoring of │ processing queue, ensuring │ intermittent tasks has │ privilege is strictly │ │ │ │ │
│ system health and rapid │ consistent performance │ resulted in a thirty │ enforced across all │ │ │ │ │
│ 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 │
@@ -15,6 +15,25 @@ const debugLogger = vi.hoisted(() => ({
vi.mock('@google/gemini-cli-core', () => ({
getPackageJson,
debugLogger,
ReleaseChannel: {
NIGHTLY: 'nightly',
PREVIEW: 'preview',
STABLE: 'stable',
},
getChannelFromVersion: (version: string) => {
if (!version || version.includes('nightly')) {
return 'nightly';
}
if (version.includes('preview')) {
return 'preview';
}
return 'stable';
},
RELEASE_CHANNEL_STABILITY: {
nightly: 0,
preview: 1,
stable: 2,
},
}));
const latestVersion = vi.hoisted(() => vi.fn());
@@ -152,4 +171,68 @@ describe('checkForUpdates', () => {
expect(result?.update.latest).toBe('1.2.3-nightly.2');
});
});
describe('channel stability', () => {
it('should NOT offer nightly update to a stable user even if tagged as latest', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
// latest points to a nightly that is semver-greater
latestVersion.mockResolvedValue('1.1.0-nightly.1');
const result = await checkForUpdates(mockSettings);
expect(result).toBeNull();
});
it('should NOT offer preview update to a stable user even if tagged as latest', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
// latest points to a preview that is semver-greater
latestVersion.mockResolvedValue('1.1.0-preview.1');
const result = await checkForUpdates(mockSettings);
expect(result).toBeNull();
});
it('should offer stable update to a stable user', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
latestVersion.mockResolvedValue('1.1.0');
const result = await checkForUpdates(mockSettings);
expect(result?.update.latest).toBe('1.1.0');
});
it('should offer stable update to a nightly user', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0-nightly.1',
});
latestVersion.mockImplementation(async (name, options) => {
if (options?.version === 'nightly') {
return '1.0.0-nightly.1'; // No nightly update
}
return '1.1.0'; // Stable update available
});
const result = await checkForUpdates(mockSettings);
expect(result?.update.latest).toBe('1.1.0');
});
it('should offer stable update to a preview user', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0-preview.1',
});
latestVersion.mockResolvedValue('1.1.0');
const result = await checkForUpdates(mockSettings);
expect(result?.update.latest).toBe('1.1.0');
});
});
});
+21 -2
View File
@@ -6,7 +6,12 @@
import latestVersion from 'latest-version';
import semver from 'semver';
import { getPackageJson, debugLogger } from '@google/gemini-cli-core';
import {
getPackageJson,
debugLogger,
getChannelFromVersion,
RELEASE_CHANNEL_STABILITY,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -65,6 +70,7 @@ export async function checkForUpdates(
}
const { name, version: currentVersion } = packageJson;
const currentChannel = getChannelFromVersion(currentVersion);
const isNightly = currentVersion.includes('nightly');
if (isNightly) {
@@ -90,8 +96,21 @@ export async function checkForUpdates(
}
} else {
const latestUpdate = await latestVersion(name);
if (!latestUpdate) {
return null;
}
if (latestUpdate && semver.gt(latestUpdate, currentVersion)) {
const targetChannel = getChannelFromVersion(latestUpdate);
// Only offer updates that are as stable or more stable than the current version
if (
RELEASE_CHANNEL_STABILITY[targetChannel] <
RELEASE_CHANNEL_STABILITY[currentChannel]
) {
return null;
}
if (semver.gt(latestUpdate, currentVersion)) {
const message = `Gemini CLI update available! ${currentVersion}${latestUpdate}`;
const type = semver.diff(latestUpdate, currentVersion) || undefined;
return {
@@ -334,7 +334,8 @@ describe('handleAutoUpdate', () => {
...mockUpdateInfo,
update: {
...mockUpdateInfo.update,
latest: '2.0.0-nightly',
current: '1.0.0-nightly.0',
latest: '2.0.0-nightly.1',
},
};
mockGetInstallationInfo.mockReturnValue({
@@ -356,6 +357,26 @@ describe('handleAutoUpdate', () => {
);
});
it('should NOT update if target is less stable than current (defense-in-depth)', async () => {
mockUpdateInfo = {
...mockUpdateInfo,
update: {
...mockUpdateInfo.update,
current: '1.0.0',
latest: '1.1.0-nightly.1',
},
};
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @google/gemini-cli@latest',
isGlobal: false,
packageManager: PackageManager.NPM,
});
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should emit "update-success" when the update process succeeds', async () => {
await new Promise<void>((resolve) => {
mockGetInstallationInfo.mockReturnValue({
+24 -1
View File
@@ -11,7 +11,11 @@ import { updateEventEmitter } from './updateEventEmitter.js';
import { MessageType, type HistoryItem } from '../ui/types.js';
import { spawnWrapper } from './spawnWrapper.js';
import type { spawn } from 'node:child_process';
import { debugLogger } from '@google/gemini-cli-core';
import {
debugLogger,
getChannelFromVersion,
RELEASE_CHANNEL_STABILITY,
} from '@google/gemini-cli-core';
let _updateInProgress = false;
@@ -122,6 +126,25 @@ export function handleAutoUpdate(
return;
}
const currentVersion = info.update.current;
if (!currentVersion) {
debugLogger.warn(
'Update check: current version is missing. Skipping automatic update for safety.',
);
return;
}
const currentChannel = getChannelFromVersion(currentVersion);
const targetChannel = getChannelFromVersion(info.update.latest);
// Defense-in-depth: prevent updates to a less stable channel
if (
RELEASE_CHANNEL_STABILITY[targetChannel] <
RELEASE_CHANNEL_STABILITY[currentChannel]
) {
return;
}
const isNightly = info.update.latest.includes('nightly');
const updateCommand = installationInfo.updateCommand.replace(
+158
View File
@@ -9,6 +9,11 @@ import {
RELAUNCH_EXIT_CODE,
relaunchApp,
_resetRelaunchStateForTesting,
isStandardSea,
getScriptArgs,
isSeaEnvironment,
getSpawnConfig,
type ProcessWithSea,
} from './processUtils.js';
import * as cleanup from './cleanup.js';
import * as handleAutoUpdate from './handleAutoUpdate.js';
@@ -36,3 +41,156 @@ describe('processUtils', () => {
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});
});
describe('SEA handling utilities', () => {
const originalArgv = process.argv;
const originalExecArgv = process.execArgv;
const originalExecPath = process.execPath;
const originalIsSea = (process as ProcessWithSea).isSea;
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv('NODE_OPTIONS', '');
process.argv = [...originalArgv];
process.execArgv = [...originalExecArgv];
process.execPath = '/fake/exec/path';
delete (process as ProcessWithSea).isSea;
});
afterEach(() => {
vi.unstubAllEnvs();
process.argv = originalArgv;
process.execArgv = originalExecArgv;
process.execPath = originalExecPath;
if (originalIsSea) {
(process as ProcessWithSea).isSea = originalIsSea;
} else {
delete (process as ProcessWithSea).isSea;
}
});
describe('isStandardSea', () => {
it('returns false if argv[0] === argv[1]', () => {
process.argv = ['/bin/gemini', '/bin/gemini', 'my-command'];
vi.stubEnv('IS_BINARY', 'true');
expect(isStandardSea()).toBe(false);
});
it('returns true if IS_BINARY is true and argv[0] !== argv[1]', () => {
process.argv = ['/bin/gemini', 'my-command'];
vi.stubEnv('IS_BINARY', 'true');
expect(isStandardSea()).toBe(true);
});
it('returns true if process.isSea() is true and argv[0] !== argv[1]', () => {
process.argv = ['/bin/gemini', 'my-command'];
(process as ProcessWithSea).isSea = () => true;
expect(isStandardSea()).toBe(true);
});
it('returns false in standard node environment', () => {
process.argv = ['/bin/node', '/path/to/script.js', 'my-command'];
expect(isStandardSea()).toBe(false);
});
});
describe('getScriptArgs', () => {
it('slices from index 1 if isStandardSea is true', () => {
process.argv = ['/bin/gemini', 'my-command', '--flag'];
vi.stubEnv('IS_BINARY', 'true');
expect(getScriptArgs()).toEqual(['my-command', '--flag']);
});
it('slices from index 2 if isStandardSea is false (relaunch SEA or standard node)', () => {
// Relaunch SEA
process.argv = ['/bin/gemini', '/bin/gemini', 'my-command', '--flag'];
vi.stubEnv('IS_BINARY', 'true');
expect(getScriptArgs()).toEqual(['my-command', '--flag']);
// Standard node
process.argv = ['/bin/node', '/path/to/script.js', 'my-command'];
vi.stubEnv('IS_BINARY', '');
expect(getScriptArgs()).toEqual(['my-command']);
});
});
describe('isSeaEnvironment', () => {
it('returns true if IS_BINARY is true', () => {
vi.stubEnv('IS_BINARY', 'true');
expect(isSeaEnvironment()).toBe(true);
});
it('returns true if process.isSea() is true', () => {
(process as ProcessWithSea).isSea = () => true;
expect(isSeaEnvironment()).toBe(true);
});
it('returns true if argv[0] === argv[1]', () => {
process.argv = ['/bin/gemini', '/bin/gemini'];
expect(isSeaEnvironment()).toBe(true);
});
it('returns false otherwise', () => {
process.argv = ['/bin/node', '/path/to/script.js'];
expect(isSeaEnvironment()).toBe(false);
});
});
describe('getSpawnConfig', () => {
it('handles standard node mode', () => {
process.argv = ['/bin/node', '/path/to/script.js', 'my-command'];
process.execArgv = ['--inspect'];
process.execPath = '/bin/node';
const config = getSpawnConfig(
['--max-old-space-size=8192'],
['my-command'],
);
expect(config.spawnArgs).toEqual([
'--inspect',
'--max-old-space-size=8192',
'/path/to/script.js',
'my-command',
]);
expect(config.env['GEMINI_CLI_NO_RELAUNCH']).toBe('true');
expect(config.env['NODE_OPTIONS']).toBeFalsy();
});
it('handles SEA binary mode with new nodeArgs', () => {
vi.stubEnv('IS_BINARY', 'true');
vi.stubEnv('NODE_OPTIONS', '--existing-flag');
process.argv = ['/bin/gemini', 'my-command'];
process.execArgv = ['--inspect']; // Should not be duplicated in NODE_OPTIONS
process.execPath = '/bin/gemini';
const config = getSpawnConfig(
['--max-old-space-size=8192'],
['my-command'],
);
expect(config.spawnArgs).toEqual([
'/bin/gemini', // explicitly uses execPath as placeholder
'my-command',
]);
expect(config.env['NODE_OPTIONS']).toBe(
'--existing-flag --max-old-space-size=8192',
);
expect(config.env['GEMINI_CLI_NO_RELAUNCH']).toBe('true');
});
it('throws error for complex nodeArgs in SEA mode', () => {
vi.stubEnv('IS_BINARY', 'true');
expect(() => {
getSpawnConfig(['--title "My App"'], []);
}).toThrow(
'Unsupported node argument for SEA relaunch: --title "My App". Complex escaping is not supported.',
);
expect(() => {
getSpawnConfig(['--title=A\\B'], []);
}).toThrow();
});
});
});
+98
View File
@@ -29,3 +29,101 @@ export async function relaunchApp(): Promise<void> {
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}
export interface ProcessWithSea extends NodeJS.Process {
isSea?: () => boolean;
}
/**
* Determines whether the current process is a "standard" SEA (Single Executable Application)
* where the user arguments start at index 1 instead of index 2.
* A relaunched SEA child will have process.argv[0] === process.argv[1] (because we inject execPath),
* so it will return false here and correctly slice from index 2.
*/
export function isStandardSea(): boolean {
return (
process.argv[0] !== process.argv[1] &&
(process.env['IS_BINARY'] === 'true' ||
(process as ProcessWithSea).isSea?.() === true)
);
}
/**
* Extracts the user-provided script arguments from process.argv,
* accounting for the differences in SEA execution modes.
*/
export function getScriptArgs(): string[] {
return process.argv.slice(isStandardSea() ? 1 : 2);
}
/**
* Determines if the current process is running in any SEA environment
* (either the initial launch or a relaunched child).
*/
export function isSeaEnvironment(): boolean {
return (
process.env['IS_BINARY'] === 'true' ||
(process as ProcessWithSea).isSea?.() === true ||
process.argv[0] === process.argv[1]
);
}
/**
* Constructs the arguments and environment for spawning a child process during relaunch.
* Handles differences between standard Node and SEA binary modes.
*/
export function getSpawnConfig(
nodeArgs: string[],
scriptArgs: string[],
): {
spawnArgs: string[];
env: NodeJS.ProcessEnv;
} {
const isBinary = isSeaEnvironment();
const newEnv: NodeJS.ProcessEnv = {
...process.env,
GEMINI_CLI_NO_RELAUNCH: 'true',
};
const finalSpawnArgs: string[] = [];
if (isBinary) {
// In SEA mode, Node flags must be passed via NODE_OPTIONS, as the binary
// passes all CLI arguments directly to the application.
// We only need to append the *new* nodeArgs (e.g., memory flags).
// Existing execArgv are inherited via the environment or baked into the binary.
if (nodeArgs.length > 0) {
for (const arg of nodeArgs) {
if (/[\s"'\\]/.test(arg)) {
throw new Error(
`Unsupported node argument for SEA relaunch: ${arg}. Complex escaping is not supported.`,
);
}
}
const existingNodeOptions = process.env['NODE_OPTIONS'] || '';
// nodeArgs in our codebase are simple flags like --max-old-space-size=X
// that do not contain spaces and do not require complex escaping.
newEnv['NODE_OPTIONS'] =
`${existingNodeOptions} ${nodeArgs.join(' ')}`.trim();
}
// Binary is its own entry point. To maintain the [node, script, ...args]
// structure expected by the application (which uses slice(2)),
// we must provide a placeholder for the script path.
// We explicitly use process.execPath to break the cycle and prevent
// compounding argument duplication on subsequent relaunches.
finalSpawnArgs.push(process.execPath, ...scriptArgs);
} else {
// Standard Node mode: pass all flags via command line.
finalSpawnArgs.push(
...process.execArgv,
...nodeArgs,
process.argv[1],
...scriptArgs,
);
}
return {
spawnArgs: finalSpawnArgs,
env: newEnv,
};
}
+110 -98
View File
@@ -59,6 +59,7 @@ describe('relaunchOnExitCode', () => {
});
afterEach(() => {
vi.unstubAllEnvs();
processExitSpy.mockRestore();
stdinResumeSpy.mockRestore();
});
@@ -116,7 +117,6 @@ describe('relaunchAppInChildProcess', () => {
let stdinResumeSpy: MockInstance;
// Store original values to restore later
const originalEnv = { ...process.env };
const originalExecArgv = [...process.execArgv];
const originalArgv = [...process.argv];
const originalExecPath = process.execPath;
@@ -125,8 +125,9 @@ describe('relaunchAppInChildProcess', () => {
vi.clearAllMocks();
mocks.writeToStderr.mockClear();
process.env = { ...originalEnv };
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', '');
vi.stubEnv('IS_BINARY', '');
vi.stubEnv('NODE_OPTIONS', '');
process.execArgv = [...originalExecArgv];
process.argv = [...originalArgv];
@@ -144,7 +145,7 @@ describe('relaunchAppInChildProcess', () => {
});
afterEach(() => {
process.env = { ...originalEnv };
vi.unstubAllEnvs();
process.execArgv = [...originalExecArgv];
process.argv = [...originalArgv];
process.execPath = originalExecPath;
@@ -156,7 +157,7 @@ describe('relaunchAppInChildProcess', () => {
describe('when GEMINI_CLI_NO_RELAUNCH is set', () => {
it('should return early without spawning a child process', async () => {
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', 'true');
await relaunchAppInChildProcess(['--test'], ['--verbose']);
@@ -167,132 +168,141 @@ describe('relaunchAppInChildProcess', () => {
describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => {
beforeEach(() => {
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', '');
});
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {
// Test the argument construction logic directly by extracting it into a testable function
// This tests the same logic that's used in relaunchAppInChildProcess
// Setup test data to verify argument ordering
const mockExecArgv = ['--inspect=9229', '--trace-warnings'];
const mockArgv = [
it('should construct correct spawn arguments and use command line for node arguments in standard Node mode', async () => {
process.execArgv = ['--inspect=9229', '--trace-warnings'];
process.argv = [
'/usr/bin/node',
'/path/to/cli.js',
'command',
'--flag=value',
'--verbose',
];
const additionalNodeArgs = [
'--max-old-space-size=4096',
'--experimental-modules',
];
const additionalScriptArgs = ['--model', 'gemini-1.5-pro', '--debug'];
// Extract the argument construction logic from relaunchAppInChildProcess
const script = mockArgv[1];
const scriptArgs = mockArgv.slice(2);
const mockChild = createMockChildProcess(0, true);
mockedSpawn.mockReturnValue(mockChild);
const nodeArgs = [
...mockExecArgv,
...additionalNodeArgs,
script,
...additionalScriptArgs,
...scriptArgs,
await expect(
relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs),
).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(mockedSpawn).toHaveBeenCalledWith(
process.execPath,
[
'--inspect=9229',
'--trace-warnings',
'--max-old-space-size=4096',
'--experimental-modules',
'/path/to/cli.js',
'--model',
'gemini-1.5-pro',
'--debug',
'command',
'--flag=value',
'--verbose',
],
expect.objectContaining({
env: expect.objectContaining({
GEMINI_CLI_NO_RELAUNCH: 'true',
}),
}),
);
const lastCall = mockedSpawn.mock.calls[0] as unknown as [
string,
string[],
{ env: NodeJS.ProcessEnv },
];
const env = lastCall[2].env;
expect(env['NODE_OPTIONS']).toBeFalsy();
});
// Verify the argument construction follows the expected pattern:
// [...process.execArgv, ...additionalNodeArgs, script, ...additionalScriptArgs, ...scriptArgs]
const expectedArgs = [
// Original node execution arguments
'--inspect=9229',
'--trace-warnings',
// Additional node arguments passed to function
'--max-old-space-size=4096',
'--experimental-modules',
// The script path
'/path/to/cli.js',
// Additional script arguments passed to function
'--model',
'gemini-1.5-pro',
'--debug',
// Original script arguments (everything after the script in process.argv)
it('should handle SEA binary mode (IS_BINARY=true) correctly using NODE_OPTIONS', async () => {
vi.stubEnv('IS_BINARY', 'true');
// execArgv should be inherited, not duplicated in NODE_OPTIONS
process.execArgv = ['--inspect=9229'];
process.argv = [
'/usr/bin/gemini',
'/usr/bin/gemini',
'command',
'--flag=value',
'--verbose',
];
expect(nodeArgs).toEqual(expectedArgs);
});
it('should handle empty additional arguments correctly', () => {
// Test edge cases with empty arrays
const mockExecArgv = ['--trace-warnings'];
const mockArgv = ['/usr/bin/node', '/app/cli.js', 'start'];
const additionalNodeArgs: string[] = [];
const additionalNodeArgs = ['--max-old-space-size=8192'];
const additionalScriptArgs: string[] = [];
// Extract the argument construction logic
const script = mockArgv[1];
const scriptArgs = mockArgv.slice(2);
const mockChild = createMockChildProcess(0, true);
mockedSpawn.mockReturnValue(mockChild);
const nodeArgs = [
...mockExecArgv,
...additionalNodeArgs,
script,
...additionalScriptArgs,
...scriptArgs,
];
await expect(
relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs),
).rejects.toThrow('PROCESS_EXIT_CALLED');
const expectedArgs = ['--trace-warnings', '/app/cli.js', 'start'];
expect(nodeArgs).toEqual(expectedArgs);
expect(mockedSpawn).toHaveBeenCalledWith(
process.execPath,
['/usr/bin/node', 'command', '--verbose'],
expect.objectContaining({
env: expect.objectContaining({
GEMINI_CLI_NO_RELAUNCH: 'true',
NODE_OPTIONS: '--max-old-space-size=8192',
}),
}),
);
});
it('should handle complex argument patterns', () => {
// Test with various argument types including flags with values, boolean flags, etc.
const mockExecArgv = ['--max-old-space-size=8192'];
const mockArgv = [
'/usr/bin/node',
'/cli.js',
'--config=/path/to/config.json',
'--verbose',
'subcommand',
'--output',
'file.txt',
];
const additionalNodeArgs = ['--inspect-brk=9230'];
const additionalScriptArgs = ['--model=gpt-4', '--temperature=0.7'];
it('should append new nodeArgs to NODE_OPTIONS in SEA mode without escaping', async () => {
vi.stubEnv('IS_BINARY', 'true');
vi.stubEnv('NODE_OPTIONS', '--existing-flag');
process.execArgv = ['--inspect']; // inherited from env/binary, should not be duplicated
process.argv = ['/usr/bin/gemini', '/usr/bin/gemini', 'command'];
const script = mockArgv[1];
const scriptArgs = mockArgv.slice(2);
// In our use case, these are simple flags like --max-old-space-size=X
const additionalNodeArgs = ['--max-old-space-size=8192'];
const additionalScriptArgs: string[] = [];
const nodeArgs = [
...mockExecArgv,
...additionalNodeArgs,
script,
...additionalScriptArgs,
...scriptArgs,
];
const mockChild = createMockChildProcess(0, true);
mockedSpawn.mockReturnValue(mockChild);
const expectedArgs = [
'--max-old-space-size=8192',
'--inspect-brk=9230',
'/cli.js',
'--model=gpt-4',
'--temperature=0.7',
'--config=/path/to/config.json',
'--verbose',
'subcommand',
'--output',
'file.txt',
];
await expect(
relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs),
).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(nodeArgs).toEqual(expectedArgs);
expect(mockedSpawn).toHaveBeenCalledWith(
process.execPath,
['/usr/bin/node', 'command'],
expect.objectContaining({
env: expect.objectContaining({
NODE_OPTIONS: '--existing-flag --max-old-space-size=8192',
}),
}),
);
});
// Note: Additional integration tests for spawn behavior are complex due to module mocking
// limitations with ES modules. The core logic is tested in relaunchOnExitCode tests.
it('should handle empty additional arguments correctly in Node mode', async () => {
process.execArgv = ['--trace-warnings'];
process.argv = ['/usr/bin/node', '/app/cli.js', 'start'];
const mockChild = createMockChildProcess(0, true);
mockedSpawn.mockReturnValue(mockChild);
await expect(relaunchAppInChildProcess([], [])).rejects.toThrow(
'PROCESS_EXIT_CALLED',
);
expect(mockedSpawn).toHaveBeenCalledWith(
process.execPath,
['--trace-warnings', '/app/cli.js', 'start'],
expect.anything(),
);
});
it('should handle null exit code from child process', async () => {
process.argv = ['/usr/bin/node', '/app/cli.js'];
@@ -342,6 +352,8 @@ function createMockChildProcess(
disconnect: vi.fn(),
unref: vi.fn(),
ref: vi.fn(),
on: mockChild.on.bind(mockChild),
emit: mockChild.emit.bind(mockChild),
});
if (autoClose) {
+9 -13
View File
@@ -5,7 +5,11 @@
*/
import { spawn } from 'node:child_process';
import { RELAUNCH_EXIT_CODE } from './processUtils.js';
import {
RELAUNCH_EXIT_CODE,
getSpawnConfig,
getScriptArgs,
} from './processUtils.js';
import {
writeToStderr,
type AdminControlsSettings,
@@ -43,24 +47,16 @@ export async function relaunchAppInChildProcess(
let latestAdminSettings = remoteAdminSettings;
const runner = () => {
// process.argv is [node, script, ...args]
// We want to construct [ ...nodeArgs, script, ...scriptArgs]
const script = process.argv[1];
const scriptArgs = process.argv.slice(2);
const nodeArgs = [
...process.execArgv,
...additionalNodeArgs,
script,
const scriptArgs = getScriptArgs();
const { spawnArgs, env: newEnv } = getSpawnConfig(additionalNodeArgs, [
...additionalScriptArgs,
...scriptArgs,
];
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
]);
// The parent process should not be reading from stdin while the child is running.
process.stdin.pause();
const child = spawn(process.execPath, nodeArgs, {
const child = spawn(process.execPath, spawnArgs, {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: newEnv,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -529,6 +529,59 @@ Body`);
});
});
it('should convert mcp_servers with auth block in local agent (oauth with full fields)', () => {
const markdown = {
kind: 'local' as const,
name: 'oauth-test-agent',
description: 'An agent to test OAuth MCP with full fields',
mcp_servers: {
'test-server': {
url: 'https://api.example.com/mcp',
type: 'http' as const,
auth: {
type: 'oauth' as const,
client_id: 'my-client-id',
client_secret: 'my-client-secret',
scopes: ['read', 'write'],
authorization_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token',
issuer: 'https://auth.example.com',
audiences: ['audience1'],
redirect_uri: 'http://localhost:8080/callback',
token_param_name: 'access_token',
registration_url: 'https://auth.example.com/register',
},
timeout: 30000,
},
},
system_prompt: 'You are a test agent.',
};
const result = markdownToAgentDefinition(
markdown,
) as LocalAgentDefinition;
expect(result.kind).toBe('local');
expect(result.mcpServers).toBeDefined();
expect(result.mcpServers!['test-server']).toMatchObject({
url: 'https://api.example.com/mcp',
type: 'http',
oauth: {
enabled: true,
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
scopes: ['read', 'write'],
authorizationUrl: 'https://auth.example.com/authorize',
tokenUrl: 'https://auth.example.com/token',
issuer: 'https://auth.example.com',
audiences: ['audience1'],
redirectUri: 'http://localhost:8080/callback',
tokenParamName: 'access_token',
registrationUrl: 'https://auth.example.com/register',
},
timeout: 30000,
});
});
it('should pass through unknown model names (e.g. auto)', () => {
const markdown = {
kind: 'local' as const,
@@ -886,6 +939,12 @@ auth:
- profile
authorization_url: https://auth.example.com/authorize
token_url: https://auth.example.com/token
issuer: https://auth.example.com
audiences:
- audience1
redirect_uri: http://localhost:8080/callback
token_param_name: access_token
registration_url: https://auth.example.com/register
---
`);
const result = await parseAgentMarkdown(filePath);
@@ -900,6 +959,11 @@ auth:
scopes: ['openid', 'profile'],
authorization_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token',
issuer: 'https://auth.example.com',
audiences: ['audience1'],
redirect_uri: 'http://localhost:8080/callback',
token_param_name: 'access_token',
registration_url: 'https://auth.example.com/register',
},
});
});
+20
View File
@@ -79,6 +79,11 @@ const mcpServerSchema = z.object({
scopes: z.array(z.string()).optional(),
authorization_url: z.string().url().optional(),
token_url: z.string().url().optional(),
issuer: z.string().url().optional(),
audiences: z.array(z.string()).optional(),
redirect_uri: z.string().url().optional(),
token_param_name: z.string().optional(),
registration_url: z.string().url().optional(),
}),
])
.optional(),
@@ -148,6 +153,11 @@ const oauth2AuthSchema = z.object({
scopes: z.array(z.string()).optional(),
authorization_url: z.string().url().optional(),
token_url: z.string().url().optional(),
issuer: z.string().url().optional(),
audiences: z.array(z.string()).optional(),
redirect_uri: z.string().url().optional(),
token_param_name: z.string().optional(),
registration_url: z.string().url().optional(),
});
const authConfigSchema = z
@@ -459,6 +469,11 @@ function convertFrontmatterAuthToConfig(
scopes: frontmatter.scopes,
authorization_url: frontmatter.authorization_url,
token_url: frontmatter.token_url,
issuer: frontmatter.issuer,
audiences: frontmatter.audiences,
redirect_uri: frontmatter.redirect_uri,
token_param_name: frontmatter.token_param_name,
registration_url: frontmatter.registration_url,
};
default: {
@@ -552,6 +567,11 @@ export function markdownToAgentDefinition(
scopes: config.auth.scopes,
authorizationUrl: config.auth.authorization_url,
tokenUrl: config.auth.token_url,
issuer: config.auth.issuer,
audiences: config.auth.audiences,
redirectUri: config.auth.redirect_uri,
tokenParamName: config.auth.token_param_name,
registrationUrl: config.auth.registration_url,
};
}
}
@@ -77,6 +77,11 @@ export interface OAuth2AuthConfig extends BaseAuthConfig {
authorization_url?: string;
/** Override or provide the token endpoint URL. Discovered from agent card if omitted. */
token_url?: string;
issuer?: string;
audiences?: string[];
redirect_uri?: string;
token_param_name?: string;
registration_url?: string;
}
/** Client config corresponding to OpenIdConnectSecurityScheme. */
+7
View File
@@ -709,6 +709,7 @@ export interface ConfigParameters {
disabledSkills?: string[];
adminSkillsEnabled?: boolean;
experimentalJitContext?: boolean;
enableEnhanceCommand?: boolean;
autoDistillation?: boolean;
experimentalMemoryV2?: boolean;
experimentalAutoMemory?: boolean;
@@ -950,6 +951,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly vertexAiRouting: VertexAiRoutingConfig | undefined;
private readonly enableAgents: boolean;
private readonly enableEnhanceCommand: boolean;
private agents: AgentSettings;
private readonly enableEventDrivenScheduler: boolean;
private readonly skillsSupport: boolean;
@@ -1116,6 +1118,7 @@ export class Config implements McpContext, AgentLoopContext {
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? true;
this.enableEnhanceCommand = params.enableEnhanceCommand ?? false;
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? true;
@@ -2972,6 +2975,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.planEnabled;
}
isEnhanceCommandEnabled(): boolean {
return this.enableEnhanceCommand;
}
isVoiceModeEnabled(): boolean {
return this.voiceMode;
}
@@ -103,7 +103,7 @@ export class SimulationHarness {
);
// 3. Yield to event loop to allow internal async subscribers and orchestrator to finish
await new Promise((resolve) => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 200));
// 3.1 Simulate what projectCompressedHistory does with the sync handlers
let currentView = this.contextManager.getNodes();
@@ -1898,30 +1898,6 @@ describe('PolicyEngine', () => {
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should NOT downgrade to ASK_USER for redirected commands in YOLO mode even without sandbox', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: 10,
},
];
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
sandboxManager: new NoopSandboxManager(),
});
const command = 'npm test 2>&1 | tail -80';
const { decision } = await engine.check(
{ name: 'run_shell_command', args: { command } },
undefined,
);
expect(decision).toBe(PolicyDecision.ALLOW);
});
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
+5 -4
View File
@@ -288,11 +288,12 @@ export class PolicyEngine {
if (allowRedirection) return false;
if (!hasRedirection(command)) return false;
// Do not downgrade (do not ask user) if in AUTO_EDIT or YOLO mode.
// These modes trust the agent's actions (YOLO) or specific task (AUTO_EDIT).
// Do not downgrade (do not ask user) if sandboxing is enabled and in AUTO_EDIT or YOLO
const sandboxEnabled = !(this.sandboxManager instanceof NoopSandboxManager);
if (
this.approvalMode === ApprovalMode.AUTO_EDIT ||
this.approvalMode === ApprovalMode.YOLO
sandboxEnabled &&
(this.approvalMode === ApprovalMode.AUTO_EDIT ||
this.approvalMode === ApprovalMode.YOLO)
) {
return false;
}
@@ -19,7 +19,6 @@ import {
import type { Config } from '../config/config.js';
import * as sdk from './sdk.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import { EventMetadataKey } from './clearcut-logger/event-metadata-key.js';
vi.mock('@opentelemetry/api-logs');
vi.mock('./sdk.js');
@@ -145,174 +144,4 @@ describe('conseca-logger', () => {
expect(mockLogger.emit).not.toHaveBeenCalled();
});
it('should omit user_prompt/trusted_content/policy from OTEL when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaPolicyGenerationEvent(
'sensitive prompt',
'sensitive content',
'sensitive policy',
);
logConsecaPolicyGeneration(configNoPrompts, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBeUndefined();
expect(attrs['trusted_content']).toBeUndefined();
expect(attrs['policy']).toBeUndefined();
expect(attrs['event.name']).toBe(EVENT_CONSECA_POLICY_GENERATION);
});
it('should omit user_prompt/trusted_content/policy from Clearcut when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaPolicyGenerationEvent(
'sensitive prompt',
'sensitive content',
'sensitive policy',
'some error',
);
logConsecaPolicyGeneration(configNoPrompts, event);
expect(mockClearcutLogger.createLogEvent).toHaveBeenCalledWith(
expect.anything(),
[
{
gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
value: 'some error',
},
],
);
});
it('should include user_prompt/trusted_content/policy in OTEL when logPrompts is enabled', () => {
const event = new ConsecaPolicyGenerationEvent(
'visible prompt',
'visible content',
'visible policy',
);
logConsecaPolicyGeneration(mockConfig, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBe('visible prompt');
expect(attrs['trusted_content']).toBe('visible content');
expect(attrs['policy']).toBe('visible policy');
});
it('should omit sensitive fields from verdict OTEL when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaVerdictEvent(
'sensitive prompt',
'sensitive policy',
'sensitive tool call',
'allow',
'sensitive rationale',
);
logConsecaVerdict(configNoPrompts, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBeUndefined();
expect(attrs['policy']).toBeUndefined();
expect(attrs['tool_call']).toBeUndefined();
expect(attrs['verdict_rationale']).toBeUndefined();
// verdict (the allow/deny result) is not sensitive and should be present
expect(attrs['verdict']).toBe('allow');
});
it('should omit sensitive fields from verdict Clearcut when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaVerdictEvent(
'sensitive prompt',
'sensitive policy',
'sensitive tool call',
'allow',
'sensitive rationale',
'some error',
);
logConsecaVerdict(configNoPrompts, event);
expect(mockClearcutLogger.createLogEvent).toHaveBeenCalledWith(
expect.anything(),
[
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RESULT,
value: '"allow"',
},
{
gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
value: 'some error',
},
],
);
});
it('should include sensitive fields in verdict OTEL when logPrompts is enabled', () => {
const event = new ConsecaVerdictEvent(
'visible prompt',
'visible policy',
'visible tool call',
'deny',
'visible rationale',
);
logConsecaVerdict(mockConfig, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBe('visible prompt');
expect(attrs['policy']).toBe('visible policy');
expect(attrs['tool_call']).toBe('visible tool call');
expect(attrs['verdict_rationale']).toBe('visible rationale');
expect(attrs['verdict']).toBe('deny');
});
});
+31 -41
View File
@@ -11,7 +11,6 @@ import { isTelemetrySdkInitialized } from './sdk.js';
import {
ClearcutLogger,
EventNames,
type EventValue,
} from './clearcut-logger/clearcut-logger.js';
import { EventMetadataKey } from './clearcut-logger/event-metadata-key.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
@@ -28,24 +27,20 @@ export function logConsecaPolicyGeneration(
debugLogger.debug('Conseca Policy Generation Event:', event);
const clearcutLogger = ClearcutLogger.getInstance(config);
if (clearcutLogger) {
const data: EventValue[] = [];
if (config.getTelemetryLogPromptsEnabled()) {
data.push(
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_TRUSTED_CONTENT,
value: safeJsonStringify(event.trusted_content),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
);
}
const data = [
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_TRUSTED_CONTENT,
value: safeJsonStringify(event.trusted_content),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
];
if (event.error) {
data.push({
@@ -76,34 +71,29 @@ export function logConsecaVerdict(
debugLogger.debug('Conseca Verdict Event:', event);
const clearcutLogger = ClearcutLogger.getInstance(config);
if (clearcutLogger) {
const data: EventValue[] = [
const data = [
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME,
value: safeJsonStringify(event.tool_call),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RESULT,
value: safeJsonStringify(event.verdict),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RATIONALE,
value: event.verdict_rationale,
},
];
if (config.getTelemetryLogPromptsEnabled()) {
data.push(
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME,
value: safeJsonStringify(event.tool_call),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RATIONALE,
value: event.verdict_rationale,
},
);
}
if (event.error) {
data.push({
gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
+15 -168
View File
@@ -642,54 +642,6 @@ describe('loggers', () => {
}),
});
});
it('should not include response_text when logPrompts is disabled', () => {
const mockConfigNoPrompts = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const event = new ApiResponseEvent(
'test-model',
100,
{ prompt_id: 'prompt-id-noprompts', contents: [] },
{ candidates: [] },
AuthType.LOGIN_WITH_GOOGLE,
{},
'this response should be hidden',
);
logApiResponse(mockConfigNoPrompts, event);
const firstEmitCall = mockLogger.emit.mock.calls[0][0];
expect(firstEmitCall.attributes['response_text']).toBeUndefined();
});
it('should include response_text when logPrompts is enabled', () => {
const event = new ApiResponseEvent(
'test-model',
100,
{ prompt_id: 'prompt-id-withprompts', contents: [] },
{ candidates: [] },
AuthType.LOGIN_WITH_GOOGLE,
{},
'this response should be visible',
);
logApiResponse(mockConfig, event);
const firstEmitCall = mockLogger.emit.mock.calls[0][0];
expect(firstEmitCall.attributes['response_text']).toBe(
'this response should be visible',
);
});
});
describe('logApiError', () => {
@@ -1124,10 +1076,6 @@ describe('loggers', () => {
expect(attributes['gen_ai.provider.name']).toBe('gcp.vertex_ai');
// Ensure prompt messages are NOT included
expect(attributes['gen_ai.input.messages']).toBeUndefined();
// Ensure request_text is also NOT included in the first (toLogRecord) log
const firstLogCall = mockLogger.emit.mock.calls[0][0];
expect(firstLogCall.attributes['request_text']).toBeUndefined();
});
it('should correctly derive model from prompt details if available in semantic log', () => {
@@ -1425,20 +1373,16 @@ describe('loggers', () => {
error_type: undefined,
mcp_server_name: undefined,
extension_id: undefined,
metadata: JSON.stringify(
{
model_added_lines: 1,
model_removed_lines: 2,
model_added_chars: 3,
model_removed_chars: 4,
user_added_lines: 5,
user_removed_lines: 6,
user_added_chars: 7,
user_removed_chars: 8,
},
null,
2,
),
metadata: {
model_added_lines: 1,
model_removed_lines: 2,
model_added_chars: 3,
model_removed_chars: 4,
user_added_lines: 5,
user_removed_lines: 6,
user_added_chars: 7,
user_removed_chars: 8,
},
content_length: 13,
},
});
@@ -1511,16 +1455,12 @@ describe('loggers', () => {
body: 'Tool call: ask_user. Decision: accept. Success: true. Duration: 100ms.',
attributes: expect.objectContaining({
function_name: 'ask_user',
metadata: JSON.stringify(
{
ask_user: {
question_types: ['choice'],
dismissed: false,
},
metadata: expect.objectContaining({
ask_user: {
question_types: ['choice'],
dismissed: false,
},
null,
2,
),
}),
}),
});
});
@@ -1927,99 +1867,6 @@ describe('loggers', () => {
});
});
describe('logToolCall — logPrompts flag', () => {
it('should omit function_args when logPrompts is disabled', () => {
const mockConfigNoPrompts = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const call: CompletedToolCall = {
status: CoreToolCallStatus.Success,
request: {
name: 'run_bash',
args: { command: 'echo sensitive' },
callId: 'call-1',
isClientInitiated: false,
prompt_id: 'prompt-noprompts',
},
response: {
callId: 'call-1',
responseParts: [],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
contentLength: undefined,
},
tool: undefined as unknown as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
durationMs: 50,
};
const event = new ToolCallEvent(call);
logToolCall(mockConfigNoPrompts, event);
const emitted = mockLogger.emit.mock.calls[0][0] as {
attributes: Record<string, unknown>;
};
expect(emitted.attributes['function_args']).toBeUndefined();
expect(emitted.attributes['function_name']).toBe('run_bash');
});
it('should include function_args when logPrompts is enabled', () => {
const mockConfigWithPrompts = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const call: CompletedToolCall = {
status: CoreToolCallStatus.Success,
request: {
name: 'run_bash',
args: { command: 'echo visible' },
callId: 'call-2',
isClientInitiated: false,
prompt_id: 'prompt-withprompts',
},
response: {
callId: 'call-2',
responseParts: [],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
contentLength: undefined,
},
tool: undefined as unknown as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
durationMs: 50,
};
const event = new ToolCallEvent(call);
logToolCall(mockConfigWithPrompts, event);
const emitted = mockLogger.emit.mock.calls[0][0] as {
attributes: Record<string, unknown>;
};
expect(emitted.attributes['function_args']).toBe(
JSON.stringify({ command: 'echo visible' }, null, 2),
);
});
});
describe('logMalformedJsonResponse', () => {
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logMalformedJsonResponseEvent');
+11 -57
View File
@@ -231,17 +231,6 @@ export class UserPromptEvent implements BaseTelemetryEvent {
}
export const EVENT_TOOL_CALL = 'gemini_cli.tool_call';
const TOOL_CALL_METADATA_SAFE_KEYS = [
'model_added_lines',
'model_removed_lines',
'model_added_chars',
'model_removed_chars',
'user_added_lines',
'user_removed_lines',
'user_added_chars',
'user_removed_chars',
] as const;
export class ToolCallEvent implements BaseTelemetryEvent {
'event.name': 'tool_call';
'event.timestamp': string;
@@ -366,6 +355,7 @@ export class ToolCallEvent implements BaseTelemetryEvent {
'event.name': EVENT_TOOL_CALL,
'event.timestamp': this['event.timestamp'],
function_name: this.function_name,
function_args: safeJsonStringify(this.function_args, 2),
duration_ms: this.duration_ms,
success: this.success,
decision: this.decision,
@@ -377,22 +367,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
extension_id: this.extension_id,
start_time: this.start_time,
end_time: this.end_time,
metadata: this.metadata,
};
if (config.getTelemetryLogPromptsEnabled() && this.function_args) {
attributes['function_args'] = safeJsonStringify(this.function_args, 2);
}
if (this.metadata) {
const metadata = config.getTelemetryLogPromptsEnabled()
? this.metadata
: Object.fromEntries(
Object.entries(this.metadata).filter(([k]) =>
(TOOL_CALL_METADATA_SAFE_KEYS as readonly string[]).includes(k),
),
);
if (Object.keys(metadata).length > 0) {
attributes['metadata'] = safeJsonStringify(metadata, 2);
}
}
if (this.error) {
attributes[CoreToolCallStatus.Error] = this.error;
@@ -447,10 +423,8 @@ export class ApiRequestEvent implements BaseTelemetryEvent {
'event.timestamp': this['event.timestamp'],
model: this.model,
prompt_id: this.prompt.prompt_id,
request_text: this.request_text,
};
if (config.getTelemetryLogPromptsEnabled() && this.request_text) {
attributes['request_text'] = this.request_text;
}
if (this.role) {
attributes['role'] = this.role;
}
@@ -718,7 +692,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent {
if (this.role) {
attributes['role'] = this.role;
}
if (config.getTelemetryLogPromptsEnabled() && this.response_text) {
if (this.response_text) {
attributes['response_text'] = this.response_text;
}
if (this.status_code) {
@@ -980,20 +954,11 @@ export class ConsecaPolicyGenerationEvent implements BaseTelemetryEvent {
...getCommonAttributes(config),
'event.name': EVENT_CONSECA_POLICY_GENERATION,
'event.timestamp': this['event.timestamp'],
user_prompt: this.user_prompt,
trusted_content: this.trusted_content,
policy: this.policy,
};
if (config.getTelemetryLogPromptsEnabled()) {
if (this.user_prompt) {
attributes['user_prompt'] = this.user_prompt;
}
if (this.trusted_content) {
attributes['trusted_content'] = this.trusted_content;
}
if (this.policy) {
attributes['policy'] = this.policy;
}
}
if (this.error) {
attributes['error'] = this.error;
}
@@ -1040,24 +1005,13 @@ export class ConsecaVerdictEvent implements BaseTelemetryEvent {
...getCommonAttributes(config),
'event.name': EVENT_CONSECA_VERDICT,
'event.timestamp': this['event.timestamp'],
user_prompt: this.user_prompt,
policy: this.policy,
tool_call: this.tool_call,
verdict: this.verdict,
verdict_rationale: this.verdict_rationale,
};
if (config.getTelemetryLogPromptsEnabled()) {
if (this.user_prompt) {
attributes['user_prompt'] = this.user_prompt;
}
if (this.policy) {
attributes['policy'] = this.policy;
}
if (this.tool_call) {
attributes['tool_call'] = this.tool_call;
}
if (this.verdict_rationale) {
attributes['verdict_rationale'] = this.verdict_rationale;
}
}
if (this.error) {
attributes['error'] = this.error;
}
+23 -8
View File
@@ -12,6 +12,15 @@ export enum ReleaseChannel {
STABLE = 'stable',
}
/**
* Stability ranking for release channels. Higher number means more stable.
*/
export const RELEASE_CHANNEL_STABILITY: Record<ReleaseChannel, number> = {
[ReleaseChannel.NIGHTLY]: 0,
[ReleaseChannel.PREVIEW]: 1,
[ReleaseChannel.STABLE]: 2,
};
const cache = new Map<string, ReleaseChannel>();
/**
@@ -22,6 +31,19 @@ export function _clearCache() {
cache.clear();
}
/**
* Determines the release channel for a given version string.
*/
export function getChannelFromVersion(version: string): ReleaseChannel {
if (!version || version.includes('nightly')) {
return ReleaseChannel.NIGHTLY;
}
if (version.includes('preview')) {
return ReleaseChannel.PREVIEW;
}
return ReleaseChannel.STABLE;
}
export async function getReleaseChannel(cwd: string): Promise<ReleaseChannel> {
if (cache.has(cwd)) {
return cache.get(cwd)!;
@@ -30,14 +52,7 @@ export async function getReleaseChannel(cwd: string): Promise<ReleaseChannel> {
const packageJson = await getPackageJson(cwd);
const version = packageJson?.version ?? '';
let channel: ReleaseChannel;
if (version.includes('nightly') || version === '') {
channel = ReleaseChannel.NIGHTLY;
} else if (version.includes('preview')) {
channel = ReleaseChannel.PREVIEW;
} else {
channel = ReleaseChannel.STABLE;
}
const channel = getChannelFromVersion(version);
cache.set(cwd, channel);
return channel;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.41.2",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+7
View File
@@ -3181,6 +3181,13 @@
"markdownDescription": "Deprecated: Use general.topicUpdateNarration instead.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"enhanceCommand": {
"title": "Enhance Command",
"description": "Enable the experimental /enhance slash command.",
"markdownDescription": "Enable the experimental /enhance slash command.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
+3 -1
View File
@@ -13,6 +13,7 @@ import {
copyFileSync,
writeFileSync,
readFileSync,
chmodSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -454,6 +455,7 @@ console.log('Injecting SEA blob...');
const sentinelFuse = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2';
try {
chmodSync(targetBinaryPath, 0o755);
const args = [
'postject',
targetBinaryPath,
@@ -467,7 +469,7 @@ try {
args.push('--macho-segment-name', 'NODE_SEA');
}
runCommand('npx', args);
runCommand('npx', ['--yes', ...args]);
console.log('Injection successful.');
} catch (e) {
console.error('Postject failed:', e.message);