mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-27 02:01:00 -07:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a30cc4a8a9 | |||
| 0e169ee968 | |||
| 6c23f0c7e1 | |||
| 83792d51c1 | |||
| 07ab16dbbe | |||
| afc1d50c20 | |||
| ae123c547c | |||
| c2705e8332 | |||
| 9574855435 | |||
| bf6dae4690 | |||
| b5529c2475 | |||
| 9e74a7ec18 | |||
| 4034c030e7 | |||
| 765fb67011 | |||
| 97c99f263a | |||
| f1a3c35dee | |||
| ebe98fdee9 | |||
| ba71ffa736 | |||
| 320c8aba4c | |||
| e7dccabf14 | |||
| a84d4d876e |
@@ -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
@@ -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,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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 change—behavioral, structural, and stylistic—is 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', () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user