mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2df15df871 | |||
| de128bc172 | |||
| a10425c947 | |||
| bd12535d0b | |||
| d35b390b33 | |||
| 3a8b101063 | |||
| d18586fec2 | |||
| 428a4808db | |||
| 4bb389efb7 | |||
| 4b72b7f846 | |||
| 3658d0ebc7 | |||
| ee092dc172 | |||
| e293424bb4 | |||
| 21ad42f677 | |||
| 561418c554 | |||
| d0d3639e16 | |||
| 2f7f967189 | |||
| 46d6b119b6 | |||
| 80929c48c5 | |||
| 35efdfc409 | |||
| bea57a2f3d | |||
| 1df5c98b33 | |||
| 3e95b8ec59 | |||
| 5b5f87abc7 | |||
| 991dca4c40 | |||
| dfba0e91e2 | |||
| 117a2d3844 | |||
| 4b20d93e1d | |||
| 44cdb3e376 |
@@ -3,9 +3,13 @@
|
||||
"plan": true,
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"memoryManager": true
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
},
|
||||
"security": {
|
||||
"toolSandboxing": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.35.2
|
||||
# Latest stable release: v0.35.3
|
||||
|
||||
Released: March 26, 2026
|
||||
Released: March 28, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -29,6 +29,9 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 765fb67 to release/v0.35.2-pr-24055 [CONFLICTS] by
|
||||
@gemini-cli-robot in
|
||||
[#24063](https://github.com/google-gemini/gemini-cli/pull/24063)
|
||||
- fix(core): allow disabling environment variable redaction by @galz10 in
|
||||
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
|
||||
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||
@@ -385,4 +388,4 @@ npm install -g @google/gemini-cli
|
||||
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.2
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.36.0-preview.5
|
||||
# Preview release: v0.36.0-preview.6
|
||||
|
||||
Released: March 27, 2026
|
||||
Released: March 28, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -31,6 +31,10 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 765fb67 to release/v0.36.0-preview.5-pr-24055 to patch
|
||||
version v0.36.0-preview.5 and create version 0.36.0-preview.6 by
|
||||
@gemini-cli-robot in
|
||||
[#24061](https://github.com/google-gemini/gemini-cli/pull/24061)
|
||||
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||
- docs(core): document agent_card_json string literal options for remote agents
|
||||
@@ -386,4 +390,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.6
|
||||
|
||||
+13
-15
@@ -59,6 +59,7 @@ they appear in the UI.
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `false` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
|
||||
@@ -155,21 +156,18 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Agent History Truncation | `experimental.agentHistoryTruncation` | Enable truncation window logic for the Agent History Provider. | `false` |
|
||||
| Agent History Truncation Threshold | `experimental.agentHistoryTruncationThreshold` | The maximum number of messages before history is truncated. | `30` |
|
||||
| Agent History Retained Messages | `experimental.agentHistoryRetainedMessages` | The number of recent messages to retain after truncation. | `15` |
|
||||
| Agent History Summarization | `experimental.agentHistorySummarization` | Enable summarization of truncated content via a small model for the Agent History Provider. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -257,6 +257,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Show the "? for shortcuts" hint above the input.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.compactToolOutput`** (boolean):
|
||||
- **Description:** Display tool outputs (like directory listings and file
|
||||
reads) in a compact, structured format.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.hideBanner`** (boolean):
|
||||
- **Description:** Hide the application banner
|
||||
- **Default:** `false`
|
||||
@@ -1702,25 +1707,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryTruncation`** (boolean):
|
||||
- **Description:** Enable truncation window logic for the Agent History
|
||||
Provider.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryTruncationThreshold`** (number):
|
||||
- **Description:** The maximum number of messages before history is truncated.
|
||||
- **Default:** `30`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryRetainedMessages`** (number):
|
||||
- **Description:** The number of recent messages to retain after truncation.
|
||||
- **Default:** `15`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistorySummarization`** (boolean):
|
||||
- **Description:** Enable summarization of truncated content via a small model
|
||||
for the Agent History Provider.
|
||||
- **`experimental.contextManagement`** (boolean):
|
||||
- **Description:** Enable logic for context management.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -1815,6 +1803,49 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
prioritize available tools dynamically.
|
||||
- **Default:** `[]`
|
||||
|
||||
#### `contextManagement`
|
||||
|
||||
- **`contextManagement.historyWindow.maxTokens`** (number):
|
||||
- **Description:** The number of tokens to allow before triggering
|
||||
compression.
|
||||
- **Default:** `150000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.historyWindow.retainedTokens`** (number):
|
||||
- **Description:** The number of tokens to always retain.
|
||||
- **Default:** `40000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.messageLimits.normalMaxTokens`** (number):
|
||||
- **Description:** The target number of tokens to budget for a normal
|
||||
conversation turn.
|
||||
- **Default:** `2500`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.messageLimits.retainedMaxTokens`** (number):
|
||||
- **Description:** The maximum number of tokens a single conversation turn can
|
||||
consume before truncation.
|
||||
- **Default:** `12000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.messageLimits.normalizationHeadRatio`** (number):
|
||||
- **Description:** The ratio of tokens to retain from the beginning of a
|
||||
truncated message (0.0 to 1.0).
|
||||
- **Default:** `0.25`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.toolDistillation.maxOutputTokens`** (number):
|
||||
- **Description:** Maximum tokens to show when truncating large tool outputs.
|
||||
- **Default:** `10000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.toolDistillation.summarizationThresholdTokens`**
|
||||
(number):
|
||||
- **Description:** Threshold above which truncated tool outputs will be
|
||||
summarized by an LLM.
|
||||
- **Default:** `20000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `admin`
|
||||
|
||||
- **`admin.secureModeEnabled`** (boolean):
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('update_topic_behavior', () => {
|
||||
// Constants for tool names and params for robustness
|
||||
const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
|
||||
|
||||
/**
|
||||
* Verifies the desired behavior of the update_topic tool. update_topic is used by the
|
||||
* agent to share periodic, concise updates about what the agent is working on, independent
|
||||
* of the regular model output and/or thoughts. This tool is expected to be called at least
|
||||
* at the start and end of the session, and typically at least once in the middle, but no
|
||||
* more than 1/4 turns.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'update_topic should be used at start, end and middle for complex tasks',
|
||||
prompt: `Create a simple users REST API using Express.
|
||||
1. Initialize a new npm project and install express.
|
||||
2. Create src/app.ts as the main entry point.
|
||||
3. Create src/routes/userRoutes.ts for user routes.
|
||||
4. Create src/controllers/userController.ts for user logic.
|
||||
5. Implement GET /users, POST /users, and GET /users/:id using an in-memory array.
|
||||
6. Add a 'start' script to package.json.
|
||||
7. Finally, run a quick grep to verify the routes are in src/app.ts.`,
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'users-api',
|
||||
version: '1.0.0',
|
||||
private: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'.gemini/settings.json': JSON.stringify({
|
||||
experimental: {
|
||||
topicUpdateNarration: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
assert: async (rig, result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const topicCalls = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === UPDATE_TOPIC_TOOL_NAME,
|
||||
);
|
||||
|
||||
// 1. Assert that update_topic is called at least 3 times (start, middle, end)
|
||||
expect(
|
||||
topicCalls.length,
|
||||
`Expected at least 3 update_topic calls, but found ${topicCalls.length}`,
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// 2. Assert update_topic is called at the very beginning (first tool call)
|
||||
expect(
|
||||
toolLogs[0].toolRequest.name,
|
||||
'First tool call should be update_topic',
|
||||
).toBe(UPDATE_TOPIC_TOOL_NAME);
|
||||
|
||||
// 3. Assert update_topic is called near the end
|
||||
const lastTopicCallIndex = toolLogs
|
||||
.map((l) => l.toolRequest.name)
|
||||
.lastIndexOf(UPDATE_TOPIC_TOOL_NAME);
|
||||
expect(
|
||||
lastTopicCallIndex,
|
||||
'Expected update_topic to be used near the end of the task',
|
||||
).toBeGreaterThanOrEqual(toolLogs.length * 0.7);
|
||||
|
||||
// 4. Assert there is at least one update_topic call in the middle (between start and end phases)
|
||||
const middleTopicCalls = topicCalls.slice(1, -1);
|
||||
|
||||
expect(
|
||||
middleTopicCalls.length,
|
||||
'Expected at least one update_topic call in the middle of the task',
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 5. Turn Ratio Assertion: update_topic should be <= 1/2 of total turns.
|
||||
// We only enforce this for tasks that take more than 5 turns, as shorter tasks
|
||||
// naturally have a higher ratio when following the "start, middle, end" rule.
|
||||
const uniquePromptIds = new Set(
|
||||
toolLogs
|
||||
.map((l) => l.toolRequest.prompt_id)
|
||||
.filter((id) => id !== undefined),
|
||||
);
|
||||
const totalTurns = uniquePromptIds.size;
|
||||
|
||||
if (totalTurns > 5) {
|
||||
const topicTurns = new Set(
|
||||
topicCalls
|
||||
.map((l) => l.toolRequest.prompt_id)
|
||||
.filter((id) => id !== undefined),
|
||||
);
|
||||
const topicTurnCount = topicTurns.size;
|
||||
|
||||
const ratio = topicTurnCount / totalTurns;
|
||||
|
||||
expect(
|
||||
ratio,
|
||||
`update_topic was used in ${topicTurnCount} out of ${totalTurns} turns (${(ratio * 100).toFixed(1)}%). Expected <= 50%.`,
|
||||
).toBeLessThanOrEqual(0.5);
|
||||
|
||||
// Ideal ratio is closer to 1/5 (20%). We log high usage as a warning.
|
||||
if (ratio > 0.25) {
|
||||
console.warn(
|
||||
`[Efficiency Warning] update_topic usage is high: ${(ratio * 100).toFixed(1)}% (Goal: ~20%)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0}],"finishReason":"STOP"}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0,"finishReason":"STOP"}]}]}
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('file-system', () => {
|
||||
).toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe('hello');
|
||||
expect(newFileContent.trim()).toBe('hello');
|
||||
});
|
||||
|
||||
it('should perform a read-then-write sequence', async () => {
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args: 'Create a file called hello.txt in the current directory.',
|
||||
args: 'Attempt to create a file named "hello.txt" in the current directory. Do not create a plan file, try to write hello.txt directly.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
@@ -109,12 +109,8 @@ export function createMockConfig(
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
isExperimentalAgentHistoryTruncationEnabled: vi.fn().mockReturnValue(false),
|
||||
getExperimentalAgentHistoryTruncationThreshold: vi.fn().mockReturnValue(50),
|
||||
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(30),
|
||||
isExperimentalAgentHistorySummarizationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
isAutoDistillationEnabled: vi.fn().mockReturnValue(false),
|
||||
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -977,14 +977,10 @@ export async function loadCliConfig(
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
experimentalAgentHistoryTruncation:
|
||||
settings.experimental?.agentHistoryTruncation,
|
||||
experimentalAgentHistoryTruncationThreshold:
|
||||
settings.experimental?.agentHistoryTruncationThreshold,
|
||||
experimentalAgentHistoryRetainedMessages:
|
||||
settings.experimental?.agentHistoryRetainedMessages,
|
||||
experimentalAgentHistorySummarization:
|
||||
settings.experimental?.agentHistorySummarization,
|
||||
contextManagement: {
|
||||
enabled: settings.experimental?.contextManagement,
|
||||
...settings?.contextManagement,
|
||||
},
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
|
||||
@@ -561,6 +561,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Show the "? for shortcuts" hint above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
compactToolOutput: {
|
||||
type: 'boolean',
|
||||
label: 'Compact Tool Output',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Display tool outputs (like directory listings and file reads) in a compact, structured format.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
@@ -2169,44 +2179,13 @@ const SETTINGS_SCHEMA = {
|
||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryTruncation: {
|
||||
contextManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Agent History Truncation',
|
||||
label: 'Enable Context Management',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable truncation window logic for the Agent History Provider.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryTruncationThreshold: {
|
||||
type: 'number',
|
||||
label: 'Agent History Truncation Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 30,
|
||||
description:
|
||||
'The maximum number of messages before history is truncated.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryRetainedMessages: {
|
||||
type: 'number',
|
||||
label: 'Agent History Retained Messages',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 15,
|
||||
description:
|
||||
'The number of recent messages to retain after truncation.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistorySummarization: {
|
||||
type: 'boolean',
|
||||
label: 'Agent History Summarization',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable summarization of truncated content via a small model for the Agent History Provider.',
|
||||
description: 'Enable logic for context management.',
|
||||
showInDialog: true,
|
||||
},
|
||||
topicUpdateNarration: {
|
||||
@@ -2485,6 +2464,118 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
contextManagement: {
|
||||
type: 'object',
|
||||
label: 'Context Management',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Settings for agent history and tool distillation context management.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
historyWindow: {
|
||||
type: 'object',
|
||||
label: 'History Window Settings',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxTokens: {
|
||||
type: 'number',
|
||||
label: 'Max Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 150_000,
|
||||
description:
|
||||
'The number of tokens to allow before triggering compression.',
|
||||
showInDialog: false,
|
||||
},
|
||||
retainedTokens: {
|
||||
type: 'number',
|
||||
label: 'Retained Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 40_000,
|
||||
description: 'The number of tokens to always retain.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
messageLimits: {
|
||||
type: 'object',
|
||||
label: 'Message Limits',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
normalMaxTokens: {
|
||||
type: 'number',
|
||||
label: 'Normal Maximum Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 2500,
|
||||
description:
|
||||
'The target number of tokens to budget for a normal conversation turn.',
|
||||
showInDialog: false,
|
||||
},
|
||||
retainedMaxTokens: {
|
||||
type: 'number',
|
||||
label: 'Retained Maximum Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 12000,
|
||||
description:
|
||||
'The maximum number of tokens a single conversation turn can consume before truncation.',
|
||||
showInDialog: false,
|
||||
},
|
||||
normalizationHeadRatio: {
|
||||
type: 'number',
|
||||
label: 'Normalization Head Ratio',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 0.25,
|
||||
description:
|
||||
'The ratio of tokens to retain from the beginning of a truncated message (0.0 to 1.0).',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
toolDistillation: {
|
||||
type: 'object',
|
||||
label: 'Tool Distillation',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxOutputTokens: {
|
||||
type: 'number',
|
||||
label: 'Max Output Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 10_000,
|
||||
description:
|
||||
'Maximum tokens to show when truncating large tool outputs.',
|
||||
showInDialog: false,
|
||||
},
|
||||
summarizationThresholdTokens: {
|
||||
type: 'number',
|
||||
label: 'Tool Summarization Threshold',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 20_000,
|
||||
description:
|
||||
'Threshold above which truncated tool outputs will be summarized by an LLM.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
type: 'object',
|
||||
label: 'Admin',
|
||||
|
||||
@@ -46,6 +46,7 @@ import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
@@ -57,6 +58,7 @@ export async function startInteractiveUI(
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
initializeConsoleStore();
|
||||
// Never enter Ink alternate buffer mode when screen reader mode is enabled
|
||||
// as there is no benefit of alternate buffer mode when using a screen reader
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
|
||||
@@ -194,6 +194,17 @@ export function createMockSettings(
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||
getSnapshot: vi.fn().mockReturnValue({
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
isTrusted: true,
|
||||
errors: [],
|
||||
merged,
|
||||
}),
|
||||
setValue: vi.fn(),
|
||||
...overrides,
|
||||
merged,
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
@@ -534,7 +534,6 @@ export const mockAppState: AppState = {
|
||||
};
|
||||
|
||||
const mockUIActions: UIActions = {
|
||||
toggleAlternateBuffer: vi.fn(),
|
||||
handleThemeSelect: vi.fn(),
|
||||
closeThemeDialog: vi.fn(),
|
||||
handleThemeHighlight: vi.fn(),
|
||||
@@ -614,6 +613,7 @@ export const renderWithProviders = async (
|
||||
mouseEventsEnabled = false,
|
||||
config,
|
||||
uiActions,
|
||||
toolActions,
|
||||
persistentState,
|
||||
appState = mockAppState,
|
||||
}: {
|
||||
@@ -624,6 +624,11 @@ export const renderWithProviders = async (
|
||||
mouseEventsEnabled?: boolean;
|
||||
config?: Config;
|
||||
uiActions?: Partial<UIActions>;
|
||||
toolActions?: Partial<{
|
||||
isExpanded: (callId: string) => boolean;
|
||||
toggleExpansion: (callId: string) => void;
|
||||
toggleAllExpansion: (callIds: string[]) => void;
|
||||
}>;
|
||||
persistentState?: {
|
||||
get?: typeof persistentStateMock.get;
|
||||
set?: typeof persistentStateMock.set;
|
||||
@@ -711,6 +716,16 @@ export const renderWithProviders = async (
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={
|
||||
toolActions?.isExpanded ??
|
||||
vi.fn().mockReturnValue(false)
|
||||
}
|
||||
toggleExpansion={
|
||||
toolActions?.toggleExpansion ?? vi.fn()
|
||||
}
|
||||
toggleAllExpansion={
|
||||
toolActions?.toggleAllExpansion ?? vi.fn()
|
||||
}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
|
||||
@@ -68,10 +68,8 @@ import {
|
||||
writeToStdout,
|
||||
disableMouseEvents,
|
||||
enterAlternateScreen,
|
||||
exitAlternateScreen,
|
||||
enableMouseEvents,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
SessionStartSource,
|
||||
@@ -170,6 +168,7 @@ import { useSuspend } from './hooks/useSuspend.js';
|
||||
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
|
||||
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
|
||||
import {
|
||||
getLastTurnToolCallIds,
|
||||
isToolExecuting,
|
||||
isToolAwaitingConfirmation,
|
||||
getAllToolCalls,
|
||||
@@ -215,7 +214,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
});
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
@@ -240,6 +239,39 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
|
||||
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpansion = useCallback((callId: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(callId)) {
|
||||
next.delete(callId);
|
||||
} else {
|
||||
next.add(callId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAllExpansion = useCallback((callIds: string[]) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
const anyCollapsed = callIds.some((id) => !next.has(id));
|
||||
|
||||
if (anyCollapsed) {
|
||||
callIds.forEach((id) => next.add(id));
|
||||
} else {
|
||||
callIds.forEach((id) => next.delete(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(callId: string) => expandedTools.has(callId),
|
||||
[expandedTools],
|
||||
);
|
||||
|
||||
const [shellModeActive, setShellModeActive] = useState(false);
|
||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||
useState<boolean>(false);
|
||||
@@ -995,6 +1027,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
if (config.isJitContextEnabled()) {
|
||||
await config.getContextManager()?.refresh();
|
||||
config.updateSystemInstructionIfInitialized();
|
||||
flattenedMemory = flattenMemory(config.getUserMemory());
|
||||
fileCount = config.getGeminiMdFileCount();
|
||||
} else {
|
||||
@@ -1139,11 +1172,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
);
|
||||
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
() => isToolAwaitingConfirmation(pendingHistoryItems),
|
||||
[pendingHistoryItems],
|
||||
);
|
||||
|
||||
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
|
||||
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
|
||||
backgroundTasksRef.current = backgroundTasks;
|
||||
@@ -1552,23 +1580,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS);
|
||||
|
||||
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAlternateBuffer) return;
|
||||
|
||||
const isLongHistory = historyManager.history.length > 15;
|
||||
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
|
||||
|
||||
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
|
||||
showTransientMessage({
|
||||
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
|
||||
type: TransientMessageType.Hint
|
||||
});
|
||||
setShownBufferToggleHint(true);
|
||||
}
|
||||
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
|
||||
|
||||
const {
|
||||
isFolderTrustDialogOpen,
|
||||
discoveryResults: folderDiscoveryResults,
|
||||
@@ -1719,11 +1730,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
|
||||
toggleAlternateBuffer();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||
// This should happen regardless of the count.
|
||||
@@ -1751,13 +1757,25 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
}
|
||||
|
||||
const toggleLastTurnTools = () => {
|
||||
triggerExpandHint(true);
|
||||
|
||||
const targetToolCallIds = getLastTurnToolCallIds(
|
||||
historyManager.history,
|
||||
pendingHistoryItems,
|
||||
);
|
||||
|
||||
if (targetToolCallIds.length > 0) {
|
||||
toggleAllExpansion(targetToolCallIds);
|
||||
}
|
||||
};
|
||||
|
||||
let enteringConstrainHeightMode = false;
|
||||
if (!constrainHeight) {
|
||||
enteringConstrainHeightMode = true;
|
||||
setConstrainHeight(true);
|
||||
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
|
||||
// If the user manually collapses the view, show the hint and reset the x-second timer.
|
||||
triggerExpandHint(true);
|
||||
toggleLastTurnTools();
|
||||
}
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
@@ -1805,11 +1823,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!enteringConstrainHeightMode
|
||||
) {
|
||||
setConstrainHeight(false);
|
||||
// If the user manually expands the view, show the hint and reset the x-second timer.
|
||||
triggerExpandHint(true);
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
toggleLastTurnTools();
|
||||
refreshStatic();
|
||||
return true;
|
||||
} else if (
|
||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||
@@ -1914,6 +1929,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
triggerExpandHint,
|
||||
keyMatchers,
|
||||
isHelpDismissKey,
|
||||
historyManager.history,
|
||||
pendingHistoryItems,
|
||||
toggleAllExpansion,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2057,6 +2075,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
authState === AuthState.AwaitingApiKeyInput ||
|
||||
!!newAgents;
|
||||
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
() => isToolAwaitingConfirmation(pendingHistoryItems),
|
||||
[pendingHistoryItems],
|
||||
);
|
||||
|
||||
const hasConfirmUpdateExtensionRequests =
|
||||
confirmUpdateExtensionRequests.length > 0;
|
||||
const hasLoopDetectionConfirmationRequest =
|
||||
@@ -2228,11 +2251,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
};
|
||||
}, [config, refreshStatic]);
|
||||
|
||||
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
|
||||
|
||||
const uiState: UIState = useMemo(
|
||||
() => ({
|
||||
isAlternateBuffer,
|
||||
history: historyManager.history,
|
||||
historyManager,
|
||||
isThemeDialogOpen,
|
||||
@@ -2358,7 +2378,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
showIsAlternateBufferHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
|
||||
hintBuffer: '',
|
||||
@@ -2485,8 +2504,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
showIsAlternateBufferHint,
|
||||
isAlternateBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2495,31 +2512,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[setShowPrivacyNotice],
|
||||
);
|
||||
|
||||
const toggleAlternateBuffer = useCallback(() => {
|
||||
setIsAlternateBuffer(prev => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
enterAlternateScreen();
|
||||
enableMouseEvents();
|
||||
disableLineWrapping();
|
||||
} else {
|
||||
exitAlternateScreen();
|
||||
disableMouseEvents();
|
||||
enableLineWrapping();
|
||||
writeToStdout('\x1b[2J\x1b[H');
|
||||
}
|
||||
process.stdout.emit('resize');
|
||||
|
||||
// Give a tick for resize to process, then trigger remount to force full redraw
|
||||
setImmediate(() => {
|
||||
refreshStatic();
|
||||
setForceRerenderKey((prev) => prev + 1);
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
|
||||
|
||||
const uiActions: UIActions = useMemo(
|
||||
() => ({
|
||||
handleThemeSelect,
|
||||
@@ -2531,7 +2523,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
exitPrivacyNotice,
|
||||
toggleAlternateBuffer,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openAgentConfigDialog,
|
||||
@@ -2670,7 +2661,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
historyManager,
|
||||
getPreferredEditor,
|
||||
toggleAlternateBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2696,7 +2686,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
startupWarnings: props.startupWarnings || [],
|
||||
}}
|
||||
>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAllExpansion={toggleAllExpansion}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
</ShellFocusContext.Provider>
|
||||
|
||||
@@ -35,10 +35,7 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
|
||||
describe('DetailedMessagesDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: [],
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
vi.mocked(useConsoleMessages).mockReturnValue([]);
|
||||
});
|
||||
it('renders nothing when messages are empty', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
@@ -58,10 +55,7 @@ describe('DetailedMessagesDisplay', () => {
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
{ type: 'debug', content: 'Debug message', count: 1 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
vi.mocked(useConsoleMessages).mockReturnValue(messages);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||
@@ -79,10 +73,7 @@ describe('DetailedMessagesDisplay', () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
vi.mocked(useConsoleMessages).mockReturnValue(messages);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||
@@ -98,10 +89,7 @@ describe('DetailedMessagesDisplay', () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
vi.mocked(useConsoleMessages).mockReturnValue(messages);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||
@@ -117,10 +105,7 @@ describe('DetailedMessagesDisplay', () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'log', content: 'Repeated message', count: 5 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
vi.mocked(useConsoleMessages).mockReturnValue(messages);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
|
||||
|
||||
@@ -29,7 +29,7 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
> = ({ maxHeight, width, hasFocus }) => {
|
||||
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
|
||||
|
||||
const { consoleMessages } = useConsoleMessages();
|
||||
const consoleMessages = useConsoleMessages();
|
||||
const config = useConfig();
|
||||
|
||||
const messages = useMemo(() => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
AuthType,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -56,7 +55,6 @@ describe('<ModelDialog />', () => {
|
||||
const mockGetGemini31FlashLiteLaunchedSync = vi.fn();
|
||||
const mockGetProModelNoAccess = vi.fn();
|
||||
const mockGetProModelNoAccessSync = vi.fn();
|
||||
const mockGetUserTier = vi.fn();
|
||||
|
||||
interface MockConfig extends Partial<Config> {
|
||||
setModel: (model: string, isTemporary?: boolean) => void;
|
||||
@@ -67,7 +65,6 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: () => boolean;
|
||||
getProModelNoAccess: () => Promise<boolean>;
|
||||
getProModelNoAccessSync: () => boolean;
|
||||
getUserTier: () => UserTierId | undefined;
|
||||
}
|
||||
|
||||
const mockConfig: MockConfig = {
|
||||
@@ -79,7 +76,6 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
|
||||
getProModelNoAccess: mockGetProModelNoAccess,
|
||||
getProModelNoAccessSync: mockGetProModelNoAccessSync,
|
||||
getUserTier: mockGetUserTier,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -90,7 +86,6 @@ describe('<ModelDialog />', () => {
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
|
||||
|
||||
// Default implementation for getDisplayString
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
@@ -136,7 +131,6 @@ describe('<ModelDialog />', () => {
|
||||
mockGetProModelNoAccess.mockResolvedValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.FREE);
|
||||
mockGetDisplayString.mockImplementation((val: string) => val);
|
||||
|
||||
const { lastFrame, unmount } = await renderComponent();
|
||||
@@ -442,34 +436,11 @@ describe('<ModelDialog />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides Flash Lite Preview model for users with pro access', async () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Flash Lite Preview model for free tier users', async () => {
|
||||
it('shows Flash Lite Preview model regardless of tier when flag is enabled', async () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.FREE);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
AuthType,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
isProModel,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
@@ -190,7 +189,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
}, [config, shouldShowPreviewModels, manualModelSelected, useGemini31]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
const isFreeTier = config?.getUserTier() === UserTierId.FREE;
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config?.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
@@ -207,9 +205,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
if (m.tier === 'auto') return false;
|
||||
// Pro models are shown for users with pro access
|
||||
if (!hasAccessToProModel && m.tier === 'pro') return false;
|
||||
// 3.1 Preview Flash-lite is only available on free tier
|
||||
if (m.tier === 'flash-lite' && m.isPreview && !isFreeTier)
|
||||
return false;
|
||||
|
||||
// Flag Guard: Versioned models only show if their flag is active.
|
||||
if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false;
|
||||
@@ -292,7 +287,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
if (isFreeTier && useGemini31FlashLite) {
|
||||
if (useGemini31FlashLite) {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
|
||||
@@ -142,38 +142,4 @@ describe('<StatusRow />', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Tip: Test Tip');
|
||||
});
|
||||
|
||||
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: false,
|
||||
showTips: false,
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
showIsAlternateBufferHint: true,
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={false}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,12 +206,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
return uiState.currentTip;
|
||||
}
|
||||
|
||||
// 2. Buffer Toggle Hint
|
||||
if (uiState.showIsAlternateBufferHint) {
|
||||
return '[Alt+T] Switch to Full Screen';
|
||||
}
|
||||
|
||||
// 3. Shortcut Hint (Fallback)
|
||||
// 2. Shortcut Hint (Fallback)
|
||||
if (
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
|
||||
+3
@@ -43,10 +43,12 @@ Tips for getting started:
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ o tool3 Description for tool 3 │
|
||||
│ │
|
||||
@@ -93,6 +95,7 @@ Tips for getting started:
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
|
||||
@@ -6,12 +6,11 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 █ │
|
||||
│ Line 15 │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
│ Line 18 █ │
|
||||
@@ -27,12 +26,11 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 █ │
|
||||
│ Line 15 │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
│ Line 18 █ │
|
||||
@@ -47,8 +45,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 10 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 11 │
|
||||
│ ... first 11 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type DiffStat,
|
||||
type FileDiff,
|
||||
type GrepResult,
|
||||
type ListDirectoryResult,
|
||||
type ReadManyFilesResult,
|
||||
makeFakeConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
} from '../../types.js';
|
||||
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
|
||||
describe('DenseToolMessage', () => {
|
||||
const defaultProps = {
|
||||
callId: 'call-1',
|
||||
name: 'test-tool',
|
||||
description: 'Test description',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'Success result' as ToolResultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
terminalWidth: 80,
|
||||
};
|
||||
|
||||
it('renders correctly for a successful string result', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage {...defaultProps} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('test-tool');
|
||||
expect(output).toContain('Test description');
|
||||
expect(output).toContain('→ Success result');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('truncates long string results', async () => {
|
||||
const longResult = 'A'.repeat(200);
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={longResult as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('…');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('flattens newlines in string results', async () => {
|
||||
const multilineResult = 'Line 1\nLine 2';
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={multilineResult as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Line 1 Line 2');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for file diff results with stats', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+diff content',
|
||||
fileName: 'test.ts',
|
||||
filePath: '/path/to/test.ts',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
diffStat: {
|
||||
user_added_lines: 5,
|
||||
user_removed_lines: 2,
|
||||
user_added_chars: 50,
|
||||
user_removed_chars: 20,
|
||||
model_added_lines: 10,
|
||||
model_removed_lines: 4,
|
||||
model_added_chars: 100,
|
||||
model_removed_chars: 40,
|
||||
},
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('test.ts → Accepted (+15, -6)');
|
||||
expect(output).toContain('diff content');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Edit tool using confirmationDetails', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'edit' as const,
|
||||
title: 'Confirm Edit',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
fileDiff:
|
||||
'@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
|
||||
originalContent: 'body { color: blue; }',
|
||||
newContent: 'body { color: red; }',
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={CoreToolCallStatus.AwaitingApproval}
|
||||
resultDisplay={undefined}
|
||||
confirmationDetails={
|
||||
confirmationDetails as SerializableConfirmationDetails
|
||||
}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss');
|
||||
expect(output).toContain('→ Confirming');
|
||||
expect(output).toContain('body { color: red; }');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Rejected Edit tool', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
originalContent: 'old line',
|
||||
newContent: 'new line',
|
||||
diffStat: {
|
||||
user_added_lines: 1,
|
||||
user_removed_lines: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
},
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={CoreToolCallStatus.Cancelled}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss → Rejected (+1, -1)');
|
||||
expect(output).toContain('- old line');
|
||||
expect(output).toContain('+ new line');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Rejected Edit tool with confirmationDetails and diffStat', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'edit' as const,
|
||||
title: 'Confirm Edit',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
fileDiff:
|
||||
'@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
|
||||
originalContent: 'body { color: blue; }',
|
||||
newContent: 'body { color: red; }',
|
||||
diffStat: {
|
||||
user_added_lines: 1,
|
||||
user_removed_lines: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
} as DiffStat,
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={CoreToolCallStatus.Cancelled}
|
||||
resultDisplay={undefined}
|
||||
confirmationDetails={
|
||||
confirmationDetails as unknown as SerializableConfirmationDetails
|
||||
}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss → Rejected (+1, -1)');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for WriteFile tool', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
|
||||
fileName: 'config.json',
|
||||
filePath: '/path/to/config.json',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
diffStat: {
|
||||
user_added_lines: 1,
|
||||
user_removed_lines: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
},
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="WriteFile"
|
||||
status={CoreToolCallStatus.Success}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('WriteFile');
|
||||
expect(output).toContain('config.json → Accepted (+1, -1)');
|
||||
expect(output).toContain('+ new content');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Rejected WriteFile tool', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
|
||||
fileName: 'config.json',
|
||||
filePath: '/path/to/config.json',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="WriteFile"
|
||||
status={CoreToolCallStatus.Cancelled}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('WriteFile');
|
||||
expect(output).toContain('config.json');
|
||||
expect(output).toContain('→ Rejected');
|
||||
expect(output).toContain('- old content');
|
||||
expect(output).toContain('+ new content');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Errored Edit tool', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
originalContent: 'old line',
|
||||
newContent: 'new line',
|
||||
diffStat: {
|
||||
user_added_lines: 1,
|
||||
user_removed_lines: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
},
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={CoreToolCallStatus.Error}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss → Failed (+1, -1)');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for grep results', async () => {
|
||||
const grepResult: GrepResult = {
|
||||
summary: 'Found 2 matches',
|
||||
matches: [
|
||||
{
|
||||
filePath: 'file1.ts',
|
||||
absolutePath: '/file1.ts',
|
||||
lineNumber: 10,
|
||||
line: 'match 1',
|
||||
},
|
||||
{
|
||||
filePath: 'file2.ts',
|
||||
absolutePath: '/file2.ts',
|
||||
lineNumber: 20,
|
||||
line: 'match 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={grepResult as unknown as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Found 2 matches');
|
||||
// Matches are rendered in a secondary list for high-signal summaries
|
||||
expect(output).toContain('file1.ts:10: match 1');
|
||||
expect(output).toContain('file2.ts:20: match 2');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for ls results', async () => {
|
||||
const lsResult: ListDirectoryResult = {
|
||||
summary: 'Listed 2 files. (1 ignored)',
|
||||
files: ['file1.ts', 'dir1'],
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={lsResult as unknown as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Listed 2 files. (1 ignored)');
|
||||
// Directory listings should not have a payload in dense mode
|
||||
expect(output).not.toContain('file1.ts');
|
||||
expect(output).not.toContain('dir1');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for ReadManyFiles results', async () => {
|
||||
const rmfResult: ReadManyFilesResult = {
|
||||
summary: 'Read 3 file(s)',
|
||||
files: ['file1.ts', 'file2.ts', 'file3.ts'],
|
||||
include: ['**/*.ts'],
|
||||
skipped: [{ path: 'skipped.bin', reason: 'binary' }],
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={rmfResult as unknown as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Attempting to read files from **/*.ts');
|
||||
expect(output).toContain('→ Read 3 file(s) (1 ignored)');
|
||||
expect(output).toContain('file1.ts');
|
||||
expect(output).toContain('file2.ts');
|
||||
expect(output).toContain('file3.ts');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for todo updates', async () => {
|
||||
const todoResult = {
|
||||
todos: [],
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={todoResult as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Todos updated');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders generic output message for unknown object results', async () => {
|
||||
const genericResult = {
|
||||
some: 'data',
|
||||
} as unknown as ToolResultDisplay;
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage {...defaultProps} resultDisplay={genericResult} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Returned (possible empty result)');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for error status with string message', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
status={CoreToolCallStatus.Error}
|
||||
resultDisplay={'Error occurred' as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Error occurred');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders generic failure message for error status without string message', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
status={CoreToolCallStatus.Error}
|
||||
resultDisplay={undefined}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Failed');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not render result arrow if resultDisplay is missing', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
status={CoreToolCallStatus.Scheduled}
|
||||
resultDisplay={undefined}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('→');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('Toggleable Diff View (Alternate Buffer)', () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
|
||||
fileName: 'test.ts',
|
||||
filePath: '/path/to/test.ts',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
};
|
||||
|
||||
it('hides diff content by default when in alternate buffer mode', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Accepted');
|
||||
expect(output).not.toContain('new line');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows diff content by default when NOT in alternate buffer mode', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Accepted');
|
||||
expect(output).toContain('new line');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows diff content when expanded via ToolActionsContext', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
toolActions: {
|
||||
isExpanded: () => true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Verify it shows the diff when expanded
|
||||
expect(lastFrame()).toContain('new line');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual Regression', () => {
|
||||
it('matches SVG snapshot for an Accepted file edit with diff stats', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileName: 'test.ts',
|
||||
filePath: '/mock/test.ts',
|
||||
fileDiff: '--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
diffStat: {
|
||||
model_added_lines: 1,
|
||||
model_removed_lines: 1,
|
||||
model_added_chars: 3,
|
||||
model_removed_chars: 3,
|
||||
user_added_lines: 0,
|
||||
user_removed_lines: 0,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const renderResult = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="edit"
|
||||
description="Editing test.ts"
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>,
|
||||
);
|
||||
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
});
|
||||
|
||||
it('matches SVG snapshot for a Rejected tool call', async () => {
|
||||
const renderResult = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="read_file"
|
||||
description="Reading important.txt"
|
||||
resultDisplay="Rejected by user"
|
||||
status={CoreToolCallStatus.Cancelled}
|
||||
/>,
|
||||
);
|
||||
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type FileDiff,
|
||||
type ListDirectoryResult,
|
||||
type ReadManyFilesResult,
|
||||
isFileDiff,
|
||||
hasSummary,
|
||||
isGrepResult,
|
||||
isListResult,
|
||||
isReadManyFilesResult,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type IndividualToolCallDisplay,
|
||||
type ToolResultDisplay,
|
||||
isTodoList,
|
||||
} from '../../types.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { ToolStatusIndicator } from './ToolShared.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
DiffRenderer,
|
||||
renderDiffLines,
|
||||
isNewFile,
|
||||
parseDiffWithLineNumbers,
|
||||
} from './DiffRenderer.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { getFileExtension } from '../../utils/fileUtils.js';
|
||||
|
||||
const PAYLOAD_MARGIN_LEFT = 6;
|
||||
const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols)
|
||||
const PAYLOAD_SCROLL_GUTTER = 4;
|
||||
const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER;
|
||||
|
||||
interface DenseToolMessageProps extends IndividualToolCallDisplay {
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
|
||||
interface ViewParts {
|
||||
// brief description of action
|
||||
description?: React.ReactNode;
|
||||
// result summary or status text
|
||||
summary?: React.ReactNode;
|
||||
// detailed output, e.g. diff or command output
|
||||
payload?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface PayloadResult {
|
||||
summary: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const hasPayload = (res: unknown): res is PayloadResult => {
|
||||
if (!hasSummary(res)) return false;
|
||||
if (!('payload' in res)) return false;
|
||||
|
||||
const value = (res as { payload?: unknown }).payload;
|
||||
return typeof value === 'string';
|
||||
};
|
||||
|
||||
const RenderItemsList: React.FC<{
|
||||
items?: string[];
|
||||
maxVisible?: number;
|
||||
}> = ({ items, maxVisible = 20 }) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{items.slice(0, maxVisible).map((item, i) => (
|
||||
<Text key={i} color={theme.text.secondary}>
|
||||
{item}
|
||||
</Text>
|
||||
))}
|
||||
{items.length > maxVisible && (
|
||||
<Text color={theme.text.secondary}>
|
||||
... and {items.length - maxVisible} more
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function getFileOpData(
|
||||
diff: FileDiff,
|
||||
status: CoreToolCallStatus,
|
||||
resultDisplay: ToolResultDisplay | undefined,
|
||||
terminalWidth: number,
|
||||
availableTerminalHeight: number | undefined,
|
||||
isClickable: boolean,
|
||||
): ViewParts {
|
||||
const added =
|
||||
(diff.diffStat?.model_added_lines ?? 0) +
|
||||
(diff.diffStat?.user_added_lines ?? 0);
|
||||
const removed =
|
||||
(diff.diffStat?.model_removed_lines ?? 0) +
|
||||
(diff.diffStat?.user_removed_lines ?? 0);
|
||||
|
||||
const isAcceptedOrConfirming =
|
||||
status === CoreToolCallStatus.Success ||
|
||||
status === CoreToolCallStatus.Executing ||
|
||||
status === CoreToolCallStatus.AwaitingApproval;
|
||||
|
||||
const addColor = isAcceptedOrConfirming
|
||||
? theme.status.success
|
||||
: theme.text.secondary;
|
||||
const removeColor = isAcceptedOrConfirming
|
||||
? theme.status.error
|
||||
: theme.text.secondary;
|
||||
|
||||
// Always show diff stats if available, using neutral colors for rejected
|
||||
const showDiffStat = !!diff.diffStat;
|
||||
|
||||
const description = (
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{diff.fileName}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
let resultSummary = '';
|
||||
let resultColor = theme.text.secondary;
|
||||
|
||||
if (status === CoreToolCallStatus.AwaitingApproval) {
|
||||
resultSummary = 'Confirming';
|
||||
} else if (
|
||||
status === CoreToolCallStatus.Success ||
|
||||
status === CoreToolCallStatus.Executing
|
||||
) {
|
||||
resultSummary = 'Accepted';
|
||||
resultColor = theme.text.accent;
|
||||
} else if (status === CoreToolCallStatus.Cancelled) {
|
||||
resultSummary = 'Rejected';
|
||||
resultColor = theme.status.error;
|
||||
} else if (status === CoreToolCallStatus.Error) {
|
||||
resultSummary =
|
||||
typeof resultDisplay === 'string' ? resultDisplay : 'Failed';
|
||||
resultColor = theme.status.error;
|
||||
}
|
||||
|
||||
const summary = (
|
||||
<Box flexDirection="row">
|
||||
{resultSummary && (
|
||||
<Text color={resultColor} wrap="truncate-end">
|
||||
→{' '}
|
||||
<Text underline={isClickable}>
|
||||
{resultSummary.replace(/\n/g, ' ')}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
{showDiffStat && (
|
||||
<Box marginLeft={1} marginRight={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{'('}
|
||||
<Text color={addColor}>+{added}</Text>
|
||||
{', '}
|
||||
<Text color={removeColor}>-{removed}</Text>
|
||||
{')'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const payload = (
|
||||
<DiffRenderer
|
||||
diffContent={diff.fileDiff}
|
||||
filename={diff.fileName}
|
||||
terminalWidth={terminalWidth - PAYLOAD_MARGIN_LEFT}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
disableColor={status === CoreToolCallStatus.Cancelled}
|
||||
/>
|
||||
);
|
||||
|
||||
return { description, summary, payload };
|
||||
}
|
||||
|
||||
function getReadManyFilesData(result: ReadManyFilesResult): ViewParts {
|
||||
const items = result.files ?? [];
|
||||
const maxVisible = 10;
|
||||
const includePatterns = result.include?.join(', ') ?? '';
|
||||
const description = (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
Attempting to read files from {includePatterns}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const skippedCount = result.skipped?.length ?? 0;
|
||||
const summaryStr = `Read ${items.length} file(s)${
|
||||
skippedCount > 0 ? ` (${skippedCount} ignored)` : ''
|
||||
}`;
|
||||
const summary = <Text color={theme.text.accent}>→ {summaryStr}</Text>;
|
||||
const hasItems = items.length > 0;
|
||||
const payload = hasItems ? (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{hasItems && <RenderItemsList items={items} maxVisible={maxVisible} />}
|
||||
</Box>
|
||||
) : undefined;
|
||||
|
||||
return { description, summary, payload };
|
||||
}
|
||||
|
||||
function getListDirectoryData(
|
||||
result: ListDirectoryResult,
|
||||
originalDescription?: string,
|
||||
): ViewParts {
|
||||
const description = originalDescription ? (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{originalDescription}
|
||||
</Text>
|
||||
) : undefined;
|
||||
const summary = <Text color={theme.text.accent}>→ {result.summary}</Text>;
|
||||
|
||||
// For directory listings, we want NO payload in dense mode
|
||||
return { description, summary, payload: undefined };
|
||||
}
|
||||
|
||||
function getListResultData(
|
||||
result: ListDirectoryResult | ReadManyFilesResult,
|
||||
originalDescription?: string,
|
||||
): ViewParts {
|
||||
if (isReadManyFilesResult(result)) {
|
||||
return getReadManyFilesData(result);
|
||||
}
|
||||
return getListDirectoryData(result, originalDescription);
|
||||
}
|
||||
|
||||
function getGenericSuccessData(
|
||||
resultDisplay: unknown,
|
||||
originalDescription?: string,
|
||||
): ViewParts {
|
||||
let summary: React.ReactNode;
|
||||
let payload: React.ReactNode;
|
||||
|
||||
const description = originalDescription ? (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{originalDescription}
|
||||
</Text>
|
||||
) : undefined;
|
||||
|
||||
if (typeof resultDisplay === 'string') {
|
||||
const flattened = resultDisplay.replace(/\n/g, ' ').trim();
|
||||
summary = (
|
||||
<Text color={theme.text.accent} wrap="truncate-end">
|
||||
→ {flattened}
|
||||
</Text>
|
||||
);
|
||||
} else if (isGrepResult(resultDisplay)) {
|
||||
summary = <Text color={theme.text.accent}>→ {resultDisplay.summary}</Text>;
|
||||
const matches = resultDisplay.matches;
|
||||
if (matches.length > 0) {
|
||||
payload = (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
<RenderItemsList
|
||||
items={matches.map(
|
||||
(m) => `${m.filePath}:${m.lineNumber}: ${m.line.trim()}`,
|
||||
)}
|
||||
maxVisible={10}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
} else if (isTodoList(resultDisplay)) {
|
||||
summary = (
|
||||
<Text color={theme.text.accent} wrap="wrap">
|
||||
→ Todos updated
|
||||
</Text>
|
||||
);
|
||||
} else if (hasPayload(resultDisplay)) {
|
||||
summary = <Text color={theme.text.accent}>→ {resultDisplay.summary}</Text>;
|
||||
payload = (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{resultDisplay.payload}</Text>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
summary = (
|
||||
<Text color={theme.text.accent} wrap="wrap">
|
||||
→ Returned (possible empty result)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return { description, summary, payload };
|
||||
}
|
||||
|
||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const {
|
||||
callId,
|
||||
name,
|
||||
status,
|
||||
resultDisplay,
|
||||
confirmationDetails,
|
||||
outputFile,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
description: originalDescription,
|
||||
} = props;
|
||||
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
|
||||
// Handle optional context members
|
||||
const [localIsExpanded, setLocalIsExpanded] = useState(false);
|
||||
const isExpanded = isExpandedInContext
|
||||
? isExpandedInContext(callId)
|
||||
: localIsExpanded;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const toggleRef = useRef<DOMElement>(null);
|
||||
|
||||
// Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
|
||||
const diff = useMemo((): FileDiff | undefined => {
|
||||
if (isFileDiff(resultDisplay)) return resultDisplay;
|
||||
if (confirmationDetails?.type === 'edit') {
|
||||
const details = confirmationDetails;
|
||||
return {
|
||||
fileName: details.fileName,
|
||||
fileDiff: details.fileDiff,
|
||||
filePath: details.filePath,
|
||||
originalContent: details.originalContent,
|
||||
newContent: details.newContent,
|
||||
diffStat: details.diffStat,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [resultDisplay, confirmationDetails]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !isExpanded;
|
||||
if (!next) {
|
||||
setIsFocused(false);
|
||||
} else {
|
||||
setIsFocused(true);
|
||||
}
|
||||
|
||||
if (toggleExpansion) {
|
||||
toggleExpansion(callId);
|
||||
} else {
|
||||
setLocalIsExpanded(next);
|
||||
}
|
||||
};
|
||||
|
||||
useMouseClick(toggleRef, handleToggle, {
|
||||
isActive: isAlternateBuffer && !!diff,
|
||||
});
|
||||
|
||||
// State-to-View Coordination
|
||||
const viewParts = useMemo((): ViewParts => {
|
||||
if (diff) {
|
||||
return getFileOpData(
|
||||
diff,
|
||||
status,
|
||||
resultDisplay,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
isAlternateBuffer,
|
||||
);
|
||||
}
|
||||
if (isListResult(resultDisplay)) {
|
||||
return getListResultData(resultDisplay, originalDescription);
|
||||
}
|
||||
|
||||
if (isGrepResult(resultDisplay)) {
|
||||
return getGenericSuccessData(resultDisplay, originalDescription);
|
||||
}
|
||||
|
||||
if (status === CoreToolCallStatus.Success && resultDisplay) {
|
||||
return getGenericSuccessData(resultDisplay, originalDescription);
|
||||
}
|
||||
if (status === CoreToolCallStatus.Error) {
|
||||
const text =
|
||||
typeof resultDisplay === 'string'
|
||||
? resultDisplay.replace(/\n/g, ' ')
|
||||
: 'Failed';
|
||||
const errorSummary = (
|
||||
<Text color={theme.status.error} wrap="truncate-end">
|
||||
→ {text}
|
||||
</Text>
|
||||
);
|
||||
const descriptionText = originalDescription ? (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{originalDescription}
|
||||
</Text>
|
||||
) : undefined;
|
||||
return {
|
||||
description: descriptionText,
|
||||
summary: errorSummary,
|
||||
payload: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const descriptionText = originalDescription ? (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{originalDescription}
|
||||
</Text>
|
||||
) : undefined;
|
||||
return {
|
||||
description: descriptionText,
|
||||
summary: undefined,
|
||||
payload: undefined,
|
||||
};
|
||||
}, [
|
||||
diff,
|
||||
status,
|
||||
resultDisplay,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
originalDescription,
|
||||
isAlternateBuffer,
|
||||
]);
|
||||
|
||||
const { description, summary } = viewParts;
|
||||
|
||||
const diffLines = useMemo(() => {
|
||||
if (!diff || !isExpanded || !isAlternateBuffer) return [];
|
||||
|
||||
const parsedLines = parseDiffWithLineNumbers(diff.fileDiff);
|
||||
const isNewFileResult = isNewFile(parsedLines);
|
||||
|
||||
if (isNewFileResult) {
|
||||
const addedContent = parsedLines
|
||||
.filter((line) => line.type === 'add')
|
||||
.map((line) => line.content)
|
||||
.join('\n');
|
||||
|
||||
const fileExtension = getFileExtension(diff.fileName);
|
||||
|
||||
return colorizeCode({
|
||||
code: addedContent,
|
||||
language: fileExtension,
|
||||
maxWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
settings,
|
||||
disableColor: status === CoreToolCallStatus.Cancelled,
|
||||
returnLines: true,
|
||||
});
|
||||
} else {
|
||||
return renderDiffLines({
|
||||
parsedLines,
|
||||
filename: diff.fileName,
|
||||
terminalWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
disableColor: status === CoreToolCallStatus.Cancelled,
|
||||
});
|
||||
}
|
||||
}, [diff, isExpanded, isAlternateBuffer, terminalWidth, settings, status]);
|
||||
|
||||
const showPayload = useMemo(() => {
|
||||
const policy = !isAlternateBuffer || !diff || isExpanded;
|
||||
if (!policy) return false;
|
||||
|
||||
if (diff) {
|
||||
if (isAlternateBuffer) {
|
||||
return isExpanded && diffLines.length > 0;
|
||||
}
|
||||
// In non-alternate buffer mode, we always show the diff.
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(viewParts.payload || outputFile);
|
||||
}, [
|
||||
isAlternateBuffer,
|
||||
diff,
|
||||
isExpanded,
|
||||
diffLines.length,
|
||||
viewParts.payload,
|
||||
outputFile,
|
||||
]);
|
||||
|
||||
const keyExtractor = (_item: React.ReactNode, index: number) =>
|
||||
`diff-line-${index}`;
|
||||
const renderItem = ({ item }: { item: React.ReactNode }) => (
|
||||
<Box minHeight={1}>{item}</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<Box maxWidth={25} flexShrink={1} flexGrow={0}>
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{name}{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={1} flexShrink={1} flexGrow={0}>
|
||||
{description}
|
||||
</Box>
|
||||
{summary && (
|
||||
<Box
|
||||
key="tool-summary"
|
||||
ref={isAlternateBuffer && diff ? toggleRef : undefined}
|
||||
marginLeft={1}
|
||||
flexGrow={0}
|
||||
>
|
||||
{summary}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{showPayload && isAlternateBuffer && diffLines.length > 0 && (
|
||||
<Box
|
||||
marginLeft={PAYLOAD_MARGIN_LEFT}
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
height={
|
||||
Math.min(diffLines.length, COMPACT_TOOL_SUBVIEW_MAX_LINES) + 2
|
||||
}
|
||||
maxHeight={COMPACT_TOOL_SUBVIEW_MAX_LINES + 2}
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
borderDimColor={true}
|
||||
maxWidth={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
)}
|
||||
>
|
||||
<ScrollableList
|
||||
data={diffLines}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
hasFocus={isFocused}
|
||||
width={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth -
|
||||
PAYLOAD_MARGIN_LEFT -
|
||||
PAYLOAD_BORDER_CHROME_WIDTH -
|
||||
PAYLOAD_SCROLL_GUTTER,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showPayload && (!isAlternateBuffer || !diff) && viewParts.payload && (
|
||||
<Box marginLeft={PAYLOAD_MARGIN_LEFT} marginTop={1} marginBottom={1}>
|
||||
{viewParts.payload}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showPayload && outputFile && (
|
||||
<Box marginLeft={PAYLOAD_MARGIN_LEFT} marginTop={1} marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Output saved to: {outputFile})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -55,6 +55,7 @@ index 0000000..e69de29
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
disableColor: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -89,6 +90,7 @@ index 0000000..e69de29
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
disableColor: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -119,6 +121,7 @@ index 0000000..e69de29
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
disableColor: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,21 +7,21 @@
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import crypto from 'node:crypto';
|
||||
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme as semanticTheme } from '../../semantic-colors.js';
|
||||
import type { Theme } from '../../themes/theme.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { getFileExtension } from '../../utils/fileUtils.js';
|
||||
|
||||
interface DiffLine {
|
||||
export interface DiffLine {
|
||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||
oldLine?: number;
|
||||
newLine?: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
|
||||
export function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
|
||||
const lines = diffContent.split(/\r?\n/);
|
||||
const result: DiffLine[] = [];
|
||||
let currentOldLine = 0;
|
||||
@@ -88,6 +88,7 @@ interface DiffRendererProps {
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
theme?: Theme;
|
||||
disableColor?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||
@@ -99,6 +100,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
disableColor = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
|
||||
@@ -111,17 +113,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
return parseDiffWithLineNumbers(diffContent);
|
||||
}, [diffContent]);
|
||||
|
||||
const isNewFile = useMemo(() => {
|
||||
if (parsedLines.length === 0) return false;
|
||||
return parsedLines.every(
|
||||
(line) =>
|
||||
line.type === 'add' ||
|
||||
line.type === 'hunk' ||
|
||||
line.type === 'other' ||
|
||||
line.content.startsWith('diff --git') ||
|
||||
line.content.startsWith('new file mode'),
|
||||
);
|
||||
}, [parsedLines]);
|
||||
const isNewFileResult = useMemo(() => isNewFile(parsedLines), [parsedLines]);
|
||||
|
||||
const renderedOutput = useMemo(() => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
@@ -151,14 +143,14 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isNewFile) {
|
||||
if (isNewFileResult) {
|
||||
// Extract only the added lines' content
|
||||
const addedContent = parsedLines
|
||||
.filter((line) => line.type === 'add')
|
||||
.map((line) => line.content)
|
||||
.join('\n');
|
||||
// Attempt to infer language from filename, default to plain text if no filename
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const fileExtension = getFileExtension(filename);
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
@@ -169,39 +161,71 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
maxWidth: terminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
disableColor,
|
||||
});
|
||||
} else {
|
||||
return renderDiffContent(
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
const key = filename ? `diff-box-${filename}` : undefined;
|
||||
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{renderDiffLines({
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
terminalWidth,
|
||||
disableColor,
|
||||
})}
|
||||
</MaxSizedBox>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
diffContent,
|
||||
parsedLines,
|
||||
screenReaderEnabled,
|
||||
isNewFile,
|
||||
isNewFileResult,
|
||||
filename,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
tabWidth,
|
||||
disableColor,
|
||||
]);
|
||||
|
||||
return renderedOutput;
|
||||
};
|
||||
|
||||
const renderDiffContent = (
|
||||
parsedLines: DiffLine[],
|
||||
filename: string | undefined,
|
||||
export const isNewFile = (parsedLines: DiffLine[]): boolean => {
|
||||
if (parsedLines.length === 0) return false;
|
||||
return parsedLines.every(
|
||||
(line) =>
|
||||
line.type === 'add' ||
|
||||
line.type === 'hunk' ||
|
||||
line.type === 'other' ||
|
||||
line.content.startsWith('diff --git') ||
|
||||
line.content.startsWith('new file mode'),
|
||||
);
|
||||
};
|
||||
|
||||
export interface RenderDiffLinesOptions {
|
||||
parsedLines: DiffLine[];
|
||||
filename?: string;
|
||||
tabWidth?: number;
|
||||
terminalWidth: number;
|
||||
disableColor?: boolean;
|
||||
}
|
||||
|
||||
export const renderDiffLines = ({
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight: number | undefined,
|
||||
terminalWidth: number,
|
||||
) => {
|
||||
terminalWidth,
|
||||
disableColor = false,
|
||||
}: RenderDiffLinesOptions): React.ReactNode[] => {
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map((line) => ({
|
||||
...line,
|
||||
@@ -214,15 +238,16 @@ const renderDiffContent = (
|
||||
);
|
||||
|
||||
if (displayableLines.length === 0) {
|
||||
return (
|
||||
return [
|
||||
<Box
|
||||
key="no-changes"
|
||||
borderStyle="round"
|
||||
borderColor={semanticTheme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
<Text dimColor>No changes detected.</Text>
|
||||
</Box>
|
||||
);
|
||||
</Box>,
|
||||
];
|
||||
}
|
||||
|
||||
const maxLineNumber = Math.max(
|
||||
@@ -232,7 +257,7 @@ const renderDiffContent = (
|
||||
);
|
||||
const gutterWidth = Math.max(1, maxLineNumber.toString().length);
|
||||
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const fileExtension = getFileExtension(filename);
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
@@ -252,10 +277,6 @@ const renderDiffContent = (
|
||||
baseIndentation = 0;
|
||||
}
|
||||
|
||||
const key = filename
|
||||
? `diff-box-${filename}`
|
||||
: `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`;
|
||||
|
||||
let lastLineNumber: number | null = null;
|
||||
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
|
||||
|
||||
@@ -321,12 +342,26 @@ const renderDiffContent = (
|
||||
|
||||
const displayContent = line.content.substring(baseIndentation);
|
||||
|
||||
const backgroundColor =
|
||||
line.type === 'add'
|
||||
const backgroundColor = disableColor
|
||||
? undefined
|
||||
: line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: line.type === 'del'
|
||||
? semanticTheme.background.diff.removed
|
||||
: undefined;
|
||||
|
||||
const gutterColor = disableColor
|
||||
? undefined
|
||||
: semanticTheme.text.secondary;
|
||||
|
||||
const symbolColor = disableColor
|
||||
? undefined
|
||||
: line.type === 'add'
|
||||
? semanticTheme.status.success
|
||||
: line.type === 'del'
|
||||
? semanticTheme.status.error
|
||||
: undefined;
|
||||
|
||||
acc.push(
|
||||
<Box key={lineKey} flexDirection="row">
|
||||
<Box
|
||||
@@ -336,32 +371,24 @@ const renderDiffContent = (
|
||||
backgroundColor={backgroundColor}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Text color={semanticTheme.text.secondary}>{gutterNumStr}</Text>
|
||||
<Text color={gutterColor}>{gutterNumStr}</Text>
|
||||
</Box>
|
||||
{line.type === 'context' ? (
|
||||
<>
|
||||
<Text>{prefixSymbol} </Text>
|
||||
<Text wrap="wrap">{colorizeLine(displayContent, language)}</Text>
|
||||
<Text wrap="wrap">
|
||||
{colorizeLine(
|
||||
displayContent,
|
||||
language,
|
||||
undefined,
|
||||
disableColor,
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
backgroundColor={
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: semanticTheme.background.diff.removed
|
||||
}
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
line.type === 'add'
|
||||
? semanticTheme.status.success
|
||||
: semanticTheme.status.error
|
||||
}
|
||||
>
|
||||
{prefixSymbol}
|
||||
</Text>{' '}
|
||||
{colorizeLine(displayContent, language)}
|
||||
<Text backgroundColor={backgroundColor} wrap="wrap">
|
||||
<Text color={symbolColor}>{prefixSymbol}</Text>{' '}
|
||||
{colorizeLine(displayContent, language, undefined, disableColor)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>,
|
||||
@@ -371,15 +398,7 @@ const renderDiffContent = (
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{content}
|
||||
</MaxSizedBox>
|
||||
);
|
||||
return content;
|
||||
};
|
||||
|
||||
const getLanguageFromExtension = (extension: string): string | null => {
|
||||
|
||||
@@ -19,8 +19,12 @@ import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import {
|
||||
SHELL_CONTENT_OVERHEAD,
|
||||
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
describe('<ShellToolMessage />', () => {
|
||||
const baseProps: ShellToolMessageProps = {
|
||||
@@ -35,6 +39,7 @@ describe('<ShellToolMessage />', () => {
|
||||
isFirst: true,
|
||||
borderColor: 'green',
|
||||
borderDimColor: false,
|
||||
isExpandable: false,
|
||||
config: {
|
||||
getEnableInteractiveShell: () => true,
|
||||
} as unknown as Config,
|
||||
@@ -52,6 +57,11 @@ describe('<ShellToolMessage />', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('interactive shell focus', () => {
|
||||
@@ -59,14 +69,14 @@ describe('<ShellToolMessage />', () => {
|
||||
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
|
||||
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
|
||||
])('clicks inside the shell area sets focus for %s', async (_, name) => {
|
||||
const { lastFrame, simulateClick, unmount } = await renderWithProviders(
|
||||
<ShellToolMessage {...baseProps} name={name} />,
|
||||
{ uiActions, mouseEventsEnabled: true },
|
||||
);
|
||||
const { lastFrame, simulateClick, unmount, waitUntilReady } =
|
||||
await renderWithProviders(
|
||||
<ShellToolMessage {...baseProps} name={name} />,
|
||||
{ uiActions, mouseEventsEnabled: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('A shell command');
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('A shell command');
|
||||
|
||||
await simulateClick(2, 2);
|
||||
|
||||
@@ -75,6 +85,7 @@ describe('<ShellToolMessage />', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('resets focus when shell finishes', async () => {
|
||||
let updateStatus: (s: CoreToolCallStatus) => void = () => {};
|
||||
|
||||
@@ -86,19 +97,21 @@ describe('<ShellToolMessage />', () => {
|
||||
return <ShellToolMessage {...baseProps} status={status} ptyId={1} />;
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(<Wrapper />, {
|
||||
uiActions,
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
embeddedShellFocused: true,
|
||||
activePtyId: 1,
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
<Wrapper />,
|
||||
{
|
||||
uiActions,
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
embeddedShellFocused: true,
|
||||
activePtyId: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// Verify it is initially focused
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
|
||||
|
||||
// Now update status to Success
|
||||
await act(async () => {
|
||||
@@ -184,29 +197,33 @@ describe('<ShellToolMessage />', () => {
|
||||
[
|
||||
'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES',
|
||||
10,
|
||||
7,
|
||||
10 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 7 (Header height is 3, but calculation uses reserved=3)
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
|
||||
100,
|
||||
ACTIVE_SHELL_MAX_LINES - 4,
|
||||
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'uses full availableTerminalHeight when focused in alternate buffer mode',
|
||||
100,
|
||||
97,
|
||||
100 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 97
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
|
||||
undefined,
|
||||
ACTIVE_SHELL_MAX_LINES - 4,
|
||||
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
])(
|
||||
@@ -217,29 +234,34 @@ describe('<ShellToolMessage />', () => {
|
||||
expectedMaxLines,
|
||||
focused,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
) => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
renderOutputAsMarkdown={false}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
ptyId={1}
|
||||
status={CoreToolCallStatus.Executing}
|
||||
/>,
|
||||
{
|
||||
uiActions,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({
|
||||
ui: { useAlternateBuffer: true },
|
||||
}),
|
||||
uiState: {
|
||||
activePtyId: focused ? 1 : 2,
|
||||
embeddedShellFocused: focused,
|
||||
constrainHeight,
|
||||
const { lastFrame, waitUntilReady, unmount } =
|
||||
await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
renderOutputAsMarkdown={false}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
ptyId={1}
|
||||
status={CoreToolCallStatus.Executing}
|
||||
isExpandable={isExpandable}
|
||||
/>,
|
||||
{
|
||||
uiActions,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({
|
||||
ui: { useAlternateBuffer: true },
|
||||
}),
|
||||
uiState: {
|
||||
activePtyId: focused ? 1 : 2,
|
||||
embeddedShellFocused: focused,
|
||||
constrainHeight,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
|
||||
@@ -249,7 +271,7 @@ describe('<ShellToolMessage />', () => {
|
||||
);
|
||||
|
||||
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
@@ -264,16 +286,15 @@ describe('<ShellToolMessage />', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
});
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
@@ -292,17 +313,16 @@ describe('<ShellToolMessage />', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines because constrainHeight is false and isExpandable is true
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
});
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines because constrainHeight is false and isExpandable is true
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
@@ -321,11 +341,12 @@ describe('<ShellToolMessage />', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should still be constrained to 11 (15 - 4) because isExpandable is false
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(11);
|
||||
});
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should still be constrained to 11 (15 - 4) because isExpandable is false
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(
|
||||
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -34,6 +34,9 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: mockConfirm,
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
@@ -458,7 +461,11 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
@@ -485,7 +492,11 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
@@ -512,6 +523,9 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: true,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
@@ -728,6 +742,9 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: mockConfirm,
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/mockConfig.js';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
LS_DISPLAY_NAME,
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { expect, it, describe } from 'vitest';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
|
||||
describe('ToolGroupMessage Compact Rendering', () => {
|
||||
const defaultProps = {
|
||||
item: {
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
type: 'help' as const, // Adding type property to satisfy HistoryItem type
|
||||
},
|
||||
terminalWidth: 80,
|
||||
};
|
||||
|
||||
const compactSettings = createMockSettings({
|
||||
merged: {
|
||||
ui: {
|
||||
compactToolOutput: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('renders consecutive compact tools without empty lines between them', async () => {
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call1',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file1.txt\nfile2.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
{
|
||||
callId: 'call2',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file3.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not add an extra empty line between a compact tool and a standard tool', async () => {
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call1',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file1.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
{
|
||||
callId: 'call2',
|
||||
name: 'non-compact-tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'some large output',
|
||||
description: 'Doing something',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not add an extra empty line if a compact tool has a dense payload', async () => {
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call1',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file1.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
{
|
||||
callId: 'call2',
|
||||
name: READ_FILE_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: {
|
||||
summary: 'read file',
|
||||
payload: 'file content',
|
||||
files: ['file.txt'],
|
||||
}, // Dense payload
|
||||
description: 'Reading file',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not add an extra empty line between a standard tool and a compact tool', async () => {
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call1',
|
||||
name: 'non-compact-tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'some large output',
|
||||
description: 'Doing something',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
{
|
||||
callId: 'call2',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file1.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -481,7 +481,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
|
||||
<Scrollable height={12} hasFocus={true} scrollToBottom={true}>
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />
|
||||
</Scrollable>,
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, Fragment } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -17,6 +17,7 @@ import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { TopicMessage, isTopicTool } from './TopicMessage.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
@@ -24,10 +25,84 @@ import {
|
||||
shouldHideToolCall,
|
||||
CoreToolCallStatus,
|
||||
Kind,
|
||||
EDIT_DISPLAY_NAME,
|
||||
GLOB_DISPLAY_NAME,
|
||||
WEB_SEARCH_DISPLAY_NAME,
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
LS_DISPLAY_NAME,
|
||||
GREP_DISPLAY_NAME,
|
||||
WEB_FETCH_DISPLAY_NAME,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
READ_MANY_FILES_DISPLAY_NAME,
|
||||
isFileDiff,
|
||||
isGrepResult,
|
||||
isListResult,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import {
|
||||
TOOL_RESULT_STATIC_HEIGHT,
|
||||
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
const COMPACT_OUTPUT_ALLOWLIST = new Set([
|
||||
EDIT_DISPLAY_NAME,
|
||||
GLOB_DISPLAY_NAME,
|
||||
WEB_SEARCH_DISPLAY_NAME,
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
LS_DISPLAY_NAME,
|
||||
GREP_DISPLAY_NAME,
|
||||
WEB_FETCH_DISPLAY_NAME,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
READ_MANY_FILES_DISPLAY_NAME,
|
||||
]);
|
||||
|
||||
// Helper to identify if a tool should use the compact view
|
||||
export const isCompactTool = (
|
||||
tool: IndividualToolCallDisplay,
|
||||
isCompactModeEnabled: boolean,
|
||||
): boolean => {
|
||||
const hasCompactOutputSupport = COMPACT_OUTPUT_ALLOWLIST.has(tool.name);
|
||||
const displayStatus = mapCoreStatusToDisplayStatus(tool.status);
|
||||
return (
|
||||
isCompactModeEnabled &&
|
||||
hasCompactOutputSupport &&
|
||||
displayStatus !== ToolCallStatus.Confirming
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to identify if a compact tool has a payload (diff, list, etc.)
|
||||
export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
|
||||
if (tool.outputFile) return true;
|
||||
const res = tool.resultDisplay;
|
||||
if (!res) return false;
|
||||
|
||||
// TODO(24053): Usage of type guards makes this class too aware of internals
|
||||
if (isFileDiff(res)) return true;
|
||||
if (tool.confirmationDetails?.type === 'edit') return true;
|
||||
if (isGrepResult(res) && res.matches.length > 0) return true;
|
||||
|
||||
// ReadManyFilesResult check (has 'include' and 'files')
|
||||
if (isListResult(res) && 'include' in res) {
|
||||
const includeProp = (res as { include?: unknown }).include;
|
||||
if (Array.isArray(includeProp) && res.files.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Generic summary/payload pattern
|
||||
if (
|
||||
typeof res === 'object' &&
|
||||
res !== null &&
|
||||
'summary' in res &&
|
||||
'payload' in res
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
item: HistoryItem | HistoryItemWithoutId;
|
||||
@@ -54,11 +129,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
|
||||
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
|
||||
|
||||
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
|
||||
const toolCalls = useMemo(
|
||||
const visibleToolCalls = useMemo(
|
||||
() =>
|
||||
allToolCalls.filter((t) => {
|
||||
// Hide internal errors unless full verbosity
|
||||
if (
|
||||
isLowErrorVerbosity &&
|
||||
t.status === CoreToolCallStatus.Error &&
|
||||
@@ -66,19 +143,34 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Standard hiding logic (e.g. Plan Mode internal edits)
|
||||
if (
|
||||
shouldHideToolCall({
|
||||
displayName: t.name,
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
parentCallId: t.parentCallId,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shouldHideToolCall({
|
||||
displayName: t.name,
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
parentCallId: t.parentCallId,
|
||||
});
|
||||
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
|
||||
// from the History log. They live in the Global Queue or wait for their turn.
|
||||
// Only show tools that are actually running or finished.
|
||||
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
|
||||
|
||||
// We hide Confirming tools from the history log because they are
|
||||
// currently being rendered in the interactive ToolConfirmationQueue.
|
||||
// We show everything else, including Pending (waiting to run) and
|
||||
// Canceled (rejected by user), to ensure the history is complete
|
||||
// and to avoid tools "vanishing" after approval.
|
||||
return displayStatus !== ToolCallStatus.Confirming;
|
||||
}),
|
||||
[allToolCalls, isLowErrorVerbosity],
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const {
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
@@ -86,6 +178,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
pendingHistoryItems,
|
||||
} = useUIState();
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
getToolGroupBorderAppearance(
|
||||
@@ -104,41 +198,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
|
||||
// from the History log. They live in the Global Queue or wait for their turn.
|
||||
// Only show tools that are actually running or finished.
|
||||
// We explicitly exclude Pending and Confirming to ensure they only
|
||||
// appear in the Global Queue until they are approved and start executing.
|
||||
const visibleToolCalls = useMemo(
|
||||
() =>
|
||||
toolCalls.filter((t) => {
|
||||
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
|
||||
// We hide Confirming tools from the history log because they are
|
||||
// currently being rendered in the interactive ToolConfirmationQueue.
|
||||
// We show everything else, including Pending (waiting to run) and
|
||||
// Canceled (rejected by user), to ensure the history is complete
|
||||
// and to avoid tools "vanishing" after approval.
|
||||
return displayStatus !== ToolCallStatus.Confirming;
|
||||
}),
|
||||
|
||||
[toolCalls],
|
||||
);
|
||||
|
||||
const staticHeight = /* border */ 2;
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (
|
||||
tool.kind !== Kind.Agent &&
|
||||
tool.resultDisplay !== undefined &&
|
||||
tool.resultDisplay !== ''
|
||||
) {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
}
|
||||
const countOneLineToolCalls =
|
||||
visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length -
|
||||
countToolCallsWithResults;
|
||||
const groupedTools = useMemo(() => {
|
||||
const groups: Array<
|
||||
IndividualToolCallDisplay | IndividualToolCallDisplay[]
|
||||
@@ -158,10 +217,81 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
return groups;
|
||||
}, [visibleToolCalls]);
|
||||
|
||||
const staticHeight = useMemo(() => {
|
||||
let height = 0;
|
||||
for (let i = 0; i < groupedTools.length; i++) {
|
||||
const group = groupedTools[i];
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === groupedTools.length - 1;
|
||||
const prevGroup = i > 0 ? groupedTools[i - 1] : null;
|
||||
const prevIsCompact =
|
||||
prevGroup &&
|
||||
!Array.isArray(prevGroup) &&
|
||||
isCompactTool(prevGroup, isCompactModeEnabled);
|
||||
|
||||
const nextGroup = !isLast ? groupedTools[i + 1] : null;
|
||||
const nextIsCompact =
|
||||
nextGroup &&
|
||||
!Array.isArray(nextGroup) &&
|
||||
isCompactTool(nextGroup, isCompactModeEnabled);
|
||||
|
||||
const isAgentGroup = Array.isArray(group);
|
||||
const isCompact =
|
||||
!isAgentGroup && isCompactTool(group, isCompactModeEnabled);
|
||||
|
||||
const showClosingBorder = !isCompact && (nextIsCompact || isLast);
|
||||
|
||||
if (isFirst) {
|
||||
height += borderTopOverride ? 1 : 0;
|
||||
} else if (isCompact !== prevIsCompact) {
|
||||
// Add a 1-line gap when transitioning between compact and standard tools (or vice versa)
|
||||
height += 1;
|
||||
}
|
||||
|
||||
const isFirstProp = !!(isFirst
|
||||
? (borderTopOverride ?? true)
|
||||
: prevIsCompact);
|
||||
|
||||
if (isAgentGroup) {
|
||||
// Agent group
|
||||
height += 1; // Header
|
||||
height += group.length; // 1 line per agent
|
||||
if (isFirstProp) height += 1; // Top border
|
||||
if (showClosingBorder) height += 1; // Bottom border
|
||||
} else {
|
||||
if (isCompact) {
|
||||
height += 1; // Base height for compact tool
|
||||
} else {
|
||||
// Static overhead for standard tool header:
|
||||
height +=
|
||||
TOOL_RESULT_STATIC_HEIGHT +
|
||||
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
|
||||
}
|
||||
}
|
||||
}
|
||||
return height;
|
||||
}, [groupedTools, isCompactModeEnabled, borderTopOverride]);
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (tool.kind !== Kind.Agent) {
|
||||
if (isCompactTool(tool, isCompactModeEnabled)) {
|
||||
if (hasDensePayload(tool)) {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
} else if (
|
||||
tool.resultDisplay !== undefined &&
|
||||
tool.resultDisplay !== ''
|
||||
) {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const availableTerminalHeightPerToolMessage = availableTerminalHeight
|
||||
? Math.max(
|
||||
Math.floor(
|
||||
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
|
||||
(availableTerminalHeight - staticHeight) /
|
||||
Math.max(1, countToolCallsWithResults),
|
||||
),
|
||||
1,
|
||||
@@ -176,7 +306,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
// explicit "closing slice" (tools: []) used to bridge static/pending sections,
|
||||
// and only if it's actually continuing an open box from above.
|
||||
const isExplicitClosingSlice = allToolCalls.length === 0;
|
||||
if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) {
|
||||
const shouldShowGroup =
|
||||
visibleToolCalls.length > 0 ||
|
||||
(isExplicitClosingSlice && borderBottomOverride === true);
|
||||
|
||||
if (!shouldShowGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -191,7 +325,24 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
*/
|
||||
width={terminalWidth}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
// When border will be present, add margin of 1 to create spacing from the
|
||||
// previous message.
|
||||
marginBottom={(borderBottomOverride ?? true) ? 1 : 0}
|
||||
>
|
||||
{visibleToolCalls.length === 0 &&
|
||||
isExplicitClosingSlice &&
|
||||
borderBottomOverride === true && (
|
||||
<Box
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)}
|
||||
{groupedTools.map((group, index) => {
|
||||
let isFirst = index === 0;
|
||||
if (!isFirst) {
|
||||
@@ -207,98 +358,149 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
isFirst = allPreviousWereTopics;
|
||||
}
|
||||
|
||||
const resolvedIsFirst =
|
||||
borderTopOverride !== undefined
|
||||
? borderTopOverride && isFirst
|
||||
: isFirst;
|
||||
const isLast = index === groupedTools.length - 1;
|
||||
|
||||
if (Array.isArray(group)) {
|
||||
const prevGroup = index > 0 ? groupedTools[index - 1] : null;
|
||||
const prevIsCompact =
|
||||
prevGroup &&
|
||||
!Array.isArray(prevGroup) &&
|
||||
isCompactTool(prevGroup, isCompactModeEnabled);
|
||||
|
||||
const nextGroup = !isLast ? groupedTools[index + 1] : null;
|
||||
const nextIsCompact =
|
||||
nextGroup &&
|
||||
!Array.isArray(nextGroup) &&
|
||||
isCompactTool(nextGroup, isCompactModeEnabled);
|
||||
|
||||
const isAgentGroup = Array.isArray(group);
|
||||
const isCompact =
|
||||
!isAgentGroup && isCompactTool(group, isCompactModeEnabled);
|
||||
const isTopicToolCall = !isAgentGroup && isTopicTool(group.name);
|
||||
|
||||
// When border is present, add margin of 1 to create spacing from the
|
||||
// previous message.
|
||||
let marginTop = 0;
|
||||
if (isFirst) {
|
||||
marginTop = (borderTopOverride ?? false) ? 1 : 0;
|
||||
} else if (isCompact && prevIsCompact) {
|
||||
marginTop = 0;
|
||||
} else if (isCompact || prevIsCompact) {
|
||||
marginTop = 1;
|
||||
} else {
|
||||
// For subsequent standard tools scenarios, the ToolMessage and
|
||||
// ShellToolMessage components manage their own top spacing by passing
|
||||
// `isFirst=false` to their internal StickyHeader which then applies
|
||||
// a paddingTop=1 to create desired gap between standard tool outputs.
|
||||
marginTop = 0;
|
||||
}
|
||||
|
||||
const isFirstProp = !!(isFirst
|
||||
? (borderTopOverride ?? true)
|
||||
: prevIsCompact);
|
||||
|
||||
const showClosingBorder =
|
||||
!isCompact && !isTopicToolCall && (nextIsCompact || isLast);
|
||||
|
||||
if (isAgentGroup) {
|
||||
return (
|
||||
<SubagentGroupDisplay
|
||||
<Box
|
||||
key={group[0].callId}
|
||||
toolCalls={group}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={contentWidth}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
isFirst={resolvedIsFirst}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
marginTop={marginTop}
|
||||
flexDirection="column"
|
||||
width={contentWidth}
|
||||
>
|
||||
<SubagentGroupDisplay
|
||||
toolCalls={group}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={contentWidth}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
isFirst={isFirstProp}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
{showClosingBorder && (
|
||||
<Box
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={isLast ? (borderBottomOverride ?? true) : true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const tool = group;
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
const isTopicToolCall = isTopicTool(tool.name);
|
||||
|
||||
const commonProps = {
|
||||
...tool,
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: 'medium' as const,
|
||||
isFirst: resolvedIsFirst,
|
||||
isFirst: isCompact ? false : isFirstProp,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={tool.callId}
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={contentWidth}
|
||||
>
|
||||
{isTopicToolCall ? (
|
||||
<TopicMessage {...commonProps} />
|
||||
) : isShellToolCall ? (
|
||||
<ShellToolMessage {...commonProps} config={config} />
|
||||
) : (
|
||||
<ToolMessage {...commonProps} />
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Fragment key={tool.callId}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={contentWidth}
|
||||
marginTop={marginTop}
|
||||
>
|
||||
{isCompact ? (
|
||||
<DenseToolMessage {...commonProps} />
|
||||
) : isTopicToolCall ? (
|
||||
<TopicMessage {...commonProps} />
|
||||
) : isShellToolCall ? (
|
||||
<ShellToolMessage {...commonProps} config={config} />
|
||||
) : (
|
||||
<ToolMessage {...commonProps} />
|
||||
)}
|
||||
{!isCompact && tool.outputFile && (
|
||||
<Box
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{showClosingBorder && (
|
||||
<Box
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderBottom={isLast ? (borderBottomOverride ?? true) : true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{/*
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/}
|
||||
{(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
|
||||
borderBottomOverride !== false &&
|
||||
(visibleToolCalls.length === 0 ||
|
||||
!visibleToolCalls.every((tool) => isTopicTool(tool.name))) && (
|
||||
<Box
|
||||
height={0}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={borderBottomOverride ?? true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
isSubagentProgress,
|
||||
isStructuredToolResult,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
@@ -123,7 +124,28 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
{contentData}
|
||||
</Text>
|
||||
);
|
||||
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
|
||||
} else if (isStructuredToolResult(contentData)) {
|
||||
if (renderOutputAsMarkdown) {
|
||||
content = (
|
||||
<MarkdownDisplay
|
||||
text={contentData.summary}
|
||||
terminalWidth={childWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{contentData.summary}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
typeof contentData === 'object' &&
|
||||
contentData !== null &&
|
||||
'fileDiff' in contentData
|
||||
) {
|
||||
content = (
|
||||
<DiffRenderer
|
||||
diffContent={
|
||||
@@ -157,10 +179,13 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
// Final render based on session mode
|
||||
if (isAlternateBuffer) {
|
||||
// Use maxLines if provided, otherwise fall back to the calculated available height
|
||||
const effectiveMaxHeight = maxLines ?? availableHeight;
|
||||
|
||||
return (
|
||||
<Scrollable
|
||||
width={childWidth}
|
||||
maxHeight={maxLines ?? availableHeight}
|
||||
maxHeight={effectiveMaxHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
scrollToBottom={true}
|
||||
reportOverflow={true}
|
||||
|
||||
@@ -120,10 +120,10 @@ describe('ToolMessage Sticky Header Regression', () => {
|
||||
expect(lastFrame()).toContain('tool-1');
|
||||
});
|
||||
expect(lastFrame()).toContain('Description for tool-1');
|
||||
// Content lines 1-4 should be scrolled off
|
||||
// Content lines 1-5 should be scrolled off
|
||||
expect(lastFrame()).not.toContain('c1-01');
|
||||
expect(lastFrame()).not.toContain('c1-04');
|
||||
// Line 6 and 7 should be visible (terminalHeight=5 means only 2 lines of content show below 3-line header)
|
||||
expect(lastFrame()).not.toContain('c1-05');
|
||||
// Line 6 and 7 should be visible (terminalHeight=5 means 2 lines of content show below 3-line header)
|
||||
expect(lastFrame()).toContain('c1-06');
|
||||
expect(lastFrame()).toContain('c1-07');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="37" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="2" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs" font-weight="bold">-</text>
|
||||
<text x="45" y="2" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">read_file </text>
|
||||
<text x="144" y="2" fill="#afafaf" textLength="189" lengthAdjust="spacingAndGlyphs">Reading important.txt</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 693 B |
+33
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="88" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="2" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">✓</text>
|
||||
<text x="45" y="2" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs" font-weight="bold">edit </text>
|
||||
<text x="99" y="2" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">test.ts</text>
|
||||
<text x="171" y="2" fill="#d7afff" textLength="90" lengthAdjust="spacingAndGlyphs">→ Accepted</text>
|
||||
<text x="270" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">(</text>
|
||||
<text x="279" y="2" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">+1</text>
|
||||
<text x="297" y="2" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">, </text>
|
||||
<text x="315" y="2" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-1</text>
|
||||
<text x="333" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">)</text>
|
||||
<rect x="54" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<text x="54" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
|
||||
<rect x="63" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="72" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<text x="72" y="36" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
|
||||
<rect x="81" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="90" y="34" width="27" height="17" fill="#5f0000" />
|
||||
<text x="90" y="36" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">old</text>
|
||||
<rect x="54" y="51" width="9" height="17" fill="#005f00" />
|
||||
<text x="54" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
|
||||
<rect x="63" y="51" width="9" height="17" fill="#005f00" />
|
||||
<rect x="72" y="51" width="9" height="17" fill="#005f00" />
|
||||
<text x="72" y="53" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
|
||||
<rect x="81" y="51" width="9" height="17" fill="#005f00" />
|
||||
<rect x="90" y="51" width="27" height="17" fill="#005f00" />
|
||||
<text x="90" y="53" fill="#0000ee" textLength="27" lengthAdjust="spacingAndGlyphs">new</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,143 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > hides diff content by default when in alternate buffer mode 1`] = `
|
||||
" ✓ test-tool test.ts → Accepted
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff content by default when NOT in alternate buffer mode 1`] = `
|
||||
" ✓ test-tool test.ts → Accepted
|
||||
|
||||
1 - old line
|
||||
1 + new line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`;
|
||||
|
||||
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `
|
||||
" ✓ edit test.ts → Accepted (+1, -1)
|
||||
|
||||
1 - old
|
||||
1 + new
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > does not render result arrow if resultDisplay is missing 1`] = `
|
||||
" o test-tool Test description
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > flattens newlines in string results 1`] = `
|
||||
" ✓ test-tool Test description → Line 1 Line 2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Edit tool using confirmationDetails 1`] = `
|
||||
" ? Edit styles.scss → Confirming
|
||||
|
||||
1 - body { color: blue; }
|
||||
1 + body { color: red; }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Errored Edit tool 1`] = `
|
||||
" x Edit styles.scss → Failed (+1, -1)
|
||||
|
||||
1 - old line
|
||||
1 + new line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for ReadManyFiles results 1`] = `
|
||||
" ✓ test-tool Attempting to read files from **/*.ts → Read 3 file(s) (1 ignored)
|
||||
|
||||
file1.ts
|
||||
file2.ts
|
||||
file3.ts
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Rejected Edit tool 1`] = `
|
||||
" - Edit styles.scss → Rejected (+1, -1)
|
||||
|
||||
1 - old line
|
||||
1 + new line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Rejected Edit tool with confirmationDetails and diffStat 1`] = `
|
||||
" - Edit styles.scss → Rejected (+1, -1)
|
||||
|
||||
1 - body { color: blue; }
|
||||
1 + body { color: red; }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Rejected WriteFile tool 1`] = `
|
||||
" - WriteFile config.json → Rejected
|
||||
|
||||
1 - old content
|
||||
1 + new content
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for WriteFile tool 1`] = `
|
||||
" ✓ WriteFile config.json → Accepted (+1, -1)
|
||||
|
||||
1 - old content
|
||||
1 + new content
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for a successful string result 1`] = `
|
||||
" ✓ test-tool Test description → Success result
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for error status with string message 1`] = `
|
||||
" x test-tool Test description → Error occurred
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for file diff results with stats 1`] = `
|
||||
" ✓ test-tool test.ts → Accepted (+15, -6)
|
||||
|
||||
1 - old line
|
||||
1 + diff content
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for grep results 1`] = `
|
||||
" ✓ test-tool Test description → Found 2 matches
|
||||
|
||||
file1.ts:10: match 1
|
||||
file2.ts:20: match 2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for ls results 1`] = `
|
||||
" ✓ test-tool Test description → Listed 2 files. (1 ignored)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for todo updates 1`] = `
|
||||
" ✓ test-tool Test description → Todos updated
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders generic failure message for error status without string message 1`] = `
|
||||
" x test-tool Test description → Failed
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders generic output message for unknown object results 1`] = `
|
||||
" ✓ test-tool Test description → Returned (possible empty result)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > truncates long string results 1`] = `
|
||||
" ✓ test-tool Test description
|
||||
→ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA…
|
||||
"
|
||||
`;
|
||||
+2
-7
@@ -4,7 +4,6 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
@@ -129,7 +128,6 @@ exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalH
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 94 │
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
@@ -143,7 +141,6 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
@@ -161,7 +158,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
@@ -179,11 +175,10 @@ exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
|
||||
│ │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 █ │
|
||||
│ Line 8 █ │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 █ │
|
||||
│ Line 10 █ │
|
||||
│ Line 11 █ │
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a compact tool and a standard tool 1`] = `
|
||||
" ✓ ReadFolder Listing files → file1.txt
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ non-compact-tool Doing something │
|
||||
│ │
|
||||
│ some large output │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a standard tool and a compact tool 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ non-compact-tool Doing something │
|
||||
│ │
|
||||
│ some large output │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
✓ ReadFolder Listing files → file1.txt
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line if a compact tool has a dense payload 1`] = `
|
||||
" ✓ ReadFolder Listing files → file1.txt
|
||||
✓ ReadFile Reading file → read file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolGroupMessage Compact Rendering > renders consecutive compact tools without empty lines between them 1`] = `
|
||||
" ✓ ReadFolder Listing files → file1.txt file2.txt
|
||||
✓ ReadFolder Listing files → file3.txt
|
||||
"
|
||||
`;
|
||||
+7
-5
@@ -63,7 +63,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls arra
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
|
||||
│──────────────────────────────────────────────────────────────────────────│
|
||||
│──────────────────────────────────────────────────────────────────────────│ ▄
|
||||
│ line4 │ █
|
||||
│ line5 │ █
|
||||
│ │ █
|
||||
│ ✓ tool-2 Description 2 │ █
|
||||
@@ -71,6 +72,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
|
||||
│ line1 │ █
|
||||
│ line2 │ █
|
||||
╰──────────────────────────────────────────────────────────────────────────╯ █
|
||||
█
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -129,12 +131,12 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with output
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
|
||||
"╰──────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-2 Description 2 │
|
||||
│ │ ▄
|
||||
│ line1 │ █
|
||||
│ │
|
||||
│ line1 │ ▄
|
||||
╰──────────────────────────────────────────────────────────────────────────╯ █
|
||||
█
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
+1
-2
@@ -37,8 +37,7 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
|
||||
`;
|
||||
|
||||
exports[`ToolResultDisplay > truncates very long string results 1`] = `
|
||||
"... 249 hidden (Ctrl+O) ...
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
"... 250 hidden (Ctrl+O) ...
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮ █
|
||||
│ ✓ Shell Command Description for Shell Command │ █
|
||||
│ ✓ Shell Command Description for Shell Command │ ▀
|
||||
│ │
|
||||
│ shell-01 │
|
||||
│ shell-02 │
|
||||
@@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command Description for Shell Command │ ▄
|
||||
│ ✓ Shell Command Description for Shell Command │
|
||||
│────────────────────────────────────────────────────────────────────────│ █
|
||||
│ shell-06 │ ▀
|
||||
│ shell-07 │
|
||||
|
||||
@@ -62,3 +62,6 @@ export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
|
||||
/** Documentation URL for skills setup and configuration */
|
||||
export const SKILLS_DOCS_URL =
|
||||
'https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md';
|
||||
|
||||
/** Max lines to show for a compact tool subview (e.g. diff) */
|
||||
export const COMPACT_TOOL_SUBVIEW_MAX_LINES = 15;
|
||||
|
||||
@@ -205,8 +205,6 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
|
||||
'\u03A9': 'z', // "Ω" Option+z
|
||||
'\u00B8': 'Z', // "¸" Option+Shift+z
|
||||
'\u2202': 'd', // "∂" delete word forward
|
||||
'\u2020': 't', // "†" toggle full screen buffer
|
||||
'\u00E5': 'a', // "å" Option+a for alternate buffer
|
||||
};
|
||||
|
||||
function nonKeyboardEventFilter(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { act, useState, useCallback } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
|
||||
@@ -71,16 +71,61 @@ describe('ToolActionsContext', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to a pending promise to avoid unwanted async state updates in tests
|
||||
// that don't specifically test the IdeClient initialization.
|
||||
vi.mocked(IdeClient.getInstance).mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ToolActionsProvider config={mockConfig} toolCalls={mockToolCalls}>
|
||||
{children}
|
||||
</ToolActionsProvider>
|
||||
);
|
||||
const WrapperReactComp = ({ children }: { children: React.ReactNode }) => {
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(callId: string) => expandedTools.has(callId),
|
||||
[expandedTools],
|
||||
);
|
||||
|
||||
const toggleExpansion = useCallback((callId: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(callId)) {
|
||||
next.delete(callId);
|
||||
} else {
|
||||
next.add(callId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAllExpansion = useCallback((callIds: string[]) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
const anyCollapsed = callIds.some((id) => !next.has(id));
|
||||
|
||||
if (anyCollapsed) {
|
||||
callIds.forEach((id) => next.add(id));
|
||||
} else {
|
||||
callIds.forEach((id) => next.delete(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<ToolActionsProvider
|
||||
config={mockConfig}
|
||||
toolCalls={mockToolCalls}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAllExpansion={toggleAllExpansion}
|
||||
>
|
||||
{children}
|
||||
</ToolActionsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('publishes to MessageBus for tools with correlationId', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
await result.current.confirm(
|
||||
'modern-call',
|
||||
@@ -98,7 +143,9 @@ describe('ToolActionsContext', () => {
|
||||
});
|
||||
|
||||
it('handles cancel by calling confirm with Cancel outcome', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
await result.current.cancel('modern-call');
|
||||
|
||||
@@ -127,7 +174,9 @@ describe('ToolActionsContext', () => {
|
||||
);
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
deferredIdeClient.resolve(mockIdeClient);
|
||||
@@ -169,7 +218,9 @@ describe('ToolActionsContext', () => {
|
||||
);
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
deferredIdeClient.resolve(mockIdeClient);
|
||||
@@ -214,7 +265,13 @@ describe('ToolActionsContext', () => {
|
||||
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ToolActionsProvider config={mockConfig} toolCalls={[legacyTool]}>
|
||||
<ToolActionsProvider
|
||||
config={mockConfig}
|
||||
toolCalls={[legacyTool]}
|
||||
isExpanded={vi.fn().mockReturnValue(false)}
|
||||
toggleExpansion={vi.fn()}
|
||||
toggleAllExpansion={vi.fn()}
|
||||
>
|
||||
{children}
|
||||
</ToolActionsProvider>
|
||||
),
|
||||
@@ -233,4 +290,58 @@ describe('ToolActionsContext', () => {
|
||||
);
|
||||
expect(mockMessageBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('toggleAllExpansion', () => {
|
||||
it('expands all when none are expanded', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(true);
|
||||
});
|
||||
|
||||
it('expands all when some are expanded', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion('modern-call');
|
||||
});
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses all when all are expanded', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion('modern-call');
|
||||
result.current.toggleExpansion('edit-call');
|
||||
});
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded('modern-call')).toBe(false);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,11 +48,14 @@ interface ToolActionsContextValue {
|
||||
) => Promise<void>;
|
||||
cancel: (callId: string) => Promise<void>;
|
||||
isDiffingEnabled: boolean;
|
||||
isExpanded: (callId: string) => boolean;
|
||||
toggleExpansion: (callId: string) => void;
|
||||
toggleAllExpansion: (callIds: string[]) => void;
|
||||
}
|
||||
|
||||
const ToolActionsContext = createContext<ToolActionsContextValue | null>(null);
|
||||
|
||||
export const useToolActions = () => {
|
||||
export const useToolActions = (): ToolActionsContextValue => {
|
||||
const context = useContext(ToolActionsContext);
|
||||
if (!context) {
|
||||
throw new Error('useToolActions must be used within a ToolActionsProvider');
|
||||
@@ -64,12 +67,22 @@ interface ToolActionsProviderProps {
|
||||
children: React.ReactNode;
|
||||
config: Config;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
isExpanded: (callId: string) => boolean;
|
||||
toggleExpansion: (callId: string) => void;
|
||||
toggleAllExpansion: (callIds: string[]) => void;
|
||||
}
|
||||
|
||||
export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
props: ToolActionsProviderProps,
|
||||
) => {
|
||||
const { children, config, toolCalls } = props;
|
||||
const {
|
||||
children,
|
||||
config,
|
||||
toolCalls,
|
||||
isExpanded,
|
||||
toggleExpansion,
|
||||
toggleAllExpansion,
|
||||
} = props;
|
||||
|
||||
// Hoist IdeClient logic here to keep UI pure
|
||||
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
|
||||
@@ -77,24 +90,23 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
let activeClient: IdeClient | null = null;
|
||||
|
||||
const handleStatusChange = () => {
|
||||
if (isMounted && activeClient) {
|
||||
setIsDiffingEnabled(activeClient.isDiffingEnabled());
|
||||
}
|
||||
};
|
||||
|
||||
if (config.getIdeMode()) {
|
||||
IdeClient.getInstance()
|
||||
.then((client) => {
|
||||
if (!isMounted) return;
|
||||
activeClient = client;
|
||||
setIdeClient(client);
|
||||
setIsDiffingEnabled(client.isDiffingEnabled());
|
||||
|
||||
const handleStatusChange = () => {
|
||||
if (isMounted) {
|
||||
setIsDiffingEnabled(client.isDiffingEnabled());
|
||||
}
|
||||
};
|
||||
|
||||
client.addStatusChangeListener(handleStatusChange);
|
||||
// Return a cleanup function for the listener
|
||||
return () => {
|
||||
client.removeStatusChangeListener(handleStatusChange);
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
debugLogger.error('Failed to get IdeClient instance:', error);
|
||||
@@ -102,6 +114,9 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
}
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (activeClient) {
|
||||
activeClient.removeStatusChangeListener(handleStatusChange);
|
||||
}
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
@@ -164,7 +179,16 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolActionsContext.Provider value={{ confirm, cancel, isDiffingEnabled }}>
|
||||
<ToolActionsContext.Provider
|
||||
value={{
|
||||
confirm,
|
||||
cancel,
|
||||
isDiffingEnabled,
|
||||
isExpanded,
|
||||
toggleExpansion,
|
||||
toggleAllExpansion,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ToolActionsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -78,7 +78,6 @@ export interface UIActions {
|
||||
setShortcutsHelpVisible: (visible: boolean) => void;
|
||||
setCleanUiDetailsVisible: (visible: boolean) => void;
|
||||
toggleCleanUiDetailsVisible: () => void;
|
||||
toggleAlternateBuffer: () => void;
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
|
||||
@@ -118,8 +118,6 @@ export interface UIState {
|
||||
isEditorDialogOpen: boolean;
|
||||
showPrivacyNotice: boolean;
|
||||
corgiMode: boolean;
|
||||
isAlternateBuffer: boolean;
|
||||
showIsAlternateBufferHint: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
|
||||
@@ -11,47 +11,49 @@ import {
|
||||
isAlternateBufferEnabled,
|
||||
} from './useAlternateBuffer.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
vi.mock('../contexts/ConfigContext.js', () => ({
|
||||
useConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
const mockUseConfig = vi.mocked(
|
||||
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
|
||||
);
|
||||
|
||||
describe('useAlternateBuffer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return false when uiState.isAlternateBuffer is false', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
it('should return false when config.getUseAlternateBuffer returns false', async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => false,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
|
||||
const { result } = await renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when uiState.isAlternateBuffer is true', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: true,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
it('should return true when config.getUseAlternateBuffer returns true', async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
|
||||
const { result } = await renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should react to state changes', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
it('should return the immutable config value, not react to settings changes', async () => {
|
||||
const mockConfig = {
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>;
|
||||
|
||||
mockUseConfig.mockReturnValue(mockConfig);
|
||||
|
||||
const { result, rerender } = await renderHook(() => useAlternateBuffer());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: true,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
// Value should remain true even after rerender
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender();
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export const isAlternateBufferEnabled = (config: Config): boolean =>
|
||||
config.getUseAlternateBuffer();
|
||||
|
||||
// This is read from UIState so that the UI can toggle dynamically
|
||||
// This is read from Config so that the UI reads the same value per application session
|
||||
export const useAlternateBuffer = (): boolean => {
|
||||
const uiState = useUIState();
|
||||
return uiState.isAlternateBuffer;
|
||||
const config = useConfig();
|
||||
return isAlternateBufferEnabled(config);
|
||||
};
|
||||
|
||||
@@ -7,76 +7,93 @@
|
||||
import { act, useCallback } from 'react';
|
||||
import { vi } from 'vitest';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { useConsoleMessages } from './useConsoleMessages.js';
|
||||
import { CoreEvent, type ConsoleLogPayload } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock coreEvents
|
||||
let consoleLogHandler: ((payload: ConsoleLogPayload) => void) | undefined;
|
||||
import {
|
||||
useConsoleMessages,
|
||||
useErrorCount,
|
||||
initializeConsoleStore,
|
||||
} from './useConsoleMessages.js';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = (await importOriginal()) as any;
|
||||
const actual = await importOriginal();
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
...(actual as Record<string, unknown>),
|
||||
coreEvents: {
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === CoreEvent.ConsoleLog) {
|
||||
consoleLogHandler = handler;
|
||||
}
|
||||
...((actual as Record<string, unknown>)['coreEvents'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
on: vi.fn((event: string, handler: (payload: unknown) => void) => {
|
||||
handlers.set(event, handler);
|
||||
}),
|
||||
off: vi.fn((event) => {
|
||||
if (event === CoreEvent.ConsoleLog) {
|
||||
consoleLogHandler = undefined;
|
||||
}
|
||||
off: vi.fn((event: string) => {
|
||||
handlers.delete(event);
|
||||
}),
|
||||
emitConsoleLog: vi.fn(),
|
||||
// Helper for testing to trigger the handlers
|
||||
_trigger: (event: string, payload: unknown) => {
|
||||
handlers.get(event)?.(payload);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('useConsoleMessages', () => {
|
||||
let unmounts: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
consoleLogHandler = undefined;
|
||||
initializeConsoleStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const unmount of unmounts) {
|
||||
try {
|
||||
unmount();
|
||||
} catch (_e) {
|
||||
// Ignore unmount errors
|
||||
}
|
||||
}
|
||||
unmounts = [];
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const useTestableConsoleMessages = () => {
|
||||
const { ...rest } = useConsoleMessages();
|
||||
const consoleMessages = useConsoleMessages();
|
||||
const log = useCallback((content: string) => {
|
||||
if (consoleLogHandler) {
|
||||
consoleLogHandler({ type: 'log', content });
|
||||
}
|
||||
// @ts-expect-error - internal testing helper
|
||||
coreEvents._trigger('console-log', { type: 'log', content });
|
||||
}, []);
|
||||
const error = useCallback((content: string) => {
|
||||
if (consoleLogHandler) {
|
||||
consoleLogHandler({ type: 'error', content });
|
||||
}
|
||||
// @ts-expect-error - internal testing helper
|
||||
coreEvents._trigger('console-log', { type: 'error', content });
|
||||
}, []);
|
||||
const clearConsoleMessages = useCallback(() => {
|
||||
initializeConsoleStore();
|
||||
}, []);
|
||||
return {
|
||||
...rest,
|
||||
consoleMessages,
|
||||
log,
|
||||
error,
|
||||
clearConsoleMessages: rest.clearConsoleMessages,
|
||||
clearConsoleMessages,
|
||||
};
|
||||
};
|
||||
|
||||
const renderConsoleMessagesHook = async () => {
|
||||
let hookResult: ReturnType<typeof useTestableConsoleMessages>;
|
||||
let hookResult: ReturnType<typeof useTestableConsoleMessages> | undefined;
|
||||
function TestComponent() {
|
||||
hookResult = useTestableConsoleMessages();
|
||||
return null;
|
||||
}
|
||||
const { unmount } = await render(<TestComponent />);
|
||||
unmounts.push(unmount);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
return hookResult!;
|
||||
},
|
||||
},
|
||||
unmount,
|
||||
@@ -93,10 +110,7 @@ describe('useConsoleMessages', () => {
|
||||
|
||||
act(() => {
|
||||
result.current.log('Test message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
@@ -111,10 +125,7 @@ describe('useConsoleMessages', () => {
|
||||
result.current.log('Test message');
|
||||
result.current.log('Test message');
|
||||
result.current.log('Test message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
@@ -128,10 +139,7 @@ describe('useConsoleMessages', () => {
|
||||
act(() => {
|
||||
result.current.log('First message');
|
||||
result.current.error('Second message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
@@ -139,53 +147,85 @@ describe('useConsoleMessages', () => {
|
||||
{ type: 'error', content: 'Second message', count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear all messages when clearConsoleMessages is called', async () => {
|
||||
const { result } = await renderConsoleMessagesHook();
|
||||
describe('useErrorCount', () => {
|
||||
let unmounts: Array<() => void> = [];
|
||||
|
||||
act(() => {
|
||||
result.current.log('A message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toHaveLength(0);
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
initializeConsoleStore();
|
||||
});
|
||||
|
||||
it('should clear the pending timeout when clearConsoleMessages is called', async () => {
|
||||
const { result } = await renderConsoleMessagesHook();
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
act(() => {
|
||||
result.current.log('A message');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
// clearTimeoutSpy.mockRestore() is handled by afterEach restoreAllMocks
|
||||
afterEach(() => {
|
||||
for (const unmount of unmounts) {
|
||||
try {
|
||||
unmount();
|
||||
} catch (_e) {
|
||||
// Ignore unmount errors
|
||||
}
|
||||
}
|
||||
unmounts = [];
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should clean up the timeout on unmount', async () => {
|
||||
const { result, unmount } = await renderConsoleMessagesHook();
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
const renderErrorCountHook = async () => {
|
||||
let hookResult: ReturnType<typeof useErrorCount>;
|
||||
function TestComponent() {
|
||||
hookResult = useErrorCount();
|
||||
return null;
|
||||
}
|
||||
const { unmount } = await render(<TestComponent />);
|
||||
unmounts.push(unmount);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
},
|
||||
},
|
||||
unmount,
|
||||
};
|
||||
};
|
||||
|
||||
it('should initialize with an error count of 0', async () => {
|
||||
const { result } = await renderErrorCountHook();
|
||||
expect(result.current.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should increment error count when an error is logged', async () => {
|
||||
const { result } = await renderErrorCountHook();
|
||||
act(() => {
|
||||
// @ts-expect-error - internal testing helper
|
||||
coreEvents._trigger('console-log', { type: 'error', content: 'error' });
|
||||
vi.runAllTimers();
|
||||
});
|
||||
expect(result.current.errorCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should not increment error count for non-error logs', async () => {
|
||||
const { result } = await renderErrorCountHook();
|
||||
act(() => {
|
||||
// @ts-expect-error - internal testing helper
|
||||
coreEvents._trigger('console-log', { type: 'log', content: 'log' });
|
||||
vi.runAllTimers();
|
||||
});
|
||||
expect(result.current.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear the error count', async () => {
|
||||
const { result } = await renderErrorCountHook();
|
||||
act(() => {
|
||||
// @ts-expect-error - internal testing helper
|
||||
coreEvents._trigger('console-log', { type: 'error', content: 'error' });
|
||||
vi.runAllTimers();
|
||||
});
|
||||
expect(result.current.errorCount).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.log('A message');
|
||||
result.current.clearErrorCount();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
expect(result.current.errorCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
startTransition,
|
||||
} from 'react';
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
import type { ConsoleMessageItem } from '../types.js';
|
||||
import {
|
||||
coreEvents,
|
||||
@@ -18,207 +12,170 @@ import {
|
||||
type ConsoleLogPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface UseConsoleMessagesReturn {
|
||||
consoleMessages: ConsoleMessageItem[];
|
||||
clearConsoleMessages: () => void;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: 'ADD_MESSAGES'; payload: ConsoleMessageItem[] }
|
||||
| { type: 'CLEAR' };
|
||||
|
||||
function consoleMessagesReducer(
|
||||
state: ConsoleMessageItem[],
|
||||
action: Action,
|
||||
): ConsoleMessageItem[] {
|
||||
const MAX_CONSOLE_MESSAGES = 1000;
|
||||
switch (action.type) {
|
||||
case 'ADD_MESSAGES': {
|
||||
const newMessages = [...state];
|
||||
for (const queuedMessage of action.payload) {
|
||||
const lastMessage = newMessages[newMessages.length - 1];
|
||||
if (
|
||||
lastMessage &&
|
||||
lastMessage.type === queuedMessage.type &&
|
||||
lastMessage.content === queuedMessage.content
|
||||
) {
|
||||
// Create a new object for the last message to ensure React detects
|
||||
// the change, preventing mutation of the existing state object.
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
count: lastMessage.count + 1,
|
||||
};
|
||||
} else {
|
||||
newMessages.push({ ...queuedMessage, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of messages to prevent memory issues
|
||||
if (newMessages.length > MAX_CONSOLE_MESSAGES) {
|
||||
return newMessages.slice(newMessages.length - MAX_CONSOLE_MESSAGES);
|
||||
}
|
||||
|
||||
return newMessages;
|
||||
}
|
||||
case 'CLEAR':
|
||||
return [];
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
const [consoleMessages, dispatch] = useReducer(consoleMessagesReducer, []);
|
||||
const messageQueueRef = useRef<ConsoleMessageItem[]>([]);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
const processQueue = useCallback(() => {
|
||||
if (messageQueueRef.current.length > 0) {
|
||||
isProcessingRef.current = true;
|
||||
const messagesToProcess = messageQueueRef.current;
|
||||
messageQueueRef.current = [];
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'ADD_MESSAGES', payload: messagesToProcess });
|
||||
});
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleNewMessage = useCallback(
|
||||
(message: ConsoleMessageItem) => {
|
||||
messageQueueRef.current.push(message);
|
||||
if (!isProcessingRef.current && !timeoutRef.current) {
|
||||
// Batch updates using a timeout. 50ms is a reasonable delay to batch
|
||||
// rapid-fire messages without noticeable lag while avoiding React update
|
||||
// queue flooding.
|
||||
timeoutRef.current = setTimeout(processQueue, 50);
|
||||
}
|
||||
},
|
||||
[processQueue],
|
||||
);
|
||||
|
||||
// Once the updated consoleMessages have been committed to the screen,
|
||||
// we can safely process the next batch of queued messages if any exist.
|
||||
// This completely eliminates overlapping concurrent updates to this state.
|
||||
useEffect(() => {
|
||||
isProcessingRef.current = false;
|
||||
if (messageQueueRef.current.length > 0 && !timeoutRef.current) {
|
||||
timeoutRef.current = setTimeout(processQueue, 50);
|
||||
}
|
||||
}, [consoleMessages, processQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsoleLog = (payload: ConsoleLogPayload) => {
|
||||
let content = payload.content;
|
||||
const MAX_CONSOLE_MSG_LENGTH = 10000;
|
||||
if (content.length > MAX_CONSOLE_MSG_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_CONSOLE_MSG_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_CONSOLE_MSG_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
handleNewMessage({
|
||||
type: payload.type,
|
||||
content,
|
||||
count: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOutput = (payload: {
|
||||
isStderr: boolean;
|
||||
chunk: Uint8Array | string;
|
||||
}) => {
|
||||
let content =
|
||||
typeof payload.chunk === 'string'
|
||||
? payload.chunk
|
||||
: new TextDecoder().decode(payload.chunk);
|
||||
|
||||
const MAX_OUTPUT_CHUNK_LENGTH = 10000;
|
||||
if (content.length > MAX_OUTPUT_CHUNK_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_OUTPUT_CHUNK_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_OUTPUT_CHUNK_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
// It would be nice if we could show stderr as 'warn' but unfortunately
|
||||
// we log non warning info to stderr before the app starts so that would
|
||||
// be misleading.
|
||||
handleNewMessage({ type: 'log', content, count: 1 });
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
coreEvents.on(CoreEvent.Output, handleOutput);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
coreEvents.off(CoreEvent.Output, handleOutput);
|
||||
};
|
||||
}, [handleNewMessage]);
|
||||
|
||||
const clearConsoleMessages = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
messageQueueRef.current = [];
|
||||
isProcessingRef.current = true;
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'CLEAR' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { consoleMessages, clearConsoleMessages };
|
||||
}
|
||||
|
||||
export interface UseErrorCountReturn {
|
||||
errorCount: number;
|
||||
clearErrorCount: () => void;
|
||||
}
|
||||
|
||||
// --- Global Console Store ---
|
||||
|
||||
const MAX_CONSOLE_MESSAGES = 1000;
|
||||
let globalConsoleMessages: ConsoleMessageItem[] = [];
|
||||
let globalErrorCount = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let messageQueue: ConsoleMessageItem[] = [];
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Initializes the global console store and subscribes to coreEvents.
|
||||
* Acts as a safe reset function, making it idempotent and useful for test isolation.
|
||||
* Must be called during application startup.
|
||||
*/
|
||||
export function initializeConsoleStore() {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
messageQueue = [];
|
||||
globalConsoleMessages = [];
|
||||
globalErrorCount = 0;
|
||||
notifyListeners();
|
||||
|
||||
// Safely detach first to ensure idempotency and prevent listener leaks
|
||||
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
coreEvents.off(CoreEvent.Output, handleOutput);
|
||||
|
||||
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
coreEvents.on(CoreEvent.Output, handleOutput);
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function processQueue() {
|
||||
if (messageQueue.length === 0) return;
|
||||
|
||||
// Create a new array to trigger React updates
|
||||
const newMessages = [...globalConsoleMessages];
|
||||
|
||||
for (const queuedMessage of messageQueue) {
|
||||
if (queuedMessage.type === 'error') {
|
||||
globalErrorCount++;
|
||||
}
|
||||
|
||||
// Coalesce consecutive identical messages
|
||||
const prev = newMessages[newMessages.length - 1];
|
||||
if (
|
||||
prev &&
|
||||
prev.type === queuedMessage.type &&
|
||||
prev.content === queuedMessage.content
|
||||
) {
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...prev,
|
||||
count: prev.count + 1,
|
||||
};
|
||||
} else {
|
||||
newMessages.push({ ...queuedMessage, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
globalConsoleMessages =
|
||||
newMessages.length > MAX_CONSOLE_MESSAGES
|
||||
? newMessages.slice(-MAX_CONSOLE_MESSAGES)
|
||||
: newMessages;
|
||||
|
||||
messageQueue = [];
|
||||
timeoutId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
function handleNewMessage(message: ConsoleMessageItem) {
|
||||
messageQueue.push(message);
|
||||
if (!timeoutId) {
|
||||
// Batch updates using a timeout. 50ms is a reasonable delay to batch
|
||||
// rapid-fire messages without noticeable lag while avoiding React update
|
||||
// queue flooding.
|
||||
timeoutId = setTimeout(processQueue, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Subscription API for useSyncExternalStore ---
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getConsoleMessagesSnapshot() {
|
||||
return globalConsoleMessages;
|
||||
}
|
||||
|
||||
function getErrorCountSnapshot() {
|
||||
return globalErrorCount;
|
||||
}
|
||||
|
||||
// --- Core Event Listeners (Always active at module level) ---
|
||||
|
||||
const handleConsoleLog = (payload: ConsoleLogPayload) => {
|
||||
let content = payload.content;
|
||||
const MAX_CONSOLE_MSG_LENGTH = 10000;
|
||||
if (content.length > MAX_CONSOLE_MSG_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_CONSOLE_MSG_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_CONSOLE_MSG_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
handleNewMessage({
|
||||
type: payload.type,
|
||||
content,
|
||||
count: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOutput = (payload: {
|
||||
isStderr: boolean;
|
||||
chunk: Uint8Array | string;
|
||||
}) => {
|
||||
let content =
|
||||
typeof payload.chunk === 'string'
|
||||
? payload.chunk
|
||||
: new TextDecoder().decode(payload.chunk);
|
||||
|
||||
const MAX_OUTPUT_CHUNK_LENGTH = 10000;
|
||||
if (content.length > MAX_OUTPUT_CHUNK_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_OUTPUT_CHUNK_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_OUTPUT_CHUNK_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
handleNewMessage({ type: 'log', content, count: 1 });
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access the global console message history.
|
||||
* Decoupled from any component lifecycle to ensure history is preserved even
|
||||
* when the UI is unmounted.
|
||||
*/
|
||||
export function useConsoleMessages(): ConsoleMessageItem[] {
|
||||
return useSyncExternalStore(subscribe, getConsoleMessagesSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the global error count.
|
||||
* Uses the same external store as useConsoleMessages for consistency.
|
||||
*/
|
||||
export function useErrorCount(): UseErrorCountReturn {
|
||||
const [errorCount, dispatch] = useReducer(
|
||||
(state: number, action: 'INCREMENT' | 'CLEAR') => {
|
||||
switch (action) {
|
||||
case 'INCREMENT':
|
||||
return state + 1;
|
||||
case 'CLEAR':
|
||||
return 0;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsoleLog = (payload: ConsoleLogPayload) => {
|
||||
if (payload.type === 'error') {
|
||||
startTransition(() => {
|
||||
dispatch('INCREMENT');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
};
|
||||
}, []);
|
||||
const errorCount = useSyncExternalStore(subscribe, getErrorCountSnapshot);
|
||||
|
||||
const clearErrorCount = useCallback(() => {
|
||||
startTransition(() => {
|
||||
dispatch('CLEAR');
|
||||
});
|
||||
globalErrorCount = 0;
|
||||
notifyListeners();
|
||||
}, []);
|
||||
|
||||
return { errorCount, clearErrorCount };
|
||||
|
||||
@@ -84,6 +84,7 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { useLogger } from './useLogger.js';
|
||||
import { SHELL_COMMAND_NAME } from '../constants.js';
|
||||
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
|
||||
import { isCompactTool } from '../components/messages/ToolGroupMessage.js';
|
||||
import {
|
||||
useToolScheduler,
|
||||
type TrackedToolCall,
|
||||
@@ -303,9 +304,32 @@ export const useGeminiStream = (
|
||||
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
|
||||
);
|
||||
if (toolsToPush.length > 0) {
|
||||
const isCompactModeEnabled =
|
||||
settings.merged.ui?.compactToolOutput === true;
|
||||
const firstToolToPush = toolsToPush[0];
|
||||
const tcIndex = toolCalls.indexOf(firstToolToPush);
|
||||
const prevTool = tcIndex > 0 ? toolCalls[tcIndex - 1] : null;
|
||||
|
||||
let borderTop = isFirstToolInGroupRef.current;
|
||||
if (!borderTop && prevTool) {
|
||||
// If the first tool in this push is non-compact but follows a compact tool,
|
||||
// we must start a new border group.
|
||||
const currentIsCompact = isCompactTool(
|
||||
mapTrackedToolCallsToDisplay(firstToolToPush).tools[0],
|
||||
isCompactModeEnabled,
|
||||
);
|
||||
const prevWasCompact = isCompactTool(
|
||||
mapTrackedToolCallsToDisplay(prevTool).tools[0],
|
||||
isCompactModeEnabled,
|
||||
);
|
||||
if (!currentIsCompact && prevWasCompact) {
|
||||
borderTop = true;
|
||||
}
|
||||
}
|
||||
|
||||
addItem(
|
||||
mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], {
|
||||
borderTop: isFirstToolInGroupRef.current,
|
||||
borderTop,
|
||||
borderBottom: true,
|
||||
borderColor: theme.border.default,
|
||||
borderDimColor: false,
|
||||
@@ -340,9 +364,7 @@ export const useGeminiStream = (
|
||||
}
|
||||
|
||||
// Handle tool response submission immediately when tools complete
|
||||
await handleCompletedTools(
|
||||
completedToolCallsFromScheduler as TrackedToolCall[],
|
||||
);
|
||||
await handleCompletedTools(completedToolCallsFromScheduler);
|
||||
}
|
||||
},
|
||||
config,
|
||||
@@ -472,26 +494,85 @@ export const useGeminiStream = (
|
||||
|
||||
if (toolsToPush.length > 0) {
|
||||
const newPushed = new Set(pushedToolCallIdsRef.current);
|
||||
const isFirstInThisPush = isFirstToolInGroupRef.current;
|
||||
const isCompactModeEnabled =
|
||||
settings.merged.ui?.compactToolOutput === true;
|
||||
|
||||
const groups: TrackedToolCall[][] = [];
|
||||
let currentGroup: TrackedToolCall[] = [];
|
||||
|
||||
for (const tc of toolsToPush) {
|
||||
newPushed.add(tc.request.callId);
|
||||
|
||||
if (tc.tool?.kind === Kind.Agent) {
|
||||
currentGroup.push(tc);
|
||||
} else {
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
currentGroup = [];
|
||||
}
|
||||
groups.push([tc]);
|
||||
}
|
||||
}
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
const isLastInBatch =
|
||||
toolsToPush[toolsToPush.length - 1] === toolCalls[toolCalls.length - 1];
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
const isFirstInBatch = i === 0 && isFirstInThisPush;
|
||||
const lastTcInGroup = group[group.length - 1];
|
||||
const tcIndexInBatch = toolCalls.indexOf(lastTcInGroup);
|
||||
const isLastInBatch = tcIndexInBatch === toolCalls.length - 1;
|
||||
|
||||
const historyItem = mapTrackedToolCallsToDisplay(toolsToPush, {
|
||||
borderTop: isFirstToolInGroupRef.current,
|
||||
borderBottom: isLastInBatch,
|
||||
...getToolGroupBorderAppearance(
|
||||
{ type: 'tool_group', tools: toolCalls },
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundTasks,
|
||||
),
|
||||
});
|
||||
addItem(historyItem);
|
||||
const nextTcInBatch =
|
||||
tcIndexInBatch < toolCalls.length - 1
|
||||
? toolCalls[tcIndexInBatch + 1]
|
||||
: null;
|
||||
const prevTcInBatch =
|
||||
toolCalls.indexOf(group[0]) > 0
|
||||
? toolCalls[toolCalls.indexOf(group[0]) - 1]
|
||||
: null;
|
||||
|
||||
const historyItem = mapTrackedToolCallsToDisplay(group, {
|
||||
...getToolGroupBorderAppearance(
|
||||
{ type: 'tool_group', tools: toolCalls },
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundTasks,
|
||||
),
|
||||
});
|
||||
|
||||
// Determine if this group starts with a compact tool
|
||||
const currentIsCompact =
|
||||
historyItem.tools.length === 1 &&
|
||||
isCompactTool(historyItem.tools[0], isCompactModeEnabled);
|
||||
|
||||
let nextIsCompact = false;
|
||||
if (nextTcInBatch) {
|
||||
const nextHistoryItem = mapTrackedToolCallsToDisplay(nextTcInBatch);
|
||||
nextIsCompact =
|
||||
nextHistoryItem.tools.length === 1 &&
|
||||
isCompactTool(nextHistoryItem.tools[0], isCompactModeEnabled);
|
||||
}
|
||||
|
||||
let prevWasCompact = false;
|
||||
if (prevTcInBatch) {
|
||||
const prevHistoryItem = mapTrackedToolCallsToDisplay(prevTcInBatch);
|
||||
prevWasCompact =
|
||||
prevHistoryItem.tools.length === 1 &&
|
||||
isCompactTool(prevHistoryItem.tools[0], isCompactModeEnabled);
|
||||
}
|
||||
|
||||
historyItem.borderTop =
|
||||
isFirstInBatch || (!currentIsCompact && prevWasCompact);
|
||||
historyItem.borderBottom = currentIsCompact
|
||||
? isLastInBatch && !nextIsCompact
|
||||
: isLastInBatch || nextIsCompact;
|
||||
|
||||
addItem(historyItem);
|
||||
}
|
||||
|
||||
setPushedToolCallIds(newPushed);
|
||||
|
||||
@@ -516,6 +597,7 @@ export const useGeminiStream = (
|
||||
activeShellPtyId,
|
||||
isShellFocused,
|
||||
backgroundTasks,
|
||||
settings.merged.ui?.compactToolOutput,
|
||||
]);
|
||||
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
|
||||
const remainingTools = toolCalls.filter(
|
||||
@@ -569,8 +651,7 @@ export const useGeminiStream = (
|
||||
toolCalls.length > 0 &&
|
||||
toolCalls.every((tc) => pushedToolCallIds.has(tc.request.callId));
|
||||
|
||||
const anyVisibleInHistory = pushedToolCallIds.size > 0;
|
||||
const anyVisibleInPending = remainingTools.some((tc) => {
|
||||
const isToolVisible = (tc: TrackedToolCall) => {
|
||||
const displayName = tc.tool?.displayName ?? tc.request.name;
|
||||
|
||||
let hasResultDisplay = false;
|
||||
@@ -607,12 +688,25 @@ export const useGeminiStream = (
|
||||
// ToolGroupMessage now shows all non-canceled tools, so they are visible
|
||||
// in pending and we need to draw the closing border for them.
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
let lastVisibleIsCompact = false;
|
||||
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
|
||||
for (let i = toolCalls.length - 1; i >= 0; i--) {
|
||||
if (isToolVisible(toolCalls[i])) {
|
||||
const mapped = mapTrackedToolCallsToDisplay(toolCalls[i]);
|
||||
lastVisibleIsCompact = mapped.tools[0]
|
||||
? isCompactTool(mapped.tools[0], isCompactModeEnabled)
|
||||
: false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
toolCalls.length > 0 &&
|
||||
!(allTerminal && allPushed) &&
|
||||
(anyVisibleInHistory || anyVisibleInPending)
|
||||
toolCalls.some(isToolVisible) &&
|
||||
!lastVisibleIsCompact
|
||||
) {
|
||||
items.push({
|
||||
type: 'tool_group' as const,
|
||||
@@ -630,6 +724,7 @@ export const useGeminiStream = (
|
||||
activeShellPtyId,
|
||||
isShellFocused,
|
||||
backgroundTasks,
|
||||
settings.merged.ui?.compactToolOutput,
|
||||
]);
|
||||
|
||||
const lastQueryRef = useRef<PartListUnion | null>(null);
|
||||
|
||||
@@ -95,7 +95,6 @@ export enum Command {
|
||||
RESTART_APP = 'app.restart',
|
||||
SUSPEND_APP = 'app.suspend',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
|
||||
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
|
||||
|
||||
// Background Shell Controls
|
||||
BACKGROUND_SHELL_ESCAPE = 'background.escape',
|
||||
@@ -393,7 +392,6 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
|
||||
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
|
||||
|
||||
// Background Shell Controls
|
||||
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
|
||||
@@ -611,7 +609,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
|
||||
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
||||
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
|
||||
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
|
||||
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
|
||||
[Command.CYCLE_APPROVAL_MODE]:
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
|
||||
|
||||
@@ -370,6 +370,7 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
|
||||
showSchema: boolean;
|
||||
};
|
||||
|
||||
// Individually exported types extending HistoryItemBase
|
||||
export type HistoryItemWithoutId =
|
||||
| HistoryItemUser
|
||||
| HistoryItemUserShell
|
||||
|
||||
@@ -19,7 +19,8 @@ Tips for getting started:
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
│ Searching... │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = `
|
||||
@@ -41,7 +42,8 @@ Tips for getting started:
|
||||
│ ⊶ run_shell_command │
|
||||
│ │
|
||||
│ Running command... │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for an empty slice following a search tool 1`] = `
|
||||
@@ -63,5 +65,6 @@ Tips for getting started:
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
│ Searching... │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* Gets the file extension from a filename or path, excluding the leading dot.
|
||||
* Returns null if no extension is found.
|
||||
*/
|
||||
export function getFileExtension(
|
||||
filename: string | null | undefined,
|
||||
): string | null {
|
||||
if (!filename) return null;
|
||||
const ext = path.extname(filename);
|
||||
return ext ? ext.slice(1) : null;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
*/
|
||||
export const TOOL_RESULT_STATIC_HEIGHT = 1;
|
||||
export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6;
|
||||
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 3;
|
||||
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 4;
|
||||
export const TOOL_RESULT_MIN_LINES_SHOWN = 2;
|
||||
|
||||
/**
|
||||
|
||||
@@ -348,10 +348,9 @@ describe('LocalAgentExecutor', () => {
|
||||
get: () => 'test-prompt-id',
|
||||
configurable: true,
|
||||
});
|
||||
parentToolRegistry = new ToolRegistry(mockConfig, mockConfig.messageBus);
|
||||
parentToolRegistry.registerTool(
|
||||
new LSTool(mockConfig, mockConfig.messageBus),
|
||||
);
|
||||
const { messageBus } = mockConfig as unknown as { messageBus: MessageBus };
|
||||
parentToolRegistry = new ToolRegistry(mockConfig, messageBus);
|
||||
parentToolRegistry.registerTool(new LSTool(mockConfig, messageBus));
|
||||
parentToolRegistry.registerTool(
|
||||
new MockTool({ name: READ_FILE_TOOL_NAME }),
|
||||
);
|
||||
@@ -779,6 +778,25 @@ describe('LocalAgentExecutor', () => {
|
||||
// Assert that there is exactly ONE schema for this tool
|
||||
expect(foundSchemas).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should provide tools to the model when toolConfig is OMITTED (default to all tools)', async () => {
|
||||
const fullDefinition = createTestDefinition();
|
||||
const { toolConfig: _, ...definition } = fullDefinition;
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition as LocalAgentDefinition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
const toolsList = (
|
||||
executor as unknown as { prepareToolsList: () => FunctionDeclaration[] }
|
||||
).prepareToolsList();
|
||||
|
||||
// Verify that LS_TOOL_NAME is in the list (since LS was registered in beforeEach)
|
||||
const toolNames = toolsList.map((t) => t.name);
|
||||
expect(toolNames).toContain(LS_TOOL_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run (Execution Loop and Logic)', () => {
|
||||
|
||||
@@ -1329,9 +1329,13 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
toolsList.push(toolRef);
|
||||
}
|
||||
}
|
||||
// Add schemas from tools that were explicitly registered by name, wildcard, or instance.
|
||||
toolsList.push(...this.toolRegistry.getFunctionDeclarations());
|
||||
}
|
||||
// Add schemas from tools that were explicitly registered by name, wildcard, or instance.
|
||||
toolsList.push(
|
||||
...this.toolRegistry.getFunctionDeclarations(
|
||||
this.definition.modelConfig.model,
|
||||
),
|
||||
);
|
||||
|
||||
// Always inject complete_task.
|
||||
// Configure its schema based on whether output is expected.
|
||||
|
||||
@@ -227,6 +227,7 @@ vi.mock('../services/contextManager.js', () => ({
|
||||
getGlobalMemory: vi.fn().mockReturnValue(''),
|
||||
getExtensionMemory: vi.fn().mockReturnValue(''),
|
||||
getEnvironmentMemory: vi.fn().mockReturnValue(''),
|
||||
getUserProjectMemory: vi.fn().mockReturnValue(''),
|
||||
getLoadedPaths: vi.fn().mockReturnValue(new Set()),
|
||||
})),
|
||||
}));
|
||||
@@ -1681,6 +1682,12 @@ describe('setApprovalMode with folder trust', () => {
|
||||
expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should NOT throw an error when setting PLAN mode in an untrusted folder', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(false);
|
||||
expect(() => config.setApprovalMode(ApprovalMode.PLAN)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should NOT throw an error when setting any mode in a trusted folder', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
|
||||
@@ -2948,6 +2955,7 @@ describe('Config JIT Initialization', () => {
|
||||
getEnvironmentMemory: vi
|
||||
.fn()
|
||||
.mockReturnValue('Environment Memory\n\nMCP Instructions'),
|
||||
getUserProjectMemory: vi.fn().mockReturnValue(''),
|
||||
getLoadedPaths: vi.fn().mockReturnValue(new Set(['/path/to/GEMINI.md'])),
|
||||
} as unknown as ContextManager;
|
||||
(ContextManager as unknown as Mock).mockImplementation(
|
||||
@@ -2975,6 +2983,7 @@ describe('Config JIT Initialization', () => {
|
||||
global: 'Global Memory',
|
||||
extension: 'Extension Memory',
|
||||
project: 'Environment Memory\n\nMCP Instructions',
|
||||
userProjectMemory: '',
|
||||
});
|
||||
|
||||
// Tier 1: system instruction gets only global memory
|
||||
|
||||
@@ -11,6 +11,7 @@ import { inspect } from 'node:util';
|
||||
import process from 'node:process';
|
||||
import { z } from 'zod';
|
||||
import type { ConversationRecord } from '../services/chatRecordingService.js';
|
||||
import type { AgentHistoryProviderConfig } from '../services/types.js';
|
||||
export type { ConversationRecord };
|
||||
import {
|
||||
AuthType,
|
||||
@@ -204,6 +205,23 @@ export interface OutputSettings {
|
||||
format?: OutputFormat;
|
||||
}
|
||||
|
||||
export interface ContextManagementConfig {
|
||||
enabled: boolean;
|
||||
historyWindow: {
|
||||
maxTokens: number;
|
||||
retainedTokens: number;
|
||||
};
|
||||
messageLimits: {
|
||||
normalMaxTokens: number;
|
||||
retainedMaxTokens: number;
|
||||
normalizationHeadRatio: number;
|
||||
};
|
||||
toolDistillation: {
|
||||
maxOutputTokens: number;
|
||||
summarizationThresholdTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolOutputMaskingConfig {
|
||||
enabled: boolean;
|
||||
toolProtectionThreshold: number;
|
||||
@@ -674,6 +692,7 @@ export interface ConfigParameters {
|
||||
enableHooks?: boolean;
|
||||
enableHooksUI?: boolean;
|
||||
experiments?: Experiments;
|
||||
contextManagement?: Partial<ContextManagementConfig>;
|
||||
hooks?: { [K in HookEventName]?: HookDefinition[] };
|
||||
disabledHooks?: string[];
|
||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] };
|
||||
@@ -683,6 +702,7 @@ export interface ConfigParameters {
|
||||
disabledSkills?: string[];
|
||||
adminSkillsEnabled?: boolean;
|
||||
experimentalJitContext?: boolean;
|
||||
autoDistillation?: boolean;
|
||||
experimentalMemoryManager?: boolean;
|
||||
experimentalAgentHistoryTruncation?: boolean;
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
@@ -919,13 +939,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly skillsSupport: boolean;
|
||||
private disabledSkills: string[];
|
||||
private readonly adminSkillsEnabled: boolean;
|
||||
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly experimentalMemoryManager: boolean;
|
||||
private readonly experimentalAgentHistoryTruncation: boolean;
|
||||
private readonly experimentalAgentHistoryTruncationThreshold: number;
|
||||
private readonly experimentalAgentHistoryRetainedMessages: number;
|
||||
private readonly experimentalAgentHistorySummarization: boolean;
|
||||
private readonly memoryBoundaryMarkers: readonly string[];
|
||||
private readonly topicUpdateNarration: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
@@ -934,6 +949,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly planModeRoutingEnabled: boolean;
|
||||
private readonly modelSteering: boolean;
|
||||
private contextManager?: ContextManager;
|
||||
private readonly contextManagement: ContextManagementConfig;
|
||||
private terminalBackground: string | undefined = undefined;
|
||||
private remoteAdminSettings: AdminControlsSettings | undefined;
|
||||
private latestApiRequest: GenerateContentParameters | undefined;
|
||||
@@ -1136,15 +1152,31 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? true;
|
||||
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
|
||||
this.experimentalAgentHistoryTruncation =
|
||||
params.experimentalAgentHistoryTruncation ?? false;
|
||||
this.experimentalAgentHistoryTruncationThreshold =
|
||||
params.experimentalAgentHistoryTruncationThreshold ?? 30;
|
||||
this.experimentalAgentHistoryRetainedMessages =
|
||||
params.experimentalAgentHistoryRetainedMessages ?? 15;
|
||||
this.experimentalAgentHistorySummarization =
|
||||
params.experimentalAgentHistorySummarization ?? false;
|
||||
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
|
||||
this.contextManagement = {
|
||||
enabled: params.contextManagement?.enabled ?? false,
|
||||
historyWindow: {
|
||||
maxTokens: params.contextManagement?.historyWindow?.maxTokens ?? 150000,
|
||||
retainedTokens:
|
||||
params.contextManagement?.historyWindow?.retainedTokens ?? 40000,
|
||||
},
|
||||
messageLimits: {
|
||||
normalMaxTokens:
|
||||
params.contextManagement?.messageLimits?.normalMaxTokens ?? 2500,
|
||||
retainedMaxTokens:
|
||||
params.contextManagement?.messageLimits?.retainedMaxTokens ?? 12000,
|
||||
normalizationHeadRatio:
|
||||
params.contextManagement?.messageLimits?.normalizationHeadRatio ??
|
||||
0.25,
|
||||
},
|
||||
toolDistillation: {
|
||||
maxOutputTokens:
|
||||
params.contextManagement?.toolDistillation?.maxOutputTokens ?? 10000,
|
||||
summarizationThresholdTokens:
|
||||
params.contextManagement?.toolDistillation
|
||||
?.summarizationThresholdTokens ?? 20000,
|
||||
},
|
||||
};
|
||||
this.topicUpdateNarration = params.topicUpdateNarration ?? false;
|
||||
this.modelSteering = params.modelSteering ?? false;
|
||||
this.injectionService = new InjectionService(() =>
|
||||
@@ -2250,6 +2282,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
global: this.contextManager.getGlobalMemory(),
|
||||
extension: this.contextManager.getExtensionMemory(),
|
||||
project: this.contextManager.getEnvironmentMemory(),
|
||||
userProjectMemory: this.contextManager.getUserProjectMemory(),
|
||||
};
|
||||
}
|
||||
return this.userMemory;
|
||||
@@ -2279,13 +2312,20 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
/**
|
||||
* Returns memory for the system instruction.
|
||||
* When JIT is enabled, only global memory (Tier 1) goes in the system
|
||||
* instruction. Extension and project memory (Tier 2) are placed in the
|
||||
* first user message instead, per the tiered context model.
|
||||
* When JIT is enabled, global memory and user project memory (Tier 1) go
|
||||
* in the system instruction. Extension and project memory (Tier 2) are
|
||||
* placed in the first user message instead, per the tiered context model.
|
||||
* User project memory is in Tier 1 so mid-session saves are reflected
|
||||
* via system instruction updates.
|
||||
*/
|
||||
getSystemInstructionMemory(): string | HierarchicalMemory {
|
||||
if (this.experimentalJitContext && this.contextManager) {
|
||||
return this.contextManager.getGlobalMemory();
|
||||
const global = this.contextManager.getGlobalMemory();
|
||||
const userProjectMemory = this.contextManager.getUserProjectMemory();
|
||||
if (userProjectMemory?.trim()) {
|
||||
return { global, userProjectMemory };
|
||||
}
|
||||
return global;
|
||||
}
|
||||
return this.userMemory;
|
||||
}
|
||||
@@ -2330,6 +2370,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalJitContext;
|
||||
}
|
||||
|
||||
isAutoDistillationEnabled(): boolean {
|
||||
return this.contextManagement.enabled;
|
||||
}
|
||||
|
||||
getMemoryBoundaryMarkers(): readonly string[] {
|
||||
return this.memoryBoundaryMarkers;
|
||||
}
|
||||
@@ -2338,20 +2382,22 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalMemoryManager;
|
||||
}
|
||||
|
||||
isExperimentalAgentHistoryTruncationEnabled(): boolean {
|
||||
return this.experimentalAgentHistoryTruncation;
|
||||
getContextManagementConfig(): ContextManagementConfig {
|
||||
return this.contextManagement;
|
||||
}
|
||||
|
||||
getExperimentalAgentHistoryTruncationThreshold(): number {
|
||||
return this.experimentalAgentHistoryTruncationThreshold;
|
||||
}
|
||||
|
||||
getExperimentalAgentHistoryRetainedMessages(): number {
|
||||
return this.experimentalAgentHistoryRetainedMessages;
|
||||
}
|
||||
|
||||
isExperimentalAgentHistorySummarizationEnabled(): boolean {
|
||||
return this.experimentalAgentHistorySummarization;
|
||||
get agentHistoryProviderConfig(): AgentHistoryProviderConfig {
|
||||
return {
|
||||
isTruncationEnabled: this.contextManagement.enabled,
|
||||
isSummarizationEnabled: this.contextManagement.enabled,
|
||||
maxTokens: this.contextManagement.historyWindow.maxTokens,
|
||||
retainedTokens: this.contextManagement.historyWindow.retainedTokens,
|
||||
normalMessageTokens: this.contextManagement.messageLimits.normalMaxTokens,
|
||||
maximumMessageTokens:
|
||||
this.contextManagement.messageLimits.retainedMaxTokens,
|
||||
normalizationHeadRatio:
|
||||
this.contextManagement.messageLimits.normalizationHeadRatio,
|
||||
};
|
||||
}
|
||||
|
||||
isTopicUpdateNarrationEnabled(): boolean {
|
||||
@@ -2471,7 +2517,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
setApprovalMode(mode: ApprovalMode): void {
|
||||
if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) {
|
||||
if (
|
||||
!this.isTrustedFolder() &&
|
||||
mode !== ApprovalMode.DEFAULT &&
|
||||
mode !== ApprovalMode.PLAN
|
||||
) {
|
||||
throw new Error(
|
||||
'Cannot enable privileged approval modes in an untrusted folder.',
|
||||
);
|
||||
@@ -3241,6 +3291,14 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
);
|
||||
}
|
||||
|
||||
getToolMaxOutputTokens(): number {
|
||||
return this.contextManagement.toolDistillation.maxOutputTokens;
|
||||
}
|
||||
|
||||
getToolSummarizationThresholdTokens(): number {
|
||||
return this.contextManagement.toolDistillation.summarizationThresholdTokens;
|
||||
}
|
||||
|
||||
getNextCompressionTruncationId(): number {
|
||||
return ++this.compressionTruncationCounter;
|
||||
}
|
||||
@@ -3430,7 +3488,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
);
|
||||
if (!this.isMemoryManagerEnabled()) {
|
||||
maybeRegister(MemoryTool, () =>
|
||||
registry.registerTool(new MemoryTool(this.messageBus)),
|
||||
registry.registerTool(new MemoryTool(this.messageBus, this.storage)),
|
||||
);
|
||||
}
|
||||
maybeRegister(WebSearchTool, () =>
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface HierarchicalMemory {
|
||||
global?: string;
|
||||
extension?: string;
|
||||
project?: string;
|
||||
userProjectMemory?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,12 @@ export function flattenMemory(memory?: string | HierarchicalMemory): string {
|
||||
if (memory.global?.trim()) {
|
||||
sections.push({ name: 'Global', content: memory.global.trim() });
|
||||
}
|
||||
if (memory.userProjectMemory?.trim()) {
|
||||
sections.push({
|
||||
name: 'User Project Memory',
|
||||
content: memory.userProjectMemory.trim(),
|
||||
});
|
||||
}
|
||||
if (memory.extension?.trim()) {
|
||||
sections.push({ name: 'Extension', content: memory.extension.trim() });
|
||||
}
|
||||
|
||||
@@ -147,6 +147,17 @@ describe('Storage – additional helpers', () => {
|
||||
expect(storage.getProjectAgentsDir()).toBe(expected);
|
||||
});
|
||||
|
||||
it('getProjectMemoryDir returns ~/.gemini/memory/<identifier>', async () => {
|
||||
await storage.initialize();
|
||||
const expected = path.join(
|
||||
os.homedir(),
|
||||
GEMINI_DIR,
|
||||
'memory',
|
||||
PROJECT_SLUG,
|
||||
);
|
||||
expect(storage.getProjectMemoryDir()).toBe(expected);
|
||||
});
|
||||
|
||||
it('getMcpOAuthTokensPath returns ~/.gemini/mcp-oauth-tokens.json', () => {
|
||||
const expected = path.join(
|
||||
os.homedir(),
|
||||
|
||||
@@ -266,6 +266,11 @@ export class Storage {
|
||||
return path.join(historyDir, identifier);
|
||||
}
|
||||
|
||||
getProjectMemoryDir(): string {
|
||||
const identifier = this.getProjectIdentifier();
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'memory', identifier);
|
||||
}
|
||||
|
||||
getWorkspaceSettingsPath(): string {
|
||||
return path.join(this.getGeminiDir(), 'settings.json');
|
||||
}
|
||||
|
||||
@@ -164,7 +164,10 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -342,7 +345,10 @@ An approved plan is available for this task at \`/tmp/plans/feature-x.md\`.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -627,7 +633,10 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -782,7 +791,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -923,7 +935,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -1047,7 +1062,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -1688,7 +1706,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -1843,7 +1864,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -2002,7 +2026,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -2161,7 +2188,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -2316,7 +2346,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -2465,7 +2498,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -2619,7 +2655,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -2774,7 +2813,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -3064,7 +3106,10 @@ You are operating with a persistent file-based task tracking system located at \
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -3460,7 +3505,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -3615,7 +3663,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -3882,7 +3933,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -4037,7 +4091,10 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
|
||||
- **Memory Tool:** Use \`save_memory\` to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task. If unsure whether a fact is global or project-specific, ask the user.
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
|
||||
@@ -279,16 +279,9 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getActiveModel: vi.fn().mockReturnValue('test-model'),
|
||||
setActiveModel: vi.fn(),
|
||||
resetTurn: vi.fn(),
|
||||
isExperimentalAgentHistoryTruncationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
getExperimentalAgentHistoryTruncationThreshold: vi
|
||||
.fn()
|
||||
.mockReturnValue(30),
|
||||
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(15),
|
||||
isExperimentalAgentHistorySummarizationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
|
||||
isAutoDistillationEnabled: vi.fn().mockReturnValue(false),
|
||||
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
|
||||
getModelAvailabilityService: vi
|
||||
.fn()
|
||||
.mockReturnValue(createAvailabilityServiceMock()),
|
||||
@@ -716,9 +709,9 @@ describe('Gemini Client (client.ts)', () => {
|
||||
describe('sendMessageStream', () => {
|
||||
it('calls AgentHistoryProvider.manageHistory when history truncation is enabled', async () => {
|
||||
// Arrange
|
||||
mockConfig.isExperimentalAgentHistoryTruncationEnabled = vi
|
||||
mockConfig.getContextManagementConfig = vi
|
||||
.fn()
|
||||
.mockReturnValue(true);
|
||||
.mockReturnValue({ enabled: true });
|
||||
const manageHistorySpy = vi
|
||||
.spyOn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -2164,48 +2157,6 @@ ${JSON.stringify(
|
||||
expect(mockTurnRunFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not retry with "Please continue." when InvalidStream event is received for non-Gemini-2 models', async () => {
|
||||
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
|
||||
true,
|
||||
);
|
||||
// Arrange - router returns a non-Gemini-2 model
|
||||
mockRouterService.route.mockResolvedValue({
|
||||
model: 'gemini-3.0-pro',
|
||||
reason: 'test',
|
||||
});
|
||||
|
||||
const mockStream1 = (async function* () {
|
||||
yield { type: GeminiEventType.InvalidStream };
|
||||
})();
|
||||
|
||||
mockTurnRunFn.mockReturnValueOnce(mockStream1);
|
||||
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
setTools: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
getLastPromptTokenCount: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
const initialRequest = [{ text: 'Hi' }];
|
||||
const promptId = 'prompt-id-invalid-stream-non-g2';
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
// Act
|
||||
const stream = client.sendMessageStream(initialRequest, signal, promptId);
|
||||
const events = await fromAsync(stream);
|
||||
|
||||
// Assert
|
||||
expect(events).toEqual([
|
||||
{ type: GeminiEventType.ModelInfo, value: 'gemini-3.0-pro' },
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
]);
|
||||
|
||||
// Verify that turn.run was called only once (no retry)
|
||||
expect(mockTurnRunFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop recursing after one retry when InvalidStream events are repeatedly received', async () => {
|
||||
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
|
||||
true,
|
||||
|
||||
@@ -71,11 +71,7 @@ import {
|
||||
applyModelSelection,
|
||||
createAvailabilityContextProvider,
|
||||
} from '../availability/policyHelpers.js';
|
||||
import {
|
||||
getDisplayString,
|
||||
resolveModel,
|
||||
isGemini2Model,
|
||||
} from '../config/models.js';
|
||||
import { getDisplayString, resolveModel } from '../config/models.js';
|
||||
import { partToString } from '../utils/partUtils.js';
|
||||
import { coreEvents, CoreEvent } from '../utils/events.js';
|
||||
|
||||
@@ -115,12 +111,10 @@ export class GeminiClient {
|
||||
constructor(private readonly context: AgentLoopContext) {
|
||||
this.loopDetector = new LoopDetectionService(this.config);
|
||||
this.compressionService = new ChatCompressionService();
|
||||
this.agentHistoryProvider = new AgentHistoryProvider(this.config, {
|
||||
truncationThreshold:
|
||||
this.config.getExperimentalAgentHistoryTruncationThreshold(),
|
||||
retainedMessages:
|
||||
this.config.getExperimentalAgentHistoryRetainedMessages(),
|
||||
});
|
||||
this.agentHistoryProvider = new AgentHistoryProvider(
|
||||
this.config.agentHistoryProviderConfig,
|
||||
this.config,
|
||||
);
|
||||
this.toolOutputMaskingService = new ToolOutputMaskingService();
|
||||
this.lastPromptId = this.config.getSessionId();
|
||||
|
||||
@@ -621,7 +615,7 @@ export class GeminiClient {
|
||||
// Check for context window overflow
|
||||
const modelForLimitCheck = this._getActiveModelForCurrentTurn();
|
||||
|
||||
if (this.config.isExperimentalAgentHistoryTruncationEnabled()) {
|
||||
if (this.config.getContextManagementConfig().enabled) {
|
||||
const newHistory = await this.agentHistoryProvider.manageHistory(
|
||||
this.getHistory(),
|
||||
signal,
|
||||
@@ -822,10 +816,7 @@ export class GeminiClient {
|
||||
}
|
||||
|
||||
if (isInvalidStream) {
|
||||
if (
|
||||
this.config.getContinueOnFailedApiCall() &&
|
||||
isGemini2Model(modelToUse)
|
||||
) {
|
||||
if (this.config.getContinueOnFailedApiCall()) {
|
||||
if (isInvalidStreamRetry) {
|
||||
logContentRetryFailure(
|
||||
this.config,
|
||||
|
||||
@@ -1140,41 +1140,6 @@ describe('GeminiChat', () => {
|
||||
});
|
||||
|
||||
describe('sendMessageStream with retries', () => {
|
||||
it('should not retry on invalid content if model does not start with gemini-2', async () => {
|
||||
// Mock the stream to fail.
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
|
||||
async () =>
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [{ content: { parts: [{ text: '' }] } }],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'gemini-1.5-pro' },
|
||||
'test',
|
||||
'prompt-id-no-retry',
|
||||
new AbortController().signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of stream) {
|
||||
// Must loop to trigger the internal logic that throws.
|
||||
}
|
||||
})(),
|
||||
).rejects.toThrow(InvalidStreamError);
|
||||
|
||||
// Should be called only 1 time (no retry)
|
||||
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(mockLogContentRetry).not.toHaveBeenCalled();
|
||||
expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should yield a RETRY event when an invalid stream is encountered', async () => {
|
||||
// ARRANGE: Mock the stream to fail once, then succeed.
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
|
||||
@@ -25,11 +25,7 @@ import {
|
||||
getRetryErrorType,
|
||||
} from '../utils/retry.js';
|
||||
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import {
|
||||
resolveModel,
|
||||
isGemini2Model,
|
||||
supportsModernFeatures,
|
||||
} from '../config/models.js';
|
||||
import { resolveModel, supportsModernFeatures } from '../config/models.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
import type { CompletedToolCall } from '../scheduler/types.js';
|
||||
@@ -167,34 +163,90 @@ function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
|
||||
return [];
|
||||
}
|
||||
const curatedHistory: Content[] = [];
|
||||
const length = comprehensiveHistory.length;
|
||||
let i = 0;
|
||||
while (i < length) {
|
||||
if (comprehensiveHistory[i].role === 'user') {
|
||||
curatedHistory.push(comprehensiveHistory[i]);
|
||||
i++;
|
||||
} else {
|
||||
const modelOutput: Content[] = [];
|
||||
let isValid = true;
|
||||
while (i < length && comprehensiveHistory[i].role === 'model') {
|
||||
modelOutput.push(comprehensiveHistory[i]);
|
||||
if (isValid && !isValidContent(comprehensiveHistory[i])) {
|
||||
isValid = false;
|
||||
}
|
||||
i++;
|
||||
|
||||
for (const entry of comprehensiveHistory) {
|
||||
const role = entry.role;
|
||||
const rawParts = entry.parts || [];
|
||||
|
||||
// Filter out invalid/empty parts
|
||||
const validParts = rawParts.filter((part) => {
|
||||
if (part === undefined || Object.keys(part).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (isValid) {
|
||||
curatedHistory.push(...modelOutput);
|
||||
// Thought parts must be non-empty strings
|
||||
if (part.thought !== undefined) {
|
||||
return (
|
||||
typeof part.thought === 'string' &&
|
||||
(part.thought as string).trim() !== ''
|
||||
);
|
||||
}
|
||||
// Text parts must be non-empty strings
|
||||
if (part.text !== undefined) {
|
||||
return typeof part.text === 'string' && part.text.trim() !== '';
|
||||
}
|
||||
// Keep other parts (functionCall, functionResponse, etc.)
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validParts.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastEntry = curatedHistory[curatedHistory.length - 1];
|
||||
if (!lastEntry || lastEntry.role !== role) {
|
||||
// Create new entry
|
||||
lastEntry = {
|
||||
role,
|
||||
parts: [],
|
||||
};
|
||||
curatedHistory.push(lastEntry);
|
||||
}
|
||||
|
||||
const lastEntryParts = lastEntry.parts || [];
|
||||
for (const part of validParts) {
|
||||
if (part.text !== undefined) {
|
||||
// Coalesce text into the last text part of this turn
|
||||
const existingTextPart = lastEntryParts.find(
|
||||
(p) => p.text !== undefined,
|
||||
);
|
||||
if (existingTextPart) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const lastText = existingTextPart.text as string;
|
||||
const separator = lastText.endsWith('\n') ? '' : '\n';
|
||||
|
||||
existingTextPart.text = lastText + separator + part.text;
|
||||
} else {
|
||||
lastEntryParts.push({ ...part });
|
||||
}
|
||||
} else if (part.thought !== undefined) {
|
||||
// Coalesce thought into the last thought part of this turn
|
||||
const existingThoughtPart = lastEntryParts.find(
|
||||
(p) => p.thought !== undefined,
|
||||
);
|
||||
if (existingThoughtPart) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const lastThought = existingThoughtPart.thought as unknown as string;
|
||||
const separator = lastThought.endsWith('\n') ? '' : '\n';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
existingThoughtPart.thought = (lastThought +
|
||||
separator +
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(part.thought as unknown as string)) as unknown as boolean;
|
||||
} else {
|
||||
// Insert thought at the beginning for model consistency
|
||||
lastEntryParts.unshift({ ...part });
|
||||
}
|
||||
} else {
|
||||
// Add as is (functionCall, functionResponse)
|
||||
lastEntryParts.push({ ...part });
|
||||
}
|
||||
}
|
||||
lastEntry.parts = lastEntryParts;
|
||||
}
|
||||
|
||||
return curatedHistory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error to signal that a stream completed with invalid content,
|
||||
* which should trigger a retry.
|
||||
*/
|
||||
export class InvalidStreamError extends Error {
|
||||
readonly type:
|
||||
| 'NO_FINISH_REASON'
|
||||
@@ -346,7 +398,17 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
// Add user content to history ONCE before any attempts.
|
||||
this.history.push(userContent);
|
||||
if (
|
||||
this.history.length > 0 &&
|
||||
this.history[this.history.length - 1].role === 'user'
|
||||
) {
|
||||
const lastUser = this.history[this.history.length - 1];
|
||||
const lastUserParts = lastUser.parts || [];
|
||||
const currentUserParts = userContent.parts || [];
|
||||
lastUser.parts = [...lastUserParts, ...currentUserParts];
|
||||
} else {
|
||||
this.history.push(userContent);
|
||||
}
|
||||
const requestContents = this.getHistory(true);
|
||||
|
||||
const streamWithRetries = async function* (
|
||||
@@ -423,10 +485,7 @@ export class GeminiChat {
|
||||
? error.type
|
||||
: getRetryErrorType(error);
|
||||
|
||||
if (
|
||||
(isContentError && isGemini2Model(model)) ||
|
||||
(isRetryable && !signal.aborted)
|
||||
) {
|
||||
if (isContentError || (isRetryable && !signal.aborted)) {
|
||||
// The issue requests exactly 3 retries (4 attempts) for API errors during stream iteration.
|
||||
// Regardless of the global maxAttempts (e.g. 10), we only want to retry these mid-stream API errors
|
||||
// up to 3 times before finally throwing the error to the user.
|
||||
@@ -942,7 +1001,8 @@ export class GeminiChat {
|
||||
isValidNonThoughtTextPart(lastPart) &&
|
||||
isValidNonThoughtTextPart(part)
|
||||
) {
|
||||
lastPart.text += part.text;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(lastPart as { text: string }).text += part.text as string;
|
||||
} else {
|
||||
consolidatedParts.push(part);
|
||||
}
|
||||
@@ -950,7 +1010,8 @@ export class GeminiChat {
|
||||
|
||||
const responseText = consolidatedParts
|
||||
.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
.map((part) => part.text as string)
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
@@ -1056,7 +1117,6 @@ export class GeminiChat {
|
||||
|
||||
const thoughtPart = content.parts[0];
|
||||
if (thoughtPart.text) {
|
||||
// Extract subject and description using the same logic as turn.ts
|
||||
const rawText = thoughtPart.text;
|
||||
const subjectStringMatches = rawText.match(/\*\*(.*?)\*\*/s);
|
||||
const subject = subjectStringMatches
|
||||
|
||||
@@ -274,7 +274,7 @@ describe('Turn', () => {
|
||||
expect(reportError).toHaveBeenCalledWith(
|
||||
error,
|
||||
'Error when talking to Gemini API',
|
||||
[...historyContent, { role: 'user', parts: reqParts }],
|
||||
[...historyContent],
|
||||
'Turn.run-sendMessageStream',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createUserContent,
|
||||
type PartListUnion,
|
||||
type GenerateContentResponse,
|
||||
type FunctionCall,
|
||||
@@ -375,10 +374,7 @@ export class Turn {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const contextForReport = [
|
||||
...this.chat.getHistory(/*curated*/ true),
|
||||
createUserContent(req),
|
||||
];
|
||||
const contextForReport = [...this.chat.getHistory(/*curated*/ true)];
|
||||
await reportError(
|
||||
error,
|
||||
'Error when talking to Gemini API',
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
getAllGeminiMdFilenames,
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
} from '../tools/memoryTool.js';
|
||||
import { PREVIEW_GEMINI_MODEL } from '../config/models.js';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { MockTool } from '../test-utils/mock-tool.js';
|
||||
@@ -301,5 +304,20 @@ describe('PromptProvider', () => {
|
||||
|
||||
expect(prompt).toContain(`<tool>\`${UPDATE_TOPIC_TOOL_NAME}\`</tool>`);
|
||||
});
|
||||
|
||||
it('should include topic update instructions in legacy model prompt when enabled', () => {
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
|
||||
|
||||
const provider = new PromptProvider();
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).toContain('## Topic Updates');
|
||||
expect(prompt).toContain(UPDATE_TOPIC_TOOL_NAME);
|
||||
expect(prompt).toContain('No Chitchat');
|
||||
expect(prompt).toContain('Topic Model');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('renderOperationalGuidelines - memoryManagerEnabled', () => {
|
||||
it('should include standard memory tool guidance when memoryManagerEnabled is false', () => {
|
||||
const result = renderOperationalGuidelines(baseOptions);
|
||||
expect(result).toContain('save_memory');
|
||||
expect(result).toContain('persistent user-related information');
|
||||
expect(result).toContain('persist facts across sessions');
|
||||
expect(result).not.toContain('subagent');
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
TRACKER_LIST_TASKS_TOOL_NAME,
|
||||
TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_SUMMARY,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
WRITE_TODOS_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
@@ -51,6 +54,7 @@ export interface CoreMandatesOptions {
|
||||
isGemini3: boolean;
|
||||
hasSkills: boolean;
|
||||
hasHierarchicalMemory: boolean;
|
||||
topicUpdateNarration?: boolean;
|
||||
}
|
||||
|
||||
export interface PrimaryWorkflowsOptions {
|
||||
@@ -60,6 +64,7 @@ export interface PrimaryWorkflowsOptions {
|
||||
enableEnterPlanModeTool: boolean;
|
||||
approvedPlan?: { path: string };
|
||||
taskTracker?: boolean;
|
||||
topicUpdateNarration?: boolean;
|
||||
}
|
||||
|
||||
export interface OperationalGuidelinesOptions {
|
||||
@@ -67,6 +72,7 @@ export interface OperationalGuidelinesOptions {
|
||||
isGemini3: boolean;
|
||||
enableShellEfficiency: boolean;
|
||||
interactiveShellEnabled: boolean;
|
||||
topicUpdateNarration?: boolean;
|
||||
memoryManagerEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -177,7 +183,11 @@ export function renderCoreMandates(options?: CoreMandatesOptions): string {
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
- ${mandateConfirm(options.interactive)}
|
||||
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
|
||||
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}${mandateExplainBeforeActing(options.isGemini3)}${mandateContinueWork(options.interactive)}
|
||||
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}${
|
||||
options.topicUpdateNarration
|
||||
? mandateTopicUpdateModel()
|
||||
: mandateExplainBeforeActing(options.isGemini3)
|
||||
}${mandateContinueWork(options.interactive)}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
@@ -272,7 +282,12 @@ ${shellEfficiencyGuidelines(options.enableShellEfficiency)}
|
||||
## Tone and Style (CLI Interaction)
|
||||
- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
|
||||
- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query.
|
||||
- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous.${toneAndStyleNoChitchat(options.isGemini3)}
|
||||
- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous.${
|
||||
options.topicUpdateNarration
|
||||
? `
|
||||
- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes...") unless they are part of the **Topic Model**.`
|
||||
: toneAndStyleNoChitchat(options.isGemini3)
|
||||
}
|
||||
- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
|
||||
- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself.
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
|
||||
@@ -390,6 +405,11 @@ ${trimmed}
|
||||
`<global_context>\n${memory.global.trim()}\n</global_context>`,
|
||||
);
|
||||
}
|
||||
if (memory.userProjectMemory?.trim()) {
|
||||
sections.push(
|
||||
`<user_project_memory>\n--- User's Project Memory (private, not committed to repo) ---\n${memory.userProjectMemory.trim()}\n--- End User's Project Memory ---\n</user_project_memory>`,
|
||||
);
|
||||
}
|
||||
if (memory.extension?.trim()) {
|
||||
sections.push(
|
||||
`<extension_context>\n${memory.extension.trim()}\n</extension_context>`,
|
||||
@@ -493,6 +513,23 @@ function mandateConfirm(interactive: boolean): string {
|
||||
: '**Handle Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, do not perform it automatically.';
|
||||
}
|
||||
|
||||
function mandateTopicUpdateModel(): string {
|
||||
return `
|
||||
## Topic Updates
|
||||
As you work, the user follows along by reading topic updates that you publish with ${UPDATE_TOPIC_TOOL_NAME}. Keep them informed by doing the following:
|
||||
|
||||
- Always call ${UPDATE_TOPIC_TOOL_NAME} in your first and last turn. The final turn should always recap what was done.
|
||||
- Each topic update should give a concise description of what you are doing for the next few turns in the \`${TOPIC_PARAM_SUMMARY}\` parameter.
|
||||
- Provide topic updates whenever you change "topics". A topic is typically a discrete subgoal and will be every 3 to 10 turns. Do not use ${UPDATE_TOPIC_TOOL_NAME} on every turn.
|
||||
- The typical user message should call ${UPDATE_TOPIC_TOOL_NAME} 3 or more times. Each corresponds to a distinct phase of the task, such as "Researching X", "Researching Y", "Implementing Z with X", and "Testing Z".
|
||||
- Remember to call ${UPDATE_TOPIC_TOOL_NAME} when you experience an unexpected event (e.g., a test failure, compilation error, environment issue, or unexpected learning) that requires a strategic detour.
|
||||
- **Examples:**
|
||||
- ${UPDATE_TOPIC_TOOL_NAME}(${TOPIC_PARAM_TITLE}="Researching Parser", ${TOPIC_PARAM_SUMMARY}="I am starting an investigation into the parser timeout bug. My goal is to first understand the current test coverage and then attempt to reproduce the failure. This phase will focus on identifying the bottleneck in the main loop before we move to implementation.")
|
||||
- ${UPDATE_TOPIC_TOOL_NAME}(${TOPIC_PARAM_TITLE}="Implementing Buffer Fix", ${TOPIC_PARAM_SUMMARY}="I have completed the research phase and identified a race condition in the tokenizer's buffer management. I am now transitioning to implementation. This new chapter will focus on refactoring the buffer logic to handle async chunks safely, followed by unit testing the fix.")
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
function mandateSkillGuidance(hasSkills: boolean): string {
|
||||
if (!hasSkills) return '';
|
||||
return `
|
||||
|
||||
@@ -517,6 +517,11 @@ ${trimmed}
|
||||
`<global_context>\n${memory.global.trim()}\n</global_context>`,
|
||||
);
|
||||
}
|
||||
if (memory.userProjectMemory?.trim()) {
|
||||
sections.push(
|
||||
`<user_project_memory>\n--- User's Project Memory (private, not committed to repo) ---\n${memory.userProjectMemory.trim()}\n--- End User's Project Memory ---\n</user_project_memory>`,
|
||||
);
|
||||
}
|
||||
if (memory.extension?.trim()) {
|
||||
sections.push(
|
||||
`<extension_context>\n${memory.extension.trim()}\n</extension_context>`,
|
||||
@@ -798,9 +803,12 @@ function toolUsageRememberingFacts(
|
||||
- **Memory Tool:** You MUST use ${formatToolName(MEMORY_TOOL_NAME)} to proactively record facts, preferences, and workflows that apply across all sessions. Whenever the user explicitly tells you to "remember" something, or when they state a preference or workflow (like "always lint after editing"), you MUST immediately call the save_memory subagent. Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is strictly for persistent general knowledge.`;
|
||||
}
|
||||
const base = `
|
||||
- **Memory Tool:** Use ${formatToolName(MEMORY_TOOL_NAME)} only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only.`;
|
||||
- **Memory Tool:** Use ${formatToolName(MEMORY_TOOL_NAME)} to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
- \`"global"\` (default): Cross-project preferences and personal facts loaded in every workspace.
|
||||
- \`"project"\`: Facts specific to the current workspace, private to the user (not committed to the repo). Use this for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task.`;
|
||||
const suffix = options.interactive
|
||||
? ' If unsure whether a fact is worth remembering globally, ask the user.'
|
||||
? ' If unsure whether a fact is global or project-specific, ask the user.'
|
||||
: '';
|
||||
return base + suffix;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
({
|
||||
check: async () => ({ decision: 'allow' }),
|
||||
}) as unknown as PolicyEngine,
|
||||
isAutoDistillationEnabled: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
const mockConfig = Object.assign({}, baseConfig, overrides) as Config;
|
||||
|
||||
@@ -75,6 +75,7 @@ describe('ToolExecutor', () => {
|
||||
vi.mocked(fileUtils.formatTruncatedToolOutput).mockReturnValue(
|
||||
'TruncatedContent...',
|
||||
);
|
||||
vi.spyOn(config, 'isAutoDistillationEnabled').mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { isAbortError } from '../utils/errors.js';
|
||||
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { ToolOutputDistillationService } from '../services/toolDistillationService.js';
|
||||
import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
|
||||
import {
|
||||
saveTruncatedToolOutput,
|
||||
@@ -196,6 +197,15 @@ export class ToolExecutor {
|
||||
call: ToolCall,
|
||||
content: PartListUnion,
|
||||
): Promise<{ truncatedContent: PartListUnion; outputFile?: string }> {
|
||||
if (this.config.isAutoDistillationEnabled()) {
|
||||
const distiller = new ToolOutputDistillationService(
|
||||
this.config,
|
||||
this.context.geminiClient,
|
||||
this.context.promptId,
|
||||
);
|
||||
return distiller.distill(call.request.name, call.request.callId, content);
|
||||
}
|
||||
|
||||
const toolName = call.request.name;
|
||||
const callId = call.request.callId;
|
||||
let outputFile: string | undefined;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AgentHistoryProvider > should handle summarizer failures gracefully 1`] = `
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "[System Note: Prior conversation history was truncated. The most recent user message before truncation was:]
|
||||
|
||||
Message 18",
|
||||
},
|
||||
{
|
||||
"text": "Message 20",
|
||||
},
|
||||
],
|
||||
"role": "user",
|
||||
}
|
||||
`;
|
||||
@@ -6,13 +6,28 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AgentHistoryProvider } from './agentHistoryProvider.js';
|
||||
import type { Content, GenerateContentResponse } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
|
||||
|
||||
vi.mock('../utils/tokenCalculation.js', () => ({
|
||||
estimateTokenCountSync: vi.fn(),
|
||||
ASCII_TOKENS_PER_CHAR: 0.25,
|
||||
NON_ASCII_TOKENS_PER_CHAR: 1.3,
|
||||
}));
|
||||
|
||||
import type { Content, GenerateContentResponse, Part } from '@google/genai';
|
||||
import type { Config, ContextManagementConfig } from '../config/config.js';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import type { AgentHistoryProviderConfig } from './types.js';
|
||||
import {
|
||||
TEXT_TRUNCATION_PREFIX,
|
||||
TOOL_TRUNCATION_PREFIX,
|
||||
truncateProportionally,
|
||||
} from 'src/utils/truncation.js';
|
||||
|
||||
describe('AgentHistoryProvider', () => {
|
||||
let config: Config;
|
||||
let provider: AgentHistoryProvider;
|
||||
let providerConfig: AgentHistoryProviderConfig;
|
||||
let generateContentMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -20,12 +35,14 @@ describe('AgentHistoryProvider', () => {
|
||||
isExperimentalAgentHistoryTruncationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
isExperimentalAgentHistorySummarizationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
getContextManagementConfig: vi.fn().mockReturnValue(false),
|
||||
getBaseLlmClient: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
// By default, messages are small
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 100,
|
||||
);
|
||||
generateContentMock = vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'Mock intent summary' }] } }],
|
||||
} as unknown as GenerateContentResponse);
|
||||
@@ -33,11 +50,16 @@ describe('AgentHistoryProvider', () => {
|
||||
config.getBaseLlmClient = vi.fn().mockReturnValue({
|
||||
generateContent: generateContentMock,
|
||||
} as unknown as BaseLlmClient);
|
||||
|
||||
provider = new AgentHistoryProvider(config, {
|
||||
truncationThreshold: 30,
|
||||
retainedMessages: 15,
|
||||
});
|
||||
providerConfig = {
|
||||
maxTokens: 60000,
|
||||
retainedTokens: 40000,
|
||||
normalMessageTokens: 2500,
|
||||
maximumMessageTokens: 10000,
|
||||
normalizationHeadRatio: 0.2,
|
||||
isSummarizationEnabled: false,
|
||||
isTruncationEnabled: false,
|
||||
};
|
||||
provider = new AgentHistoryProvider(providerConfig, config);
|
||||
});
|
||||
|
||||
const createMockHistory = (count: number): Content[] =>
|
||||
@@ -47,10 +69,7 @@ describe('AgentHistoryProvider', () => {
|
||||
}));
|
||||
|
||||
it('should return history unchanged if truncation is disabled', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(false);
|
||||
providerConfig.isTruncationEnabled = false;
|
||||
|
||||
const history = createMockHistory(40);
|
||||
const result = await provider.manageHistory(history);
|
||||
@@ -60,10 +79,7 @@ describe('AgentHistoryProvider', () => {
|
||||
});
|
||||
|
||||
it('should return history unchanged if length is under threshold', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(true);
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
|
||||
const history = createMockHistory(20); // Threshold is 30
|
||||
const result = await provider.manageHistory(history);
|
||||
@@ -72,60 +88,72 @@ describe('AgentHistoryProvider', () => {
|
||||
expect(result.length).toBe(20);
|
||||
});
|
||||
|
||||
it('should truncate mechanically to RETAINED_MESSAGES without summarization when sum flag is off', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(true);
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistorySummarizationEnabled',
|
||||
).mockReturnValue(false);
|
||||
it('should truncate when total tokens exceed budget, preserving structural integrity', async () => {
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.maxTokens = 60000;
|
||||
providerConfig.retainedTokens = 60000;
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: false,
|
||||
} as unknown as ContextManagementConfig);
|
||||
|
||||
const history = createMockHistory(35); // Above 30 threshold, should truncate to 15
|
||||
// Make each message cost 4000 tokens
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 4000,
|
||||
);
|
||||
const history = createMockHistory(35); // 35 * 4000 = 140,000 total tokens > maxTokens
|
||||
const result = await provider.manageHistory(history);
|
||||
// Budget = 60000. Each message costs 4000. 60000 / 4000 = 15.
|
||||
// However, some messages get normalized.
|
||||
// The grace period is 15 messages. Their target is MAXIMUM_MESSAGE_TOKENS (10000).
|
||||
// So the 15 newest messages remain at 4000 tokens each.
|
||||
// That's 15 * 4000 = 60000 tokens EXACTLY!
|
||||
// The next older message will push it over budget.
|
||||
// So EXACTLY 15 messages will be retained.
|
||||
// If the 15th newest message is a user message with a functionResponse, it might pull in the model call.
|
||||
// In our createMockHistory, we don't use functionResponses.
|
||||
|
||||
expect(result.length).toBe(15);
|
||||
expect(generateContentMock).not.toHaveBeenCalled();
|
||||
|
||||
// Check fallback message logic
|
||||
// Messages 20 to 34 are retained. Message 20 is 'user'.
|
||||
expect(result[0].role).toBe('user');
|
||||
expect(result[0].parts![0].text).toContain(
|
||||
'System Note: Prior conversation history was truncated',
|
||||
'### [System Note: Conversation History Truncated]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call summarizer and prepend summary when summarization is enabled', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(true);
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistorySummarizationEnabled',
|
||||
).mockReturnValue(true);
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.isSummarizationEnabled = true;
|
||||
providerConfig.maxTokens = 60000;
|
||||
providerConfig.retainedTokens = 60000;
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
} as unknown as ContextManagementConfig);
|
||||
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 4000,
|
||||
);
|
||||
const history = createMockHistory(35);
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
expect(result.length).toBe(15); // retained messages
|
||||
expect(result.length).toBe(15);
|
||||
expect(result[0].role).toBe('user');
|
||||
expect(result[0].parts![0].text).toContain('<intent_summary>');
|
||||
expect(result[0].parts![0].text).toContain('Mock intent summary');
|
||||
});
|
||||
|
||||
it('should handle summarizer failures gracefully', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(true);
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistorySummarizationEnabled',
|
||||
).mockReturnValue(true);
|
||||
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.isSummarizationEnabled = true;
|
||||
providerConfig.maxTokens = 60000;
|
||||
providerConfig.retainedTokens = 60000;
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
} as unknown as ContextManagementConfig);
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 4000,
|
||||
);
|
||||
generateContentMock.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const history = createMockHistory(35);
|
||||
@@ -133,6 +161,346 @@ describe('AgentHistoryProvider', () => {
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
expect(result.length).toBe(15);
|
||||
expect(result[0]).toMatchSnapshot();
|
||||
// Should fallback to fallback text
|
||||
expect(result[0].parts![0].text).toContain(
|
||||
'[System Note: Conversation History Truncated]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass the contextual bridge to the summarizer', async () => {
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.isSummarizationEnabled = true;
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
} as unknown as ContextManagementConfig);
|
||||
|
||||
// Max tokens 30 means if total tokens > 30, it WILL truncate.
|
||||
providerConfig.maxTokens = 30;
|
||||
// budget 20 tokens means it will keep 2 messages if they are 10 each.
|
||||
providerConfig.retainedTokens = 20;
|
||||
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 10,
|
||||
);
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Old Message' }] },
|
||||
{ role: 'model', parts: [{ text: 'Old Response' }] },
|
||||
{ role: 'user', parts: [{ text: 'Keep 1' }] },
|
||||
{ role: 'user', parts: [{ text: 'Keep 2' }] },
|
||||
];
|
||||
|
||||
await provider.manageHistory(history);
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
const callArgs = generateContentMock.mock.calls[0][0];
|
||||
const prompt = callArgs.contents[0].parts[0].text;
|
||||
|
||||
expect(prompt).toContain('ACTIVE BRIDGE (LOOKAHEAD):');
|
||||
expect(prompt).toContain('Keep 1');
|
||||
expect(prompt).toContain('Keep 2');
|
||||
});
|
||||
|
||||
it('should detect a previous summary in the truncated head', async () => {
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.isSummarizationEnabled = true;
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
} as unknown as ContextManagementConfig);
|
||||
|
||||
providerConfig.maxTokens = 20;
|
||||
providerConfig.retainedTokens = 10;
|
||||
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 10,
|
||||
);
|
||||
const history: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: '<intent_summary>Previous Mandate</intent_summary>' }],
|
||||
},
|
||||
{ role: 'model', parts: [{ text: 'Work' }] },
|
||||
{ role: 'user', parts: [{ text: 'New Work' }] },
|
||||
];
|
||||
|
||||
await provider.manageHistory(history);
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
const callArgs = generateContentMock.mock.calls[0][0];
|
||||
const prompt = callArgs.contents[0].parts[0].text;
|
||||
|
||||
expect(prompt).toContain('1. **Previous Summary:**');
|
||||
expect(prompt).toContain('PREVIOUS SUMMARY AND TRUNCATED HISTORY:');
|
||||
});
|
||||
|
||||
it('should include the Action Path (necklace of function names) in the prompt', async () => {
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.isSummarizationEnabled = true;
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
} as unknown as ContextManagementConfig);
|
||||
|
||||
providerConfig.maxTokens = 20;
|
||||
providerConfig.retainedTokens = 10;
|
||||
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 10,
|
||||
);
|
||||
const history: Content[] = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { name: 'tool_a', args: {} } },
|
||||
{ functionCall: { name: 'tool_b', args: {} } },
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'Keep' }] },
|
||||
];
|
||||
|
||||
await provider.manageHistory(history);
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
const callArgs = generateContentMock.mock.calls[0][0];
|
||||
const prompt = callArgs.contents[0].parts[0].text;
|
||||
|
||||
expect(prompt).toContain('The Action Path:');
|
||||
expect(prompt).toContain('tool_a → tool_b');
|
||||
});
|
||||
|
||||
describe('Tiered Normalization Logic', () => {
|
||||
it('normalizes large messages incrementally: newest and exit-grace', async () => {
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.retainedTokens = 30000;
|
||||
providerConfig.maximumMessageTokens = 10000;
|
||||
providerConfig.normalMessageTokens = 2500; // History of 35 messages.
|
||||
// Index 34: Newest (Grace Zone) -> Target 10000 tokens (~40000 chars)
|
||||
// Index 19: Exit Grace (35-1-15=19) -> Target 2500 tokens (~10000 chars)
|
||||
// Index 10: Archived -> Should NOT be normalized in this turn (Incremental optimization)
|
||||
const history = createMockHistory(35);
|
||||
const hugeText = 'H'.repeat(100000);
|
||||
|
||||
history[34] = { role: 'user', parts: [{ text: hugeText }] };
|
||||
history[19] = { role: 'model', parts: [{ text: hugeText }] };
|
||||
history[10] = { role: 'user', parts: [{ text: hugeText }] };
|
||||
|
||||
// Mock token count to trigger normalization (100k chars = 25k tokens @ 4 chars/token)
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation((parts: Part[]) => {
|
||||
if (!parts?.[0]) return 10;
|
||||
const text = parts[0].text || '';
|
||||
if (text.startsWith('H')) return 25000;
|
||||
return 10;
|
||||
});
|
||||
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
// 1. Newest message (index 34) normalized to ~40000 chars
|
||||
const normalizedLast = result[34].parts![0].text!;
|
||||
expect(normalizedLast).toContain(TEXT_TRUNCATION_PREFIX);
|
||||
expect(normalizedLast.length).toBeLessThan(50000);
|
||||
expect(normalizedLast.length).toBeGreaterThan(30000);
|
||||
|
||||
// 2. Exit grace message (index 19) normalized to ~10000 chars
|
||||
const normalizedArchived = result[19].parts![0].text!;
|
||||
expect(normalizedArchived).toContain(TEXT_TRUNCATION_PREFIX);
|
||||
expect(normalizedArchived.length).toBeLessThan(15000);
|
||||
expect(normalizedArchived.length).toBeGreaterThan(8000);
|
||||
|
||||
// 3. Archived message (index 10) IS touched and normalized to ~10000 chars
|
||||
const normalizedPastArchived = result[10].parts![0].text!;
|
||||
expect(normalizedPastArchived).toContain(TEXT_TRUNCATION_PREFIX);
|
||||
expect(normalizedPastArchived.length).toBeLessThan(15000);
|
||||
expect(normalizedPastArchived.length).toBeGreaterThan(8000);
|
||||
});
|
||||
|
||||
it('normalize function responses correctly by targeting large string values', async () => {
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.maximumMessageTokens = 1000;
|
||||
|
||||
const hugeValue = 'O'.repeat(5000);
|
||||
const history: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test_tool',
|
||||
id: '1',
|
||||
response: {
|
||||
stdout: hugeValue,
|
||||
stderr: 'small error',
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: readonly Part[]) => {
|
||||
if (parts?.[0]?.functionResponse) return 5000;
|
||||
return 10;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
const fr = result[0].parts![0].functionResponse!;
|
||||
const resp = fr.response as Record<string, unknown>;
|
||||
|
||||
// stdout should be truncated
|
||||
expect(resp['stdout']).toContain(TOOL_TRUNCATION_PREFIX);
|
||||
expect((resp['stdout'] as string).length).toBeLessThan(hugeValue.length);
|
||||
|
||||
// stderr and exitCode should be PRESERVED (JSON integrity)
|
||||
expect(resp['stderr']).toBe('small error');
|
||||
expect(resp['exitCode']).toBe(0);
|
||||
|
||||
// Schema should be intact
|
||||
expect(fr.name).toBe('test_tool');
|
||||
expect(fr.id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateProportionally', () => {
|
||||
it('returns original string if under target chars', () => {
|
||||
const str = 'A'.repeat(50);
|
||||
expect(truncateProportionally(str, 100, TEXT_TRUNCATION_PREFIX)).toBe(
|
||||
str,
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates proportionally with prefix and ellipsis', () => {
|
||||
const str = 'A'.repeat(500) + 'B'.repeat(500); // 1000 chars
|
||||
const target = 100;
|
||||
const result = truncateProportionally(
|
||||
str,
|
||||
target,
|
||||
TEXT_TRUNCATION_PREFIX,
|
||||
);
|
||||
|
||||
expect(result.startsWith(TEXT_TRUNCATION_PREFIX)).toBe(true);
|
||||
expect(result).toContain('\n...\n');
|
||||
|
||||
// The prefix and ellipsis take up some space
|
||||
// It should keep ~20% head and ~80% tail of the *available* space
|
||||
const ellipsis = '\n...\n';
|
||||
const overhead = TEXT_TRUNCATION_PREFIX.length + ellipsis.length + 1; // +1 for the newline after prefix
|
||||
const availableChars = Math.max(0, target - overhead);
|
||||
const expectedHeadChars = Math.floor(availableChars * 0.2);
|
||||
const expectedTailChars = availableChars - expectedHeadChars;
|
||||
|
||||
// Extract parts around the ellipsis
|
||||
const parts = result.split(ellipsis);
|
||||
expect(parts.length).toBe(2);
|
||||
|
||||
// Remove prefix + newline from the first part to check head length
|
||||
const actualHead = parts[0].replace(TEXT_TRUNCATION_PREFIX + '\n', '');
|
||||
const actualTail = parts[1];
|
||||
|
||||
expect(actualHead.length).toBe(expectedHeadChars);
|
||||
expect(actualTail.length).toBe(expectedTailChars);
|
||||
});
|
||||
|
||||
it('handles very small targets gracefully by just returning prefix', () => {
|
||||
const str = 'A'.repeat(100);
|
||||
const result = truncateProportionally(str, 10, TEXT_TRUNCATION_PREFIX);
|
||||
expect(result).toBe(TEXT_TRUNCATION_PREFIX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-part Proportional Normalization', () => {
|
||||
it('distributes token budget proportionally across multiple large parts', async () => {
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.maximumMessageTokens = 2500; // Small limit to trigger normalization on last msg
|
||||
|
||||
const history = createMockHistory(35);
|
||||
|
||||
// Make newest message (index 34) have two large parts
|
||||
// Part 1: 10000 chars (~2500 tokens at 4 chars/token)
|
||||
// Part 2: 30000 chars (~7500 tokens at 4 chars/token)
|
||||
// Total tokens = 10000. Target = 2500. Ratio = 0.25.
|
||||
const part1Text = 'A'.repeat(10000);
|
||||
const part2Text = 'B'.repeat(30000);
|
||||
|
||||
history[34] = {
|
||||
role: 'user',
|
||||
parts: [{ text: part1Text }, { text: part2Text }],
|
||||
};
|
||||
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: readonly Part[]) => {
|
||||
if (!parts || parts.length === 0) return 0;
|
||||
let tokens = 0;
|
||||
for (const p of parts) {
|
||||
if (p.text?.startsWith('A')) tokens += 2500;
|
||||
else if (p.text?.startsWith('B')) tokens += 7500;
|
||||
else tokens += 10;
|
||||
}
|
||||
return tokens;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
const normalizedMsg = result[34];
|
||||
expect(normalizedMsg.parts!.length).toBe(2);
|
||||
|
||||
const p1 = normalizedMsg.parts![0].text!;
|
||||
const p2 = normalizedMsg.parts![1].text!;
|
||||
|
||||
expect(p1).toContain(TEXT_TRUNCATION_PREFIX);
|
||||
expect(p2).toContain(TEXT_TRUNCATION_PREFIX);
|
||||
|
||||
// Part 1: Target chars ~ 2500 * 0.25 * 4 = 2500
|
||||
// Part 2: Target chars ~ 7500 * 0.25 * 4 = 7500
|
||||
expect(p1.length).toBeLessThan(3500);
|
||||
expect(p2.length).toBeLessThan(9000);
|
||||
expect(p1.length).toBeLessThan(p2.length);
|
||||
});
|
||||
|
||||
it('preserves small parts while truncating large parts in the same message', async () => {
|
||||
providerConfig.isTruncationEnabled = true;
|
||||
providerConfig.maximumMessageTokens = 2500;
|
||||
|
||||
const history = createMockHistory(35);
|
||||
|
||||
const smallText = 'Hello I am small';
|
||||
const hugeText = 'B'.repeat(40000); // 10000 tokens
|
||||
|
||||
history[34] = {
|
||||
role: 'user',
|
||||
parts: [{ text: smallText }, { text: hugeText }],
|
||||
};
|
||||
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: readonly Part[]) => {
|
||||
if (!parts || parts.length === 0) return 0;
|
||||
let tokens = 0;
|
||||
for (const p of parts) {
|
||||
if (p.text === smallText) tokens += 10;
|
||||
else if (p.text?.startsWith('B')) tokens += 10000;
|
||||
else tokens += 10;
|
||||
}
|
||||
return tokens;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
const normalizedMsg = result[34];
|
||||
expect(normalizedMsg.parts!.length).toBe(2);
|
||||
|
||||
const p1 = normalizedMsg.parts![0].text!;
|
||||
const p2 = normalizedMsg.parts![1].text!;
|
||||
|
||||
// Small part should be preserved
|
||||
expect(p1).toBe(smallText);
|
||||
|
||||
// Huge part should be truncated
|
||||
expect(p2).toContain(TEXT_TRUNCATION_PREFIX);
|
||||
// Target tokens for huge part = ~2500 * (10000/10010) = ~2500
|
||||
// Target chars = ~10000
|
||||
expect(p2.length).toBeLessThan(12000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,21 +4,27 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
|
||||
import { LlmRole } from '../telemetry/llmRole.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface AgentHistoryProviderConfig {
|
||||
truncationThreshold: number;
|
||||
retainedMessages: number;
|
||||
}
|
||||
import type { AgentHistoryProviderConfig } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
MIN_TARGET_TOKENS,
|
||||
MIN_CHARS_FOR_TRUNCATION,
|
||||
TEXT_TRUNCATION_PREFIX,
|
||||
estimateCharsFromTokens,
|
||||
truncateProportionally,
|
||||
normalizeFunctionResponse,
|
||||
} from '../utils/truncation.js';
|
||||
|
||||
export class AgentHistoryProvider {
|
||||
// TODO(joshualitt): just pass the BaseLlmClient instead of the whole Config.
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly providerConfig: AgentHistoryProviderConfig,
|
||||
private readonly config: Config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -29,12 +35,29 @@ export class AgentHistoryProvider {
|
||||
history: readonly Content[],
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<readonly Content[]> {
|
||||
if (!this.shouldTruncate(history)) {
|
||||
if (!this.providerConfig.isTruncationEnabled || history.length === 0) {
|
||||
return history;
|
||||
}
|
||||
|
||||
// Step 1: Normalize newest messages.
|
||||
const normalizedHistory = this.enforceMessageSizeLimits(history);
|
||||
|
||||
const totalTokens = estimateTokenCountSync(
|
||||
normalizedHistory.flatMap((c) => c.parts || []),
|
||||
);
|
||||
|
||||
// Step 2: Check if truncation is needed based on the token threshold (High Watermark)
|
||||
if (totalTokens <= this.providerConfig.maxTokens) {
|
||||
return normalizedHistory;
|
||||
}
|
||||
|
||||
// Step 3: Split into keep/truncate boundaries
|
||||
const { messagesToKeep, messagesToTruncate } =
|
||||
this.splitHistoryForTruncation(history);
|
||||
this.splitHistoryForTruncation(normalizedHistory);
|
||||
|
||||
if (messagesToTruncate.length === 0) {
|
||||
return messagesToKeep;
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`AgentHistoryProvider: Truncating ${messagesToTruncate.length} messages, retaining ${messagesToKeep.length} messages.`,
|
||||
@@ -42,65 +65,230 @@ export class AgentHistoryProvider {
|
||||
|
||||
const summaryText = await this.getSummaryText(
|
||||
messagesToTruncate,
|
||||
messagesToKeep,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
return this.mergeSummaryWithHistory(summaryText, messagesToKeep);
|
||||
}
|
||||
|
||||
private shouldTruncate(history: readonly Content[]): boolean {
|
||||
if (!this.config.isExperimentalAgentHistoryTruncationEnabled()) {
|
||||
return false;
|
||||
/**
|
||||
* Enforces message size limits on the most recent message and the message
|
||||
* that just exited the grace zone.
|
||||
* - Recent messages have a high MAXIMUM limit.
|
||||
* - Older messages (already processed) are restricted to the NORMAL limit
|
||||
* once they exit the grace period.
|
||||
*/
|
||||
private enforceMessageSizeLimits(
|
||||
history: readonly Content[],
|
||||
): readonly Content[] {
|
||||
if (history.length === 0) return history;
|
||||
|
||||
let hasChanges = false;
|
||||
let accumulatedTokens = 0;
|
||||
|
||||
// Scan backwards to find the index where the token budget is exhausted
|
||||
let graceStartIndex = 0;
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const msgTokens = estimateTokenCountSync(history[i].parts || []);
|
||||
accumulatedTokens += msgTokens;
|
||||
if (accumulatedTokens > this.providerConfig.retainedTokens) {
|
||||
graceStartIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return history.length > this.providerConfig.truncationThreshold;
|
||||
|
||||
const newHistory = history.map((msg, i) => {
|
||||
const targetTokens =
|
||||
i < graceStartIndex
|
||||
? this.providerConfig.normalMessageTokens
|
||||
: this.providerConfig.maximumMessageTokens;
|
||||
|
||||
const normalizedMsg = this.normalizeMessage(msg, targetTokens);
|
||||
if (normalizedMsg !== msg) {
|
||||
hasChanges = true;
|
||||
}
|
||||
return normalizedMsg;
|
||||
});
|
||||
|
||||
return hasChanges ? newHistory : history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a message by proportionally masking its text or function response
|
||||
* if its total token count exceeds the target token limit.
|
||||
*/
|
||||
private normalizeMessage(msg: Content, targetTokens: number): Content {
|
||||
const currentTokens = estimateTokenCountSync(msg.parts || []);
|
||||
if (currentTokens <= targetTokens) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Calculate the compression ratio to apply to all large parts
|
||||
const ratio = targetTokens / currentTokens;
|
||||
|
||||
// Proportional compression of the parts to fit the targetTokens budget
|
||||
// while maintaining API structure (never dropping a part completely).
|
||||
const newParts: Part[] = [];
|
||||
for (const part of msg.parts || []) {
|
||||
if (part.text) {
|
||||
const partTokens = estimateTokenCountSync([part]);
|
||||
const targetPartTokens = Math.max(
|
||||
MIN_TARGET_TOKENS,
|
||||
Math.floor(partTokens * ratio),
|
||||
);
|
||||
const targetChars = estimateCharsFromTokens(
|
||||
part.text,
|
||||
targetPartTokens,
|
||||
);
|
||||
|
||||
if (
|
||||
part.text.length > targetChars &&
|
||||
targetChars > MIN_CHARS_FOR_TRUNCATION
|
||||
) {
|
||||
const newText = truncateProportionally(
|
||||
part.text,
|
||||
targetChars,
|
||||
TEXT_TRUNCATION_PREFIX,
|
||||
this.providerConfig.normalizationHeadRatio,
|
||||
);
|
||||
newParts.push({ text: newText });
|
||||
} else {
|
||||
newParts.push(part);
|
||||
}
|
||||
} else if (part.functionResponse) {
|
||||
newParts.push(
|
||||
normalizeFunctionResponse(
|
||||
part,
|
||||
ratio,
|
||||
this.providerConfig.normalizationHeadRatio,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
newParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...msg, parts: newParts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the boundary for splitting history based on the token budget,
|
||||
* keeping recent messages under a specific target token threshold,
|
||||
* while ensuring structural integrity (e.g. keeping functionCall/functionResponse pairs).
|
||||
*/
|
||||
private splitHistoryForTruncation(history: readonly Content[]): {
|
||||
messagesToKeep: readonly Content[];
|
||||
messagesToTruncate: readonly Content[];
|
||||
} {
|
||||
let accumulatedTokens = 0;
|
||||
let truncationBoundary = 0; // The index of the first message to keep
|
||||
|
||||
// Scan backwards to calculate the boundary based on token budget
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const msg = history[i];
|
||||
const msgTokens = estimateTokenCountSync(msg.parts || []);
|
||||
|
||||
// Token Budget
|
||||
if (accumulatedTokens + msgTokens > this.providerConfig.retainedTokens) {
|
||||
// Exceeded budget, stop retaining messages here.
|
||||
truncationBoundary = i + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
accumulatedTokens += msgTokens;
|
||||
}
|
||||
|
||||
// Ensure structural integrity of the boundary
|
||||
truncationBoundary = this.adjustBoundaryForIntegrity(
|
||||
history,
|
||||
truncationBoundary,
|
||||
);
|
||||
|
||||
const messagesToKeep = history.slice(truncationBoundary);
|
||||
const messagesToTruncate = history.slice(0, truncationBoundary);
|
||||
|
||||
return {
|
||||
messagesToKeep: history.slice(-this.providerConfig.retainedMessages),
|
||||
messagesToTruncate: history.slice(
|
||||
0,
|
||||
history.length - this.providerConfig.retainedMessages,
|
||||
),
|
||||
messagesToKeep,
|
||||
messagesToTruncate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the truncation boundary backwards to prevent breaking functionCall/functionResponse pairs.
|
||||
*/
|
||||
private adjustBoundaryForIntegrity(
|
||||
history: readonly Content[],
|
||||
boundary: number,
|
||||
): number {
|
||||
let currentBoundary = boundary;
|
||||
// Ensure we don't start at index 0 or out of bounds.
|
||||
if (currentBoundary <= 0 || currentBoundary >= history.length) {
|
||||
return currentBoundary;
|
||||
}
|
||||
|
||||
while (
|
||||
currentBoundary > 0 &&
|
||||
currentBoundary < history.length &&
|
||||
history[currentBoundary].role === 'user' &&
|
||||
history[currentBoundary].parts?.some((p) => p.functionResponse) &&
|
||||
history[currentBoundary - 1].role === 'model' &&
|
||||
history[currentBoundary - 1].parts?.some((p) => p.functionCall)
|
||||
) {
|
||||
currentBoundary--; // Include the functionCall in the retained history
|
||||
}
|
||||
return currentBoundary;
|
||||
}
|
||||
|
||||
private getFallbackSummaryText(
|
||||
messagesToTruncate: readonly Content[],
|
||||
): string {
|
||||
const defaultNote =
|
||||
'System Note: Prior conversation history was truncated to maintain performance and focus. Important context should have been saved to memory.';
|
||||
const userMessages = messagesToTruncate.filter((m) => m.role === 'user');
|
||||
const modelMessages = messagesToTruncate.filter((m) => m.role === 'model');
|
||||
|
||||
let lastUserText = '';
|
||||
for (let i = messagesToTruncate.length - 1; i >= 0; i--) {
|
||||
const msg = messagesToTruncate[i];
|
||||
if (msg.role === 'user') {
|
||||
lastUserText =
|
||||
msg.parts
|
||||
?.map((p) => p.text || '')
|
||||
.join('')
|
||||
.trim() || '';
|
||||
if (lastUserText) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const lastUserText = userMessages
|
||||
.slice(-1)[0]
|
||||
?.parts?.map((p) => p.text || '')
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
const actionPath = modelMessages
|
||||
.flatMap(
|
||||
(m) =>
|
||||
m.parts
|
||||
?.filter((p) => p.functionCall)
|
||||
.map((p) => p.functionCall!.name) || [],
|
||||
)
|
||||
.join(' → ');
|
||||
|
||||
const summaryParts = [
|
||||
'### [System Note: Conversation History Truncated]',
|
||||
'Prior context was offloaded to maintain performance. Key highlights from the truncated history:',
|
||||
];
|
||||
|
||||
if (lastUserText) {
|
||||
return `[System Note: Prior conversation history was truncated. The most recent user message before truncation was:]\n\n${lastUserText}`;
|
||||
summaryParts.push(`- **Last User Intent:** "${lastUserText}"`);
|
||||
}
|
||||
|
||||
return defaultNote;
|
||||
if (actionPath) {
|
||||
summaryParts.push(`- **Action Path:** ${actionPath}`);
|
||||
}
|
||||
|
||||
summaryParts.push(
|
||||
'- **Notice:** For deeper context, review persistent memory or task-specific logs.',
|
||||
);
|
||||
|
||||
return summaryParts.join('\n');
|
||||
}
|
||||
|
||||
private async getSummaryText(
|
||||
messagesToTruncate: readonly Content[],
|
||||
messagesToKeep: readonly Content[],
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (!this.config.isExperimentalAgentHistorySummarizationEnabled()) {
|
||||
if (messagesToTruncate.length === 0) return '';
|
||||
|
||||
if (!this.providerConfig.isSummarizationEnabled) {
|
||||
debugLogger.log(
|
||||
'AgentHistoryProvider: Summarization disabled, using fallback note.',
|
||||
);
|
||||
@@ -108,12 +296,15 @@ export class AgentHistoryProvider {
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await this.generateIntentSummary(
|
||||
// Use the first few messages of the Grace Zone as a "contextual bridge"
|
||||
// to give the summarizer lookahead into the current state.
|
||||
const bridge = messagesToKeep.slice(0, 5);
|
||||
|
||||
return await this.generateIntentSummary(
|
||||
messagesToTruncate,
|
||||
bridge,
|
||||
abortSignal,
|
||||
);
|
||||
debugLogger.log('AgentHistoryProvider: Summarization successful.');
|
||||
return summary;
|
||||
} catch (error) {
|
||||
debugLogger.log('AgentHistoryProvider: Summarization failed.', error);
|
||||
return this.getFallbackSummaryText(messagesToTruncate);
|
||||
@@ -124,6 +315,8 @@ export class AgentHistoryProvider {
|
||||
summaryText: string,
|
||||
messagesToKeep: readonly Content[],
|
||||
): readonly Content[] {
|
||||
if (!summaryText) return messagesToKeep;
|
||||
|
||||
if (messagesToKeep.length === 0) {
|
||||
return [{ role: 'user', parts: [{ text: summaryText }] }];
|
||||
}
|
||||
@@ -152,22 +345,57 @@ export class AgentHistoryProvider {
|
||||
|
||||
private async generateIntentSummary(
|
||||
messagesToTruncate: readonly Content[],
|
||||
bridge: readonly Content[],
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const prompt = `Create a succinct, agent-continuity focused intent summary of the truncated conversation history.
|
||||
Distill the essence of the ongoing work by capturing:
|
||||
- The Original Mandate: What the user (or calling agent) originally requested and why.
|
||||
- The Agent's Strategy: How you (the agent) are approaching the task and where the work is taking place (e.g., specific files, directories, or architectural layers).
|
||||
- Evolving Context: Any significant shifts in the user's intent or the agent's technical approach over the course of the truncated history.
|
||||
// 1. Identify and extract any existing summary from the truncated head
|
||||
const firstMsg = messagesToTruncate[0];
|
||||
const firstPartText = firstMsg?.parts?.[0]?.text || '';
|
||||
const hasPreviousSummary = firstPartText.includes('<intent_summary>');
|
||||
|
||||
Write this summary to orient the active agent. Do NOT predict next steps or summarize the current task state, as those are covered by the active history. Focus purely on foundational context and strategic continuity.`;
|
||||
// 2. Extract "The Action Path" (necklace of function names)
|
||||
const actionPath = messagesToTruncate
|
||||
.filter((m) => m.role === 'model')
|
||||
.flatMap(
|
||||
(m) =>
|
||||
m.parts
|
||||
?.filter((p) => p.functionCall)
|
||||
.map((p) => p.functionCall!.name) || [],
|
||||
)
|
||||
.join(' → ');
|
||||
|
||||
const prompt = `### State Update: Agent Continuity
|
||||
|
||||
The conversation history has been truncated. You are generating a highly factual state summary to preserve the agent's exact working context.
|
||||
|
||||
You have these signals to synthesize:
|
||||
${hasPreviousSummary ? '1. **Previous Summary:** The existing state before this truncation.\n' : ''}2. **The Action Path:** A chronological list of tools called: [${actionPath}]
|
||||
3. **Truncated History:** The specific actions, tool inputs, and tool outputs being offloaded.
|
||||
4. **Active Bridge:** The first few turns of the "Grace Zone" (what follows immediately after this summary), showing the current tactical moment.
|
||||
|
||||
### Your Goal:
|
||||
Distill these into a high-density Markdown block that orientates the agent on the CONCRETE STATE of the workspace:
|
||||
- **Primary Goal:** The ultimate objective requested by the user.
|
||||
- **Verified Facts:** What has been definitively completed or proven (e.g., "File X was created", "Bug Y was reproduced").
|
||||
- **Working Set:** The exact file paths currently being analyzed or modified.
|
||||
- **Active Blockers:** Exact error messages or failing test names currently preventing progress.
|
||||
|
||||
### Constraints:
|
||||
- **Format:** Wrap the entire response in <intent_summary> tags.
|
||||
- **Factuality:** Base all points strictly on the provided history. Do not invent rationale or assume success without proof. Use exact names and quotes.
|
||||
- **Brevity:** Maximum 15 lines. No conversational preamble.
|
||||
|
||||
${hasPreviousSummary ? 'PREVIOUS SUMMARY AND TRUNCATED HISTORY:' : 'TRUNCATED HISTORY:'}
|
||||
${JSON.stringify(messagesToTruncate)}
|
||||
|
||||
ACTIVE BRIDGE (LOOKAHEAD):
|
||||
${JSON.stringify(bridge)}`;
|
||||
|
||||
const summaryResponse = await this.config
|
||||
.getBaseLlmClient()
|
||||
.generateContent({
|
||||
modelConfigKey: { model: 'agent-history-provider-summarizer' },
|
||||
contents: [
|
||||
...messagesToTruncate,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
@@ -179,7 +407,16 @@ Write this summary to orient the active agent. Do NOT predict next steps or summ
|
||||
});
|
||||
|
||||
let summary = getResponseText(summaryResponse) ?? '';
|
||||
summary = summary.replace(/<\/?intent_summary>/g, '').trim();
|
||||
return `<intent_summary>\n${summary}\n</intent_summary>`;
|
||||
// Clean up if the model included extra tags or markdown
|
||||
summary = summary
|
||||
.replace(/```markdown/g, '')
|
||||
.replace(/```/g, '')
|
||||
.trim();
|
||||
|
||||
if (!summary.includes('<intent_summary>')) {
|
||||
summary = `<intent_summary>\n${summary}\n</intent_summary>`;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('../utils/memoryDiscovery.js', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getGlobalMemoryPaths: vi.fn(),
|
||||
getUserProjectMemoryPaths: vi.fn(),
|
||||
getExtensionMemoryPaths: vi.fn(),
|
||||
getEnvironmentMemoryPaths: vi.fn(),
|
||||
readGeminiMdFiles: vi.fn(),
|
||||
@@ -47,12 +48,18 @@ describe('ContextManager', () => {
|
||||
}),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
storage: {
|
||||
getProjectMemoryDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/home/user/.gemini/memory/test-project'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
contextManager = new ContextManager(mockConfig);
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(coreEvents, 'emit');
|
||||
vi.mocked(memoryDiscovery.getExtensionMemoryPaths).mockReturnValue([]);
|
||||
vi.mocked(memoryDiscovery.getUserProjectMemoryPaths).mockResolvedValue([]);
|
||||
// default mock: deduplication returns paths as-is (no deduplication)
|
||||
vi.mocked(
|
||||
memoryDiscovery.deduplicatePathsByFileIdentity,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
loadJitSubdirectoryMemory,
|
||||
concatenateInstructions,
|
||||
getGlobalMemoryPaths,
|
||||
getUserProjectMemoryPaths,
|
||||
getExtensionMemoryPaths,
|
||||
getEnvironmentMemoryPaths,
|
||||
readGeminiMdFiles,
|
||||
@@ -25,6 +26,7 @@ export class ContextManager {
|
||||
private globalMemory: string = '';
|
||||
private extensionMemory: string = '';
|
||||
private projectMemory: string = '';
|
||||
private userProjectMemoryContent: string = '';
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
@@ -45,7 +47,7 @@ export class ContextManager {
|
||||
}
|
||||
|
||||
private async discoverMemoryPaths() {
|
||||
const [global, extension, project] = await Promise.all([
|
||||
const [global, extension, project, userProjectMemory] = await Promise.all([
|
||||
getGlobalMemoryPaths(),
|
||||
Promise.resolve(
|
||||
getExtensionMemoryPaths(this.config.getExtensionLoader()),
|
||||
@@ -56,18 +58,25 @@ export class ContextManager {
|
||||
this.config.getMemoryBoundaryMarkers(),
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
getUserProjectMemoryPaths(this.config.storage.getProjectMemoryDir()),
|
||||
]);
|
||||
|
||||
return { global, extension, project };
|
||||
return { global, extension, project, userProjectMemory };
|
||||
}
|
||||
|
||||
private async loadMemoryContents(paths: {
|
||||
global: string[];
|
||||
extension: string[];
|
||||
project: string[];
|
||||
userProjectMemory: string[];
|
||||
}) {
|
||||
const allPathsStringDeduped = Array.from(
|
||||
new Set([...paths.global, ...paths.extension, ...paths.project]),
|
||||
new Set([
|
||||
...paths.global,
|
||||
...paths.extension,
|
||||
...paths.project,
|
||||
...paths.userProjectMemory,
|
||||
]),
|
||||
);
|
||||
|
||||
// deduplicate by file identity to handle case-insensitive filesystems
|
||||
@@ -97,13 +106,19 @@ export class ContextManager {
|
||||
}
|
||||
|
||||
private categorizeMemoryContents(
|
||||
paths: { global: string[]; extension: string[]; project: string[] },
|
||||
paths: {
|
||||
global: string[];
|
||||
extension: string[];
|
||||
project: string[];
|
||||
userProjectMemory: string[];
|
||||
},
|
||||
contentsMap: Map<string, GeminiFileContent>,
|
||||
) {
|
||||
const hierarchicalMemory = categorizeAndConcatenate(paths, contentsMap);
|
||||
|
||||
this.globalMemory = hierarchicalMemory.global || '';
|
||||
this.extensionMemory = hierarchicalMemory.extension || '';
|
||||
this.userProjectMemoryContent = hierarchicalMemory.userProjectMemory || '';
|
||||
|
||||
const mcpInstructions =
|
||||
this.config.getMcpClientManager()?.getMcpInstructions() || '';
|
||||
@@ -174,6 +189,10 @@ export class ContextManager {
|
||||
return this.projectMemory;
|
||||
}
|
||||
|
||||
getUserProjectMemory(): string {
|
||||
return this.userProjectMemoryContent;
|
||||
}
|
||||
|
||||
private markAsLoaded(paths: string[]): void {
|
||||
paths.forEach((p) => this.loadedPaths.add(p));
|
||||
}
|
||||
|
||||
@@ -488,6 +488,14 @@ export class ModelConfigService {
|
||||
);
|
||||
}
|
||||
|
||||
// Automatically strip thinkingConfig if the model does not support thinking.
|
||||
const modelDefinition = this.getModelDefinition(resolved.model);
|
||||
if (modelDefinition && modelDefinition.features?.thinking === false) {
|
||||
if (resolved.generateContentConfig.thinkingConfig) {
|
||||
delete resolved.generateContentConfig.thinkingConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
model: resolved.model,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ToolOutputDistillationService } from './toolDistillationService.js';
|
||||
import type { Config, Part } from '../index.js';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
|
||||
describe('ToolOutputDistillationService', () => {
|
||||
let mockConfig: Config;
|
||||
let mockGeminiClient: GeminiClient;
|
||||
let service: ToolOutputDistillationService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
getToolMaxOutputTokens: vi.fn().mockReturnValue(100),
|
||||
getToolSummarizationThresholdTokens: vi.fn().mockReturnValue(100),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini'),
|
||||
},
|
||||
telemetry: {
|
||||
logEvent: vi.fn(),
|
||||
},
|
||||
} as unknown as Config;
|
||||
mockGeminiClient = {
|
||||
generateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'Mock Intent Summary' }] } }],
|
||||
}),
|
||||
} as unknown as GeminiClient;
|
||||
service = new ToolOutputDistillationService(
|
||||
mockConfig,
|
||||
mockGeminiClient,
|
||||
'test-prompt-id',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate a structural map for oversized content within limits', async () => {
|
||||
// > threshold * SUMMARIZATION_THRESHOLD (100 * 4 = 400)
|
||||
const largeContent = 'A'.repeat(500);
|
||||
const result = await service.distill('test-tool', 'call-1', largeContent);
|
||||
|
||||
expect(mockGeminiClient.generateContent).toHaveBeenCalled();
|
||||
const text =
|
||||
typeof result.truncatedContent === 'string'
|
||||
? result.truncatedContent
|
||||
: (result.truncatedContent as Array<{ text: string }>)[0].text;
|
||||
expect(text).toContain('Strategic Significance');
|
||||
});
|
||||
|
||||
it('should structurally truncate functionResponse while preserving schema', async () => {
|
||||
// threshold is 100
|
||||
const hugeValue = 'H'.repeat(1000);
|
||||
const content = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test_tool',
|
||||
id: '123',
|
||||
response: {
|
||||
stdout: hugeValue,
|
||||
stderr: 'no error',
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as Part[];
|
||||
|
||||
const result = await service.distill('test-tool', 'call-1', content);
|
||||
const truncatedParts = result.truncatedContent as Part[];
|
||||
expect(truncatedParts.length).toBe(1);
|
||||
const fr = truncatedParts[0].functionResponse!;
|
||||
const resp = fr.response as Record<string, unknown>;
|
||||
expect(fr.name).toBe('test_tool');
|
||||
expect(resp['stderr']).toBe('no error');
|
||||
expect(resp['stdout'] as string).toContain('[Message Normalized');
|
||||
expect(resp['stdout'] as string).toContain('Full output saved to');
|
||||
});
|
||||
|
||||
it('should skip structural map for extremely large content exceeding MAX_DISTILLATION_SIZE', async () => {
|
||||
const massiveContent = 'A'.repeat(1_000_001); // > MAX_DISTILLATION_SIZE
|
||||
const result = await service.distill('test-tool', 'call-2', massiveContent);
|
||||
|
||||
expect(mockGeminiClient.generateContent).not.toHaveBeenCalled();
|
||||
const text =
|
||||
typeof result.truncatedContent === 'string'
|
||||
? result.truncatedContent
|
||||
: (result.truncatedContent as Array<{ text: string }>)[0].text;
|
||||
expect(text).not.toContain('Strategic Significance');
|
||||
});
|
||||
|
||||
it('should skip structural map for content below summarization threshold', async () => {
|
||||
// > threshold but < threshold * SUMMARIZATION_THRESHOLD
|
||||
const mediumContent = 'A'.repeat(110);
|
||||
const result = await service.distill('test-tool', 'call-3', mediumContent);
|
||||
|
||||
expect(mockGeminiClient.generateContent).not.toHaveBeenCalled();
|
||||
expect(result.truncatedContent).not.toContain('Mock Intent Summary');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
LlmRole,
|
||||
ToolOutputTruncatedEvent,
|
||||
logToolOutputTruncated,
|
||||
debugLogger,
|
||||
type Config,
|
||||
} from '../index.js';
|
||||
import type { PartListUnion } from '@google/genai';
|
||||
import { type GeminiClient } from '../core/client.js';
|
||||
import { saveTruncatedToolOutput } from '../utils/fileUtils.js';
|
||||
import {
|
||||
READ_FILE_TOOL_NAME,
|
||||
READ_MANY_FILES_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
|
||||
import {
|
||||
truncateProportionally,
|
||||
TOOL_TRUNCATION_PREFIX,
|
||||
MIN_TARGET_TOKENS,
|
||||
estimateCharsFromTokens,
|
||||
normalizeFunctionResponse,
|
||||
} from '../utils/truncation.js';
|
||||
|
||||
// Skip structural map generation for outputs larger than this threshold (in characters)
|
||||
// as it consumes excessive tokens and may not be representative of the full content.
|
||||
const MAX_DISTILLATION_SIZE = 1_000_000;
|
||||
|
||||
export interface DistilledToolOutput {
|
||||
truncatedContent: PartListUnion;
|
||||
outputFile?: string;
|
||||
}
|
||||
|
||||
export class ToolOutputDistillationService {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly geminiClient: GeminiClient,
|
||||
private readonly promptId: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Distills a tool's output if it exceeds configured length thresholds, preserving
|
||||
* the agent's context window. This includes saving the raw output to disk, replacing
|
||||
* the output with a truncated placeholder, and optionally summarizing the output
|
||||
* via a secondary LLM call if the output is massively oversized.
|
||||
*/
|
||||
async distill(
|
||||
toolName: string,
|
||||
callId: string,
|
||||
content: PartListUnion,
|
||||
): Promise<DistilledToolOutput> {
|
||||
// Explicitly bypass escape hatches that natively handle large outputs
|
||||
if (this.isExemptFromDistillation(toolName)) {
|
||||
return { truncatedContent: content };
|
||||
}
|
||||
|
||||
const maxTokens = this.config.getToolMaxOutputTokens();
|
||||
const thresholdChars = maxTokens * 4;
|
||||
if (thresholdChars <= 0) {
|
||||
return { truncatedContent: content };
|
||||
}
|
||||
|
||||
const originalContentLength = this.calculateContentLength(content);
|
||||
|
||||
if (originalContentLength > thresholdChars) {
|
||||
return this.performDistillation(
|
||||
toolName,
|
||||
callId,
|
||||
content,
|
||||
originalContentLength,
|
||||
thresholdChars,
|
||||
);
|
||||
}
|
||||
|
||||
return { truncatedContent: content };
|
||||
}
|
||||
|
||||
private isExemptFromDistillation(toolName: string): boolean {
|
||||
return (
|
||||
toolName === READ_FILE_TOOL_NAME || toolName === READ_MANY_FILES_TOOL_NAME
|
||||
);
|
||||
}
|
||||
|
||||
private calculateContentLength(content: PartListUnion): number {
|
||||
if (typeof content === 'string') {
|
||||
return content.length;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content.reduce((acc, part) => {
|
||||
if (typeof part === 'string') return acc + part.length;
|
||||
if (part.text) return acc + part.text.length;
|
||||
if (part.functionResponse?.response) {
|
||||
// Estimate length of the response object
|
||||
return acc + JSON.stringify(part.functionResponse.response).length;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private stringifyContent(content: PartListUnion): string {
|
||||
if (typeof content === 'string') return content;
|
||||
// For arrays or other objects, we preserve the structural JSON to maintain
|
||||
// the ability to reconstruct the parts if needed from the saved output.
|
||||
return JSON.stringify(content, null, 2);
|
||||
}
|
||||
|
||||
private async performDistillation(
|
||||
toolName: string,
|
||||
callId: string,
|
||||
content: PartListUnion,
|
||||
originalContentLength: number,
|
||||
threshold: number,
|
||||
): Promise<DistilledToolOutput> {
|
||||
const stringifiedContent = this.stringifyContent(content);
|
||||
|
||||
// Save the raw, untruncated string to disk for human review
|
||||
const { outputFile: savedPath } = await saveTruncatedToolOutput(
|
||||
stringifiedContent,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.promptId,
|
||||
);
|
||||
|
||||
// If the output is massively oversized, attempt to generate an intent summary
|
||||
let intentSummaryText = '';
|
||||
const summarizationThresholdTokens =
|
||||
this.config.getToolSummarizationThresholdTokens();
|
||||
const summarizationThresholdChars = summarizationThresholdTokens * 4;
|
||||
|
||||
if (
|
||||
originalContentLength > summarizationThresholdChars &&
|
||||
originalContentLength <= MAX_DISTILLATION_SIZE
|
||||
) {
|
||||
const summary = await this.generateIntentSummary(
|
||||
toolName,
|
||||
stringifiedContent,
|
||||
Math.floor(MAX_DISTILLATION_SIZE),
|
||||
);
|
||||
|
||||
if (summary) {
|
||||
intentSummaryText = `\n\n--- Strategic Significance of Truncated Content ---\n${summary}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform structural truncation
|
||||
const ratio = threshold / originalContentLength;
|
||||
const truncatedContent = this.truncateContentStructurally(
|
||||
content,
|
||||
ratio,
|
||||
savedPath || 'Output offloaded to disk',
|
||||
intentSummaryText,
|
||||
);
|
||||
|
||||
logToolOutputTruncated(
|
||||
this.config,
|
||||
new ToolOutputTruncatedEvent(this.promptId, {
|
||||
toolName,
|
||||
originalContentLength,
|
||||
truncatedContentLength: this.calculateContentLength(truncatedContent),
|
||||
threshold,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
truncatedContent,
|
||||
outputFile: savedPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates content while maintaining its Part structure.
|
||||
*/
|
||||
private truncateContentStructurally(
|
||||
content: PartListUnion,
|
||||
ratio: number,
|
||||
savedPath: string,
|
||||
intentSummary: string,
|
||||
): PartListUnion {
|
||||
if (typeof content === 'string') {
|
||||
const targetTokens = Math.max(
|
||||
MIN_TARGET_TOKENS,
|
||||
Math.floor((content.length / 4) * ratio),
|
||||
);
|
||||
const targetChars = estimateCharsFromTokens(content, targetTokens);
|
||||
|
||||
return (
|
||||
truncateProportionally(content, targetChars, TOOL_TRUNCATION_PREFIX) +
|
||||
`\n\nFull output saved to: ${savedPath}` +
|
||||
intentSummary
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return content;
|
||||
|
||||
return content.map((part) => {
|
||||
if (typeof part === 'string') {
|
||||
const text = part;
|
||||
const targetTokens = Math.max(
|
||||
MIN_TARGET_TOKENS,
|
||||
Math.floor((text.length / 4) * ratio),
|
||||
);
|
||||
const targetChars = estimateCharsFromTokens(text, targetTokens);
|
||||
return truncateProportionally(
|
||||
text,
|
||||
targetChars,
|
||||
TOOL_TRUNCATION_PREFIX,
|
||||
);
|
||||
}
|
||||
|
||||
if (part.text) {
|
||||
const text = part.text;
|
||||
const targetTokens = Math.max(
|
||||
MIN_TARGET_TOKENS,
|
||||
Math.floor((text.length / 4) * ratio),
|
||||
);
|
||||
const targetChars = estimateCharsFromTokens(text, targetTokens);
|
||||
return {
|
||||
text:
|
||||
truncateProportionally(text, targetChars, TOOL_TRUNCATION_PREFIX) +
|
||||
`\n\nFull output saved to: ${savedPath}` +
|
||||
intentSummary,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.functionResponse) {
|
||||
return normalizeFunctionResponse(
|
||||
part,
|
||||
ratio,
|
||||
0.2, // default headRatio
|
||||
savedPath,
|
||||
intentSummary,
|
||||
);
|
||||
}
|
||||
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the secondary model to distill the strategic "why" signals and intent
|
||||
* of the truncated content before it is offloaded.
|
||||
*/
|
||||
private async generateIntentSummary(
|
||||
toolName: string,
|
||||
stringifiedContent: string,
|
||||
maxPreviewLen: number,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout
|
||||
|
||||
const promptText = `The following output from the tool '${toolName}' is large and has been truncated. Extract the most critical factual information from this output so the main agent doesn't lose context.
|
||||
|
||||
Focus strictly on concrete data points:
|
||||
1. Exact error messages, exception types, or exit codes.
|
||||
2. Specific file paths or line numbers mentioned.
|
||||
3. Definitive outcomes (e.g., 'Compilation succeeded', '3 tests failed').
|
||||
|
||||
Do not philosophize about the strategic intent. Keep the extraction under 10 lines and use exact quotes where helpful.
|
||||
|
||||
Output to summarize:
|
||||
${stringifiedContent.slice(0, maxPreviewLen)}...`;
|
||||
|
||||
const summaryResponse = await this.geminiClient.generateContent(
|
||||
{ model: 'agent-history-provider-summarizer' },
|
||||
[{ role: 'user', parts: [{ text: promptText }] }],
|
||||
controller.signal,
|
||||
LlmRole.UTILITY_COMPRESSOR,
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
return summaryResponse.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
} catch (e) {
|
||||
// Fail gracefully, summarization is a progressive enhancement
|
||||
debugLogger.debug(
|
||||
'Failed to generate intent summary for truncated output:',
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface AgentHistoryProviderConfig {
|
||||
maxTokens: number;
|
||||
retainedTokens: number;
|
||||
normalMessageTokens: number;
|
||||
maximumMessageTokens: number;
|
||||
normalizationHeadRatio: number;
|
||||
isSummarizationEnabled: boolean;
|
||||
isTruncationEnabled: boolean;
|
||||
}
|
||||
+23
-7
@@ -640,13 +640,13 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
|
||||
exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snapshot for tool: save_memory 1`] = `
|
||||
{
|
||||
"description": "
|
||||
Saves concise global user context (preferences, facts) for use across ALL workspaces.
|
||||
Saves concise user context (preferences, facts) for use across future sessions.
|
||||
|
||||
### CRITICAL: GLOBAL CONTEXT ONLY
|
||||
NEVER save workspace-specific context, local paths, or commands (e.g. "The entry point is src/index.js", "The test command is npm test"). These are local to the current workspace and must NOT be saved globally. EXCLUSIVELY for context relevant across ALL workspaces.
|
||||
Supports two scopes:
|
||||
- **global** (default): Cross-project preferences loaded in every workspace. Use for "Remember X" or clear personal facts.
|
||||
- **project**: Facts specific to the current workspace, private to the user (not committed to the repo). Use for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
|
||||
- Use for "Remember X" or clear personal facts.
|
||||
- Do NOT use for session context.",
|
||||
Do NOT use for session-specific context or temporary data.",
|
||||
"name": "save_memory",
|
||||
"parametersJsonSchema": {
|
||||
"additionalProperties": false,
|
||||
@@ -655,6 +655,14 @@ NEVER save workspace-specific context, local paths, or commands (e.g. "The entry
|
||||
"description": "The specific fact or piece of information to remember. Should be a clear, self-contained statement.",
|
||||
"type": "string",
|
||||
},
|
||||
"scope": {
|
||||
"description": "Where to save the memory. 'global' (default) saves to a file loaded in every workspace. 'project' saves to a project-specific file private to the user, not committed to the repo.",
|
||||
"enum": [
|
||||
"global",
|
||||
"project",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"fact",
|
||||
@@ -1433,13 +1441,21 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
|
||||
|
||||
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: save_memory 1`] = `
|
||||
{
|
||||
"description": "Persists global preferences or facts across ALL future sessions. Use this for recurring instructions like coding styles or tool aliases. Unlike 'write_file', which is for project-specific files, this appends to a global memory file loaded in every workspace. If you are unsure whether a fact should be remembered globally, ask the user first. CRITICAL: Do not use for session-specific context or temporary data.",
|
||||
"description": "Persists preferences or facts across ALL future sessions. Supports two scopes: 'global' (default) for cross-project preferences loaded in every workspace, and 'project' for facts specific to the current workspace that are private to the user (not committed to the repo). Use 'project' scope for things like local dev setup notes, project-specific workflows, or personal reminders about this codebase. CRITICAL: Do not use for session-specific context or temporary data.",
|
||||
"name": "save_memory",
|
||||
"parametersJsonSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fact": {
|
||||
"description": "A concise, global fact or preference (e.g., 'I prefer using tabs'). Do not include local paths or project-specific names.",
|
||||
"description": "A concise fact or preference to remember. Should be a clear, self-contained statement.",
|
||||
"type": "string",
|
||||
},
|
||||
"scope": {
|
||||
"description": "Where to save the memory. 'global' (default) saves to a file loaded in every workspace. 'project' saves to a project-specific file private to the user, not committed to the repo.",
|
||||
"enum": [
|
||||
"global",
|
||||
"project",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -92,6 +92,7 @@ export const READ_MANY_PARAM_USE_DEFAULT_EXCLUDES = 'useDefaultExcludes';
|
||||
// -- save_memory --
|
||||
export const MEMORY_TOOL_NAME = 'save_memory';
|
||||
export const MEMORY_PARAM_FACT = 'fact';
|
||||
export const MEMORY_PARAM_SCOPE = 'scope';
|
||||
|
||||
// -- get_internal_docs --
|
||||
export const GET_INTERNAL_DOCS_TOOL_NAME = 'get_internal_docs';
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
READ_MANY_PARAM_RECURSIVE,
|
||||
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
|
||||
MEMORY_PARAM_FACT,
|
||||
MEMORY_PARAM_SCOPE,
|
||||
TODOS_PARAM_TODOS,
|
||||
TODOS_ITEM_PARAM_DESCRIPTION,
|
||||
TODOS_ITEM_PARAM_STATUS,
|
||||
@@ -513,13 +514,13 @@ Use this tool when the user's query implies needing the content of several files
|
||||
save_memory: {
|
||||
name: MEMORY_TOOL_NAME,
|
||||
description: `
|
||||
Saves concise global user context (preferences, facts) for use across ALL workspaces.
|
||||
Saves concise user context (preferences, facts) for use across future sessions.
|
||||
|
||||
### CRITICAL: GLOBAL CONTEXT ONLY
|
||||
NEVER save workspace-specific context, local paths, or commands (e.g. "The entry point is src/index.js", "The test command is npm test"). These are local to the current workspace and must NOT be saved globally. EXCLUSIVELY for context relevant across ALL workspaces.
|
||||
Supports two scopes:
|
||||
- **global** (default): Cross-project preferences loaded in every workspace. Use for "Remember X" or clear personal facts.
|
||||
- **project**: Facts specific to the current workspace, private to the user (not committed to the repo). Use for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
|
||||
|
||||
- Use for "Remember X" or clear personal facts.
|
||||
- Do NOT use for session context.`,
|
||||
Do NOT use for session-specific context or temporary data.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -528,6 +529,12 @@ NEVER save workspace-specific context, local paths, or commands (e.g. "The entry
|
||||
description:
|
||||
'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
|
||||
},
|
||||
[MEMORY_PARAM_SCOPE]: {
|
||||
type: 'string',
|
||||
enum: ['global', 'project'],
|
||||
description:
|
||||
"Where to save the memory. 'global' (default) saves to a file loaded in every workspace. 'project' saves to a project-specific file private to the user, not committed to the repo.",
|
||||
},
|
||||
},
|
||||
required: [MEMORY_PARAM_FACT],
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
READ_MANY_PARAM_RECURSIVE,
|
||||
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
|
||||
MEMORY_PARAM_FACT,
|
||||
MEMORY_PARAM_SCOPE,
|
||||
TODOS_PARAM_TODOS,
|
||||
TODOS_ITEM_PARAM_DESCRIPTION,
|
||||
TODOS_ITEM_PARAM_STATUS,
|
||||
@@ -495,14 +496,20 @@ Use this tool when the user's query implies needing the content of several files
|
||||
|
||||
save_memory: {
|
||||
name: MEMORY_TOOL_NAME,
|
||||
description: `Persists global preferences or facts across ALL future sessions. Use this for recurring instructions like coding styles or tool aliases. Unlike '${WRITE_FILE_TOOL_NAME}', which is for project-specific files, this appends to a global memory file loaded in every workspace. If you are unsure whether a fact should be remembered globally, ask the user first. CRITICAL: Do not use for session-specific context or temporary data.`,
|
||||
description: `Persists preferences or facts across ALL future sessions. Supports two scopes: 'global' (default) for cross-project preferences loaded in every workspace, and 'project' for facts specific to the current workspace that are private to the user (not committed to the repo). Use 'project' scope for things like local dev setup notes, project-specific workflows, or personal reminders about this codebase. CRITICAL: Do not use for session-specific context or temporary data.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[MEMORY_PARAM_FACT]: {
|
||||
type: 'string',
|
||||
description:
|
||||
"A concise, global fact or preference (e.g., 'I prefer using tabs'). Do not include local paths or project-specific names.",
|
||||
'A concise fact or preference to remember. Should be a clear, self-contained statement.',
|
||||
},
|
||||
[MEMORY_PARAM_SCOPE]: {
|
||||
type: 'string',
|
||||
enum: ['global', 'project'],
|
||||
description:
|
||||
"Where to save the memory. 'global' (default) saves to a file loaded in every workspace. 'project' saves to a project-specific file private to the user, not committed to the repo.",
|
||||
},
|
||||
},
|
||||
required: [MEMORY_PARAM_FACT],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { MAX_LINE_LENGTH_TEXT_FILE } from '../utils/constants.js';
|
||||
import type { GrepResult } from './tools.js';
|
||||
|
||||
/**
|
||||
* Result object for a single grep match
|
||||
@@ -148,12 +149,18 @@ export async function formatGrepResults(
|
||||
},
|
||||
searchLocationDescription: string,
|
||||
totalMaxMatches: number,
|
||||
): Promise<{ llmContent: string; returnDisplay: string }> {
|
||||
): Promise<{ llmContent: string; returnDisplay: GrepResult }> {
|
||||
const { pattern, names_only, include_pattern } = params;
|
||||
|
||||
if (allMatches.length === 0) {
|
||||
const noMatchMsg = `No matches found for pattern "${pattern}" ${searchLocationDescription}${include_pattern ? ` (filter: "${include_pattern}")` : ''}.`;
|
||||
return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
|
||||
return {
|
||||
llmContent: noMatchMsg,
|
||||
returnDisplay: {
|
||||
summary: 'No matches found',
|
||||
matches: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const matchesByFile = groupMatchesByFile(allMatches);
|
||||
@@ -181,7 +188,10 @@ export async function formatGrepResults(
|
||||
llmContent += filePaths.join('\n');
|
||||
return {
|
||||
llmContent: llmContent.trim(),
|
||||
returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`,
|
||||
returnDisplay: {
|
||||
summary: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`,
|
||||
matches: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,8 +223,16 @@ export async function formatGrepResults(
|
||||
|
||||
return {
|
||||
llmContent: llmContent.trim(),
|
||||
returnDisplay: `Found ${matchCount} ${matchTerm}${
|
||||
wasTruncated ? ' (limited)' : ''
|
||||
}`,
|
||||
returnDisplay: {
|
||||
summary: `Found ${matchCount} ${matchTerm}${wasTruncated ? ' (limited)' : ''}`,
|
||||
matches: allMatches
|
||||
.filter((m) => !m.isContext)
|
||||
.map((m) => ({
|
||||
filePath: m.filePath,
|
||||
absolutePath: m.absolutePath,
|
||||
lineNumber: m.lineNumber,
|
||||
line: m.line,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { GrepTool, type GrepToolParams } from './grep.js';
|
||||
import type { ToolResult } from './tools.js';
|
||||
import type { ToolResult, GrepResult } from './tools.js';
|
||||
import path from 'node:path';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import fs from 'node:fs/promises';
|
||||
@@ -187,7 +187,9 @@ describe('GrepTool', () => {
|
||||
`File: ${path.join('sub', 'fileC.txt')}`,
|
||||
);
|
||||
expect(result.llmContent).toContain('L1: another world in sub dir');
|
||||
expect(result.returnDisplay).toBe('Found 3 matches');
|
||||
expect((result.returnDisplay as GrepResult)?.summary).toBe(
|
||||
'Found 3 matches',
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it('should include files that start with ".." in JS fallback', async () => {
|
||||
@@ -228,7 +230,9 @@ describe('GrepTool', () => {
|
||||
);
|
||||
expect(result.llmContent).toContain('File: fileC.txt'); // Path relative to 'sub'
|
||||
expect(result.llmContent).toContain('L1: another world in sub dir');
|
||||
expect(result.returnDisplay).toBe('Found 1 match');
|
||||
expect((result.returnDisplay as GrepResult)?.summary).toBe(
|
||||
'Found 1 match',
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it('should find matches with an include glob', async () => {
|
||||
@@ -245,7 +249,9 @@ describe('GrepTool', () => {
|
||||
expect(result.llmContent).toContain(
|
||||
'L2: function baz() { return "hello"; }',
|
||||
);
|
||||
expect(result.returnDisplay).toBe('Found 1 match');
|
||||
expect((result.returnDisplay as GrepResult)?.summary).toBe(
|
||||
'Found 1 match',
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it('should find matches with an include glob and path', async () => {
|
||||
@@ -265,7 +271,9 @@ describe('GrepTool', () => {
|
||||
);
|
||||
expect(result.llmContent).toContain('File: another.js');
|
||||
expect(result.llmContent).toContain('L1: const greeting = "hello";');
|
||||
expect(result.returnDisplay).toBe('Found 1 match');
|
||||
expect((result.returnDisplay as GrepResult)?.summary).toBe(
|
||||
'Found 1 match',
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it('should return "No matches found" when pattern does not exist', async () => {
|
||||
@@ -275,7 +283,9 @@ describe('GrepTool', () => {
|
||||
expect(result.llmContent).toContain(
|
||||
'No matches found for pattern "nonexistentpattern" in the workspace directory.',
|
||||
);
|
||||
expect(result.returnDisplay).toBe('No matches found');
|
||||
expect((result.returnDisplay as GrepResult)?.summary).toBe(
|
||||
'No matches found',
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it('should handle regex special characters correctly', async () => {
|
||||
@@ -501,7 +511,9 @@ describe('GrepTool', () => {
|
||||
expect(result.llmContent).toContain('L2: second line with world');
|
||||
// And sub/fileC.txt should be excluded because limit reached
|
||||
expect(result.llmContent).not.toContain('File: sub/fileC.txt');
|
||||
expect(result.returnDisplay).toBe('Found 2 matches (limited)');
|
||||
expect((result.returnDisplay as GrepResult)?.summary).toBe(
|
||||
'Found 2 matches (limited)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect max_matches_per_file in JS fallback', async () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import { isGitRepository } from '../utils/gitUtils.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { FileExclusions } from '../utils/ignorePatterns.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { GREP_TOOL_NAME } from './tool-names.js';
|
||||
import { GREP_TOOL_NAME, GREP_DISPLAY_NAME } from './tool-names.js';
|
||||
import { buildPatternArgsPattern } from '../policy/utils.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { GREP_DEFINITION } from './definitions/coreTools.js';
|
||||
@@ -653,7 +653,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
) {
|
||||
super(
|
||||
GrepTool.Name,
|
||||
'SearchText',
|
||||
GREP_DISPLAY_NAME,
|
||||
GREP_DEFINITION.base.description!,
|
||||
Kind.Search,
|
||||
GREP_DEFINITION.base.parametersJsonSchema,
|
||||
|
||||
@@ -131,7 +131,10 @@ describe('LSTool', () => {
|
||||
|
||||
expect(result.llmContent).toContain('[DIR] subdir');
|
||||
expect(result.llmContent).toContain('file1.txt');
|
||||
expect(result.returnDisplay).toBe('Listed 2 item(s).');
|
||||
expect(result.returnDisplay).toEqual({
|
||||
summary: 'Listed 2 item(s).',
|
||||
files: ['[DIR] subdir', 'file1.txt'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should list files from secondary workspace directory', async () => {
|
||||
@@ -146,7 +149,10 @@ describe('LSTool', () => {
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('secondary-file.txt');
|
||||
expect(result.returnDisplay).toBe('Listed 1 item(s).');
|
||||
expect(result.returnDisplay).toEqual({
|
||||
summary: 'Listed 1 item(s).',
|
||||
files: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty directories', async () => {
|
||||
@@ -171,7 +177,10 @@ describe('LSTool', () => {
|
||||
|
||||
expect(result.llmContent).toContain('file1.txt');
|
||||
expect(result.llmContent).not.toContain('file2.log');
|
||||
expect(result.returnDisplay).toBe('Listed 1 item(s).');
|
||||
expect(result.returnDisplay).toEqual({
|
||||
summary: 'Listed 1 item(s).',
|
||||
files: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect gitignore patterns', async () => {
|
||||
@@ -185,7 +194,9 @@ describe('LSTool', () => {
|
||||
expect(result.llmContent).toContain('file1.txt');
|
||||
expect(result.llmContent).not.toContain('file2.log');
|
||||
// .git is always ignored by default.
|
||||
expect(result.returnDisplay).toBe('Listed 2 item(s). (2 ignored)');
|
||||
expect(result.returnDisplay).toEqual(
|
||||
expect.objectContaining({ summary: 'Listed 2 item(s). (2 ignored)' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect geminiignore patterns', async () => {
|
||||
@@ -200,7 +211,9 @@ describe('LSTool', () => {
|
||||
|
||||
expect(result.llmContent).toContain('file1.txt');
|
||||
expect(result.llmContent).not.toContain('file2.log');
|
||||
expect(result.returnDisplay).toBe('Listed 2 item(s). (1 ignored)');
|
||||
expect(result.returnDisplay).toEqual(
|
||||
expect.objectContaining({ summary: 'Listed 2 item(s). (1 ignored)' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-directory paths', async () => {
|
||||
@@ -287,7 +300,10 @@ describe('LSTool', () => {
|
||||
// Should still list the other files
|
||||
expect(result.llmContent).toContain('file1.txt');
|
||||
expect(result.llmContent).not.toContain('problematic.txt');
|
||||
expect(result.returnDisplay).toBe('Listed 1 item(s).');
|
||||
expect(result.returnDisplay).toEqual({
|
||||
summary: 'Listed 1 item(s).',
|
||||
files: expect.any(Array),
|
||||
});
|
||||
|
||||
statSpy.mockRestore();
|
||||
});
|
||||
@@ -347,7 +363,10 @@ describe('LSTool', () => {
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('secondary-file.txt');
|
||||
expect(result.returnDisplay).toBe('Listed 1 item(s).');
|
||||
expect(result.returnDisplay).toEqual({
|
||||
summary: 'Listed 1 item(s).',
|
||||
files: expect.any(Array),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { LS_TOOL_NAME } from './tool-names.js';
|
||||
import { LS_TOOL_NAME, LS_DISPLAY_NAME } from './tool-names.js';
|
||||
import { buildDirPathArgsPattern } from '../policy/utils.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { LS_DEFINITION } from './definitions/coreTools.js';
|
||||
@@ -143,7 +143,6 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
|
||||
): ToolResult {
|
||||
return {
|
||||
llmContent,
|
||||
// Keep returnDisplay simpler in core logic
|
||||
returnDisplay: `Error: ${returnDisplay}`,
|
||||
error: {
|
||||
message: llmContent,
|
||||
@@ -284,7 +283,12 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
|
||||
|
||||
return {
|
||||
llmContent: resultMessage,
|
||||
returnDisplay: displayMessage,
|
||||
returnDisplay: {
|
||||
summary: displayMessage,
|
||||
files: entries.map(
|
||||
(entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`,
|
||||
),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
|
||||
@@ -309,7 +313,7 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
|
||||
) {
|
||||
super(
|
||||
LSTool.Name,
|
||||
'ReadFolder',
|
||||
LS_DISPLAY_NAME,
|
||||
LS_DEFINITION.base.description!,
|
||||
Kind.Search,
|
||||
LS_DEFINITION.base.parametersJsonSchema,
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
getCurrentGeminiMdFilename,
|
||||
getAllGeminiMdFilenames,
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
getProjectMemoryFilePath,
|
||||
} from './memoryTool.js';
|
||||
import type { Storage } from '../config/storage.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -113,9 +115,7 @@ describe('MemoryTool', () => {
|
||||
it('should have correct name, displayName, description, and schema', () => {
|
||||
expect(memoryTool.name).toBe('save_memory');
|
||||
expect(memoryTool.displayName).toBe('SaveMemory');
|
||||
expect(memoryTool.description).toContain(
|
||||
'Saves concise global user context',
|
||||
);
|
||||
expect(memoryTool.description).toContain('Saves concise user context');
|
||||
expect(memoryTool.schema).toBeDefined();
|
||||
expect(memoryTool.schema.name).toBe('save_memory');
|
||||
expect(memoryTool.schema.parametersJsonSchema).toStrictEqual({
|
||||
@@ -127,6 +127,12 @@ describe('MemoryTool', () => {
|
||||
description:
|
||||
'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['global', 'project'],
|
||||
description:
|
||||
"Where to save the memory. 'global' (default) saves to a file loaded in every workspace. 'project' saves to a project-specific file private to the user, not committed to the repo.",
|
||||
},
|
||||
},
|
||||
required: ['fact'],
|
||||
});
|
||||
@@ -378,4 +384,93 @@ describe('MemoryTool', () => {
|
||||
expect(() => memoryTool.build(attackParams)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('project-scope memory', () => {
|
||||
const mockProjectMemoryDir = path.join(
|
||||
'/mock',
|
||||
'.gemini',
|
||||
'memory',
|
||||
'test-project',
|
||||
);
|
||||
|
||||
function createMockStorage(): Storage {
|
||||
return {
|
||||
getProjectMemoryDir: () => mockProjectMemoryDir,
|
||||
} as unknown as Storage;
|
||||
}
|
||||
|
||||
it('should reject scope=project when storage is not initialized', () => {
|
||||
const bus = createMockMessageBus();
|
||||
const memoryToolNoStorage = new MemoryTool(bus);
|
||||
const params = { fact: 'project fact', scope: 'project' as const };
|
||||
|
||||
expect(memoryToolNoStorage.validateToolParams(params)).toBe(
|
||||
'Project-level memory is not available: storage is not initialized.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should write to global path when scope is not specified', async () => {
|
||||
const bus = createMockMessageBus();
|
||||
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
|
||||
const memoryToolWithStorage = new MemoryTool(bus, createMockStorage());
|
||||
const params = { fact: 'global fact' };
|
||||
const invocation = memoryToolWithStorage.build(params);
|
||||
await invocation.execute(mockAbortSignal);
|
||||
|
||||
const expectedFilePath = path.join(
|
||||
os.homedir(),
|
||||
GEMINI_DIR,
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedFilePath,
|
||||
expect.any(String),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should write to project memory path when scope is project', async () => {
|
||||
const bus = createMockMessageBus();
|
||||
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
|
||||
const memoryToolWithStorage = new MemoryTool(bus, createMockStorage());
|
||||
const params = {
|
||||
fact: 'project-specific fact',
|
||||
scope: 'project' as const,
|
||||
};
|
||||
const invocation = memoryToolWithStorage.build(params);
|
||||
await invocation.execute(mockAbortSignal);
|
||||
|
||||
const expectedFilePath = path.join(
|
||||
mockProjectMemoryDir,
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(mockProjectMemoryDir, {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedFilePath,
|
||||
expect.stringContaining('- project-specific fact'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use project path in confirmation details when scope is project', async () => {
|
||||
const bus = createMockMessageBus();
|
||||
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
|
||||
const memoryToolWithStorage = new MemoryTool(bus, createMockStorage());
|
||||
const params = { fact: 'project fact', scope: 'project' as const };
|
||||
const invocation = memoryToolWithStorage.build(params);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
|
||||
if (result && result.type === 'edit') {
|
||||
expect(result.fileName).toBe(
|
||||
getProjectMemoryFilePath(createMockStorage()),
|
||||
);
|
||||
expect(result.newContent).toContain('- project fact');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ export function getAllGeminiMdFilenames(): string[] {
|
||||
|
||||
interface SaveMemoryParams {
|
||||
fact: string;
|
||||
scope?: 'global' | 'project';
|
||||
modified_by_user?: boolean;
|
||||
modified_content?: string;
|
||||
}
|
||||
@@ -69,6 +70,10 @@ export function getGlobalMemoryFilePath(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), getCurrentGeminiMdFilename());
|
||||
}
|
||||
|
||||
export function getProjectMemoryFilePath(storage: Storage): string {
|
||||
return path.join(storage.getProjectMemoryDir(), getCurrentGeminiMdFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures proper newline separation before appending content.
|
||||
*/
|
||||
@@ -82,11 +87,11 @@ function ensureNewlineSeparation(currentContent: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current content of the memory file
|
||||
* Reads the current content of a memory file at the given path.
|
||||
*/
|
||||
async function readMemoryFileContent(): Promise<string> {
|
||||
async function readMemoryFileContent(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(getGlobalMemoryFilePath(), 'utf-8');
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const error = err as Error & { code?: string };
|
||||
@@ -146,32 +151,42 @@ class MemoryToolInvocation extends BaseToolInvocation<
|
||||
> {
|
||||
private static readonly allowlist: Set<string> = new Set();
|
||||
private proposedNewContent: string | undefined;
|
||||
private readonly storage: Storage | undefined;
|
||||
|
||||
constructor(
|
||||
params: SaveMemoryParams,
|
||||
messageBus: MessageBus,
|
||||
toolName?: string,
|
||||
displayName?: string,
|
||||
storage?: Storage,
|
||||
) {
|
||||
super(params, messageBus, toolName, displayName);
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
private getMemoryFilePath(): string {
|
||||
if (this.params.scope === 'project' && this.storage) {
|
||||
return getProjectMemoryFilePath(this.storage);
|
||||
}
|
||||
return getGlobalMemoryFilePath();
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const memoryFilePath = getGlobalMemoryFilePath();
|
||||
const memoryFilePath = this.getMemoryFilePath();
|
||||
return `in ${tildeifyPath(memoryFilePath)}`;
|
||||
}
|
||||
|
||||
protected override async getConfirmationDetails(
|
||||
_abortSignal: AbortSignal,
|
||||
): Promise<ToolEditConfirmationDetails | false> {
|
||||
const memoryFilePath = getGlobalMemoryFilePath();
|
||||
const memoryFilePath = this.getMemoryFilePath();
|
||||
const allowlistKey = memoryFilePath;
|
||||
|
||||
if (MemoryToolInvocation.allowlist.has(allowlistKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentContent = await readMemoryFileContent();
|
||||
const currentContent = await readMemoryFileContent(memoryFilePath);
|
||||
const { fact, modified_by_user, modified_content } = this.params;
|
||||
|
||||
// If an attacker injects modified_content, use it for the diff
|
||||
@@ -213,6 +228,7 @@ class MemoryToolInvocation extends BaseToolInvocation<
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const { fact, modified_by_user, modified_content } = this.params;
|
||||
const memoryFilePath = this.getMemoryFilePath();
|
||||
|
||||
try {
|
||||
let contentToWrite: string;
|
||||
@@ -233,17 +249,17 @@ class MemoryToolInvocation extends BaseToolInvocation<
|
||||
// This case can be hit in flows without a confirmation step (e.g., --auto-confirm).
|
||||
// As a fallback, we recompute the content now. This is safe because
|
||||
// computeNewContent sanitizes the input.
|
||||
const currentContent = await readMemoryFileContent();
|
||||
const currentContent = await readMemoryFileContent(memoryFilePath);
|
||||
this.proposedNewContent = computeNewContent(currentContent, fact);
|
||||
}
|
||||
contentToWrite = this.proposedNewContent;
|
||||
successMessage = `Okay, I've remembered that: "${sanitizedFact}"`;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(getGlobalMemoryFilePath()), {
|
||||
await fs.mkdir(path.dirname(memoryFilePath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(getGlobalMemoryFilePath(), contentToWrite, 'utf-8');
|
||||
await fs.writeFile(memoryFilePath, contentToWrite, 'utf-8');
|
||||
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
@@ -275,8 +291,9 @@ export class MemoryTool
|
||||
implements ModifiableDeclarativeTool<SaveMemoryParams>
|
||||
{
|
||||
static readonly Name = MEMORY_TOOL_NAME;
|
||||
private readonly storage: Storage | undefined;
|
||||
|
||||
constructor(messageBus: MessageBus) {
|
||||
constructor(messageBus: MessageBus, storage?: Storage) {
|
||||
super(
|
||||
MemoryTool.Name,
|
||||
'SaveMemory',
|
||||
@@ -287,6 +304,14 @@ export class MemoryTool
|
||||
true,
|
||||
false,
|
||||
);
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
private resolveMemoryFilePath(params: SaveMemoryParams): string {
|
||||
if (params.scope === 'project' && this.storage) {
|
||||
return getProjectMemoryFilePath(this.storage);
|
||||
}
|
||||
return getGlobalMemoryFilePath();
|
||||
}
|
||||
|
||||
protected override validateToolParamValues(
|
||||
@@ -296,6 +321,10 @@ export class MemoryTool
|
||||
return 'Parameter "fact" must be a non-empty string.';
|
||||
}
|
||||
|
||||
if (params.scope === 'project' && !this.storage) {
|
||||
return 'Project-level memory is not available: storage is not initialized.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -310,6 +339,7 @@ export class MemoryTool
|
||||
messageBus,
|
||||
toolName ?? this.name,
|
||||
displayName ?? this.displayName,
|
||||
this.storage,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,11 +349,13 @@ export class MemoryTool
|
||||
|
||||
getModifyContext(_abortSignal: AbortSignal): ModifyContext<SaveMemoryParams> {
|
||||
return {
|
||||
getFilePath: (_params: SaveMemoryParams) => getGlobalMemoryFilePath(),
|
||||
getCurrentContent: async (_params: SaveMemoryParams): Promise<string> =>
|
||||
readMemoryFileContent(),
|
||||
getFilePath: (params: SaveMemoryParams) =>
|
||||
this.resolveMemoryFilePath(params),
|
||||
getCurrentContent: async (params: SaveMemoryParams): Promise<string> =>
|
||||
readMemoryFileContent(this.resolveMemoryFilePath(params)),
|
||||
getProposedContent: async (params: SaveMemoryParams): Promise<string> => {
|
||||
const currentContent = await readMemoryFileContent();
|
||||
const filePath = this.resolveMemoryFilePath(params);
|
||||
const currentContent = await readMemoryFileContent(filePath);
|
||||
const { fact, modified_by_user, modified_content } = params;
|
||||
// Ensure the editor is populated with the same content
|
||||
// that the confirmation diff would show.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user