mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 05:01:04 -07:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbdfa50983 | |||
| 385fbbd9fb | |||
| c741ce328b | |||
| fbd8aaad57 | |||
| 2128019e70 | |||
| de8c2d8589 | |||
| 9e7c924f7b | |||
| 7c3b3ce219 | |||
| 652c1e2f5e | |||
| 4edd7c745c | |||
| 12a77da45c | |||
| b6f9f07289 | |||
| 8cfebb9e31 | |||
| f8603e990b | |||
| fcb859d9ec | |||
| 8a8590c516 | |||
| 30b2af34c8 | |||
| 03da89ed63 | |||
| 48d7a7262b | |||
| 04f05459f8 |
@@ -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": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+9
-9
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
-63
@@ -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">> 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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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,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,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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user