Compare commits

...

21 Commits

Author SHA1 Message Date
Aishanee Shah a30cc4a8a9 revert: package-lock.json changes 2026-03-28 04:14:09 +00:00
Aishanee Shah 0e169ee968 revert: remove heredoc mandate from legacy system prompt 2026-03-28 04:13:21 +00:00
Aishanee Shah 6c23f0c7e1 feat(core): append write_file suggestion when shell commands with heredocs fail 2026-03-28 04:13:21 +00:00
Aishanee Shah 83792d51c1 feat(core): harden shell tool against heredocs and update system prompt to prefer write_file 2026-03-28 04:13:20 +00:00
Christian Gunderman 07ab16dbbe feat(cli): support 'tab to queue' for messages while generating (#24052) 2026-03-28 01:31:11 +00:00
Abhijit Balaji afc1d50c20 feat(core): implement tool-based topic grouping (Chapters) (#23150)
Co-authored-by: Christian Gunderman <gundermanc@google.com>
2026-03-28 01:28:25 +00:00
Gal Zahavi ae123c547c fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox (#24057) 2026-03-28 00:14:35 +00:00
Keith Guerin c2705e8332 fix(cli): resolve layout contention and flashing loop in StatusRow (#24065) 2026-03-28 00:06:07 +00:00
Sam Roberts 9574855435 Re-word intro to Gemini 3 page. (#24069) 2026-03-28 00:01:22 +00:00
krishdef7 bf6dae4690 fix(hooks): prevent SessionEnd from firing twice in non-interactive mode (#22139)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-27 23:39:48 +00:00
Ivan Porty b5529c2475 fix(core): resolve ACP Operation Aborted Errors in grep_search (#23821)
Co-authored-by: Sri Pasumarthi <sripas@google.com>
2026-03-27 23:12:34 +00:00
Tommaso Sciortino 9e74a7ec18 feat(core): implement windows sandbox expansion and denial detection (#24027) 2026-03-27 22:35:01 +00:00
Sandy Tao 4034c030e7 feat(context): add configurable memoryBoundaryMarkers setting (#24020) 2026-03-27 21:51:32 +00:00
Gal Zahavi 765fb67011 Enable network access in sandbox configuration (#24055) 2026-03-27 21:28:01 +00:00
Jacob Richman 97c99f263a Fix test flakes by globally mocking ink-spinner (#24044) 2026-03-27 21:19:46 +00:00
gemini-cli-robot f1a3c35dee Changelog for v0.36.0-preview.5 (#24046)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-27 21:19:26 +00:00
Jacob Richman ebe98fdee9 Debug command. (#23851)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-27 21:05:22 +00:00
Abhi ba71ffa736 fix(core): switch to subshells for shell tool wrapping to fix heredocs and edge cases (#24024) 2026-03-27 20:34:39 +00:00
joshualitt 320c8aba4c feat(core): Land AgentHistoryProvider. (#23978) 2026-03-27 19:22:35 +00:00
Sri Pasumarthi e7dccabf14 fix(acp): prevent crash on empty response in ACP mode (#23952) 2026-03-27 18:49:13 +00:00
Jacob Richman a84d4d876e Increase memory limited for eslint. (#24022) 2026-03-27 18:40:26 +00:00
86 changed files with 2589 additions and 250 deletions
+8 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.36.0-preview.4
# Preview release: v0.36.0-preview.5
Released: March 26, 2026
Released: March 27, 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,11 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- 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
by @adamfweidman in
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch
@@ -381,4 +386,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.4
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5
+15 -11
View File
@@ -155,17 +155,21 @@ 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` |
| 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` |
| 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` |
### Skills
+1 -1
View File
@@ -1,6 +1,6 @@
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI.
<!-- prettier-ignore -->
> [!NOTE]
+39
View File
@@ -670,6 +670,11 @@ their corresponding top-level category object in your `settings.json` file.
"modelConfig": {
"model": "gemini-3-pro-preview"
}
},
"agent-history-provider-summarizer": {
"modelConfig": {
"model": "gemini-3-flash-preview"
}
}
}
```
@@ -1282,6 +1287,18 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Maximum number of directories to search for memory.
- **Default:** `200`
- **`context.memoryBoundaryMarkers`** (array):
- **Description:** File or directory names that mark the boundary for
GEMINI.md discovery. The upward traversal stops at the first directory
containing any of these markers. An empty array disables parent traversal.
- **Default:**
```json
[".git"]
```
- **Requires restart:** Yes
- **`context.includeDirectories`** (array):
- **Description:** Additional directories to include in the workspace context.
Missing directories will be skipped with a warning.
@@ -1677,6 +1694,28 @@ 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.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.topicUpdateNarration`** (boolean):
- **Description:** Enable the experimental Topic & Update communication model
for reduced chattiness and structured progress reporting.
+7 -6
View File
@@ -86,12 +86,13 @@ available combinations.
#### Text Input
| Command | Action | Keys |
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
| Command | Action | Keys |
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
#### App Controls
+1 -1
View File
@@ -52,7 +52,7 @@
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
"lint": "eslint . --cache --max-warnings 0",
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
"lint:ci": "npm run lint:all",
"lint:all": "node scripts/lint.js",
@@ -109,6 +109,12 @@ 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),
...overrides,
} as unknown as Config;
+27
View File
@@ -28,6 +28,7 @@ import {
LlmRole,
type GitService,
processSingleFileContent,
InvalidStreamError,
} from '@google/gemini-cli-core';
import {
SettingScope,
@@ -785,6 +786,32 @@ describe('Session', () => {
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle prompt with empty response (InvalidStreamError)', async () => {
mockChat.sendMessageStream.mockRejectedValue(
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(mockChat.sendMessageStream).toHaveBeenCalled();
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle prompt with empty response (NO_RESPONSE_TEXT anomaly)', async () => {
mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_RESPONSE_TEXT' });
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(mockChat.sendMessageStream).toHaveBeenCalled();
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
+32
View File
@@ -48,6 +48,7 @@ import {
PREVIEW_GEMINI_MODEL_AUTO,
getDisplayString,
processSingleFileContent,
InvalidStreamError,
type AgentLoopContext,
updatePolicy,
} from '@google/gemini-cli-core';
@@ -851,6 +852,37 @@ export class Session {
return { stopReason: CoreToolCallStatus.Cancelled };
}
if (
error instanceof InvalidStreamError ||
(error &&
typeof error === 'object' &&
'type' in error &&
error.type === 'NO_RESPONSE_TEXT')
) {
// The stream ended with an empty response or malformed tool call.
// Treat this as a graceful end to the model's turn rather than a crash.
return {
stopReason: 'end_turn',
_meta: {
quota: {
token_count: {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
},
model_usage: Array.from(modelUsageMap.entries()).map(
([modelName, counts]) => ({
model: modelName,
token_count: {
input_tokens: counts.input,
output_tokens: counts.output,
},
}),
),
},
},
};
}
throw new acp.RequestError(
getErrorStatus(error) || 500,
getAcpErrorMessage(error),
+4 -6
View File
@@ -989,6 +989,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true,
}),
200, // maxDirs
['.git'], // boundaryMarkers
);
});
@@ -1018,6 +1019,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
@@ -1046,6 +1048,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
});
@@ -1122,12 +1125,7 @@ describe('mergeExcludeTools', () => {
]);
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
settings,
'test-session',
argv,
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
);
+10
View File
@@ -642,6 +642,7 @@ export async function loadCliConfig(
memoryImportFormat,
memoryFileFiltering,
settings.context?.discoveryMaxDirs,
settings.context?.memoryBoundaryMarkers,
);
memoryContent = result.memoryContent;
fileCount = result.fileCount;
@@ -896,6 +897,7 @@ export async function loadCliConfig(
loadMemoryFromIncludeDirectories:
settings.context?.loadMemoryFromIncludeDirectories || false,
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
importFormat: settings.context?.importFormat,
debugMode,
question,
@@ -975,6 +977,14 @@ 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,
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
toolOutputMasking: settings.experimental?.toolOutputMasking,
@@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => {
respectGeminiIgnore: true,
}),
getDiscoveryMaxDirs: () => 200,
getMemoryBoundaryMarkers: () => ['.git'],
getMcpClientManager: () => ({
getMcpInstructions: () => '',
startExtension: vi.fn().mockResolvedValue(undefined),
+18 -18
View File
@@ -93,7 +93,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'lxc',
image: 'default/image',
});
@@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'podman',
image: 'default/image',
});
@@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'podman',
image: 'default/image',
});
@@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'env/image',
});
@@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => {
enabled: true,
command: 'podman',
allowedPaths: [],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => {
enabled: true,
image: 'custom/image',
allowedPaths: [],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: false,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: true,
allowedPaths: ['/settings-path'],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
@@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
@@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
@@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
+2 -2
View File
@@ -131,7 +131,7 @@ export async function loadSandboxConfig(
let sandboxValue: boolean | string | null | undefined;
let allowedPaths: string[] = [];
let networkAccess = false;
let networkAccess = true;
let customImage: string | undefined;
if (
@@ -142,7 +142,7 @@ export async function loadSandboxConfig(
const config = sandboxOption;
sandboxValue = config.enabled ? (config.command ?? true) : false;
allowedPaths = config.allowedPaths ?? [];
networkAccess = config.networkAccess ?? false;
networkAccess = config.networkAccess ?? true;
customImage = config.image;
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
sandboxValue = sandboxOption;
+53
View File
@@ -1291,6 +1291,19 @@ const SETTINGS_SCHEMA = {
description: 'Maximum number of directories to search for memory.',
showInDialog: true,
},
memoryBoundaryMarkers: {
type: 'array',
label: 'Memory Boundary Markers',
category: 'Context',
requiresRestart: true,
default: ['.git'] as string[],
description:
'File or directory names that mark the boundary for GEMINI.md discovery. ' +
'The upward traversal stops at the first directory containing any of these markers. ' +
'An empty array disables parent traversal.',
showInDialog: false,
items: { type: 'string' },
},
includeDirectories: {
type: 'array',
label: 'Include Directories',
@@ -2141,6 +2154,46 @@ 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: {
type: 'boolean',
label: 'Agent History Truncation',
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.',
showInDialog: true,
},
topicUpdateNarration: {
type: 'boolean',
label: 'Topic & Update Narration',
-5
View File
@@ -671,11 +671,6 @@ export async function main() {
}
}
// Register SessionEnd hook for graceful exit
registerCleanup(async () => {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
if (!input) {
debugLogger.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
+86 -4
View File
@@ -6,7 +6,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { main } from './gemini.js';
import { debugLogger, type Config } from '@google/gemini-cli-core';
import {
debugLogger,
SessionEndReason,
type Config,
type HookSystem,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -197,11 +202,11 @@ describe('gemini.tsx main function cleanup', () => {
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
} as unknown as ReturnType<typeof loadSettings>);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: vi.fn(() => false),
getQuestion: vi.fn(() => 'test'),
@@ -238,7 +243,8 @@ describe('gemini.tsx main function cleanup', () => {
setTerminalBackground: vi.fn(),
refreshAuth: vi.fn(),
getRemoteAdminSettings: vi.fn(() => undefined),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
getUseAlternateBuffer: vi.fn(() => false),
} as unknown as Config);
await main();
@@ -248,4 +254,80 @@ describe('gemini.tsx main function cleanup', () => {
expect.objectContaining({ message: 'Cleanup failed' }),
);
});
it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { registerCleanup } = await import('./utils/cleanup.js');
const mockHookSystem = {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
} as unknown as HookSystem;
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
vi.mocked(loadCliConfig).mockResolvedValue(
buildMockConfig({
getHookSystem: vi.fn(() => mockHookSystem),
}),
);
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
await main();
const registeredCallbacks = vi
.mocked(registerCleanup)
.mock.calls.map(([fn]) => fn);
for (const fn of registeredCallbacks) await fn();
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Exit,
);
});
function buildMockConfig(overrides: Partial<Config> = {}): Config {
return {
isInteractive: vi.fn(() => false),
getQuestion: vi.fn(() => 'test'),
getSandbox: vi.fn(() => false),
getDebugMode: vi.fn(() => false),
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
getEnableHooks: vi.fn(() => true),
getHookSystem: vi.fn(() => undefined),
initialize: vi.fn(),
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
getContentGeneratorConfig: vi.fn(),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getAcpMode: vi.fn(() => false),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
getListExtensions: vi.fn(() => false),
getListSessions: vi.fn(() => false),
getDeleteSession: vi.fn(() => undefined),
getToolRegistry: vi.fn(),
getExtensions: vi.fn(() => []),
getModel: vi.fn(() => 'gemini-pro'),
getEmbeddingModel: vi.fn(() => 'embedding-001'),
getApprovalMode: vi.fn(() => 'default'),
getCoreTools: vi.fn(() => []),
getTelemetryEnabled: vi.fn(() => false),
getTelemetryLogPromptsEnabled: vi.fn(() => false),
getFileFilteringRespectGitIgnore: vi.fn(() => true),
getOutputFormat: vi.fn(() => 'text'),
getUsageStatisticsEnabled: vi.fn(() => false),
setTerminalBackground: vi.fn(),
refreshAuth: vi.fn(),
getRemoteAdminSettings: vi.fn(() => undefined),
getUseAlternateBuffer: vi.fn(() => false),
...overrides,
} as unknown as Config;
}
});
@@ -163,6 +163,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import type { SpinnerName } from 'cli-spinners';
export function mockInkSpinner() {
vi.mock('ink-spinner', async () => {
const { Text } = await import('ink');
const cliSpinners = (await import('cli-spinners')).default;
return {
default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) {
const spinner = cliSpinners[type];
const frame = spinner ? spinner.frames[0] : '⠋';
return <Text>{frame}</Text>;
},
};
});
}
+1
View File
@@ -568,6 +568,7 @@ const mockUIActions: UIActions = {
handleOverageMenuChoice: vi.fn(),
handleEmptyWalletChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
addMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(),
+2
View File
@@ -2502,6 +2502,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -2593,6 +2594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
placeholder={
vimEnabled
? vimMode === 'INSERT'
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
addMessage: vi.fn(),
};
beforeEach(() => {
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
vi.mocked(clipboardy.read).mockResolvedValue('');
props = {
onQueueMessage: vi.fn(),
buffer: mockBuffer,
onSubmit: vi.fn(),
userMessages: [],
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
unmount();
});
it('queues a message when Tab is pressed during generation', async () => {
props.buffer.setText('A new prompt');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
expect(props.buffer.text).toBe('');
});
unmount();
});
it('shows an error when attempting to queue a slash command', async () => {
props.buffer.setText('/clear');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
'Slash commands cannot be queued',
);
expect(props.onQueueMessage).not.toHaveBeenCalled();
});
unmount();
});
it('shows an error when attempting to queue a shell command', async () => {
props.shellModeActive = true;
props.buffer.setText('ls');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
'Shell commands cannot be queued',
);
expect(props.onQueueMessage).not.toHaveBeenCalled();
});
unmount();
});
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace
@@ -117,6 +117,7 @@ export interface InputPromptProps {
setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState;
popAllMessages?: () => string | undefined;
onQueueMessage?: (message: string) => void;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setQueueErrorMessage,
streamingState,
popAllMessages,
onQueueMessage,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation;
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
const hasTabCompletionInteraction =
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive ||
commandSearchActive;
if (
isGenerating &&
isQueueMessageKey &&
!hasTabCompletionInteraction &&
buffer.text.trim().length > 0
) {
const trimmedMessage = buffer.text.trim();
const isSlash = isSlashCommand(trimmedMessage);
if (isSlash || shellModeActive) {
setQueueErrorMessage(
`${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
);
} else if (onQueueMessage) {
onQueueMessage(buffer.text);
buffer.setText('');
resetCompletionState();
resetReverseSearchCompletionState();
}
resetPlainTabPress();
return true;
}
if (isPlainTab && shellModeActive) {
resetPlainTabPress();
if (!shouldShowSuggestions) {
@@ -1293,6 +1319,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shortcutsHelpVisible,
setShortcutsHelpVisible,
tryLoadQueuedMessages,
onQueueMessage,
setQueueErrorMessage,
resetReverseSearchCompletionState,
setBannerVisible,
activePtyId,
setEmbeddedShellFocused,
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { StatusRow } from './StatusRow.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type TextBuffer } from '../components/shared/text-buffer.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { type ThoughtSummary } from '../types.js';
import { ApprovalMode } from '@google/gemini-cli-core';
vi.mock('../hooks/useComposerStatus.js', () => ({
useComposerStatus: vi.fn(),
}));
describe('<StatusRow />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const defaultUiState: Partial<UIState> = {
currentTip: undefined,
thought: null,
elapsedTime: 0,
currentWittyPhrase: undefined,
activeHooks: [],
buffer: { text: '' } as unknown as TextBuffer,
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
shortcutsHelpVisible: false,
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
shellModeActive: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
it('renders status and tip correctly when they both fit', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
elapsedTime: 5,
currentWittyPhrase: 'I am witty',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking...');
expect(output).toContain('I am witty');
expect(output).toContain('Tip: Test Tip');
});
it('renders correctly when interactive shell is waiting', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: true,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState: defaultUiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)');
});
it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
};
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('Tip: Test Tip');
});
});
+24 -11
View File
@@ -179,7 +179,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setTipWidth(Math.round(entry.contentRect.width));
const width = Math.round(entry.contentRect.width);
// Only update if width > 0 to prevent layout feedback loops
// when the tip is hidden. This ensures we always use the
// intrinsic width for collision detection.
if (width > 0) {
setTipWidth(width);
}
}
});
observer.observe(node);
@@ -230,6 +236,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const showRow1 = showUiDetails || showRow1Minimal;
const showRow2 = showUiDetails || showRow2Minimal;
const onStatusResize = useCallback((width: number) => {
if (width > 0) setStatusWidth(width);
}, []);
const statusNode = (
<StatusNode
showTips={showTips}
@@ -242,7 +252,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={setStatusWidth}
onResize={onStatusResize}
/>
);
@@ -322,20 +332,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<Box
flexShrink={0}
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
marginRight={
isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
showTipLine
? isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
: 0
}
position={showTipLine ? 'relative' : 'absolute'}
{...(showTipLine ? {} : { top: -100, left: -100 })}
>
{/*
We always render the tip node so it can be measured by ResizeObserver,
but we control its visibility based on the collision detection.
We always render the tip node so it can be measured by ResizeObserver.
When hidden, we use absolute positioning so it can still be measured
but doesn't affect the layout of Row 1. This prevents layout loops.
*/}
<Box display={showTipLine ? 'flex' : 'none'}>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
)}
@@ -8,11 +8,6 @@ import { render, cleanup } from '../../../test-utils/render.js';
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
import type { SubagentProgress } from '@google/gemini-cli-core';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Text } from 'ink';
vi.mock('ink-spinner', () => ({
default: () => <Text></Text>,
}));
describe('<SubagentProgressDisplay />', () => {
afterEach(() => {
@@ -70,6 +70,7 @@ export interface UIActions {
handleResumeSession: (session: SessionInfo) => Promise<void>;
handleDeleteSession: (session: SessionInfo) => Promise<void>;
setQueueErrorMessage: (message: string | null) => void;
addMessage: (message: string) => void;
popAllMessages: () => string | undefined;
handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void;
+5
View File
@@ -74,6 +74,7 @@ export enum Command {
// Text Input
SUBMIT = 'input.submit',
QUEUE_MESSAGE = 'input.queueMessage',
NEWLINE = 'input.newline',
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
@@ -354,6 +355,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
// Text Input
// Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT, [new KeyBinding('enter')]],
[Command.QUEUE_MESSAGE, [new KeyBinding('tab')]],
[
Command.NEWLINE,
[
@@ -488,6 +490,7 @@ export const commandCategories: readonly CommandCategory[] = [
title: 'Text Input',
commands: [
Command.SUBMIT,
Command.QUEUE_MESSAGE,
Command.NEWLINE,
Command.OPEN_EXTERNAL_EDITOR,
Command.PASTE_CLIPBOARD,
@@ -593,6 +596,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
// Text Input
[Command.SUBMIT]: 'Submit the current prompt.',
[Command.QUEUE_MESSAGE]:
'Queue the current prompt to be processed after the current task finishes.',
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt or the plan in an external editor.',
+20 -1
View File
@@ -803,7 +803,26 @@ function setupNetworkLogging(
// Flush buffered logs
flushBuffer();
break;
case 'trigger-debugger': {
import('node:inspector')
.then((inspector) => {
inspector.open();
debugLogger.log(
'Node debugger attached. Open chrome://inspect in Chrome to start debugging.',
);
return import('./events.js');
})
.then(({ appEvents, AppEvent, TransientMessageType }) => {
appEvents.emit(AppEvent.TransientMessage, {
message: 'Debugger attached from DevTools.',
type: TransientMessageType.Hint,
});
})
.catch((err) =>
debugLogger.debug('Failed to trigger debugger:', err),
);
break;
}
case 'ping':
sendMessage({ type: 'pong', timestamp: Date.now() });
break;
+4
View File
@@ -8,6 +8,10 @@ import { vi, beforeEach, afterEach } from 'vitest';
import { format } from 'node:util';
import { coreEvents } from '@google/gemini-cli-core';
import { themeManager } from './src/ui/themes/theme-manager.js';
import { mockInkSpinner } from './src/test-utils/mockSpinner.js';
// Globally mock ink-spinner to prevent non-deterministic snapshot/act flakes.
mockInkSpinner();
// Unset CI environment variable so that ink renders dynamically as it does in a real terminal
if (process.env.CI !== undefined) {
+12
View File
@@ -92,6 +92,7 @@ vi.mock('../tools/tool-registry', () => {
ToolRegistryMock.prototype.sortTools = vi.fn();
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
ToolRegistryMock.prototype.getTool = vi.fn();
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
return { ToolRegistry: ToolRegistryMock };
});
@@ -1563,6 +1564,17 @@ describe('Server Config (config.ts)', () => {
expect(config.getSandboxNetworkAccess()).toBe(false);
});
});
it('should have independent TopicState across instances', () => {
const config1 = new Config(baseParams);
const config2 = new Config(baseParams);
config1.topicState.setTopic('Topic 1');
config2.topicState.setTopic('Topic 2');
expect(config1.topicState.getTopic()).toBe('Topic 1');
expect(config2.topicState.getTopic()).toBe('Topic 2');
});
});
describe('GemmaModelRouterSettings', () => {
+45
View File
@@ -36,6 +36,7 @@ import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import { GeminiClient } from '../core/client.js';
@@ -681,6 +682,11 @@ export interface ConfigParameters {
adminSkillsEnabled?: boolean;
experimentalJitContext?: boolean;
experimentalMemoryManager?: boolean;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number;
experimentalAgentHistorySummarization?: boolean;
memoryBoundaryMarkers?: string[];
topicUpdateNarration?: boolean;
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
disableLLMCorrection?: boolean;
@@ -723,6 +729,7 @@ export class Config implements McpContext, AgentLoopContext {
private clientVersion: string;
private fileSystemService: FileSystemService;
private trackerService?: TrackerService;
readonly topicState = new TopicState();
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
readonly modelConfigService: ModelConfigService;
@@ -909,6 +916,11 @@ export class Config implements McpContext, AgentLoopContext {
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;
private readonly planEnabled: boolean;
@@ -1118,6 +1130,15 @@ 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.topicUpdateNarration = params.topicUpdateNarration ?? false;
this.modelSteering = params.modelSteering ?? false;
this.injectionService = new InjectionService(() =>
@@ -2294,10 +2315,30 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalJitContext;
}
getMemoryBoundaryMarkers(): readonly string[] {
return this.memoryBoundaryMarkers;
}
isMemoryManagerEnabled(): boolean {
return this.experimentalMemoryManager;
}
isExperimentalAgentHistoryTruncationEnabled(): boolean {
return this.experimentalAgentHistoryTruncation;
}
getExperimentalAgentHistoryTruncationThreshold(): number {
return this.experimentalAgentHistoryTruncationThreshold;
}
getExperimentalAgentHistoryRetainedMessages(): number {
return this.experimentalAgentHistoryRetainedMessages;
}
isExperimentalAgentHistorySummarizationEnabled(): boolean {
return this.experimentalAgentHistorySummarization;
}
isTopicUpdateNarrationEnabled(): boolean {
return this.topicUpdateNarration;
}
@@ -3315,6 +3356,10 @@ export class Config implements McpContext, AgentLoopContext {
}
};
maybeRegister(UpdateTopicTool, () =>
registry.registerTool(new UpdateTopicTool(this, this.messageBus)),
);
maybeRegister(LSTool, () =>
registry.registerTool(new LSTool(this, this.messageBus)),
);
@@ -243,6 +243,11 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-pro-preview',
},
},
'agent-history-provider-summarizer': {
modelConfig: {
model: 'gemini-3-flash-preview',
},
},
},
overrides: [
{
+47
View File
@@ -279,6 +279,16 @@ 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),
getModelAvailabilityService: vi
.fn()
.mockReturnValue(createAvailabilityServiceMock()),
@@ -704,6 +714,43 @@ describe('Gemini Client (client.ts)', () => {
});
describe('sendMessageStream', () => {
it('calls AgentHistoryProvider.manageHistory when history truncation is enabled', async () => {
// Arrange
mockConfig.isExperimentalAgentHistoryTruncationEnabled = vi
.fn()
.mockReturnValue(true);
const manageHistorySpy = vi
.spyOn(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(client as any).agentHistoryProvider,
'manageHistory',
)
.mockResolvedValue([
{ role: 'user', parts: [{ text: 'preserved message' }] },
]);
mockTurnRunFn.mockReturnValue(
(async function* () {
yield { type: 'content', value: 'Hello' };
})(),
);
// Act
const stream = client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-id-1',
);
await fromAsync(stream);
// Assert
expect(manageHistorySpy).toHaveBeenCalledWith(
expect.any(Array),
expect.any(AbortSignal),
);
});
it('emits a compression event when the context was automatically compressed', async () => {
// Arrange
mockTurnRunFn.mockReturnValue(
+21 -3
View File
@@ -44,6 +44,7 @@ import type {
import type { ContentGenerator } from './contentGenerator.js';
import { LoopDetectionService } from '../services/loopDetectionService.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import { AgentHistoryProvider } from '../services/agentHistoryProvider.js';
import { ideContextStore } from '../ide/ideContext.js';
import {
logContentRetryFailure,
@@ -98,6 +99,7 @@ export class GeminiClient {
private readonly loopDetector: LoopDetectionService;
private readonly compressionService: ChatCompressionService;
private readonly agentHistoryProvider: AgentHistoryProvider;
private readonly toolOutputMaskingService: ToolOutputMaskingService;
private lastPromptId: string;
private currentSequenceModel: string | null = null;
@@ -113,6 +115,12 @@ 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.toolOutputMaskingService = new ToolOutputMaskingService();
this.lastPromptId = this.config.getSessionId();
@@ -613,10 +621,20 @@ export class GeminiClient {
// Check for context window overflow
const modelForLimitCheck = this._getActiveModelForCurrentTurn();
const compressed = await this.tryCompressChat(prompt_id, false, signal);
if (this.config.isExperimentalAgentHistoryTruncationEnabled()) {
const newHistory = await this.agentHistoryProvider.manageHistory(
this.getHistory(),
signal,
);
if (newHistory.length !== this.getHistory().length) {
this.getChat().setHistory(newHistory);
}
} else {
const compressed = await this.tryCompressChat(prompt_id, false, signal);
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
yield { type: GeminiEventType.ChatCompressed, value: compressed };
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
yield { type: GeminiEventType.ChatCompressed, value: compressed };
}
}
const remainingTokenCount =
@@ -59,6 +59,11 @@ describe('Core System Prompt Substitution', () => {
getSkills: vi.fn().mockReturnValue([]),
}),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
isTrackerEnabled: vi.fn().mockReturnValue(false),
isModelSteeringEnabled: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
} as unknown as Config;
});
@@ -113,6 +113,13 @@ decision = "allow"
priority = 70
modes = ["plan"]
# Topic grouping tool is innocuous and used for UI organization.
[[rule]]
toolName = "update_topic"
decision = "allow"
priority = 70
modes = ["plan"]
[[rule]]
toolName = ["ask_user", "save_memory"]
decision = "ask_user"
@@ -55,4 +55,10 @@ priority = 50
[[rule]]
toolName = ["codebase_investigator", "cli_help", "get_internal_docs"]
decision = "allow"
priority = 50
# Topic grouping tool is innocuous and used for UI organization.
[[rule]]
toolName = "update_topic"
decision = "allow"
priority = 50
@@ -7,13 +7,14 @@ allowOverrides = false
[modes.default]
network = false
readonly = true
approvedTools = []
approvedTools = ['cat', 'ls', 'grep', 'head', 'tail', 'less', 'Get-Content', 'dir', 'type', 'findstr', 'Get-ChildItem', 'echo']
allowOverrides = true
[modes.accepting_edits]
network = false
readonly = false
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo']
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo', 'Add-Content', 'Set-Content']
allowOverrides = true
[commands]
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import * as path from 'node:path';
import { loadPoliciesFromToml } from './toml-loader.js';
import { PolicyEngine } from './policy-engine.js';
import { ApprovalMode, PolicyDecision } from './types.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
describe('Topic Tool Policy', () => {
async function loadDefaultPolicies() {
// Path relative to packages/core root
const policiesDir = path.resolve(process.cwd(), 'src/policy/policies');
const getPolicyTier = () => 1; // Default tier
const result = await loadPoliciesFromToml([policiesDir], getPolicyTier);
return result.rules;
}
it('should allow update_topic in DEFAULT mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.DEFAULT,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should allow update_topic in PLAN mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.PLAN,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should allow update_topic in YOLO mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
});
@@ -15,6 +15,8 @@ import { PREVIEW_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';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { TopicState } from '../tools/topicTool.js';
import type { CallableTool } from '@google/genai';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
@@ -53,6 +55,7 @@ describe('PromptProvider', () => {
).getToolRegistry?.() as unknown as ToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
topicState: new TopicState(),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
storage: {
@@ -73,6 +76,8 @@ describe('PromptProvider', () => {
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
getApprovalMode: vi.fn(),
isTrackerEnabled: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
} as unknown as Config;
});
@@ -234,4 +239,67 @@ describe('PromptProvider', () => {
expect(prompt).not.toContain('### APPROVED PLAN PRESERVATION');
});
});
describe('Topic & Update Narration', () => {
beforeEach(() => {
mockConfig.topicState.reset();
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
(mockConfig.getToolRegistry as ReturnType<typeof vi.fn>).mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue([UPDATE_TOPIC_TOOL_NAME]),
getAllTools: vi.fn().mockReturnValue([
new MockTool({
name: UPDATE_TOPIC_TOOL_NAME,
displayName: 'Topic',
}),
]),
});
vi.mocked(mockConfig.getHasAccessToPreviewModel).mockReturnValue(true);
vi.mocked(mockConfig.getGemini31LaunchedSync).mockReturnValue(true);
});
it('should include active topic context when narration is enabled', () => {
mockConfig.topicState.setTopic('Active Chapter');
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('[Active Topic: Active Chapter]');
});
it('should NOT include active topic context when narration is disabled', () => {
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
false,
);
mockConfig.topicState.setTopic('Active Chapter');
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain('[Active Topic: Active Chapter]');
});
it('should filter out update_topic tool when narration is disabled', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
false,
);
// Simulate registry behavior where it filters out update_topic
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue(
[],
);
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue([]);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain(UPDATE_TOPIC_TOOL_NAME);
});
it('should NOT filter out update_topic tool when narration is enabled', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain(`<tool>\`${UPDATE_TOPIC_TOOL_NAME}\`</tool>`);
});
});
});
+13 -2
View File
@@ -57,6 +57,7 @@ export class PromptProvider {
const skills = context.config.getSkillManager().getSkills();
const toolNames = context.toolRegistry.getAllToolNames();
const enabledToolNames = new Set(toolNames);
const approvedPlanPath = context.config.getApprovedPlanPath();
const desiredModel = resolveModel(
@@ -71,7 +72,6 @@ export class PromptProvider {
const activeSnippets = isModernModel ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames();
// --- Context Gathering ---
let planModeToolsList = '';
if (isPlanMode) {
const allTools = context.toolRegistry.getAllTools();
@@ -232,7 +232,18 @@ export class PromptProvider {
);
// Sanitize erratic newlines from composition
const sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
let sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
// Context Reinjection (Active Topic)
if (context.config.isTopicUpdateNarrationEnabled()) {
const activeTopic = context.config.topicState.getTopic();
if (activeTopic) {
const sanitizedTopic = activeTopic
.replace(/\n/g, ' ')
.replace(/\]/g, '');
sanitizedPrompt += `\n\n[Active Topic: ${sanitizedTopic}]`;
}
}
// Write back to file if requested
this.maybeWriteSystemMd(
+19 -40
View File
@@ -10,6 +10,9 @@ import {
EDIT_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
MEMORY_TOOL_NAME,
@@ -230,6 +233,7 @@ Use the following guidelines to optimize your search and read patterns.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your changebehavioral, structural, and stylisticis correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Multi-line File Creation:** ALWAYS use the ${formatToolName(WRITE_FILE_TOOL_NAME)} tool for creating or overwriting files with multiple lines of content. DO NOT use ${formatToolName(SHELL_TOOL_NAME)} with \`cat << 'EOF'\` or similar heredoc patterns, as they are prone to shell parsing errors and internal buffer limits.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. ${options.interactive ? 'For Directives, only clarify if critically underspecified; otherwise, work autonomously.' : 'For Directives, you must work autonomously as no further user input is available.'} You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.${mandateConflictResolution(options.hasHierarchicalMemory)}
@@ -239,7 +243,9 @@ Use the following guidelines to optimize your search and read patterns.
? mandateTopicUpdateModel()
: mandateExplainBeforeActing()
}
- **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)}${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,
)}${mandateContinueWork(options.interactive)}
`.trim();
}
@@ -361,7 +367,7 @@ export function renderOperationalGuidelines(
- **Role:** A senior software engineer and collaborative peer programmer.
- **High-Signal Output:** Focus exclusively on **intent** and **technical rationale**. Avoid conversational filler, apologies, and ${
options.topicUpdateNarration
? 'per-tool explanations.'
? 'unnecessary per-tool explanations.'
: 'mechanical tool-use narration (e.g., "I will now call...").'
}
- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
@@ -614,46 +620,19 @@ function mandateConfirm(interactive: boolean): string {
function mandateTopicUpdateModel(): string {
return `
- **Protocol: Topic Model**
You are an agentic system. You must maintain a visible state log that tracks broad logical phases using a specific header format.
## 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:
- **1. Topic Initialization & Persistence:**
- **The Trigger:** You MUST issue a \`Topic: <Phase> : <Brief Summary>\` header ONLY when beginning a task or when the broad logical nature of the task changes (e.g., transitioning from research to implementation).
- **The Format:** Use exactly \`Topic: <Phase> : <Brief Summary>\` (e.g., \`Topic: <Research> : Researching Agent Skills in the repo\`).
- **Persistence:** Once a Topic is declared, do NOT repeat it for subsequent tool calls or in subsequent messages within that same phase.
- **Start of Task:** Your very first tool execution must be preceded by a Topic header.
- 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(${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(${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.")\`
- **2. Tool Execution Protocol (Zero-Noise):**
- **No Per-Tool Headers:** It is a violation of protocol to print "Topic:" before every tool call.
- **Silent Mode:** No conversational filler, no "I will now...", and no summaries between tools.
- Only the Topic header at the start of a broad phase is permitted to break the silence. Everything in between must be silent.
- **3. Thinking Protocol:**
- Use internal thought blocks to keep track of what tools you have called, plan your next steps, and reason about the task.
- Without reasoning and tracking in thought blocks, you may lose context.
- Always use the required syntax for thought blocks to ensure they remain hidden from the user interface.
- **4. Completion:**
- Only when the entire task is finalized do you provide a **Final Summary**.
**IMPORTANT: Topic Headers vs. Thoughts**
The \`Topic: <Phase> : <Brief Summary>\` header must **NOT** be placed inside a thought block. It must be standard text output so that it is properly rendered and displayed in the UI.
**Correct State Log Example:**
\`\`\`
Topic: <Research> : Researching Agent Skills in the repo
<tool_call 1>
<tool_call 2>
<tool_call 3>
Topic: <Implementation> : Implementing the skill-creator logic
<tool_call 1>
<tool_call 2>
The task is complete. [Final Summary]
\`\`\`
- **Constraint Enforcement:** If you repeat a "Topic:" line without a fundamental shift in work, or if you provide a Topic for every tool call, you have failed the system integrity protocol.`;
`;
}
function mandateExplainBeforeActing(): string {
@@ -187,7 +187,7 @@ export class LinuxSandboxManager implements SandboxManager {
: false;
const workspaceWrite = !isReadonlyMode || isApproved;
const networkAccess =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
this.options.modeConfig?.network || req.policy?.networkAccess || false;
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
@@ -78,7 +78,7 @@ export class MacOsSandboxManager implements SandboxManager {
const workspaceWrite = !isReadonlyMode || isApproved;
const defaultNetwork =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
this.options.modeConfig?.network || req.policy?.networkAccess || false;
// Fetch persistent approvals for this command
const commandName = await getCommandName(req.command, req.args);
@@ -58,6 +58,13 @@ public class GeminiSandbox {
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
struct JOBOBJECT_NET_RATE_CONTROL_INFORMATION {
public ulong MaxBandwidth;
public uint ControlFlags;
public byte DscpTag;
}
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
@@ -70,6 +77,9 @@ public class GeminiSandbox {
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool DuplicateTokenEx(IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes, uint ImpersonationLevel, uint TokenType, out IntPtr phNewToken);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle);
@@ -143,6 +153,8 @@ public class GeminiSandbox {
private const int TokenIntegrityLevel = 25;
private const uint SE_GROUP_INTEGRITY = 0x00000020;
private const uint TOKEN_ALL_ACCESS = 0xF01FF;
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
static int Main(string[] args) {
if (args.Length < 3) {
@@ -182,14 +194,14 @@ public class GeminiSandbox {
IntPtr lowIntegritySid = IntPtr.Zero;
try {
// 1. Create Restricted Token
if (!OpenProcessToken(GetCurrentProcess(), 0x0002 /* TOKEN_DUPLICATE */ | 0x0008 /* TOKEN_QUERY */ | 0x0080 /* TOKEN_ADJUST_DEFAULT */, out hToken)) {
// 1. Duplicate Primary Token
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, out hToken)) {
Console.WriteLine("Error: OpenProcessToken failed (" + Marshal.GetLastWin32Error() + ")");
return 1;
}
// Flags: 0x1 (DISABLE_MAX_PRIVILEGE)
if (!CreateRestrictedToken(hToken, 1, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
// Create a restricted token to strip administrative privileges
if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
Console.WriteLine("Error: CreateRestrictedToken failed (" + Marshal.GetLastWin32Error() + ")");
return 1;
}
@@ -223,6 +235,18 @@ public class GeminiSandbox {
SetInformationJobObject(hJob, 9 /* JobObjectExtendedLimitInformation */, lpJobLimits, (uint)Marshal.SizeOf(jobLimits));
Marshal.FreeHGlobal(lpJobLimits);
if (!networkAccess) {
JOBOBJECT_NET_RATE_CONTROL_INFORMATION netLimits = new JOBOBJECT_NET_RATE_CONTROL_INFORMATION();
netLimits.MaxBandwidth = 1;
netLimits.ControlFlags = 0x1 | 0x2; // ENABLE | MAX_BANDWIDTH
netLimits.DscpTag = 0;
IntPtr lpNetLimits = Marshal.AllocHGlobal(Marshal.SizeOf(netLimits));
Marshal.StructureToPtr(netLimits, lpNetLimits, false);
SetInformationJobObject(hJob, 32 /* JobObjectNetRateControlInformation */, lpNetLimits, (uint)Marshal.SizeOf(netLimits));
Marshal.FreeHGlobal(lpNetLimits);
}
// 4. Handle Internal Commands or External Process
if (command == "__read") {
if (argIndex + 1 >= args.Length) {
@@ -158,7 +158,7 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
persistentPath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
});
@@ -227,13 +227,13 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
path.resolve(testCwd),
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
expect(icaclsArgs).toContainEqual([
path.resolve(allowedPath),
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
} finally {
fs.rmSync(allowedPath, { recursive: true, force: true });
@@ -273,7 +273,7 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
path.resolve(extraWritePath),
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
} finally {
fs.rmSync(extraWritePath, { recursive: true, force: true });
@@ -308,7 +308,7 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).not.toContainEqual([
uncPath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
},
);
@@ -343,12 +343,12 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
longPath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
expect(icaclsArgs).toContainEqual([
devicePath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
},
);
@@ -34,6 +34,7 @@ import {
isStrictlyApproved,
} from './commandSafety.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -66,8 +67,8 @@ export class WindowsSandboxManager implements SandboxManager {
return isDangerousCommand(args);
}
parseDenials(_result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return undefined; // TODO: Implement Windows-specific denial parsing
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parseWindowsSandboxDenials(result);
}
/**
@@ -235,6 +236,10 @@ export class WindowsSandboxManager implements SandboxManager {
false,
};
const defaultNetwork =
this.options.modeConfig?.network || req.policy?.networkAccess || false;
const networkAccess = defaultNetwork || mergedAdditional.network;
// 1. Handle filesystem permissions for Low Integrity
// Grant "Low Mandatory Level" write access to the workspace.
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
@@ -250,7 +255,7 @@ export class WindowsSandboxManager implements SandboxManager {
await this.grantLowIntegrityAccess(this.options.workspace);
}
// Grant "Low Mandatory Level" read access to allowedPaths.
// Grant "Low Mandatory Level" read/write access to allowedPaths.
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
for (const allowedPath of allowedPaths) {
await this.grantLowIntegrityAccess(allowedPath);
@@ -341,10 +346,6 @@ export class WindowsSandboxManager implements SandboxManager {
// GeminiSandbox.exe <network:0|1> <cwd> --forbidden-manifest <path> <command> [args...]
const program = this.helperPath;
const defaultNetwork =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
const networkAccess = defaultNetwork || mergedAdditional.network;
const args = [
networkAccess ? '1' : '0',
req.cwd,
@@ -394,7 +395,11 @@ export class WindowsSandboxManager implements SandboxManager {
}
try {
await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']);
await spawnAsync('icacls', [
resolvedPath,
'/setintegritylevel',
'(OI)(CI)Low',
]);
this.allowedCache.add(resolvedPath);
} catch (e) {
debugLogger.log(
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
describe('parseWindowsSandboxDenials', () => {
it('should detect CMD "Access is denied" and extract paths', () => {
const parsed = parseWindowsSandboxDenials({
output: 'Access is denied.\r\n',
error: new Error('Command failed: dir C:\\Windows\\System32\\config'),
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain('C:\\Windows\\System32\\config');
});
it('should detect PowerShell "Access to the path is denied"', () => {
const parsed = parseWindowsSandboxDenials({
output:
"Set-Content : Access to the path 'C:\\test.txt' is denied.\r\nAt line:1 char:1\r\n",
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain('C:\\test.txt');
});
it('should detect Node.js EPERM on Windows', () => {
const parsed = parseWindowsSandboxDenials({
error: {
message:
"Error: EPERM: operation not permitted, open 'D:\\project\\file.ts'",
},
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain('D:\\project\\file.ts');
});
it('should detect network denial (EACCES)', () => {
const parsed = parseWindowsSandboxDenials({
output: 'Error: listen EACCES: permission denied 0.0.0.0:3000',
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.network).toBe(true);
});
it('should detect native Windows error code 0x80070005', () => {
const parsed = parseWindowsSandboxDenials({
output: 'HRESULT: 0x80070005',
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
// No path in output, but recognized as denial
});
it('should handle extended-length paths', () => {
const parsed = parseWindowsSandboxDenials({
output: 'Access is denied to \\\\?\\C:\\Very\\Long\\Path\\file.txt',
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain(
'\\\\?\\C:\\Very\\Long\\Path\\file.txt',
);
});
it('should detect Windows paths with forward slashes', () => {
const parsed = parseWindowsSandboxDenials({
output:
"Error: EPERM: operation not permitted, open 'C:/project/file.ts'",
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain('C:/project/file.ts');
});
it('should return undefined if no denial detected', () => {
const parsed = parseWindowsSandboxDenials({
output:
'Directory of C:\\Users\r\n03/26/2026 11:40 AM <DIR> .',
} as unknown as ShellExecutionResult);
expect(parsed).toBeUndefined();
});
});
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ParsedSandboxDenial } from '../../services/sandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
/**
* Windows-specific sandbox denial detection.
* Extracts paths from "Access is denied" and related errors.
*/
export function parseWindowsSandboxDenials(
result: ShellExecutionResult,
): ParsedSandboxDenial | undefined {
const output = result.output || '';
const errorOutput = result.error?.message;
const combined = (output + ' ' + (errorOutput || '')).toLowerCase();
const isFileDenial = [
'access is denied',
'access to the path',
'unauthorizedaccessexception',
'0x80070005',
'eperm: operation not permitted',
].some((keyword) => combined.includes(keyword));
const isNetworkDenial = [
'eacces: permission denied',
'an attempt was made to access a socket in a way forbidden by its access permissions',
// 10013 is WSAEACCES
'10013',
].some((keyword) => combined.includes(keyword));
if (!isFileDenial && !isNetworkDenial) {
return undefined;
}
const filePaths = new Set<string>();
// Regex for Windows absolute paths (e.g., C:\Path or \\?\C:\Path)
// Handles drive letters and potentially quoted paths.
// We use two passes: one for quoted paths (which can contain spaces)
// and one for unquoted paths (which end at common separators).
// 1. Quoted paths: 'C:\Foo Bar' or "C:\Foo Bar"
const quotedRegex = /['"]((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^'"]+)['"]/g;
for (const match of output.matchAll(quotedRegex)) {
filePaths.add(match[1]);
}
if (errorOutput) {
for (const match of errorOutput.matchAll(quotedRegex)) {
filePaths.add(match[1]);
}
}
// 2. Unquoted paths or paths in PowerShell error format: PermissionDenied: (C:\path:String)
const generalRegex =
/(?:^|[\s(])((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^"'\s()<>|?*]+)/g;
for (const match of output.matchAll(generalRegex)) {
// Clean up trailing colon which might be part of the error message rather than the path
let p = match[1];
if (p.endsWith(':')) p = p.slice(0, -1);
filePaths.add(p);
}
if (errorOutput) {
for (const match of errorOutput.matchAll(generalRegex)) {
let p = match[1];
if (p.endsWith(':')) p = p.slice(0, -1);
filePaths.add(p);
}
}
return {
network: isNetworkDenial || undefined,
filePaths: filePaths.size > 0 ? Array.from(filePaths) : undefined,
};
}
@@ -74,6 +74,7 @@ import {
type AnyDeclarativeTool,
type AnyToolInvocation,
} from '../tools/tools.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import {
CoreToolCallStatus,
ROOT_SCHEDULER_ID,
@@ -441,6 +442,44 @@ describe('Scheduler (Orchestrator)', () => {
]),
);
});
it('should sort UPDATE_TOPIC_TOOL_NAME to the front of the batch', async () => {
const topicReq: ToolCallRequestInfo = {
callId: 'call-topic',
name: UPDATE_TOPIC_TOOL_NAME,
args: { title: 'New Chapter' },
prompt_id: 'p1',
isClientInitiated: false,
};
const otherReq: ToolCallRequestInfo = {
callId: 'call-other',
name: 'test-tool',
args: {},
prompt_id: 'p1',
isClientInitiated: false,
};
// Mock tool registry to return a tool for update_topic
vi.mocked(mockToolRegistry.getTool).mockImplementation((name) => {
if (name === UPDATE_TOPIC_TOOL_NAME) {
return {
name: UPDATE_TOPIC_TOOL_NAME,
build: vi.fn().mockReturnValue({}),
} as unknown as AnyDeclarativeTool;
}
return mockTool;
});
// Schedule in reverse order (other first, topic second)
await scheduler.schedule([otherReq, topicReq], signal);
// Verify they were enqueued in the correct sorted order (topic first)
const enqueueCalls = vi.mocked(mockStateManager.enqueue).mock.calls;
const lastCall = enqueueCalls[enqueueCalls.length - 1][0];
expect(lastCall[0].request.callId).toBe('call-topic');
expect(lastCall[1].request.callId).toBe('call-other');
});
});
describe('Phase 2: Queue Management', () => {
+9 -1
View File
@@ -26,6 +26,7 @@ import {
type ScheduledToolCall,
} from './types.js';
import { ToolErrorType } from '../tools/tool-error.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { PolicyDecision, type ApprovalMode } from '../policy/types.js';
import {
ToolConfirmationOutcome,
@@ -302,9 +303,16 @@ export class Scheduler {
this.state.clearBatch();
const currentApprovalMode = this.config.getApprovalMode();
// Sort requests to ensure Topic changes happen before actions in the same batch.
const sortedRequests = [...requests].sort((a, b) => {
if (a.name === UPDATE_TOPIC_TOOL_NAME) return -1;
if (b.name === UPDATE_TOPIC_TOOL_NAME) return 1;
return 0;
});
try {
const toolRegistry = this.context.toolRegistry;
const newCalls: ToolCall[] = requests.map((request) => {
const newCalls: ToolCall[] = sortedRequests.map((request) => {
const enrichedRequest: ToolCallRequestInfo = {
...request,
schedulerId: this.schedulerId,
@@ -0,0 +1,17 @@
// 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",
}
`;
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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 type { BaseLlmClient } from '../core/baseLlmClient.js';
describe('AgentHistoryProvider', () => {
let config: Config;
let provider: AgentHistoryProvider;
let generateContentMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
config = {
isExperimentalAgentHistoryTruncationEnabled: vi
.fn()
.mockReturnValue(false),
isExperimentalAgentHistorySummarizationEnabled: vi
.fn()
.mockReturnValue(false),
getBaseLlmClient: vi.fn(),
} as unknown as Config;
generateContentMock = vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'Mock intent summary' }] } }],
} as unknown as GenerateContentResponse);
config.getBaseLlmClient = vi.fn().mockReturnValue({
generateContent: generateContentMock,
} as unknown as BaseLlmClient);
provider = new AgentHistoryProvider(config, {
truncationThreshold: 30,
retainedMessages: 15,
});
});
const createMockHistory = (count: number): Content[] =>
Array.from({ length: count }).map((_, i) => ({
role: i % 2 === 0 ? 'user' : 'model',
parts: [{ text: `Message ${i}` }],
}));
it('should return history unchanged if truncation is disabled', async () => {
vi.spyOn(
config,
'isExperimentalAgentHistoryTruncationEnabled',
).mockReturnValue(false);
const history = createMockHistory(40);
const result = await provider.manageHistory(history);
expect(result).toBe(history);
expect(result.length).toBe(40);
});
it('should return history unchanged if length is under threshold', async () => {
vi.spyOn(
config,
'isExperimentalAgentHistoryTruncationEnabled',
).mockReturnValue(true);
const history = createMockHistory(20); // Threshold is 30
const result = await provider.manageHistory(history);
expect(result).toBe(history);
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);
const history = createMockHistory(35); // Above 30 threshold, should truncate to 15
const result = await provider.manageHistory(history);
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',
);
});
it('should call summarizer and prepend summary when summarization is enabled', async () => {
vi.spyOn(
config,
'isExperimentalAgentHistoryTruncationEnabled',
).mockReturnValue(true);
vi.spyOn(
config,
'isExperimentalAgentHistorySummarizationEnabled',
).mockReturnValue(true);
const history = createMockHistory(35);
const result = await provider.manageHistory(history);
expect(generateContentMock).toHaveBeenCalled();
expect(result.length).toBe(15); // retained messages
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);
generateContentMock.mockRejectedValue(new Error('API Error'));
const history = createMockHistory(35);
const result = await provider.manageHistory(history);
expect(generateContentMock).toHaveBeenCalled();
expect(result.length).toBe(15);
expect(result[0]).toMatchSnapshot();
});
});
@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { Config } from '../config/config.js';
import { getResponseText } from '../utils/partUtils.js';
import { LlmRole } from '../telemetry/llmRole.js';
import { debugLogger } from '../utils/debugLogger.js';
export interface AgentHistoryProviderConfig {
truncationThreshold: number;
retainedMessages: number;
}
export class AgentHistoryProvider {
constructor(
private readonly config: Config,
private readonly providerConfig: AgentHistoryProviderConfig,
) {}
/**
* Evaluates the chat history and performs truncation and summarization if necessary.
* Returns a new array of Content if truncation occurred, otherwise returns the original array.
*/
async manageHistory(
history: readonly Content[],
abortSignal?: AbortSignal,
): Promise<readonly Content[]> {
if (!this.shouldTruncate(history)) {
return history;
}
const { messagesToKeep, messagesToTruncate } =
this.splitHistoryForTruncation(history);
debugLogger.log(
`AgentHistoryProvider: Truncating ${messagesToTruncate.length} messages, retaining ${messagesToKeep.length} messages.`,
);
const summaryText = await this.getSummaryText(
messagesToTruncate,
abortSignal,
);
return this.mergeSummaryWithHistory(summaryText, messagesToKeep);
}
private shouldTruncate(history: readonly Content[]): boolean {
if (!this.config.isExperimentalAgentHistoryTruncationEnabled()) {
return false;
}
return history.length > this.providerConfig.truncationThreshold;
}
private splitHistoryForTruncation(history: readonly Content[]): {
messagesToKeep: readonly Content[];
messagesToTruncate: readonly Content[];
} {
return {
messagesToKeep: history.slice(-this.providerConfig.retainedMessages),
messagesToTruncate: history.slice(
0,
history.length - this.providerConfig.retainedMessages,
),
};
}
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.';
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;
}
}
}
if (lastUserText) {
return `[System Note: Prior conversation history was truncated. The most recent user message before truncation was:]\n\n${lastUserText}`;
}
return defaultNote;
}
private async getSummaryText(
messagesToTruncate: readonly Content[],
abortSignal?: AbortSignal,
): Promise<string> {
if (!this.config.isExperimentalAgentHistorySummarizationEnabled()) {
debugLogger.log(
'AgentHistoryProvider: Summarization disabled, using fallback note.',
);
return this.getFallbackSummaryText(messagesToTruncate);
}
try {
const summary = await this.generateIntentSummary(
messagesToTruncate,
abortSignal,
);
debugLogger.log('AgentHistoryProvider: Summarization successful.');
return summary;
} catch (error) {
debugLogger.log('AgentHistoryProvider: Summarization failed.', error);
return this.getFallbackSummaryText(messagesToTruncate);
}
}
private mergeSummaryWithHistory(
summaryText: string,
messagesToKeep: readonly Content[],
): readonly Content[] {
if (messagesToKeep.length === 0) {
return [{ role: 'user', parts: [{ text: summaryText }] }];
}
// To ensure strict user/model alternating roles required by the Gemini API,
// we merge the summary into the first retained message if it's from the 'user'.
const firstRetainedMessage = messagesToKeep[0];
if (firstRetainedMessage.role === 'user') {
const mergedParts = [
{ text: summaryText },
...(firstRetainedMessage.parts || []),
];
const mergedMessage: Content = {
role: 'user',
parts: mergedParts,
};
return [mergedMessage, ...messagesToKeep.slice(1)];
} else {
const summaryMessage: Content = {
role: 'user',
parts: [{ text: summaryText }],
};
return [summaryMessage, ...messagesToKeep];
}
}
private async generateIntentSummary(
messagesToTruncate: 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.
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.`;
const summaryResponse = await this.config
.getBaseLlmClient()
.generateContent({
modelConfigKey: { model: 'agent-history-provider-summarizer' },
contents: [
...messagesToTruncate,
{
role: 'user',
parts: [{ text: prompt }],
},
],
promptId: 'agent-history-provider',
abortSignal: abortSignal ?? new AbortController().signal,
role: LlmRole.UTILITY_COMPRESSOR,
});
let summary = getResponseText(summaryResponse) ?? '';
summary = summary.replace(/<\/?intent_summary>/g, '').trim();
return `<intent_summary>\n${summary}\n</intent_summary>`;
}
}
@@ -46,6 +46,7 @@ describe('ContextManager', () => {
getMcpInstructions: vi.fn().mockReturnValue('MCP Instructions'),
}),
isTrustedFolder: vi.fn().mockReturnValue(true),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
} as unknown as Config;
contextManager = new ContextManager(mockConfig);
@@ -81,12 +82,14 @@ describe('ContextManager', () => {
await contextManager.refresh();
expect(memoryDiscovery.getGlobalMemoryPaths).toHaveBeenCalled();
expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith([
'/app',
]);
expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith(
['/app'],
['.git'],
);
expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith(
expect.arrayContaining([...globalPaths, ...envPaths]),
'tree',
['.git'],
);
expect(contextManager.getGlobalMemory()).toContain('Global Content');
@@ -172,6 +175,7 @@ describe('ContextManager', () => {
expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith(
['/home/user/.gemini/GEMINI.md', '/app/gemini.md'],
'tree',
['.git'],
);
expect(contextManager.getEnvironmentMemory()).toContain(
'Project Content',
@@ -197,6 +201,7 @@ describe('ContextManager', () => {
['/app'],
expect.any(Set),
expect.any(Set),
['.git'],
);
expect(result).toMatch(/--- Context from: \/app\/src\/GEMINI\.md ---/);
expect(result).toContain('Src Content');
@@ -226,5 +231,25 @@ describe('ContextManager', () => {
expect(memoryDiscovery.loadJitSubdirectoryMemory).not.toHaveBeenCalled();
expect(result).toBe('');
});
it('should pass custom boundary markers from config', async () => {
const customMarkers = ['.monorepo-root', 'package.json'];
vi.mocked(mockConfig.getMemoryBoundaryMarkers).mockReturnValue(
customMarkers,
);
vi.mocked(memoryDiscovery.loadJitSubdirectoryMemory).mockResolvedValue({
files: [],
});
await contextManager.discoverContext('/app/src/file.ts', ['/app']);
expect(memoryDiscovery.loadJitSubdirectoryMemory).toHaveBeenCalledWith(
'/app/src/file.ts',
['/app'],
expect.any(Set),
expect.any(Set),
customMarkers,
);
});
});
});
+6 -3
View File
@@ -51,9 +51,10 @@ export class ContextManager {
getExtensionMemoryPaths(this.config.getExtensionLoader()),
),
this.config.isTrustedFolder()
? getEnvironmentMemoryPaths([
...this.config.getWorkspaceContext().getDirectories(),
])
? getEnvironmentMemoryPaths(
[...this.config.getWorkspaceContext().getDirectories()],
this.config.getMemoryBoundaryMarkers(),
)
: Promise.resolve([]),
]);
@@ -76,6 +77,7 @@ export class ContextManager {
const allContents = await readGeminiMdFiles(
allPaths,
this.config.getImportFormat(),
this.config.getMemoryBoundaryMarkers(),
);
const loadedFilePaths = allContents
@@ -133,6 +135,7 @@ export class ContextManager {
trustedRoots,
this.loadedPaths,
this.loadedFileIdentities,
this.config.getMemoryBoundaryMarkers(),
);
if (result.files.length === 0) {
@@ -10,7 +10,6 @@ import fsPromises from 'node:fs/promises';
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
import {
NoopSandboxManager,
LocalSandboxManager,
sanitizePaths,
findSecretFiles,
isSecretFile,
@@ -374,6 +373,7 @@ describe('SandboxManager', () => {
it.each([
{ platform: 'linux', expected: LinuxSandboxManager },
{ platform: 'darwin', expected: MacOsSandboxManager },
{ platform: 'win32', expected: WindowsSandboxManager },
] as const)(
'should return $expected.name if sandboxing is enabled and platform is $platform',
({ platform, expected }) => {
@@ -386,22 +386,13 @@ describe('SandboxManager', () => {
},
);
it("should return WindowsSandboxManager if sandboxing is enabled with 'windows-native' command on win32", () => {
it('should return WindowsSandboxManager if sandboxing is enabled on win32', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const manager = createSandboxManager(
{ enabled: true, command: 'windows-native' },
{ enabled: true },
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(WindowsSandboxManager);
});
it('should return LocalSandboxManager on win32 if command is not windows-native', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const manager = createSandboxManager(
{ enabled: true, command: 'docker' as unknown as 'windows-native' },
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(LocalSandboxManager);
});
});
});
@@ -33,7 +33,7 @@ export function createSandboxManager(
}
if (sandbox?.enabled) {
if (os.platform() === 'win32' && sandbox?.command === 'windows-native') {
if (os.platform() === 'win32') {
return new WindowsSandboxManager(options);
} else if (os.platform() === 'linux') {
return new LinuxSandboxManager(options);
@@ -256,5 +256,9 @@
"chat-compression-default": {
"model": "gemini-3-pro-preview",
"generateContentConfig": {}
},
"agent-history-provider-summarizer": {
"model": "gemini-3-flash-preview",
"generateContentConfig": {}
}
}
@@ -256,5 +256,9 @@
"chat-compression-default": {
"model": "gemini-3-pro-preview",
"generateContentConfig": {}
},
"agent-history-provider-summarizer": {
"model": "gemini-3-flash-preview",
"generateContentConfig": {}
}
}
+4 -1
View File
@@ -1151,8 +1151,11 @@ describe('loggers', () => {
getQuestion: () => 'test-question',
getToolRegistry: () =>
new ToolRegistry(cfg1, {} as unknown as MessageBus),
getUserMemory: () => 'user-memory',
isExperimentalAgentHistoryTruncationEnabled: () => false,
getExperimentalAgentHistoryTruncationThreshold: () => 30,
getExperimentalAgentHistoryRetainedMessages: () => 15,
isExperimentalAgentHistorySummarizationEnabled: () => false,
} as unknown as Config;
(cfg2 as unknown as { config: Config; promptId: string }).config = cfg2;
@@ -125,3 +125,9 @@ export const PLAN_MODE_PARAM_REASON = 'reason';
// -- sandbox --
export const PARAM_ADDITIONAL_PERMISSIONS = 'additional_permissions';
// -- update_topic --
export const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
export const TOPIC_PARAM_TITLE = 'title';
export const TOPIC_PARAM_SUMMARY = 'summary';
export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
@@ -17,6 +17,7 @@ import {
getShellDeclaration,
getExitPlanModeDeclaration,
getActivateSkillDeclaration,
getUpdateTopicDeclaration,
} from './dynamic-declaration-helpers.js';
// Re-export names for compatibility
@@ -38,6 +39,7 @@ export {
ASK_USER_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -91,6 +93,9 @@ export {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './base-declarations.js';
// Re-export sets for compatibility
@@ -221,6 +226,13 @@ export const ENTER_PLAN_MODE_DEFINITION: ToolDefinition = {
overrides: (modelId) => getToolSet(modelId).enter_plan_mode,
};
export const UPDATE_TOPIC_DEFINITION: ToolDefinition = {
get base() {
return getUpdateTopicDeclaration();
},
overrides: (modelId) => getToolSet(modelId).update_topic,
};
// ============================================================================
// DYNAMIC TOOL DEFINITIONS (LEGACY EXPORTS)
// ============================================================================
@@ -24,6 +24,10 @@ import {
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
PARAM_ADDITIONAL_PERMISSIONS,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './base-declarations.js';
/**
@@ -204,3 +208,34 @@ export function getActivateSkillDeclaration(
parametersJsonSchema: zodToJsonSchema(schema),
};
}
/**
* Returns the FunctionDeclaration for updating the topic context.
*/
export function getUpdateTopicDeclaration(): FunctionDeclaration {
return {
name: UPDATE_TOPIC_TOOL_NAME,
description:
'Manages your narrative flow. Include `title` and `summary` only when starting a new Chapter (logical phase) or shifting strategic intent.',
parametersJsonSchema: {
type: 'object',
properties: {
[TOPIC_PARAM_TITLE]: {
type: 'string',
description: 'The title of the new topic or chapter.',
},
[TOPIC_PARAM_SUMMARY]: {
type: 'string',
description:
'(OPTIONAL) A detailed summary (5-10 sentences) covering both the work completed in the previous topic and the strategic intent of the new topic. This is required when transitioning between topics to maintain continuity.',
},
[TOPIC_PARAM_STRATEGIC_INTENT]: {
type: 'string',
description:
'A mandatory one-sentence statement of your immediate intent.',
},
},
required: [TOPIC_PARAM_STRATEGIC_INTENT],
},
};
}
@@ -78,6 +78,7 @@ import {
getShellDeclaration,
getExitPlanModeDeclaration,
getActivateSkillDeclaration,
getUpdateTopicDeclaration,
} from '../dynamic-declaration-helpers.js';
import {
DEFAULT_MAX_LINES_TEXT_FILE,
@@ -724,4 +725,5 @@ The agent did not use the todo list because this task could be completed by a ti
exit_plan_mode: () => getExitPlanModeDeclaration(),
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
update_topic: getUpdateTopicDeclaration(),
};
@@ -50,4 +50,5 @@ export interface CoreToolSet {
enter_plan_mode: FunctionDeclaration;
exit_plan_mode: () => FunctionDeclaration;
activate_skill: (skillNames: string[]) => FunctionDeclaration;
update_topic?: FunctionDeclaration;
}
+28
View File
@@ -53,6 +53,13 @@ describe('GrepTool', () => {
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
maxFileCount: 1000,
searchTimeout: 30000,
customIgnoreFilePaths: [],
}),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
@@ -337,6 +344,13 @@ describe('GrepTool', () => {
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
maxFileCount: 1000,
searchTimeout: 30000,
customIgnoreFilePaths: [],
}),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
@@ -414,6 +428,13 @@ describe('GrepTool', () => {
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
maxFileCount: 1000,
searchTimeout: 30000,
customIgnoreFilePaths: [],
}),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
@@ -618,6 +639,13 @@ describe('GrepTool', () => {
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
maxFileCount: 1000,
searchTimeout: 30000,
customIgnoreFilePaths: [],
}),
} as unknown as Config;
const multiDirGrepTool = new GrepTool(
+16 -1
View File
@@ -215,9 +215,17 @@ class GrepToolInvocation extends BaseToolInvocation<
// Create a timeout controller to prevent indefinitely hanging searches
const timeoutController = new AbortController();
const configTimeout = this.config.getFileFilteringOptions().searchTimeout;
// If configTimeout is less than standard default, it might be too short for grep.
// We check if it's greater or if we should use DEFAULT_SEARCH_TIMEOUT_MS as a fallback.
// Let's assume the user can set it higher if they want. Using it directly if it exists, otherwise fallback.
const timeoutMs =
configTimeout && configTimeout > DEFAULT_SEARCH_TIMEOUT_MS
? configTimeout
: DEFAULT_SEARCH_TIMEOUT_MS;
const timeoutId = setTimeout(() => {
timeoutController.abort();
}, DEFAULT_SEARCH_TIMEOUT_MS);
}, timeoutMs);
// Link the passed signal to our timeout controller
const onAbort = () => timeoutController.abort();
@@ -252,6 +260,13 @@ class GrepToolInvocation extends BaseToolInvocation<
allMatches = allMatches.concat(matches);
}
} catch (error) {
if (timeoutController.signal.aborted) {
throw new Error(
`Operation timed out after ${timeoutMs}ms. In large repositories, consider narrowing your search scope by specifying a 'dir_path' or an 'include_pattern'.`,
);
}
throw error;
} finally {
clearTimeout(timeoutId);
signal.removeEventListener('abort', onAbort);
+16 -1
View File
@@ -250,9 +250,17 @@ class GrepToolInvocation extends BaseToolInvocation<
// Create a timeout controller to prevent indefinitely hanging searches
const timeoutController = new AbortController();
const configTimeout = this.config.getFileFilteringOptions().searchTimeout;
// If configTimeout is less than standard default, it might be too short for grep.
// We check if it's greater or if we should use DEFAULT_SEARCH_TIMEOUT_MS as a fallback.
// Let's assume the user can set it higher if they want. Using it directly if it exists, otherwise fallback.
const timeoutMs =
configTimeout && configTimeout > DEFAULT_SEARCH_TIMEOUT_MS
? configTimeout
: DEFAULT_SEARCH_TIMEOUT_MS;
const timeoutId = setTimeout(() => {
timeoutController.abort();
}, DEFAULT_SEARCH_TIMEOUT_MS);
}, timeoutMs);
// Link the passed signal to our timeout controller
const onAbort = () => timeoutController.abort();
@@ -279,6 +287,13 @@ class GrepToolInvocation extends BaseToolInvocation<
max_matches_per_file: this.params.max_matches_per_file,
signal: timeoutController.signal,
});
} catch (error) {
if (timeoutController.signal.aborted) {
throw new Error(
`Operation timed out after ${timeoutMs}ms. In large repositories, consider narrowing your search scope by specifying a 'dir_path' or an 'include_pattern'.`,
);
}
throw error;
} finally {
clearTimeout(timeoutId);
signal.removeEventListener('abort', onAbort);
+71 -3
View File
@@ -277,7 +277,7 @@ describe('ShellTool', () => {
const result = await promise;
const wrappedCommand = `{ my-command & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
const wrappedCommand = `(\n${'my-command &'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
tempRootDir,
@@ -295,6 +295,42 @@ describe('ShellTool', () => {
expect(fs.existsSync(tmpFile)).toBe(false);
});
it('should add a space when command ends with a backslash to prevent escaping newline', async () => {
const invocation = shellTool.build({ command: 'ls\\' });
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution();
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `(\nls\\ \n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
tempRootDir,
expect.any(Function),
expect.any(AbortSignal),
false,
expect.any(Object),
);
});
it('should handle trailing comments correctly by placing them on their own line', async () => {
const invocation = shellTool.build({ command: 'ls # comment' });
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution();
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `(\nls # comment\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
tempRootDir,
expect.any(Function),
expect.any(AbortSignal),
false,
expect.any(Object),
);
});
it('should use the provided absolute directory as cwd', async () => {
const subdir = path.join(tempRootDir, 'subdir');
const invocation = shellTool.build({
@@ -306,7 +342,7 @@ describe('ShellTool', () => {
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
const wrappedCommand = `(\n${'ls'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
subdir,
@@ -331,7 +367,7 @@ describe('ShellTool', () => {
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
const wrappedCommand = `(\n${'ls'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
path.join(tempRootDir, 'subdir'),
@@ -434,6 +470,38 @@ describe('ShellTool', () => {
expect(result.error?.message).toBe('command failed');
});
it('should include write_file suggestion when a command with a heredoc fails', async () => {
const invocation = shellTool.build({
command: "cat << 'EOF'\nhello\nEOF",
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({
exitCode: 1,
output: 'bash: syntax error',
});
const result = await promise;
expect(result.llmContent).toContain(
"Suggestion: Large heredoc detected. Please use the 'write_file' tool for better reliability.",
);
});
it('should NOT include write_file suggestion when a command with a heredoc succeeds', async () => {
const invocation = shellTool.build({
command: "cat << 'EOF'\nhello\nEOF",
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({
exitCode: 0,
output: 'hello',
});
const result = await promise;
expect(result.llmContent).not.toContain(
"Suggestion: Large heredoc detected. Please use the 'write_file' tool for better reliability.",
);
});
it('should throw an error for invalid parameters', () => {
expect(() => shellTool.build({ command: '' })).toThrow(
'Command cannot be empty.',
+50 -8
View File
@@ -51,6 +51,8 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const HEREDOC_REGEX = /<<[-]?\s*['"\\\]?EOF['"]?/;
// Delay so user does not see the output of the process before the process is moved to the background.
const BACKGROUND_DELAY_MS = 200;
@@ -76,6 +78,33 @@ export class ShellToolInvocation extends BaseToolInvocation<
super(params, messageBus, _toolName, _toolDisplayName);
}
/**
* Wraps a command in a subshell `()` to capture background process IDs (PIDs) using pgrep.
* Uses newlines to prevent breaking heredocs or trailing comments.
*
* @param command The raw command string to execute.
* @param tempFilePath Path to the temporary file where PIDs will be written.
* @param isWindows Whether the current platform is Windows (if true, the command is returned as-is).
* @returns The wrapped command string.
*/
private wrapCommandForPgrep(
command: string,
tempFilePath: string,
isWindows: boolean,
): string {
if (isWindows) {
return command;
}
let trimmed = command.trim();
if (!trimmed) {
return '';
}
if (trimmed.endsWith('\\')) {
trimmed += ' ';
}
return `(\n${trimmed}\n); __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
}
private getContextualDetails(): string {
let details = '';
// append optional [in directory]
@@ -232,14 +261,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
try {
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = isWindows
? strippedCommand
: (() => {
// wrap command to append subprocess pids (via pgrep) to temporary file
let command = strippedCommand.trim();
if (!command.endsWith('&')) command += ';';
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
})();
const commandToExecute = this.wrapCommandForPgrep(
strippedCommand,
tempFilePath,
isWindows,
);
const cwd = this.params.dir_path
? path.resolve(this.context.config.getTargetDir(), this.params.dir_path)
@@ -406,6 +432,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
} else {
llmContent += ' There was no output before it was cancelled.';
}
if (HEREDOC_REGEX.test(this.params.command)) {
llmContent +=
"\nSuggestion: Large heredoc detected. Please use the 'write_file' tool for better reliability.";
}
} else if (this.params.is_background || result.backgrounded) {
llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
data = {
@@ -444,6 +475,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
llmContentParts.push(`Process Group PGID: ${result.pid}`);
}
const failed =
!!result.error ||
!!result.signal ||
(result.exitCode !== null && result.exitCode !== 0);
if (failed && HEREDOC_REGEX.test(this.params.command)) {
llmContentParts.push(
"Suggestion: Large heredoc detected. Please use the 'write_file' tool for better reliability.",
);
}
llmContent = llmContentParts.join('\n');
}
+10
View File
@@ -75,6 +75,10 @@ import {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/coreTools.js';
export {
@@ -95,6 +99,7 @@ export {
ASK_USER_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -148,6 +153,9 @@ export {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
};
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
@@ -253,6 +261,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [
GET_INTERNAL_DOCS_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
] as const;
/**
@@ -269,6 +278,7 @@ export const PLAN_MODE_TOOLS = [
ASK_USER_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
GET_INTERNAL_DOCS_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
'codebase_investigator',
'cli_help',
] as const;
+34 -1
View File
@@ -19,7 +19,10 @@ import { Config, type ConfigParameters } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
import { DISCOVERED_TOOL_PREFIX } from './tool-names.js';
import {
DISCOVERED_TOOL_PREFIX,
UPDATE_TOPIC_TOOL_NAME,
} from './tool-names.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import {
mcpToTool,
@@ -800,6 +803,36 @@ describe('ToolRegistry', () => {
const toolNames = allTools.map((t) => t.name);
expect(toolNames).not.toContain('mcp_test-server_write-mcp-tool');
});
it('should exclude topic tool when narration is disabled in config', () => {
const topicTool = new MockTool({
name: UPDATE_TOPIC_TOOL_NAME,
displayName: 'Topic Tool',
});
toolRegistry.registerTool(topicTool);
vi.spyOn(config, 'isTopicUpdateNarrationEnabled').mockReturnValue(false);
mockConfigGetExcludedTools.mockReturnValue(new Set());
expect(toolRegistry.getAllToolNames()).not.toContain(
UPDATE_TOPIC_TOOL_NAME,
);
expect(toolRegistry.getTool(UPDATE_TOPIC_TOOL_NAME)).toBeUndefined();
});
it('should NOT exclude topic tool when narration is enabled in config', () => {
const topicTool = new MockTool({
name: UPDATE_TOPIC_TOOL_NAME,
displayName: 'Topic Tool',
});
toolRegistry.registerTool(topicTool);
vi.spyOn(config, 'isTopicUpdateNarrationEnabled').mockReturnValue(true);
mockConfigGetExcludedTools.mockReturnValue(new Set());
expect(toolRegistry.getAllToolNames()).toContain(UPDATE_TOPIC_TOOL_NAME);
expect(toolRegistry.getTool(UPDATE_TOPIC_TOOL_NAME)).toBe(topicTool);
});
});
describe('DiscoveredToolInvocation', () => {
+7
View File
@@ -30,6 +30,7 @@ import {
getToolAliases,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
} from './tool-names.js';
type ToolParams = Record<string, unknown>;
@@ -576,6 +577,12 @@ export class ToolRegistry {
),
) ?? new Set([]);
if (tool.name === UPDATE_TOPIC_TOOL_NAME) {
if (!this.config.isTopicUpdateNarrationEnabled()) {
return false;
}
}
const normalizedClassName = tool.constructor.name.replace(/^_+/, '');
const possibleNames = [tool.name, normalizedClassName];
if (tool instanceof DiscoveredMCPTool) {
+133
View File
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TopicState, UpdateTopicTool } from './topicTool.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/base-declarations.js';
import type { Config } from '../config/config.js';
describe('TopicState', () => {
let state: TopicState;
beforeEach(() => {
state = new TopicState();
});
it('should store and retrieve topic title and intent', () => {
expect(state.getTopic()).toBeUndefined();
expect(state.getIntent()).toBeUndefined();
const success = state.setTopic('Test Topic', 'Test Intent');
expect(success).toBe(true);
expect(state.getTopic()).toBe('Test Topic');
expect(state.getIntent()).toBe('Test Intent');
});
it('should sanitize newlines and carriage returns', () => {
state.setTopic('Topic\nWith\r\nLines', 'Intent\nWith\r\nLines');
expect(state.getTopic()).toBe('Topic With Lines');
expect(state.getIntent()).toBe('Intent With Lines');
});
it('should trim whitespace', () => {
state.setTopic(' Spaced Topic ', ' Spaced Intent ');
expect(state.getTopic()).toBe('Spaced Topic');
expect(state.getIntent()).toBe('Spaced Intent');
});
it('should reject empty or whitespace-only inputs', () => {
expect(state.setTopic('', '')).toBe(false);
});
it('should reset topic and intent', () => {
state.setTopic('Test Topic', 'Test Intent');
state.reset();
expect(state.getTopic()).toBeUndefined();
expect(state.getIntent()).toBeUndefined();
});
});
describe('UpdateTopicTool', () => {
let tool: UpdateTopicTool;
let mockMessageBus: MessageBus;
let mockConfig: Config;
beforeEach(() => {
mockMessageBus = new MessageBus(vi.mocked({} as PolicyEngine));
// Mock enough of Config to satisfy the tool
mockConfig = {
topicState: new TopicState(),
} as unknown as Config;
tool = new UpdateTopicTool(mockConfig, mockMessageBus);
});
it('should have correct name and display name', () => {
expect(tool.name).toBe(UPDATE_TOPIC_TOOL_NAME);
expect(tool.displayName).toBe('Update Topic Context');
});
it('should update TopicState and include strategic intent on execute', async () => {
const invocation = tool.build({
[TOPIC_PARAM_TITLE]: 'New Chapter',
[TOPIC_PARAM_SUMMARY]: 'The goal is to implement X. Previously we did Y.',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Initial Move',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Current topic: "New Chapter"');
expect(result.llmContent).toContain(
'Topic summary: The goal is to implement X. Previously we did Y.',
);
expect(result.llmContent).toContain('Strategic Intent: Initial Move');
expect(mockConfig.topicState.getTopic()).toBe('New Chapter');
expect(mockConfig.topicState.getIntent()).toBe('Initial Move');
expect(result.returnDisplay).toContain('## 📂 Topic: **New Chapter**');
expect(result.returnDisplay).toContain('**Summary:**');
expect(result.returnDisplay).toContain(
'> [!STRATEGY]\n> **Intent:** Initial Move',
);
});
it('should render only intent for tactical updates (same topic)', async () => {
mockConfig.topicState.setTopic('New Chapter');
const invocation = tool.build({
[TOPIC_PARAM_TITLE]: 'New Chapter',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Subsequent Move',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).not.toContain('## 📂 Topic:');
expect(result.returnDisplay).toBe(
'> [!STRATEGY]\n> **Intent:** Subsequent Move',
);
expect(result.llmContent).toBe('Strategic Intent: Subsequent Move');
});
it('should return error if strategic_intent is missing', async () => {
try {
tool.build({
[TOPIC_PARAM_TITLE]: 'Title',
});
expect.fail('Should have thrown validation error');
} catch (e: unknown) {
if (e instanceof Error) {
expect(e.message).toContain(
"must have required property 'strategic_intent'",
);
} else {
expect.fail('Expected Error instance');
}
}
expect(mockConfig.topicState.getTopic()).toBeUndefined();
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/coreTools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolResult,
} from './tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getUpdateTopicDeclaration } from './definitions/dynamic-declaration-helpers.js';
import type { Config } from '../config/config.js';
/**
* Manages the current active topic title and tactical intent for a session.
* Hosted within the Config instance for session-scoping.
*/
export class TopicState {
private activeTopicTitle?: string;
private activeIntent?: string;
/**
* Sanitizes and sets the topic title and/or intent.
* @returns true if the input was valid and set, false otherwise.
*/
setTopic(title?: string, intent?: string): boolean {
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
if (!sanitizedTitle && !sanitizedIntent) return false;
if (sanitizedTitle) {
this.activeTopicTitle = sanitizedTitle;
}
if (sanitizedIntent) {
this.activeIntent = sanitizedIntent;
}
return true;
}
getTopic(): string | undefined {
return this.activeTopicTitle;
}
getIntent(): string | undefined {
return this.activeIntent;
}
reset(): void {
this.activeTopicTitle = undefined;
this.activeIntent = undefined;
}
}
interface UpdateTopicParams {
[TOPIC_PARAM_TITLE]?: string;
[TOPIC_PARAM_SUMMARY]?: string;
[TOPIC_PARAM_STRATEGIC_INTENT]?: string;
}
class UpdateTopicInvocation extends BaseToolInvocation<
UpdateTopicParams,
ToolResult
> {
constructor(
params: UpdateTopicParams,
messageBus: MessageBus,
toolName: string,
private readonly config: Config,
) {
super(params, messageBus, toolName);
}
getDescription(): string {
const title = this.params[TOPIC_PARAM_TITLE];
const intent = this.params[TOPIC_PARAM_STRATEGIC_INTENT];
if (title) {
return `Update topic to: "${title}"`;
}
return `Update tactical intent: "${intent || '...'}"`;
}
async execute(): Promise<ToolResult> {
const title = this.params[TOPIC_PARAM_TITLE];
const summary = this.params[TOPIC_PARAM_SUMMARY];
const strategicIntent = this.params[TOPIC_PARAM_STRATEGIC_INTENT];
const activeTopic = this.config.topicState.getTopic();
const isNewTopic = !!(
title &&
title.trim() !== '' &&
title.trim() !== activeTopic
);
this.config.topicState.setTopic(title, strategicIntent);
const currentTopic = this.config.topicState.getTopic() || '...';
const currentIntent =
strategicIntent || this.config.topicState.getIntent() || '...';
debugLogger.log(
`[TopicTool] Update: Topic="${currentTopic}", Intent="${currentIntent}", isNew=${isNewTopic}`,
);
let llmContent = '';
let returnDisplay = '';
if (isNewTopic) {
// Handle New Topic Header & Summary
llmContent = `Current topic: "${currentTopic}"\nTopic summary: ${summary || '...'}`;
returnDisplay = `## 📂 Topic: **${currentTopic}**\n\n**Summary:**\n${summary || '...'}`;
if (strategicIntent && strategicIntent.trim()) {
llmContent += `\n\nStrategic Intent: ${strategicIntent.trim()}`;
returnDisplay += `\n\n> [!STRATEGY]\n> **Intent:** ${strategicIntent.trim()}`;
}
} else {
// Tactical update only
llmContent = `Strategic Intent: ${currentIntent}`;
returnDisplay = `> [!STRATEGY]\n> **Intent:** ${currentIntent}`;
}
return {
llmContent,
returnDisplay,
};
}
}
/**
* Tool to update semantic topic context and tactical intent for UI grouping and model focus.
*/
export class UpdateTopicTool extends BaseDeclarativeTool<
UpdateTopicParams,
ToolResult
> {
constructor(
private readonly config: Config,
messageBus: MessageBus,
) {
const declaration = getUpdateTopicDeclaration();
super(
UPDATE_TOPIC_TOOL_NAME,
'Update Topic Context',
declaration.description ?? '',
Kind.Think,
declaration.parametersJsonSchema,
messageBus,
);
}
protected createInvocation(
params: UpdateTopicParams,
messageBus: MessageBus,
): UpdateTopicInvocation {
return new UpdateTopicInvocation(
params,
messageBus,
this.name,
this.config,
);
}
}
@@ -1269,6 +1269,96 @@ included directory memory
expect(result.files[0].path).toBe(subDirMemory);
expect(result.files[0].content).toBe('Content without git');
});
it('should stop at a custom boundary marker instead of .git', async () => {
const rootDir = await createEmptyDir(
path.join(testRootDir, 'custom_marker'),
);
// Use a custom marker file instead of .git
await createTestFile(path.join(rootDir, '.monorepo-root'), '');
const subDir = await createEmptyDir(path.join(rootDir, 'packages/app'));
const targetFile = path.join(subDir, 'file.ts');
const rootMemory = await createTestFile(
path.join(rootDir, DEFAULT_CONTEXT_FILENAME),
'Root rules',
);
const subDirMemory = await createTestFile(
path.join(subDir, DEFAULT_CONTEXT_FILENAME),
'App rules',
);
const result = await loadJitSubdirectoryMemory(
targetFile,
[rootDir],
new Set(),
undefined,
['.monorepo-root'],
);
expect(result.files).toHaveLength(2);
expect(result.files.find((f) => f.path === rootMemory)).toBeDefined();
expect(result.files.find((f) => f.path === subDirMemory)).toBeDefined();
});
it('should support multiple boundary markers', async () => {
const rootDir = await createEmptyDir(
path.join(testRootDir, 'multi_marker'),
);
// Use a non-.git marker
await createTestFile(path.join(rootDir, 'package.json'), '{}');
const subDir = await createEmptyDir(path.join(rootDir, 'src'));
const targetFile = path.join(subDir, 'index.ts');
const rootMemory = await createTestFile(
path.join(rootDir, DEFAULT_CONTEXT_FILENAME),
'Root content',
);
const result = await loadJitSubdirectoryMemory(
targetFile,
[rootDir],
new Set(),
undefined,
['.git', 'package.json'],
);
// Should find the root because package.json is a marker
expect(result.files).toHaveLength(1);
expect(result.files[0].path).toBe(rootMemory);
});
it('should disable parent traversal when boundary markers array is empty', async () => {
const rootDir = await createEmptyDir(
path.join(testRootDir, 'empty_markers'),
);
await createEmptyDir(path.join(rootDir, '.git'));
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
const targetFile = path.join(subDir, 'target.txt');
await createTestFile(
path.join(rootDir, DEFAULT_CONTEXT_FILENAME),
'Root content',
);
const subDirMemory = await createTestFile(
path.join(subDir, DEFAULT_CONTEXT_FILENAME),
'Subdir content',
);
const result = await loadJitSubdirectoryMemory(
targetFile,
[rootDir],
new Set(),
undefined,
[],
);
// With empty markers, no project root is found so the trusted root
// is used as the ceiling. Traversal still finds files between the
// target path and the trusted root.
expect(result.files).toHaveLength(2);
expect(result.files.find((f) => f.path === subDirMemory)).toBeDefined();
});
});
it('refreshServerHierarchicalMemory should refresh memory and update config', async () => {
@@ -1341,6 +1431,7 @@ included directory memory
getImportFormat: vi.fn().mockReturnValue('tree'),
getFileFilteringOptions: vi.fn().mockReturnValue(undefined),
getDiscoveryMaxDirs: vi.fn().mockReturnValue(200),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
setUserMemory: vi.fn(),
setGeminiMdFileCount: vi.fn(),
setGeminiMdFilePaths: vi.fn(),
+64 -37
View File
@@ -146,41 +146,54 @@ export async function deduplicatePathsByFileIdentity(
};
}
async function findProjectRoot(startDir: string): Promise<string | null> {
async function findProjectRoot(
startDir: string,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<string | null> {
if (boundaryMarkers.length === 0) {
return null;
}
let currentDir = normalizePath(startDir);
while (true) {
const gitPath = path.join(currentDir, '.git');
try {
// Check for existence only — .git can be a directory (normal repos)
// or a file (submodules / worktrees).
await fs.access(gitPath);
return currentDir;
} catch (error: unknown) {
// Don't log ENOENT errors as they're expected when .git doesn't exist
// Also don't log errors in test environments, which often have mocked fs
const isENOENT =
typeof error === 'object' &&
error !== null &&
'code' in error &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(error as { code: string }).code === 'ENOENT';
// Only log unexpected errors in non-test environments
// process.env['NODE_ENV'] === 'test' or VITEST are common test indicators
const isTestEnv =
process.env['NODE_ENV'] === 'test' || process.env['VITEST'];
if (!isENOENT && !isTestEnv) {
if (typeof error === 'object' && error !== null && 'code' in error) {
for (const marker of boundaryMarkers) {
// Sanitize: skip markers with path traversal or absolute paths
if (path.isAbsolute(marker) || marker.includes('..')) {
continue;
}
const markerPath = path.join(currentDir, marker);
try {
// Check for existence only — marker can be a directory (normal repos)
// or a file (submodules / worktrees).
await fs.access(markerPath);
return currentDir;
} catch (error: unknown) {
// Don't log ENOENT errors as they're expected when marker doesn't exist
// Also don't log errors in test environments, which often have mocked fs
const isENOENT =
typeof error === 'object' &&
error !== null &&
'code' in error &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const fsError = error as { code: string; message: string };
logger.warn(
`Error checking for .git at ${gitPath}: ${fsError.message}`,
);
} else {
logger.warn(
`Non-standard error checking for .git at ${gitPath}: ${String(error)}`,
);
(error as { code: string }).code === 'ENOENT';
// Only log unexpected errors in non-test environments
// process.env['NODE_ENV'] === 'test' or VITEST are common test indicators
const isTestEnv =
process.env['NODE_ENV'] === 'test' || process.env['VITEST'];
if (!isENOENT && !isTestEnv) {
if (typeof error === 'object' && error !== null && 'code' in error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const fsError = error as { code: string; message: string };
logger.warn(
`Error checking for ${marker} at ${markerPath}: ${fsError.message}`,
);
} else {
logger.warn(
`Non-standard error checking for ${marker} at ${markerPath}: ${String(error)}`,
);
}
}
}
}
@@ -200,6 +213,7 @@ async function getGeminiMdFilePathsInternal(
folderTrust: boolean,
fileFilteringOptions: FileFilteringOptions,
maxDirs: number,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<{ global: string[]; project: string[] }> {
const dirs = new Set<string>([
...includeDirectoriesToReadGemini,
@@ -222,6 +236,7 @@ async function getGeminiMdFilePathsInternal(
folderTrust,
fileFilteringOptions,
maxDirs,
boundaryMarkers,
),
);
@@ -253,6 +268,7 @@ async function getGeminiMdFilePathsInternalForEachDir(
folderTrust: boolean,
fileFilteringOptions: FileFilteringOptions,
maxDirs: number,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<{ global: string[]; project: string[] }> {
const globalPaths = new Set<string>();
const projectPaths = new Set<string>();
@@ -289,7 +305,7 @@ async function getGeminiMdFilePathsInternalForEachDir(
resolvedCwd,
);
const projectRoot = await findProjectRoot(resolvedCwd);
const projectRoot = await findProjectRoot(resolvedCwd, boundaryMarkers);
debugLogger.debug(
'[DEBUG] [MemoryDiscovery] Determined project root:',
projectRoot ?? 'None',
@@ -356,6 +372,7 @@ async function getGeminiMdFilePathsInternalForEachDir(
export async function readGeminiMdFiles(
filePaths: string[],
importFormat: 'flat' | 'tree' = 'tree',
boundaryMarkers: readonly string[] = ['.git'],
): Promise<GeminiFileContent[]> {
// Process files in parallel with concurrency limit to prevent EMFILE errors
const CONCURRENT_LIMIT = 20; // Higher limit for file reads as they're typically faster
@@ -376,6 +393,7 @@ export async function readGeminiMdFiles(
undefined,
undefined,
importFormat,
boundaryMarkers,
);
debugLogger.debug(
'[DEBUG] [MemoryDiscovery] Successfully read and processed imports:',
@@ -481,13 +499,14 @@ export function getExtensionMemoryPaths(
export async function getEnvironmentMemoryPaths(
trustedRoots: string[],
boundaryMarkers: readonly string[] = ['.git'],
): Promise<string[]> {
const allPaths = new Set<string>();
// Trusted Roots Upward Traversal (Parallelized)
const traversalPromises = trustedRoots.map(async (root) => {
const resolvedRoot = normalizePath(root);
const gitRoot = await findProjectRoot(resolvedRoot);
const gitRoot = await findProjectRoot(resolvedRoot, boundaryMarkers);
const ceiling = gitRoot ? normalizePath(gitRoot) : resolvedRoot;
debugLogger.debug(
'[DEBUG] [MemoryDiscovery] Loading environment memory for trusted root:',
@@ -597,6 +616,7 @@ export async function loadServerHierarchicalMemory(
importFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
maxDirs: number = 200,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<LoadServerHierarchicalMemoryResponse> {
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
const realCwd = normalizePath(
@@ -629,6 +649,7 @@ export async function loadServerHierarchicalMemory(
folderTrust,
fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
maxDirs,
boundaryMarkers,
),
Promise.resolve(getExtensionMemoryPaths(extensionLoader)),
]);
@@ -669,7 +690,11 @@ export async function loadServerHierarchicalMemory(
}
// 2. GATHER: Read all files in parallel
const allContents = await readGeminiMdFiles(allFilePaths, importFormat);
const allContents = await readGeminiMdFiles(
allFilePaths,
importFormat,
boundaryMarkers,
);
const contentsMap = new Map(allContents.map((c) => [c.filePath, c]));
// 3. CATEGORIZE: Back into Global, Project, Extension
@@ -707,6 +732,7 @@ export async function refreshServerHierarchicalMemory(config: Config) {
config.getImportFormat(),
config.getFileFilteringOptions(),
config.getDiscoveryMaxDirs(),
config.getMemoryBoundaryMarkers(),
);
const mcpInstructions =
config.getMcpClientManager()?.getMcpInstructions() || '';
@@ -728,6 +754,7 @@ export async function loadJitSubdirectoryMemory(
trustedRoots: string[],
alreadyLoadedPaths: Set<string>,
alreadyLoadedIdentities?: Set<string>,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<MemoryLoadResult> {
const resolvedTarget = normalizePath(targetPath);
let bestRoot: string | null = null;
@@ -760,7 +787,7 @@ export async function loadJitSubdirectoryMemory(
// Find the git root to use as the traversal ceiling.
// If no git root exists, fall back to the trusted root as the ceiling.
const gitRoot = await findProjectRoot(bestRoot);
const gitRoot = await findProjectRoot(bestRoot, boundaryMarkers);
const resolvedCeiling = gitRoot ? normalizePath(gitRoot) : bestRoot;
debugLogger.debug(
@@ -850,7 +877,7 @@ export async function loadJitSubdirectoryMemory(
JSON.stringify(newPaths),
);
const contents = await readGeminiMdFiles(newPaths, 'tree');
const contents = await readGeminiMdFiles(newPaths, 'tree', boundaryMarkers);
return {
files: contents
@@ -48,18 +48,31 @@ export interface ProcessImportsResult {
importTree: MemoryFile;
}
// Helper to find the project root (looks for .git directory or file for worktrees)
async function findProjectRoot(startDir: string): Promise<string> {
// Helper to find the project root (looks for boundary marker directories/files)
async function findProjectRoot(
startDir: string,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<string> {
if (boundaryMarkers.length === 0) {
return path.resolve(startDir);
}
let currentDir = path.resolve(startDir);
while (true) {
const gitPath = path.join(currentDir, '.git');
try {
// Check for existence only — .git can be a directory (normal repos)
// or a file (submodules / worktrees).
await fs.access(gitPath);
return currentDir;
} catch {
// .git not found, continue to parent
for (const marker of boundaryMarkers) {
// Sanitize: skip markers with path traversal or absolute paths
if (path.isAbsolute(marker) || marker.includes('..')) {
continue;
}
const markerPath = path.join(currentDir, marker);
try {
// Check for existence only — marker can be a directory (normal repos)
// or a file (submodules / worktrees).
await fs.access(markerPath);
return currentDir;
} catch {
// marker not found, continue
}
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
@@ -68,7 +81,7 @@ async function findProjectRoot(startDir: string): Promise<string> {
}
currentDir = parentDir;
}
// Fallback to startDir if .git not found
// Fallback to startDir if no marker found
return path.resolve(startDir);
}
@@ -185,9 +198,10 @@ export async function processImports(
},
projectRoot?: string,
importFormat: 'flat' | 'tree' = 'tree',
boundaryMarkers: readonly string[] = ['.git'],
): Promise<ProcessImportsResult> {
if (!projectRoot) {
projectRoot = await findProjectRoot(basePath);
projectRoot = await findProjectRoot(basePath, boundaryMarkers);
}
if (importState.currentDepth >= importState.maxDepth) {
@@ -346,6 +360,7 @@ export async function processImports(
newImportState,
projectRoot,
importFormat,
boundaryMarkers,
);
result += `<!-- Imported from: ${importPath} -->\n${imported.content}\n<!-- End of import from: ${importPath} -->`;
imports.push(imported.importTree);
+16 -2
View File
@@ -408,7 +408,9 @@ function hasPromptCommandTransform(root: Node): boolean {
return false;
}
function parseBashCommandDetails(command: string): CommandParseResult | null {
export function parseBashCommandDetails(
command: string,
): CommandParseResult | null {
if (treeSitterInitializationError) {
debugLogger.debug(
'Bash parser not initialized:',
@@ -557,7 +559,19 @@ export function parseCommandDetails(
const configuration = getShellConfiguration();
if (configuration.shell === 'powershell') {
return parsePowerShellCommandDetails(command, configuration.executable);
const result = parsePowerShellCommandDetails(
command,
configuration.executable,
);
if (!result || result.hasError) {
// Fallback to bash parser which is usually good enough for simple commands
// and doesn't rely on the host PowerShell environment restrictions (e.g., ConstrainedLanguage)
const bashResult = parseBashCommandDetails(command);
if (bashResult && !bashResult.hasError) {
return bashResult;
}
}
return result;
}
if (configuration.shell === 'bash') {
+5 -4
View File
@@ -51,10 +51,11 @@ gemini.tsx / nonInteractiveCli.ts
## API Endpoints
| Endpoint | Method | Description |
| --------- | --------- | --------------------------------------------------------------------------- |
| `/ws` | WebSocket | Log ingestion from CLI sessions (register, network, console) |
| `/events` | SSE | Pushes snapshot on connect, then incremental network/console/session events |
| Endpoint | Method | Description |
| ----------------------- | --------- | --------------------------------------------------------------------------- |
| `/ws` | WebSocket | Log ingestion from CLI sessions (register, network, console) |
| `/events` | SSE | Pushes snapshot on connect, then incremental network/console/session events |
| `/api/trigger-debugger` | POST | Triggers the Node.js debugger for a specific CLI session via WebSocket |
## Development
+93 -15
View File
@@ -39,6 +39,21 @@ export default function App() {
null,
);
// --- Toast Logic ---
const [toastMessage, setToastMessage] = useState<string | null>(null);
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showToast = (msg: string) => {
setToastMessage(msg);
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
toastTimeoutRef.current = setTimeout(() => {
setToastMessage(null);
toastTimeoutRef.current = null;
}, 5000);
};
// --- Theme Logic ---
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
const saved = localStorage.getItem('devtools-theme');
@@ -306,21 +321,52 @@ export default function App() {
>
{selectedSessionId &&
connectedSessions.includes(selectedSessionId) && (
<button
onClick={handleExport}
style={{
fontSize: '11px',
padding: '4px 8px',
border: `1px solid ${t.border}`,
background: t.bg,
color: t.text,
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600,
}}
>
📤 Export
</button>
<>
<button
onClick={async () => {
try {
await fetch('/api/trigger-debugger', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: selectedSessionId }),
});
showToast(
'Node debugger attached. Open chrome://inspect in Chrome to start debugging.',
);
} catch (e) {
console.error('Failed to trigger debugger:', e);
}
}}
style={{
fontSize: '11px',
padding: '4px 8px',
border: `1px solid ${t.border}`,
background: t.bg,
color: t.text,
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600,
}}
title="Attach Node Debugger and open chrome://inspect"
>
🐞 Debug Node
</button>
<button
onClick={handleExport}
style={{
fontSize: '11px',
padding: '4px 8px',
border: `1px solid ${t.border}`,
background: t.bg,
color: t.text,
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600,
}}
>
📤 Export
</button>
</>
)}
<label
@@ -487,6 +533,38 @@ export default function App() {
</div>
)}
</div>
{/* Toast Notification */}
{toastMessage && (
<div
style={{
position: 'fixed',
bottom: '24px',
right: '24px',
background: t.accent,
color: '#fff',
padding: '12px 24px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
fontSize: '13px',
fontWeight: 500,
zIndex: 1000,
animation: 'fadeInOut 5s ease forwards',
}}
>
{toastMessage}
</div>
)}
{/* CSS Animations */}
<style>{`
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(10px); }
5% { opacity: 1; transform: translateY(0); }
95% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(10px); }
}
`}</style>
</div>
);
}
+35 -1
View File
@@ -177,7 +177,41 @@ export class DevTools extends EventEmitter {
}
// API routes
if (req.url === '/events') {
if (req.url === '/api/trigger-debugger' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
try {
const parsed: unknown = JSON.parse(body);
if (
typeof parsed !== 'object' ||
parsed === null ||
!('sessionId' in parsed) ||
typeof parsed.sessionId !== 'string'
) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request' }));
return;
}
const sessionId = parsed.sessionId;
const session = this.sessions.get(sessionId);
if (session) {
session.ws.send(JSON.stringify({ type: 'trigger-debugger' }));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
}
} catch (_err) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request' }));
}
});
} else if (req.url === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
File diff suppressed because one or more lines are too long