mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-29 03:00:58 -07:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9254fd67a | |||
| a63fa13c31 | |||
| d99625458f | |||
| 02962f522e | |||
| 07c2a11dad | |||
| d7ce06c306 | |||
| 0c919857fa | |||
| d78f54a08a | |||
| 46aa3fd193 | |||
| 73526416cf | |||
| 5b7f7b30a7 | |||
| a6c7affedb | |||
| 578d656de9 | |||
| f74f2b0780 | |||
| 0552464eed | |||
| bbdd8457df | |||
| 71a9131709 | |||
| 397ff84b0e | |||
| 1f07efb5d8 | |||
| ee425aefa6 | |||
| 466671eed4 | |||
| 11dc33eab7 | |||
| e591b51919 | |||
| 055ff92276 | |||
| 84f40768a1 | |||
| bf80e27dbc | |||
| 122b8d43ca | |||
| 999ae7827d | |||
| 1c3d397782 | |||
| 6b7dc4d822 | |||
| 139cc7b97c | |||
| 91d756f391 | |||
| fc18768155 | |||
| 893c7d3880 | |||
| dcedc42979 | |||
| 46fd7b4864 | |||
| 36e6445dba |
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: ci
|
||||
description:
|
||||
A specialized skill for Gemini CLI that provides high-performance, fail-fast
|
||||
monitoring of GitHub Actions workflows and automated local verification of CI
|
||||
failures. It handles run discovery automatically—simply provide the branch name.
|
||||
---
|
||||
|
||||
# CI Replicate & Status
|
||||
|
||||
This skill enables the agent to efficiently monitor GitHub Actions, triage
|
||||
failures, and bridge remote CI errors to local development. It defaults to
|
||||
**automatic replication** of failures to streamline the fix cycle.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- **Automatic Replication**: Automatically monitors CI and immediately executes
|
||||
suggested test or lint commands locally upon failure.
|
||||
- **Real-time Monitoring**: Aggregated status line for all concurrent workflows
|
||||
on the current branch.
|
||||
- **Fail-Fast Triage**: Immediately stops on the first job failure to provide a
|
||||
structured report.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. CI Replicate (`replicate`) - DEFAULT
|
||||
Use this as the primary path to monitor CI and **automatically** replicate
|
||||
failures locally for immediate triage and fixing.
|
||||
- **Behavior**: When this workflow is triggered, the agent will monitor the CI
|
||||
and **immediately and automatically execute** all suggested test or lint
|
||||
commands (marked with 🚀) as soon as a failure is detected.
|
||||
- **Tool**: `node .gemini/skills/ci/scripts/ci.mjs [branch]`
|
||||
- **Discovery**: The script **automatically** finds the latest active or recent
|
||||
run for the branch. Do NOT manually search for run IDs.
|
||||
- **Goal**: Reproduce the failure locally without manual intervention, then
|
||||
proceed to analyze and fix the code.
|
||||
|
||||
### 1. CI Status (`status`)
|
||||
Use this when you have pushed changes and need to monitor the CI and reproduce
|
||||
any failures locally.
|
||||
- **Tool**: `node .gemini/skills/ci/scripts/ci.mjs [branch] [run_id]`
|
||||
- **Discovery**: The script **automatically** finds the latest active or recent
|
||||
run for the branch. You should NOT manually search for \`run_id\` using \`gh run list\`
|
||||
unless a specific historical run is requested. Simply provide the branch name.
|
||||
- **Step 1 (Monitor)**: Execute the tool with the branch name.
|
||||
- **Step 2 (Extract)**: Extract suggested \`npm test\` or \`npm run lint\` commands
|
||||
from the output (marked with 🚀).
|
||||
- **Step 3 (Reproduce)**: Execute those commands locally to confirm the failure.
|
||||
- **Behavior**: It will poll every 15 seconds. If it detects a failure, it will
|
||||
exit with a structured report and provide the exact commands to run locally.
|
||||
|
||||
## Failure Categories & Actions
|
||||
|
||||
- **Test Failures**: Agent should run the specific `npm test -w <pkg> -- <path>`
|
||||
command suggested.
|
||||
- **Lint Errors**: Agent should run `npm run lint:all` or the specific package
|
||||
lint command.
|
||||
- **Build Errors**: Agent should check `tsc` output or build logs to resolve
|
||||
compilation issues.
|
||||
- **Job Errors**: Investigate `gh run view --job <job_id> --log` for
|
||||
infrastructure or setup failures.
|
||||
|
||||
## Noise Filtering
|
||||
The underlying scripts automatically filter noise (Git logs, NPM warnings, stack
|
||||
trace overhead). The agent should focus on the "Structured Failure Report"
|
||||
provided by the tool.
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const BRANCH =
|
||||
process.argv[2] || execSync('git branch --show-current').toString().trim();
|
||||
const RUN_ID_OVERRIDE = process.argv[3];
|
||||
|
||||
let REPO;
|
||||
try {
|
||||
const remoteUrl = execSync('git remote get-url origin').toString().trim();
|
||||
REPO = remoteUrl
|
||||
.replace(/.*github\.com[\/:]/, '')
|
||||
.replace(/\.git$/, '')
|
||||
.trim();
|
||||
} catch (e) {
|
||||
REPO = 'google-gemini/gemini-cli';
|
||||
}
|
||||
|
||||
const FAILED_FILES = new Set();
|
||||
|
||||
function runGh(args) {
|
||||
try {
|
||||
return execSync(`gh ${args}`, {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).toString();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFailuresViaApi(jobId) {
|
||||
try {
|
||||
const cmd = `gh api repos/${REPO}/actions/jobs/${jobId}/logs | grep -iE " FAIL |❌|ERROR|Lint failed|Build failed|Exception|failed with exit code"`;
|
||||
return execSync(cmd, {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}).toString();
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isNoise(line) {
|
||||
const lower = line.toLowerCase();
|
||||
return (
|
||||
lower.includes('* [new branch]') ||
|
||||
lower.includes('npm warn') ||
|
||||
lower.includes('fetching updates') ||
|
||||
lower.includes('node:internal/errors') ||
|
||||
lower.includes('at ') || // Stack traces
|
||||
lower.includes('checkexecsyncerror') ||
|
||||
lower.includes('node_modules')
|
||||
);
|
||||
}
|
||||
|
||||
function extractTestFile(failureText) {
|
||||
const cleanLine = failureText
|
||||
.replace(/[|#\[\]()]/g, ' ')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.trim();
|
||||
const fileMatch = cleanLine.match(/([\w\/._-]+\.test\.[jt]sx?)/);
|
||||
if (fileMatch) return fileMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function generateTestCommand(failedFilesMap) {
|
||||
const workspaceToFiles = new Map();
|
||||
for (const [file, info] of failedFilesMap.entries()) {
|
||||
if (
|
||||
['Job Error', 'Unknown File', 'Build Error', 'Lint Error'].includes(file)
|
||||
)
|
||||
continue;
|
||||
let workspace = '@google/gemini-cli';
|
||||
let relPath = file;
|
||||
if (file.startsWith('packages/core/')) {
|
||||
workspace = '@google/gemini-cli-core';
|
||||
relPath = file.replace('packages/core/', '');
|
||||
} else if (file.startsWith('packages/cli/')) {
|
||||
workspace = '@google/gemini-cli';
|
||||
relPath = file.replace('packages/cli/', '');
|
||||
}
|
||||
relPath = relPath.replace(/^.*packages\/[^\/]+\//, '');
|
||||
if (!workspaceToFiles.has(workspace))
|
||||
workspaceToFiles.set(workspace, new Set());
|
||||
workspaceToFiles.get(workspace).add(relPath);
|
||||
}
|
||||
const commands = [];
|
||||
for (const [workspace, files] of workspaceToFiles.entries()) {
|
||||
commands.push(`npm test -w ${workspace} -- ${Array.from(files).join(' ')}`);
|
||||
}
|
||||
return commands.join(' && ');
|
||||
}
|
||||
|
||||
async function monitor() {
|
||||
let targetRunIds = [];
|
||||
if (RUN_ID_OVERRIDE) {
|
||||
targetRunIds = [RUN_ID_OVERRIDE];
|
||||
} else {
|
||||
// 1. Get runs directly associated with the branch
|
||||
const runListOutput = runGh(
|
||||
`run list --branch "${BRANCH}" --limit 10 --json databaseId,status,workflowName,createdAt`,
|
||||
);
|
||||
if (runListOutput) {
|
||||
const runs = JSON.parse(runListOutput);
|
||||
const activeRuns = runs.filter((r) => r.status !== 'completed');
|
||||
if (activeRuns.length > 0) {
|
||||
targetRunIds = activeRuns.map((r) => r.databaseId);
|
||||
} else if (runs.length > 0) {
|
||||
const latestTime = new Date(runs[0].createdAt).getTime();
|
||||
targetRunIds = runs
|
||||
.filter((r) => latestTime - new Date(r.createdAt).getTime() < 60000)
|
||||
.map((r) => r.databaseId);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get runs associated with commit statuses (handles chained/indirect runs)
|
||||
try {
|
||||
const headSha = execSync(`git rev-parse "${BRANCH}"`).toString().trim();
|
||||
const statusOutput = runGh(
|
||||
`api repos/${REPO}/commits/${headSha}/status -q '.statuses[] | select(.target_url | contains("actions/runs/")) | .target_url'`,
|
||||
);
|
||||
if (statusOutput) {
|
||||
const statusRunIds = statusOutput
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((url) => {
|
||||
const match = url.match(/actions\/runs\/(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const runId of statusRunIds) {
|
||||
if (!targetRunIds.includes(runId)) {
|
||||
targetRunIds.push(runId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore if branch/SHA not found or API fails
|
||||
}
|
||||
|
||||
if (targetRunIds.length > 0) {
|
||||
const runNames = [];
|
||||
for (const runId of targetRunIds) {
|
||||
const runInfo = runGh(`run view "${runId}" --json workflowName`);
|
||||
if (runInfo) {
|
||||
runNames.push(JSON.parse(runInfo).workflowName);
|
||||
}
|
||||
}
|
||||
console.log(`Monitoring workflows: ${[...new Set(runNames)].join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetRunIds.length === 0) {
|
||||
console.log(`No runs found for branch ${BRANCH}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
let allPassed = 0,
|
||||
allFailed = 0,
|
||||
allRunning = 0,
|
||||
allQueued = 0,
|
||||
totalJobs = 0;
|
||||
let anyRunInProgress = false;
|
||||
const fileToTests = new Map();
|
||||
let failuresFoundInLoop = false;
|
||||
|
||||
for (const runId of targetRunIds) {
|
||||
const runOutput = runGh(
|
||||
`run view "${runId}" --json databaseId,status,conclusion,workflowName`,
|
||||
);
|
||||
if (!runOutput) continue;
|
||||
const run = JSON.parse(runOutput);
|
||||
if (run.status !== 'completed') anyRunInProgress = true;
|
||||
|
||||
const jobsOutput = runGh(`run view "${runId}" --json jobs`);
|
||||
if (jobsOutput) {
|
||||
const { jobs } = JSON.parse(jobsOutput);
|
||||
totalJobs += jobs.length;
|
||||
const failedJobs = jobs.filter((j) => j.conclusion === 'failure');
|
||||
if (failedJobs.length > 0) {
|
||||
failuresFoundInLoop = true;
|
||||
for (const job of failedJobs) {
|
||||
const failures = fetchFailuresViaApi(job.databaseId);
|
||||
if (failures.trim()) {
|
||||
failures.split('\n').forEach((line) => {
|
||||
if (!line.trim() || isNoise(line)) return;
|
||||
const file = extractTestFile(line);
|
||||
const filePath =
|
||||
file ||
|
||||
(line.toLowerCase().includes('lint')
|
||||
? 'Lint Error'
|
||||
: line.toLowerCase().includes('build')
|
||||
? 'Build Error'
|
||||
: 'Unknown File');
|
||||
let testName = line;
|
||||
if (line.includes(' > ')) {
|
||||
testName = line.split(' > ').slice(1).join(' > ').trim();
|
||||
}
|
||||
if (!fileToTests.has(filePath))
|
||||
fileToTests.set(filePath, new Set());
|
||||
fileToTests.get(filePath).add(testName);
|
||||
});
|
||||
} else {
|
||||
const step =
|
||||
job.steps?.find((s) => s.conclusion === 'failure')?.name ||
|
||||
'unknown';
|
||||
const category = step.toLowerCase().includes('lint')
|
||||
? 'Lint Error'
|
||||
: step.toLowerCase().includes('build')
|
||||
? 'Build Error'
|
||||
: 'Job Error';
|
||||
if (!fileToTests.has(category))
|
||||
fileToTests.set(category, new Set());
|
||||
fileToTests
|
||||
.get(category)
|
||||
.add(`${job.name}: Failed at step "${step}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const job of jobs) {
|
||||
if (job.status === 'in_progress') allRunning++;
|
||||
else if (job.status === 'queued') allQueued++;
|
||||
else if (job.conclusion === 'success') allPassed++;
|
||||
else if (job.conclusion === 'failure') allFailed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failuresFoundInLoop) {
|
||||
console.log(
|
||||
`\n\n❌ Failures detected across ${allFailed} job(s). Stopping monitor...`,
|
||||
);
|
||||
console.log('\n--- Structured Failure Report (Noise Filtered) ---');
|
||||
for (const [file, tests] of fileToTests.entries()) {
|
||||
console.log(`\nCategory/File: ${file}`);
|
||||
// Limit output per file if it's too large
|
||||
const testsArr = Array.from(tests).map((t) =>
|
||||
t.length > 500 ? t.substring(0, 500) + '... [TRUNCATED]' : t,
|
||||
);
|
||||
testsArr.slice(0, 10).forEach((t) => console.log(` - ${t}`));
|
||||
if (testsArr.length > 10)
|
||||
console.log(` ... and ${testsArr.length - 10} more`);
|
||||
}
|
||||
const testCmd = generateTestCommand(fileToTests);
|
||||
if (testCmd) {
|
||||
console.log('\n🚀 Run this to verify fixes:');
|
||||
console.log(testCmd);
|
||||
} else if (
|
||||
Array.from(fileToTests.keys()).some((k) => k.includes('Lint'))
|
||||
) {
|
||||
console.log('\n🚀 Run this to verify lint fixes:\nnpm run lint:all');
|
||||
}
|
||||
console.log('---------------------------------');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const completed = allPassed + allFailed;
|
||||
process.stdout.write(
|
||||
`\r⏳ Monitoring ${targetRunIds.length} runs... ${completed}/${totalJobs} jobs (${allPassed} passed, ${allFailed} failed, ${allRunning} running, ${allQueued} queued) `,
|
||||
);
|
||||
if (!anyRunInProgress) {
|
||||
console.log('\n✅ All workflows passed!');
|
||||
process.exit(0);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 15000));
|
||||
}
|
||||
}
|
||||
|
||||
monitor().catch((err) => {
|
||||
console.error('\nMonitor error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+32
-34
@@ -46,40 +46,38 @@ they appear in the UI.
|
||||
|
||||
### UI
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `true` |
|
||||
| Show Context Window Warning | `ui.showContextWindowWarning` | Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached. | `false` |
|
||||
| Show Context Compression Messages | `ui.showContextCompression` | Show a message in the chat history when it is compressed. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
|
||||
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
|
||||
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||
|
||||
### IDE
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ Gemini CLI supports the following authentication types:
|
||||
| `apiKey` | Send a static API key as an HTTP header. |
|
||||
| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). |
|
||||
| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. |
|
||||
| `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
|
||||
| `oauth` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
|
||||
|
||||
### Dynamic values
|
||||
|
||||
@@ -263,7 +263,7 @@ hosts:
|
||||
|
||||
Requests to any other host will be rejected with an error. If your agent is
|
||||
hosted on a different domain, use one of the other auth types (`apiKey`, `http`,
|
||||
or `oauth2`).
|
||||
or `oauth`).
|
||||
|
||||
#### Examples
|
||||
|
||||
@@ -297,7 +297,7 @@ auth:
|
||||
---
|
||||
```
|
||||
|
||||
### OAuth 2.0 (`oauth2`)
|
||||
### OAuth 2.0 (`oauth`)
|
||||
|
||||
Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first
|
||||
use, Gemini CLI opens your browser for sign-in and persists the resulting tokens
|
||||
@@ -305,7 +305,7 @@ for subsequent requests.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `oauth2`. |
|
||||
| `type` | string | Yes | Must be `oauth`. |
|
||||
| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. |
|
||||
| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. |
|
||||
| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. |
|
||||
@@ -318,7 +318,7 @@ kind: remote
|
||||
name: oauth-agent
|
||||
agent_card_url: https://example.com/.well-known/agent.json
|
||||
auth:
|
||||
type: oauth2
|
||||
type: oauth
|
||||
client_id: my-client-id.apps.example.com
|
||||
---
|
||||
```
|
||||
|
||||
@@ -263,16 +263,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **`ui.hideContextSummary`** (boolean):
|
||||
- **Description:** Hide the context summary (GEMINI.md, MCP servers) above the
|
||||
input.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.showContextWindowWarning`** (boolean):
|
||||
- **Description:** Show a warning message when the context window limit is
|
||||
nearly reached. If disabled, the CLI will attempt to automatically compress
|
||||
the history when the limit is reached.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showContextCompression`** (boolean):
|
||||
- **Description:** Show a message in the chat history when it is compressed.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.footer.items`** (array):
|
||||
@@ -859,6 +849,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"hasAccessToPreview": false
|
||||
},
|
||||
"target": "gemini-2.5-pro"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"useCustomTools": true
|
||||
},
|
||||
"target": "gemini-3.1-pro-preview-customtools"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1225,6 +1221,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Disable user input on browser window during automation.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`agents.browser.maxActionsPerTask`** (number):
|
||||
- **Description:** The maximum number of tool calls allowed per browser task.
|
||||
Enforcement is hard: the agent will be terminated when the limit is reached.
|
||||
- **Default:** `100`
|
||||
|
||||
- **`agents.browser.confirmSensitiveActions`** (boolean):
|
||||
- **Description:** Require manual confirmation for sensitive browser actions
|
||||
(e.g., fill_form, evaluate_script).
|
||||
@@ -1550,7 +1551,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.worktrees`** (boolean):
|
||||
|
||||
@@ -79,7 +79,7 @@ export function appEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
}
|
||||
|
||||
// Render the app!
|
||||
rig.render();
|
||||
await rig.render();
|
||||
|
||||
// Wait for initial ready state
|
||||
await rig.waitForIdle();
|
||||
|
||||
@@ -136,6 +136,32 @@ describe('plan_mode', () => {
|
||||
expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const exitPlanCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'exit_plan_mode',
|
||||
);
|
||||
expect(
|
||||
exitPlanCall,
|
||||
'Expected to find exit_plan_mode in tool logs',
|
||||
).toBeDefined();
|
||||
|
||||
const args = JSON.parse(exitPlanCall!.toolRequest.args);
|
||||
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
|
||||
'string',
|
||||
);
|
||||
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
|
||||
/\.md$/,
|
||||
);
|
||||
expect(
|
||||
args.plan_filename,
|
||||
'plan_filename should not be a path',
|
||||
).not.toContain('/');
|
||||
expect(
|
||||
args.plan_filename,
|
||||
'plan_filename should not be a path',
|
||||
).not.toContain('\\');
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
@@ -199,6 +225,30 @@ describe('plan_mode', () => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const exitPlanCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'exit_plan_mode',
|
||||
);
|
||||
expect(
|
||||
exitPlanCall,
|
||||
'Expected to find exit_plan_mode in tool logs',
|
||||
).toBeDefined();
|
||||
|
||||
const args = JSON.parse(exitPlanCall!.toolRequest.args);
|
||||
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
|
||||
'string',
|
||||
);
|
||||
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
|
||||
/\.md$/,
|
||||
);
|
||||
expect(
|
||||
args.plan_filename,
|
||||
'plan_filename should not be a path',
|
||||
).not.toContain('/');
|
||||
expect(
|
||||
args.plan_filename,
|
||||
'plan_filename should not be a path',
|
||||
).not.toContain('\\');
|
||||
|
||||
// Check if plan was written
|
||||
const planWrite = toolLogs.find(
|
||||
(log) =>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
describe('redundant_casts', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not add redundant or unsafe casts when modifying typescript code',
|
||||
files: {
|
||||
'src/cast_example.ts': `
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function processUser(user: User) {
|
||||
// Narrowed check
|
||||
console.log("Processing user: " + user.name);
|
||||
}
|
||||
|
||||
export function handleUnknown(data: unknown) {
|
||||
// Goal: log data.id if it exists
|
||||
console.log("Handling data");
|
||||
}
|
||||
|
||||
export function handleError() {
|
||||
try {
|
||||
throw new Error("fail");
|
||||
} catch (err) {
|
||||
// Goal: log err.message
|
||||
console.error("Error happened");
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
prompt: `
|
||||
1. In src/cast_example.ts, update processUser to return the name in uppercase.
|
||||
2. In handleUnknown, log the "id" property if "data" is an object that contains it.
|
||||
3. In handleError, log the error message from "err".
|
||||
`,
|
||||
assert: async (rig) => {
|
||||
const filePath = path.join(rig.testDir!, 'src/cast_example.ts');
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
// 1. Redundant Cast Check (Same type)
|
||||
// Bad: (user.name as string).toUpperCase()
|
||||
expect(content, 'Should not cast a known string to string').not.toContain(
|
||||
'as string',
|
||||
);
|
||||
|
||||
// 2. Unsafe Cast Check (Unknown object)
|
||||
// Bad: (data as any).id or (data as {id: string}).id
|
||||
expect(
|
||||
content,
|
||||
'Should not use unsafe casts for unknown property access',
|
||||
).not.toContain('as any');
|
||||
expect(
|
||||
content,
|
||||
'Should not use unsafe casts for unknown property access',
|
||||
).not.toContain('as {');
|
||||
|
||||
// 3. Unsafe Cast Check (Error handling)
|
||||
// Bad: (err as Error).message
|
||||
// Good: if (err instanceof Error) { ... }
|
||||
expect(
|
||||
content,
|
||||
'Should prefer instanceof over casting for errors',
|
||||
).not.toContain('as Error');
|
||||
|
||||
// Verify implementation
|
||||
expect(content).toContain('toUpperCase()');
|
||||
expect(content).toContain('message');
|
||||
expect(content).toContain('id');
|
||||
},
|
||||
});
|
||||
});
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('Sandbox recovery', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'attempts to use additional_permissions when operation not permitted',
|
||||
prompt:
|
||||
'Run ./script.sh. It will fail with "Operation not permitted". When it does, you must retry running it by passing the appropriate additional_permissions.',
|
||||
files: {
|
||||
'script.sh':
|
||||
'#!/bin/bash\necho "cat: /etc/shadow: Operation not permitted" >&2\nexit 1\n',
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const shellCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
log.toolRequest?.name === 'run_shell_command' &&
|
||||
log.toolRequest?.args?.includes('script.sh'),
|
||||
);
|
||||
|
||||
// The agent should have tried running the command.
|
||||
expect(
|
||||
shellCalls.length,
|
||||
'Agent should have called run_shell_command',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// Look for a call that includes additional_permissions.
|
||||
const hasAdditionalPermissions = shellCalls.some((call) => {
|
||||
const args =
|
||||
typeof call.toolRequest.args === 'string'
|
||||
? JSON.parse(call.toolRequest.args)
|
||||
: call.toolRequest.args;
|
||||
return args.additional_permissions !== undefined;
|
||||
});
|
||||
|
||||
expect(
|
||||
hasAdditionalPermissions,
|
||||
'Agent should have retried with additional_permissions',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
+17
-32
@@ -9,27 +9,7 @@ import path from 'node:path';
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
const DOCS_AGENT_DEFINITION = `---
|
||||
name: docs-agent
|
||||
description: An agent with expertise in updating documentation.
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
---
|
||||
You are the docs agent. Update documentation clearly and accurately.
|
||||
`;
|
||||
|
||||
const TEST_AGENT_DEFINITION = `---
|
||||
name: test-agent
|
||||
description: An agent with expertise in writing and updating tests.
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
---
|
||||
You are the test agent. Add or update tests.
|
||||
`;
|
||||
import { evalTest, TEST_AGENTS } from './test-helper.js';
|
||||
|
||||
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
|
||||
|
||||
@@ -62,12 +42,12 @@ describe('subagent eval test cases', () => {
|
||||
},
|
||||
prompt: 'Please update README.md with a description of this library.',
|
||||
files: {
|
||||
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
|
||||
...TEST_AGENTS.DOCS_AGENT.asFile(),
|
||||
'index.ts': INDEX_TS,
|
||||
'README.md': 'TODO: update the README.\n',
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
await rig.expectToolCallSuccess(['docs-agent']);
|
||||
await rig.expectToolCallSuccess([TEST_AGENTS.DOCS_AGENT.name]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,7 +72,7 @@ describe('subagent eval test cases', () => {
|
||||
prompt:
|
||||
'Rename the exported function in index.ts from add to sum and update the file directly.',
|
||||
files: {
|
||||
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
|
||||
...TEST_AGENTS.DOCS_AGENT.asFile(),
|
||||
'index.ts': INDEX_TS,
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
@@ -102,9 +82,11 @@ describe('subagent eval test cases', () => {
|
||||
}>;
|
||||
|
||||
expect(updatedIndex).toContain('export const sum =');
|
||||
expect(toolLogs.some((l) => l.toolRequest.name === 'docs-agent')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
toolLogs.some(
|
||||
(l) => l.toolRequest.name === TEST_AGENTS.DOCS_AGENT.name,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
|
||||
false,
|
||||
);
|
||||
@@ -133,7 +115,7 @@ describe('subagent eval test cases', () => {
|
||||
},
|
||||
prompt: 'Please add a small test file that verifies add(1, 2) returns 3.',
|
||||
files: {
|
||||
'.gemini/agents/test-agent.md': TEST_AGENT_DEFINITION,
|
||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||
'index.ts': INDEX_TS,
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
@@ -150,7 +132,7 @@ describe('subagent eval test cases', () => {
|
||||
toolRequest: { name: string };
|
||||
}>;
|
||||
|
||||
await rig.expectToolCallSuccess(['test-agent']);
|
||||
await rig.expectToolCallSuccess([TEST_AGENTS.TESTING_AGENT.name]);
|
||||
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
|
||||
false,
|
||||
);
|
||||
@@ -178,8 +160,8 @@ describe('subagent eval test cases', () => {
|
||||
prompt:
|
||||
'Add a short README description for this library and also add a test file that verifies add(1, 2) returns 3.',
|
||||
files: {
|
||||
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
|
||||
'.gemini/agents/test-agent.md': TEST_AGENT_DEFINITION,
|
||||
...TEST_AGENTS.DOCS_AGENT.asFile(),
|
||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||
'index.ts': INDEX_TS,
|
||||
'README.md': 'TODO: update the README.\n',
|
||||
'package.json': JSON.stringify(
|
||||
@@ -198,7 +180,10 @@ describe('subagent eval test cases', () => {
|
||||
}>;
|
||||
const readme = readProjectFile(rig, 'README.md');
|
||||
|
||||
await rig.expectToolCallSuccess(['docs-agent', 'test-agent']);
|
||||
await rig.expectToolCallSuccess([
|
||||
TEST_AGENTS.DOCS_AGENT.name,
|
||||
TEST_AGENTS.TESTING_AGENT.name,
|
||||
]);
|
||||
expect(readme).not.toContain('TODO: update the README.');
|
||||
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
|
||||
false,
|
||||
|
||||
@@ -63,9 +63,6 @@ describe.skipIf(!chromeAvailable)('browser-policy', () => {
|
||||
rig.setup('browser-policy-skip-confirmation', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
@@ -183,9 +180,6 @@ priority = 200
|
||||
rig.setup('browser-session-warning', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
general: {
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
|
||||
@@ -48,8 +48,7 @@ describe('Interactive Mode', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
await run.expectText('Context compressed', 5000);
|
||||
await run.expectText('Adjust threshold', 5000);
|
||||
await run.expectText('Chat history compressed', 5000);
|
||||
});
|
||||
|
||||
// TODO: Context compression is broken and doesn't include the system
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as os from 'node:os';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { TestRig, skipFlaky } from './test-helper.js';
|
||||
|
||||
describe('Ctrl+C exit', () => {
|
||||
describe.skipIf(skipFlaky)('Ctrl+C exit', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -183,11 +183,17 @@ describe('Policy Engine Headless Mode', () => {
|
||||
responsesFile: 'policy-headless-shell-denied.responses',
|
||||
promptCommand: ECHO_PROMPT,
|
||||
policyContent: `
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = "echo"
|
||||
decision = "deny"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = "node"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
priority = 90
|
||||
`,
|
||||
expectAllowed: false,
|
||||
expectedDenialString: 'Tool execution denied by policy',
|
||||
|
||||
@@ -58,12 +58,18 @@ function getDisallowedFileReadCommand(testFile: string): {
|
||||
const quotedPath = `"${testFile}"`;
|
||||
switch (shell) {
|
||||
case 'powershell':
|
||||
return { command: `Get-Content ${quotedPath}`, tool: 'Get-Content' };
|
||||
return {
|
||||
command: `powershell -Command "Get-Content ${quotedPath}"`,
|
||||
tool: 'powershell',
|
||||
};
|
||||
case 'cmd':
|
||||
return { command: `type ${quotedPath}`, tool: 'type' };
|
||||
return { command: `cmd /c type ${quotedPath}`, tool: 'cmd' };
|
||||
case 'bash':
|
||||
default:
|
||||
return { command: `cat ${quotedPath}`, tool: 'cat' };
|
||||
return {
|
||||
command: `node -e "console.log(require('fs').readFileSync('${testFile}', 'utf8'))"`,
|
||||
tool: 'node',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+21
-21
@@ -8696,9 +8696,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz",
|
||||
"integrity": "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8711,9 +8711,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.3.tgz",
|
||||
"integrity": "sha512-Ymnuefk6VzAhT3SxLzVUw+nMio/wB1NGypHkgetwtXcK1JfryaHk4DWQFGVwQ9XgzyS5iRZ7C2ZGI4AMsdMZ6A==",
|
||||
"version": "5.5.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz",
|
||||
"integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8722,9 +8722,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.2",
|
||||
"path-expression-matcher": "^1.1.3",
|
||||
"strnum": "^2.1.2"
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.0",
|
||||
"strnum": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -8900,9 +8900,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -13200,9 +13200,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
|
||||
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
|
||||
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15465,9 +15465,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
||||
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
||||
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -16469,9 +16469,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.19.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz",
|
||||
"integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==",
|
||||
"version": "7.24.5",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
|
||||
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
PRIORITY_YOLO_ALLOW_ALL: 998,
|
||||
Config: vi.fn().mockImplementation((params) => {
|
||||
const mockConfig = {
|
||||
...params,
|
||||
@@ -341,11 +342,11 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should default enableAgents to false when not provided', async () => {
|
||||
it('should default enableAgents to true when not provided', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: false,
|
||||
enableAgents: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function loadConfig(
|
||||
interactive: !isHeadlessMode(),
|
||||
enableInteractiveShell: !isHeadlessMode(),
|
||||
ptyInfo: 'auto',
|
||||
enableAgents: settings.experimental?.enableAgents ?? false,
|
||||
enableAgents: settings.experimental?.enableAgents ?? true,
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
|
||||
+21
-5
@@ -6,12 +6,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { main } from './src/gemini.js';
|
||||
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './src/utils/cleanup.js';
|
||||
// --- Fast Path for Version ---
|
||||
// We check for version flags at the very top to avoid loading any heavy dependencies.
|
||||
// process.env.CLI_VERSION is defined during the build process by esbuild.
|
||||
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
||||
console.log(process.env['CLI_VERSION'] || 'unknown');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
let writeToStderrFn: (message: string) => void = (msg) =>
|
||||
process.stderr.write(msg);
|
||||
|
||||
// Suppress known race condition error in node-pty on Windows
|
||||
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
|
||||
process.on('uncaughtException', (error) => {
|
||||
@@ -28,13 +35,22 @@ process.on('uncaughtException', (error) => {
|
||||
// For other errors, we rely on the default behavior, but since we attached a listener,
|
||||
// we must manually replicate it.
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
writeToStderrFn(error.stack + '\n');
|
||||
} else {
|
||||
writeToStderr(String(error) + '\n');
|
||||
writeToStderrFn(String(error) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const [{ main }, { FatalError, writeToStderr }, { runExitCleanup }] =
|
||||
await Promise.all([
|
||||
import('./src/gemini.js'),
|
||||
import('@google/gemini-cli-core'),
|
||||
import('./src/utils/cleanup.js'),
|
||||
]);
|
||||
|
||||
writeToStderrFn = writeToStderr;
|
||||
|
||||
main().catch(async (error) => {
|
||||
// Set a timeout to force exit if cleanup hangs
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
|
||||
@@ -300,6 +300,7 @@ export class GeminiAgent {
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
@@ -357,16 +358,6 @@ export class GeminiAgent {
|
||||
const { sessionData, sessionPath } =
|
||||
await sessionSelector.resolveSession(sessionId);
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(sessionData.messages);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
@@ -440,7 +431,19 @@ export class GeminiAgent {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 3. Now that we are authenticated, it is safe to initialize the config
|
||||
// 3. Set the ACP FileSystemService (if supported) before config initialization
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
// 4. Now that we are authenticated, it is safe to initialize the config
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
@@ -1625,6 +1628,7 @@ function toPermissionOptions(
|
||||
case 'info':
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
case 'sandbox_expansion':
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
|
||||
@@ -4,10 +4,25 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
import type { FileSystemService } from '@google/gemini-cli-core';
|
||||
import os from 'node:os';
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
homedir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
let mockConnection: Mocked<AgentSideConnection>;
|
||||
@@ -25,13 +40,19 @@ describe('AcpFileSystemService', () => {
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
};
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('readTextFile', () => {
|
||||
it.each([
|
||||
{
|
||||
capability: true,
|
||||
desc: 'connection if capability exists',
|
||||
path: '/path/to/file',
|
||||
desc: 'connection if capability exists and file is inside root',
|
||||
setup: () => {
|
||||
mockConnection.readTextFile.mockResolvedValue({ content: 'content' });
|
||||
},
|
||||
@@ -45,6 +66,7 @@ describe('AcpFileSystemService', () => {
|
||||
},
|
||||
{
|
||||
capability: false,
|
||||
path: '/path/to/file',
|
||||
desc: 'fallback if capability missing',
|
||||
setup: () => {
|
||||
mockFallback.readTextFile.mockResolvedValue('content');
|
||||
@@ -56,19 +78,72 @@ describe('AcpFileSystemService', () => {
|
||||
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])('should use $desc', async ({ capability, setup, verify }) => {
|
||||
{
|
||||
capability: true,
|
||||
path: '/outside/file',
|
||||
desc: 'fallback if capability exists but file is outside root',
|
||||
setup: () => {
|
||||
mockFallback.readTextFile.mockResolvedValue('content');
|
||||
},
|
||||
verify: () => {
|
||||
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
|
||||
'/outside/file',
|
||||
);
|
||||
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: true,
|
||||
path: '/home/user/.gemini/tmp/file.md',
|
||||
root: '/home/user',
|
||||
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
|
||||
setup: () => {
|
||||
mockFallback.readTextFile.mockResolvedValue('content');
|
||||
},
|
||||
verify: () => {
|
||||
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
|
||||
'/home/user/.gemini/tmp/file.md',
|
||||
);
|
||||
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])(
|
||||
'should use $desc',
|
||||
async ({ capability, path, root, setup, verify }) => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ readTextFile: capability, writeTextFile: true },
|
||||
mockFallback,
|
||||
root || '/path/to',
|
||||
);
|
||||
setup();
|
||||
|
||||
const result = await service.readTextFile(path);
|
||||
|
||||
expect(result).toBe('content');
|
||||
verify();
|
||||
},
|
||||
);
|
||||
|
||||
it('should throw normalized ENOENT error when readTextFile encounters "Resource not found"', async () => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ readTextFile: capability, writeTextFile: true },
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
mockFallback,
|
||||
'/path/to',
|
||||
);
|
||||
mockConnection.readTextFile.mockRejectedValue(
|
||||
new Error('Resource not found for document'),
|
||||
);
|
||||
setup();
|
||||
|
||||
const result = await service.readTextFile('/path/to/file');
|
||||
|
||||
expect(result).toBe('content');
|
||||
verify();
|
||||
await expect(
|
||||
service.readTextFile('/path/to/missing'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
message: 'Resource not found for document',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +151,8 @@ describe('AcpFileSystemService', () => {
|
||||
it.each([
|
||||
{
|
||||
capability: true,
|
||||
desc: 'connection if capability exists',
|
||||
path: '/path/to/file',
|
||||
desc: 'connection if capability exists and file is inside root',
|
||||
verify: () => {
|
||||
expect(mockConnection.writeTextFile).toHaveBeenCalledWith({
|
||||
path: '/path/to/file',
|
||||
@@ -88,6 +164,7 @@ describe('AcpFileSystemService', () => {
|
||||
},
|
||||
{
|
||||
capability: false,
|
||||
path: '/path/to/file',
|
||||
desc: 'fallback if capability missing',
|
||||
verify: () => {
|
||||
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
|
||||
@@ -97,17 +174,63 @@ describe('AcpFileSystemService', () => {
|
||||
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])('should use $desc', async ({ capability, verify }) => {
|
||||
{
|
||||
capability: true,
|
||||
path: '/outside/file',
|
||||
desc: 'fallback if capability exists but file is outside root',
|
||||
verify: () => {
|
||||
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
|
||||
'/outside/file',
|
||||
'content',
|
||||
);
|
||||
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: true,
|
||||
path: '/home/user/.gemini/tmp/file.md',
|
||||
root: '/home/user',
|
||||
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
|
||||
verify: () => {
|
||||
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
|
||||
'/home/user/.gemini/tmp/file.md',
|
||||
'content',
|
||||
);
|
||||
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])('should use $desc', async ({ capability, path, root, verify }) => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ writeTextFile: capability, readTextFile: true },
|
||||
mockFallback,
|
||||
root || '/path/to',
|
||||
);
|
||||
|
||||
await service.writeTextFile('/path/to/file', 'content');
|
||||
await service.writeTextFile(path, 'content');
|
||||
|
||||
verify();
|
||||
});
|
||||
|
||||
it('should throw normalized ENOENT error when writeTextFile encounters "Resource not found"', async () => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
mockFallback,
|
||||
'/path/to',
|
||||
);
|
||||
mockConnection.writeTextFile.mockRejectedValue(
|
||||
new Error('Resource not found for directory'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.writeTextFile('/path/to/missing', 'content'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
message: 'Resource not found for directory',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,44 +4,82 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { FileSystemService } from '@google/gemini-cli-core';
|
||||
import { isWithinRoot, type FileSystemService } from '@google/gemini-cli-core';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* ACP client-based implementation of FileSystemService
|
||||
*/
|
||||
export class AcpFileSystemService implements FileSystemService {
|
||||
private readonly geminiDir = path.join(os.homedir(), '.gemini');
|
||||
|
||||
constructor(
|
||||
private readonly connection: acp.AgentSideConnection,
|
||||
private readonly sessionId: string,
|
||||
private readonly capabilities: acp.FileSystemCapabilities,
|
||||
private readonly fallback: FileSystemService,
|
||||
private readonly root: string,
|
||||
) {}
|
||||
|
||||
private shouldUseFallback(filePath: string): boolean {
|
||||
// Files inside the global CLI directory must always use the native file system,
|
||||
// even if the user runs the CLI directly from their home directory (which
|
||||
// would make the IDE's project root overlap with the global directory).
|
||||
return (
|
||||
!isWithinRoot(filePath, this.root) ||
|
||||
isWithinRoot(filePath, this.geminiDir)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeFileSystemError(err: unknown): never {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (
|
||||
errorMessage.includes('Resource not found') ||
|
||||
errorMessage.includes('ENOENT') ||
|
||||
errorMessage.includes('does not exist') ||
|
||||
errorMessage.includes('No such file')
|
||||
) {
|
||||
const newErr = new Error(errorMessage) as NodeJS.ErrnoException;
|
||||
newErr.code = 'ENOENT';
|
||||
throw newErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
async readTextFile(filePath: string): Promise<string> {
|
||||
if (!this.capabilities.readTextFile) {
|
||||
if (!this.capabilities.readTextFile || this.shouldUseFallback(filePath)) {
|
||||
return this.fallback.readTextFile(filePath);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return response.content;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return response.content;
|
||||
} catch (err: unknown) {
|
||||
this.normalizeFileSystemError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async writeTextFile(filePath: string, content: string): Promise<void> {
|
||||
if (!this.capabilities.writeTextFile) {
|
||||
if (!this.capabilities.writeTextFile || this.shouldUseFallback(filePath)) {
|
||||
return this.fallback.writeTextFile(filePath, content);
|
||||
}
|
||||
|
||||
await this.connection.writeTextFile({
|
||||
path: filePath,
|
||||
content,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
try {
|
||||
await this.connection.writeTextFile({
|
||||
path: filePath,
|
||||
content,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
this.normalizeFileSystemError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2103,52 +2103,6 @@ describe('loadCliConfig compressionThreshold', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig showContextWindowWarning', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should pass showContextWindowWarning from settings to config (true)', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
ui: {
|
||||
showContextWindowWarning: true,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getShowContextWindowWarning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass showContextWindowWarning from settings to config (false)', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
ui: {
|
||||
showContextWindowWarning: false,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getShowContextWindowWarning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should default to false if not in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getShowContextWindowWarning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig useRipgrep', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
@@ -955,8 +955,6 @@ export async function loadCliConfig(
|
||||
bugCommand: settings.advanced?.bugCommand,
|
||||
model: resolvedModel,
|
||||
maxSessionTurns: settings.model?.maxSessionTurns,
|
||||
showContextWindowWarning: settings.ui?.showContextWindowWarning,
|
||||
showContextCompression: settings.ui?.showContextCompression,
|
||||
|
||||
listExtensions: argv.listExtensions || false,
|
||||
listSessions: argv.listSessions || false,
|
||||
|
||||
@@ -400,7 +400,7 @@ describe('SettingsSchema', () => {
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe('Enable local and remote subagents.');
|
||||
|
||||
@@ -575,31 +575,11 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Hide Context Summary',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showContextWindowWarning: {
|
||||
type: 'boolean',
|
||||
label: 'Show Context Window Warning',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showContextCompression: {
|
||||
type: 'boolean',
|
||||
label: 'Show Context Compression Messages',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Show a message in the chat history when it is compressed.',
|
||||
showInDialog: true,
|
||||
},
|
||||
footer: {
|
||||
type: 'object',
|
||||
label: 'Footer',
|
||||
@@ -1228,6 +1208,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Disable user input on browser window during automation.',
|
||||
showInDialog: false,
|
||||
},
|
||||
maxActionsPerTask: {
|
||||
type: 'number',
|
||||
label: 'Max Actions Per Task',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: 100,
|
||||
description:
|
||||
'The maximum number of tool calls allowed per browser task. Enforcement is hard: the agent will be terminated when the limit is reached.',
|
||||
showInDialog: false,
|
||||
},
|
||||
confirmSensitiveActions: {
|
||||
type: 'boolean',
|
||||
label: 'Confirm Sensitive Actions',
|
||||
@@ -1952,7 +1942,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Agents',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enable local and remote subagents.',
|
||||
showInDialog: false,
|
||||
},
|
||||
|
||||
@@ -700,7 +700,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Derive auth state variables for backward compatibility with UIStateContext
|
||||
const isAuthDialogOpen = authState === AuthState.Updating;
|
||||
const isAuthenticating = authState === AuthState.Unauthenticated;
|
||||
// TODO: Consider handling other auth types that should also skip the blocking screen
|
||||
const isAuthenticating =
|
||||
authState === AuthState.Unauthenticated &&
|
||||
settings.merged.security.auth.selectedType !== AuthType.USE_GEMINI;
|
||||
|
||||
// Session browser and resume functionality
|
||||
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
|
||||
@@ -1300,7 +1303,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSlash || (isIdle && isMcpReady)) {
|
||||
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
|
||||
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
|
||||
if (!isSlash) {
|
||||
const permissions = await checkPermissions(submittedValue, config);
|
||||
if (permissions.length > 0) {
|
||||
@@ -1323,10 +1327,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
void submitQuery(submittedValue);
|
||||
} else {
|
||||
// Check messageQueue.length === 0 to only notify on the first queued item
|
||||
if (isIdle && !isMcpReady && messageQueue.length === 0) {
|
||||
if (isIdle && !isMcpOrConfigReady && messageQueue.length === 0) {
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
|
||||
!isConfigInitialized
|
||||
? 'Initializing... Prompts will be queued.'
|
||||
: 'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
|
||||
);
|
||||
}
|
||||
addMessage(submittedValue);
|
||||
@@ -1350,6 +1356,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
refreshStatic,
|
||||
reset,
|
||||
handleHintSubmit,
|
||||
isConfigInitialized,
|
||||
triggerExpandHint,
|
||||
],
|
||||
);
|
||||
@@ -1380,17 +1387,28 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
* - Any future streaming states not explicitly allowed
|
||||
*/
|
||||
const isInputActive =
|
||||
isConfigInitialized &&
|
||||
!initError &&
|
||||
!isProcessing &&
|
||||
!isResuming &&
|
||||
!!slashCommands &&
|
||||
(streamingState === StreamingState.Idle ||
|
||||
streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation) &&
|
||||
!proQuotaRequest;
|
||||
!proQuotaRequest &&
|
||||
!copyModeEnabled;
|
||||
|
||||
const [controlsHeight, setControlsHeight] = useState(0);
|
||||
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!copyModeEnabled && controlsHeight > 0) {
|
||||
setLastNonCopyControlsHeight(controlsHeight);
|
||||
}
|
||||
}, [copyModeEnabled, controlsHeight]);
|
||||
|
||||
const stableControlsHeight =
|
||||
copyModeEnabled && lastNonCopyControlsHeight > 0
|
||||
? lastNonCopyControlsHeight
|
||||
: controlsHeight;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (mainControlsRef.current) {
|
||||
@@ -1402,10 +1420,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]);
|
||||
|
||||
// Compute available terminal height based on controls measurement
|
||||
// Compute available terminal height based on stable controls measurement
|
||||
const availableTerminalHeight = Math.max(
|
||||
0,
|
||||
terminalHeight - controlsHeight - backgroundShellHeight - 1,
|
||||
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
|
||||
);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
@@ -2264,6 +2282,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
staticExtraHeight,
|
||||
@@ -2385,6 +2404,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
staticExtraHeight,
|
||||
|
||||
@@ -34,12 +34,11 @@ Tips for getting started:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
|
||||
Composer
|
||||
"
|
||||
`;
|
||||
@@ -100,12 +99,11 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
|
||||
DialogManager
|
||||
"
|
||||
`;
|
||||
@@ -147,9 +145,8 @@ HistoryItemDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
|
||||
Composer
|
||||
"
|
||||
`;
|
||||
|
||||
+114
-119
@@ -1,271 +1,266 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="666" viewBox="0 0 920 666">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="700" fill="#000000" />
|
||||
<rect width="920" height="666" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="19" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<rect x="0" y="0" width="900" height="17" fill="#141414" />
|
||||
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<rect x="0" y="17" width="9" height="17" fill="#141414" />
|
||||
<rect x="9" y="17" width="18" height="17" fill="#141414" />
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="27" y="17" width="324" height="17" fill="#141414" />
|
||||
<text x="27" y="19" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
|
||||
<rect x="351" y="17" width="549" height="17" fill="#141414" />
|
||||
<rect x="0" y="34" width="900" height="17" fill="#141414" />
|
||||
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<rect x="0" y="51" width="9" height="17" fill="#141414" />
|
||||
<rect x="9" y="51" width="18" height="17" fill="#141414" />
|
||||
<text x="9" y="53" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="27" y="51" width="324" height="17" fill="#141414" />
|
||||
<text x="27" y="53" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
|
||||
<rect x="351" y="51" width="549" height="17" fill="#141414" />
|
||||
<rect x="0" y="68" width="900" height="17" fill="#141414" />
|
||||
<text x="0" y="70" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
|
||||
<text x="0" y="87" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
|
||||
<text x="0" y="53" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="70" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
|
||||
<text x="882" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="104" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
|
||||
<text x="18" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
|
||||
<text x="45" y="104" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">Edit</text>
|
||||
<text x="90" y="104" fill="#afafaf" textLength="774" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto</text>
|
||||
<text x="864" y="104" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">… </text>
|
||||
<text x="882" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
|
||||
<text x="45" y="138" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">Edit</text>
|
||||
<text x="90" y="138" fill="#afafaf" textLength="774" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto</text>
|
||||
<text x="864" y="138" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">… </text>
|
||||
<text x="18" y="138" fill="#afafaf" textLength="414" lengthAdjust="spacingAndGlyphs">... first 44 lines hidden (Ctrl+O to show) ...</text>
|
||||
<text x="882" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="155" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">45</text>
|
||||
<text x="63" y="155" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="155" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line45</text>
|
||||
<text x="171" y="155" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="155" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="155" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="172" fill="#afafaf" textLength="414" lengthAdjust="spacingAndGlyphs">... first 44 lines hidden (Ctrl+O to show) ...</text>
|
||||
<text x="18" y="172" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">46</text>
|
||||
<text x="63" y="172" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="172" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line46</text>
|
||||
<text x="171" y="172" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="172" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="172" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="189" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">45</text>
|
||||
<text x="18" y="189" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">47</text>
|
||||
<text x="63" y="189" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line45</text>
|
||||
<text x="117" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line47</text>
|
||||
<text x="171" y="189" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="189" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="189" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">46</text>
|
||||
<text x="18" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
|
||||
<text x="63" y="206" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="206" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line46</text>
|
||||
<text x="117" y="206" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line48</text>
|
||||
<text x="171" y="206" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="206" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="206" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">47</text>
|
||||
<text x="18" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
|
||||
<text x="63" y="223" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="223" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line47</text>
|
||||
<text x="117" y="223" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line49</text>
|
||||
<text x="171" y="223" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="223" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="223" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
|
||||
<text x="18" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
|
||||
<text x="63" y="240" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line48</text>
|
||||
<text x="117" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line50</text>
|
||||
<text x="171" y="240" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="240" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="240" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
|
||||
<text x="18" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
|
||||
<text x="63" y="257" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="257" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line49</text>
|
||||
<text x="117" y="257" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line51</text>
|
||||
<text x="171" y="257" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="257" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="257" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
|
||||
<text x="18" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
|
||||
<text x="63" y="274" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="274" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line50</text>
|
||||
<text x="117" y="274" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line52</text>
|
||||
<text x="171" y="274" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="274" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="274" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
|
||||
<text x="18" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
|
||||
<text x="63" y="291" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="291" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line51</text>
|
||||
<text x="117" y="291" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line53</text>
|
||||
<text x="171" y="291" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="291" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="291" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
|
||||
<text x="18" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
|
||||
<text x="63" y="308" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="308" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line52</text>
|
||||
<text x="117" y="308" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line54</text>
|
||||
<text x="171" y="308" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="308" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="308" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="325" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
|
||||
<text x="18" y="325" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">55</text>
|
||||
<text x="63" y="325" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="325" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line53</text>
|
||||
<text x="117" y="325" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line55</text>
|
||||
<text x="171" y="325" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="325" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="325" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="342" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
|
||||
<text x="18" y="342" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">56</text>
|
||||
<text x="63" y="342" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="342" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line54</text>
|
||||
<text x="117" y="342" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line56</text>
|
||||
<text x="171" y="342" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="342" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="342" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="359" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">55</text>
|
||||
<text x="18" y="359" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">57</text>
|
||||
<text x="63" y="359" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="359" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line55</text>
|
||||
<text x="117" y="359" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line57</text>
|
||||
<text x="171" y="359" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="359" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="359" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="376" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">56</text>
|
||||
<text x="18" y="376" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">58</text>
|
||||
<text x="63" y="376" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="376" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line56</text>
|
||||
<text x="117" y="376" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line58</text>
|
||||
<text x="171" y="376" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="376" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="376" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="393" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">57</text>
|
||||
<text x="18" y="393" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">59</text>
|
||||
<text x="63" y="393" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="393" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line57</text>
|
||||
<text x="117" y="393" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line59</text>
|
||||
<text x="171" y="393" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="393" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="393" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="410" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">58</text>
|
||||
<text x="18" y="410" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">60</text>
|
||||
<text x="63" y="410" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="410" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line58</text>
|
||||
<text x="117" y="410" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line60</text>
|
||||
<text x="171" y="410" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="410" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="410" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="427" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">59</text>
|
||||
<text x="63" y="427" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="427" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line59</text>
|
||||
<text x="171" y="427" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="427" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="427" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<rect x="18" y="425" width="18" height="17" fill="#5f0000" />
|
||||
<text x="18" y="427" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
|
||||
<rect x="36" y="425" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="45" y="425" width="9" height="17" fill="#5f0000" />
|
||||
<text x="45" y="427" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
|
||||
<rect x="54" y="425" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="63" y="425" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="72" y="425" width="54" height="17" fill="#5f0000" />
|
||||
<text x="72" y="427" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
|
||||
<rect x="126" y="425" width="234" height="17" fill="#5f0000" />
|
||||
<text x="126" y="427" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
|
||||
<text x="882" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="444" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">60</text>
|
||||
<text x="63" y="444" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
|
||||
<text x="117" y="444" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line60</text>
|
||||
<text x="171" y="444" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
|
||||
<text x="198" y="444" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="234" y="444" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<rect x="18" y="442" width="18" height="17" fill="#005f00" />
|
||||
<text x="18" y="444" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
|
||||
<rect x="36" y="442" width="9" height="17" fill="#005f00" />
|
||||
<rect x="45" y="442" width="9" height="17" fill="#005f00" />
|
||||
<text x="45" y="444" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
|
||||
<rect x="54" y="442" width="9" height="17" fill="#005f00" />
|
||||
<rect x="63" y="442" width="9" height="17" fill="#005f00" />
|
||||
<rect x="72" y="442" width="54" height="17" fill="#005f00" />
|
||||
<text x="72" y="444" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
|
||||
<rect x="126" y="442" width="234" height="17" fill="#005f00" />
|
||||
<text x="126" y="444" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
|
||||
<text x="882" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="18" y="459" width="18" height="17" fill="#5f0000" />
|
||||
<text x="18" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
|
||||
<rect x="36" y="459" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="45" y="459" width="9" height="17" fill="#5f0000" />
|
||||
<text x="45" y="461" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
|
||||
<rect x="54" y="459" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="63" y="459" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="72" y="459" width="54" height="17" fill="#5f0000" />
|
||||
<text x="72" y="461" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
|
||||
<rect x="126" y="459" width="234" height="17" fill="#5f0000" />
|
||||
<text x="126" y="461" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
|
||||
<text x="18" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">62</text>
|
||||
<text x="63" y="461" fill="#e5e5e5" textLength="180" lengthAdjust="spacingAndGlyphs"> buffer: TextBuffer;</text>
|
||||
<text x="882" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="18" y="476" width="18" height="17" fill="#005f00" />
|
||||
<text x="18" y="478" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
|
||||
<rect x="36" y="476" width="9" height="17" fill="#005f00" />
|
||||
<rect x="45" y="476" width="9" height="17" fill="#005f00" />
|
||||
<text x="45" y="478" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
|
||||
<rect x="54" y="476" width="9" height="17" fill="#005f00" />
|
||||
<rect x="63" y="476" width="9" height="17" fill="#005f00" />
|
||||
<rect x="72" y="476" width="54" height="17" fill="#005f00" />
|
||||
<text x="72" y="478" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
|
||||
<rect x="126" y="476" width="234" height="17" fill="#005f00" />
|
||||
<text x="126" y="478" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
|
||||
<text x="18" y="478" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">63</text>
|
||||
<text x="72" y="478" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">onSubmit</text>
|
||||
<text x="144" y="478" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">: (</text>
|
||||
<text x="171" y="478" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">value</text>
|
||||
<text x="216" y="478" fill="#e5e5e5" textLength="18" lengthAdjust="spacingAndGlyphs">: </text>
|
||||
<text x="234" y="478" fill="#00cdcd" textLength="54" lengthAdjust="spacingAndGlyphs">string</text>
|
||||
<text x="288" y="478" fill="#e5e5e5" textLength="45" lengthAdjust="spacingAndGlyphs">) => </text>
|
||||
<text x="333" y="478" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">void</text>
|
||||
<text x="369" y="478" fill="#e5e5e5" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="495" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">62</text>
|
||||
<text x="63" y="495" fill="#e5e5e5" textLength="180" lengthAdjust="spacingAndGlyphs"> buffer: TextBuffer;</text>
|
||||
<text x="18" y="495" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Apply this change?</text>
|
||||
<text x="882" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">63</text>
|
||||
<text x="72" y="512" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">onSubmit</text>
|
||||
<text x="144" y="512" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">: (</text>
|
||||
<text x="171" y="512" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">value</text>
|
||||
<text x="216" y="512" fill="#e5e5e5" textLength="18" lengthAdjust="spacingAndGlyphs">: </text>
|
||||
<text x="234" y="512" fill="#00cdcd" textLength="54" lengthAdjust="spacingAndGlyphs">string</text>
|
||||
<text x="288" y="512" fill="#e5e5e5" textLength="45" lengthAdjust="spacingAndGlyphs">) => </text>
|
||||
<text x="333" y="512" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">void</text>
|
||||
<text x="369" y="512" fill="#e5e5e5" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="882" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="529" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Apply this change?</text>
|
||||
<rect x="18" y="527" width="9" height="17" fill="#001a00" />
|
||||
<text x="18" y="529" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">●</text>
|
||||
<rect x="27" y="527" width="9" height="17" fill="#001a00" />
|
||||
<rect x="36" y="527" width="18" height="17" fill="#001a00" />
|
||||
<text x="36" y="529" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
|
||||
<rect x="54" y="527" width="9" height="17" fill="#001a00" />
|
||||
<rect x="63" y="527" width="90" height="17" fill="#001a00" />
|
||||
<text x="63" y="529" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
|
||||
<rect x="153" y="527" width="288" height="17" fill="#001a00" />
|
||||
<text x="882" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="546" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
|
||||
<text x="63" y="546" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
|
||||
<text x="882" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="18" y="561" width="9" height="17" fill="#001a00" />
|
||||
<text x="18" y="563" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">●</text>
|
||||
<rect x="27" y="561" width="9" height="17" fill="#001a00" />
|
||||
<rect x="36" y="561" width="18" height="17" fill="#001a00" />
|
||||
<text x="36" y="563" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
|
||||
<rect x="54" y="561" width="9" height="17" fill="#001a00" />
|
||||
<rect x="63" y="561" width="90" height="17" fill="#001a00" />
|
||||
<text x="63" y="563" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
|
||||
<rect x="153" y="561" width="288" height="17" fill="#001a00" />
|
||||
<text x="36" y="563" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
|
||||
<text x="63" y="563" fill="#ffffff" textLength="378" lengthAdjust="spacingAndGlyphs">Allow for this file in all future sessions</text>
|
||||
<text x="882" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
|
||||
<text x="63" y="580" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
|
||||
<text x="36" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">4.</text>
|
||||
<text x="63" y="580" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Modify with external editor</text>
|
||||
<text x="882" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
|
||||
<text x="63" y="597" fill="#ffffff" textLength="378" lengthAdjust="spacingAndGlyphs">Allow for this file in all future sessions</text>
|
||||
<text x="36" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">5.</text>
|
||||
<text x="63" y="597" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
|
||||
<text x="882" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="614" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">4.</text>
|
||||
<text x="63" y="614" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Modify with external editor</text>
|
||||
<text x="882" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="631" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="631" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">5.</text>
|
||||
<text x="63" y="631" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
|
||||
<text x="882" y="631" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="631" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╰─────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="648" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="648" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="665" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╰─────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
@@ -1,9 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
|
||||
"3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Can you edit InputPrompt.tsx for me?
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -11,9 +9,9 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo
|
||||
│ │
|
||||
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
|
||||
│ │
|
||||
│ ... first 44 lines hidden (Ctrl+O to show) ... │█
|
||||
│ 45 const line45 = true; │█
|
||||
│ 46 const line46 = true; │█
|
||||
│ ... first 44 lines hidden (Ctrl+O to show) ... │
|
||||
│ 45 const line45 = true; │
|
||||
│ 46 const line46 = true; │
|
||||
│ 47 const line47 = true; │█
|
||||
│ 48 const line48 = true; │█
|
||||
│ 49 const line49 = true; │█
|
||||
|
||||
@@ -4,42 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as Core from '@google/gemini-cli-core';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
CompressionStatus,
|
||||
type ChatCompressionInfo,
|
||||
type GeminiClient,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { compressCommand } from './compressCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = (await importOriginal()) as any;
|
||||
return {
|
||||
...actual,
|
||||
tokenLimit: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('compressCommand', () => {
|
||||
let context: ReturnType<typeof createMockCommandContext>;
|
||||
let mockTryCompressChat: ReturnType<typeof vi.fn>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockTryCompressChat = vi.fn();
|
||||
vi.mocked(Core.tokenLimit).mockReturnValue(1000);
|
||||
context = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getModel: () => 'test-model',
|
||||
getContextWindowCompressionThreshold: () => 0.2,
|
||||
},
|
||||
geminiClient: {
|
||||
tryCompressChat: mockTryCompressChat,
|
||||
} as unknown as Core.GeminiClient,
|
||||
} as unknown as GeminiClient,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -50,10 +36,9 @@ describe('compressCommand', () => {
|
||||
type: MessageType.COMPRESSION,
|
||||
compression: {
|
||||
isPending: true,
|
||||
beforePercentage: null,
|
||||
afterPercentage: null,
|
||||
originalTokenCount: null,
|
||||
newTokenCount: null,
|
||||
compressionStatus: null,
|
||||
isManual: true,
|
||||
},
|
||||
};
|
||||
await compressCommand.action!(context, '');
|
||||
@@ -69,9 +54,9 @@ describe('compressCommand', () => {
|
||||
});
|
||||
|
||||
it('should set pending item, call tryCompressChat, and add result on success', async () => {
|
||||
const compressedResult: Core.ChatCompressionInfo = {
|
||||
const compressedResult: ChatCompressionInfo = {
|
||||
originalTokenCount: 200,
|
||||
compressionStatus: Core.CompressionStatus.COMPRESSED,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
newTokenCount: 100,
|
||||
};
|
||||
mockTryCompressChat.mockResolvedValue(compressedResult);
|
||||
@@ -83,9 +68,8 @@ describe('compressCommand', () => {
|
||||
compression: {
|
||||
isPending: true,
|
||||
compressionStatus: null,
|
||||
beforePercentage: null,
|
||||
afterPercentage: null,
|
||||
isManual: true,
|
||||
originalTokenCount: null,
|
||||
newTokenCount: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,11 +83,9 @@ describe('compressCommand', () => {
|
||||
type: MessageType.COMPRESSION,
|
||||
compression: {
|
||||
isPending: false,
|
||||
compressionStatus: Core.CompressionStatus.COMPRESSED,
|
||||
beforePercentage: 20,
|
||||
afterPercentage: 10,
|
||||
isManual: true,
|
||||
thresholdPercentage: 20,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
originalTokenCount: 200,
|
||||
newTokenCount: 100,
|
||||
},
|
||||
},
|
||||
expect.any(Number),
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { MessageType, type HistoryItemCompression } from '../types.js';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { tokenLimit, type CompressionStatus } from '@google/gemini-cli-core';
|
||||
|
||||
export const compressCommand: SlashCommand = {
|
||||
name: 'compress',
|
||||
@@ -15,21 +14,7 @@ export const compressCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const { ui, services } = context;
|
||||
const agentContext = services.agentContext;
|
||||
if (!agentContext) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Agent context not found.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = agentContext.config;
|
||||
|
||||
const { ui } = context;
|
||||
if (ui.pendingItem) {
|
||||
ui.addItem(
|
||||
{
|
||||
@@ -45,43 +30,29 @@ export const compressCommand: SlashCommand = {
|
||||
type: MessageType.COMPRESSION,
|
||||
compression: {
|
||||
isPending: true,
|
||||
beforePercentage: null,
|
||||
afterPercentage: null,
|
||||
originalTokenCount: null,
|
||||
newTokenCount: null,
|
||||
compressionStatus: null,
|
||||
isManual: true,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
ui.setPendingItem(pendingMessage);
|
||||
const promptId = `compress-${Date.now()}`;
|
||||
const compressed = await agentContext.geminiClient.tryCompressChat(
|
||||
promptId,
|
||||
true,
|
||||
);
|
||||
const compressed =
|
||||
await context.services.agentContext?.geminiClient?.tryCompressChat(
|
||||
promptId,
|
||||
true,
|
||||
);
|
||||
if (compressed) {
|
||||
const limit = tokenLimit(config.getModel());
|
||||
const threshold = config.getContextWindowCompressionThreshold();
|
||||
const beforePercentage = Math.round(
|
||||
(compressed.originalTokenCount / limit) * 100,
|
||||
);
|
||||
const afterPercentage = Math.round(
|
||||
(compressed.newTokenCount / limit) * 100,
|
||||
);
|
||||
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.COMPRESSION,
|
||||
compression: {
|
||||
isPending: false,
|
||||
beforePercentage,
|
||||
afterPercentage,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
compressionStatus: Number(
|
||||
compressed.compressionStatus,
|
||||
) as unknown as CompressionStatus,
|
||||
isManual: true,
|
||||
thresholdPercentage: Math.round(threshold * 100),
|
||||
originalTokenCount: compressed.originalTokenCount,
|
||||
newTokenCount: compressed.newTokenCount,
|
||||
compressionStatus: compressed.compressionStatus,
|
||||
},
|
||||
} as HistoryItemCompression,
|
||||
Date.now(),
|
||||
|
||||
@@ -108,7 +108,7 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
{updateInfo && (
|
||||
{updateInfo?.isUpdating && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
<CliSpinner /> Updating
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { ApprovalMode, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
tokenLimit,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { TransientMessageType } from '../../utils/events.js';
|
||||
@@ -729,6 +733,37 @@ describe('Composer', () => {
|
||||
expect(output).toContain('Press Esc again to rewind.');
|
||||
expect(output).not.toContain('ContextSummaryDisplay');
|
||||
});
|
||||
|
||||
it('shows context usage bleed-through when over 60%', async () => {
|
||||
const model = 'gemini-2.5-pro';
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
currentModel: model,
|
||||
sessionStats: {
|
||||
sessionId: 'test-session',
|
||||
sessionStartTime: new Date(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metrics: {} as any,
|
||||
lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7),
|
||||
promptCount: 0,
|
||||
},
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
footer: { hideContextPercentage: false },
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode
|
||||
expect(lastFrame()).toContain('StatusDisplay');
|
||||
expect(lastFrame()).toContain('70% used');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Details Display', () => {
|
||||
|
||||
@@ -20,11 +20,13 @@ import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { isContextUsageHigh } from '../utils/contextUsage.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
|
||||
@@ -255,6 +257,11 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
|
||||
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
|
||||
const miniMode_ShowTip = showTipLine;
|
||||
const miniMode_ShowContext = isContextUsageHigh(
|
||||
uiState.sessionStats.lastPromptTokenCount,
|
||||
uiState.currentModel,
|
||||
settings.merged.model?.compressionThreshold,
|
||||
);
|
||||
|
||||
// Composite Mini Mode Triggers
|
||||
const showRow1_MiniMode =
|
||||
@@ -263,7 +270,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
miniMode_ShowShortcuts ||
|
||||
miniMode_ShowTip;
|
||||
|
||||
const showRow2_MiniMode = miniMode_ShowApprovalMode;
|
||||
const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
|
||||
|
||||
// Final Display Rules (Stable Footer Architecture)
|
||||
const showRow1 = showUiDetails || showRow1_MiniMode;
|
||||
@@ -481,9 +488,22 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
alignItems="center"
|
||||
marginLeft={isNarrow ? 1 : 0}
|
||||
>
|
||||
{showUiDetails && (
|
||||
{(showUiDetails || miniMode_ShowContext) && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
{miniMode_ShowContext && !showUiDetails && (
|
||||
<Box marginLeft={1}>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||
model={
|
||||
typeof uiState.currentModel === 'string'
|
||||
? uiState.currentModel
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -498,12 +518,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexGrow={0}
|
||||
flexShrink={0}
|
||||
>
|
||||
{(!uiState.slashCommands ||
|
||||
!uiState.isConfigInitialized ||
|
||||
uiState.isResuming) && (
|
||||
<ConfigInitDisplay
|
||||
message={uiState.isResuming ? 'Resuming session...' : undefined}
|
||||
/>
|
||||
{uiState.isResuming && (
|
||||
<ConfigInitDisplay message="Resuming session..." />
|
||||
)}
|
||||
|
||||
{showUiDetails && (
|
||||
@@ -572,12 +588,15 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
streamingState={uiState.streamingState}
|
||||
suggestionsPosition={suggestionsPosition}
|
||||
onSuggestionsVisibilityChange={setSuggestionsVisible}
|
||||
copyModeEnabled={uiState.copyModeEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showUiDetails &&
|
||||
!settings.merged.ui.hideFooter &&
|
||||
!isScreenReaderEnabled && <Footer />}
|
||||
!isScreenReaderEnabled && (
|
||||
<Footer copyModeEnabled={uiState.copyModeEnabled} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,16 +12,14 @@ import { theme } from '../semantic-colors.js';
|
||||
export const CopyModeWarning: React.FC = () => {
|
||||
const { copyModeEnabled } = useUIState();
|
||||
|
||||
if (!copyModeEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.warning}>
|
||||
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other key
|
||||
to exit.
|
||||
</Text>
|
||||
<Box height={1}>
|
||||
{copyModeEnabled && (
|
||||
<Text color={theme.status.warning}>
|
||||
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
|
||||
key to exit.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
Debug console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={maxHeight} width={width - borderAndPadding}>
|
||||
|
||||
@@ -80,7 +80,6 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
const pathError = await validatePlanPath(
|
||||
planPath,
|
||||
config.storage.getPlansDir(),
|
||||
config.getTargetDir(),
|
||||
);
|
||||
if (ignore) return;
|
||||
if (pathError) {
|
||||
|
||||
@@ -175,12 +175,18 @@ interface FooterColumn {
|
||||
isHighPriority: boolean;
|
||||
}
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
|
||||
copyModeEnabled = false,
|
||||
}) => {
|
||||
const uiState = useUIState();
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
|
||||
if (copyModeEnabled) {
|
||||
return <Box height={1} />;
|
||||
}
|
||||
|
||||
const {
|
||||
model,
|
||||
targetDir,
|
||||
@@ -353,7 +359,17 @@ export const Footer: React.FC = () => {
|
||||
break;
|
||||
}
|
||||
case 'memory-usage': {
|
||||
addCol(id, header, () => <MemoryUsageDisplay color={itemColor} />, 10);
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<MemoryUsageDisplay
|
||||
color={itemColor}
|
||||
isActive={!uiState.copyModeEnabled}
|
||||
/>
|
||||
),
|
||||
10,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'session-id': {
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface InputPromptProps {
|
||||
popAllMessages?: () => string | undefined;
|
||||
suggestionsPosition?: 'above' | 'below';
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
copyModeEnabled?: boolean;
|
||||
}
|
||||
|
||||
// The input content, input container, and input suggestions list may have different widths
|
||||
@@ -212,6 +213,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
popAllMessages,
|
||||
suggestionsPosition = 'below',
|
||||
setBannerVisible,
|
||||
copyModeEnabled = false,
|
||||
}) => {
|
||||
const isHelpDismissKey = useIsHelpDismissKey();
|
||||
const keyMatchers = useKeyMatchers();
|
||||
@@ -331,7 +333,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
isShellSuggestionsVisible,
|
||||
} = completion;
|
||||
|
||||
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
|
||||
const showCursor =
|
||||
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
|
||||
|
||||
// Notify parent component about escape prompt state changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('<LoadingIndicator />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly in human-readable format', async () => {
|
||||
it('should display the elapsedTime correctly in minutes and OMIT seconds for > 1m', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 125,
|
||||
@@ -130,7 +130,9 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('(esc to cancel, 2m)');
|
||||
expect(output).not.toContain('5s');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -258,15 +260,13 @@ describe('<LoadingIndicator />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
if (output) {
|
||||
// Should NOT contain "Thinking... " prefix because the subject already starts with "Thinking"
|
||||
expect(output).not.toContain('Thinking... Thinking');
|
||||
expect(output).not.toContain('Gemini is thinking');
|
||||
expect(output).toContain('Thinking about something...');
|
||||
expect(output).not.toContain('and other stuff.');
|
||||
}
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT prepend "Thinking... " even if the subject does not start with "Thinking"', async () => {
|
||||
it('should NOT prepend "Thinking... " if a subject is provided', async () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'Planning the response...',
|
||||
|
||||
@@ -11,7 +11,6 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
@@ -74,7 +73,11 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
const cancelAndTimerContent =
|
||||
showCancelAndTimer &&
|
||||
streamingState !== StreamingState.WaitingForConfirmation
|
||||
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
||||
? `(esc to cancel, ${
|
||||
elapsedTime < 60
|
||||
? `${elapsedTime}s`
|
||||
: `${Math.floor(elapsedTime / 60)}m`
|
||||
})`
|
||||
: null;
|
||||
|
||||
const wittyPhraseNode =
|
||||
|
||||
@@ -11,13 +11,18 @@ import { theme } from '../semantic-colors.js';
|
||||
import process from 'node:process';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
|
||||
export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
|
||||
color = theme.text.primary,
|
||||
}) => {
|
||||
export const MemoryUsageDisplay: React.FC<{
|
||||
color?: string;
|
||||
isActive?: boolean;
|
||||
}> = ({ color = theme.text.primary, isActive = true }) => {
|
||||
const [memoryUsage, setMemoryUsage] = useState<string>('');
|
||||
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateMemory = () => {
|
||||
const usage = process.memoryUsage().rss;
|
||||
setMemoryUsage(formatBytes(usage));
|
||||
@@ -25,10 +30,11 @@ export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
|
||||
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
|
||||
);
|
||||
};
|
||||
|
||||
const intervalId = setInterval(updateMemory, 2000);
|
||||
updateMemory(); // Initial update
|
||||
return () => clearInterval(intervalId);
|
||||
}, [color]);
|
||||
}, [color, isActive]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
||||
@@ -72,9 +72,7 @@ const createMockConfig = (overrides = {}) => ({
|
||||
const renderStatusDisplay = async (
|
||||
props: { hideContextSummary: boolean } = { hideContextSummary: false },
|
||||
uiState: UIState = createMockUIState(),
|
||||
settings = createMockSettings({
|
||||
ui: { hideContextSummary: true },
|
||||
}),
|
||||
settings = createMockSettings(),
|
||||
config = createMockConfig(),
|
||||
) => {
|
||||
const result = await render(
|
||||
@@ -99,21 +97,16 @@ describe('StatusDisplay', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing by default', async () => {
|
||||
const { lastFrame, unmount } = await renderStatusDisplay();
|
||||
it('renders nothing by default if context summary is hidden via props', async () => {
|
||||
const { lastFrame, unmount } = await renderStatusDisplay({
|
||||
hideContextSummary: true,
|
||||
});
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders ContextSummaryDisplay when hideContextSummary is false', async () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { hideContextSummary: false },
|
||||
});
|
||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
undefined,
|
||||
settings,
|
||||
);
|
||||
it('renders ContextSummaryDisplay by default', async () => {
|
||||
const { lastFrame, unmount } = await renderStatusDisplay();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -125,6 +118,34 @@ describe('StatusDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders HookStatusDisplay when hooks are active', async () => {
|
||||
const uiState = createMockUIState({
|
||||
activeHooks: [{ name: 'hook', eventName: 'event' }],
|
||||
});
|
||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
uiState,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does NOT render HookStatusDisplay if notifications are disabled in settings', async () => {
|
||||
const uiState = createMockUIState({
|
||||
activeHooks: [{ name: 'hook', eventName: 'event' }],
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
hooksConfig: { notifications: false },
|
||||
});
|
||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
uiState,
|
||||
settings,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides ContextSummaryDisplay if configured in settings', async () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { hideContextSummary: true },
|
||||
@@ -142,13 +163,9 @@ describe('StatusDisplay', () => {
|
||||
const uiState = createMockUIState({
|
||||
backgroundShellCount: 3,
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
ui: { hideContextSummary: false },
|
||||
});
|
||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
uiState,
|
||||
settings,
|
||||
);
|
||||
expect(lastFrame()).toContain('Shells: 3');
|
||||
unmount();
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
const mockConfig = {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getModel: () => 'gemini-pro',
|
||||
getDebugMode: () => false,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
exports[`DetailedMessagesDisplay > renders message counts 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Debug Console (F12 to close) │
|
||||
│ Debug console (F12 to close) │
|
||||
│ │
|
||||
│ ℹ Repeated message (x5) │
|
||||
│ │
|
||||
@@ -18,7 +18,7 @@ exports[`DetailedMessagesDisplay > renders message counts 1`] = `
|
||||
exports[`DetailedMessagesDisplay > renders messages correctly 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Debug Console (F12 to close) │
|
||||
│ Debug console (F12 to close) │
|
||||
│ │
|
||||
│ ℹ Log message │
|
||||
│ ⚠ Warning message │
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`StatusDisplay > renders ContextSummaryDisplay when hideContextSummary is false 1`] = `
|
||||
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `
|
||||
"Mock Context Summary Display (Skills: 2, Shells: 0)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `
|
||||
"Mock Context Summary Display (Skills: 2, Shells: 0)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -11,22 +11,17 @@ import {
|
||||
} from './CompressionMessage.js';
|
||||
import { CompressionStatus } from '@google/gemini-cli-core';
|
||||
import { type CompressionProps } from '../../types.js';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('<CompressionMessage />', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createCompressionProps = (
|
||||
overrides: Partial<CompressionProps> = {},
|
||||
): CompressionDisplayProps => ({
|
||||
compression: {
|
||||
isPending: false,
|
||||
beforePercentage: null,
|
||||
afterPercentage: null,
|
||||
originalTokenCount: null,
|
||||
newTokenCount: null,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
isManual: true,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
@@ -34,10 +29,9 @@ describe('<CompressionMessage />', () => {
|
||||
describe('pending state', () => {
|
||||
it('renders pending message when compression is in progress', async () => {
|
||||
const props = createCompressionProps({ isPending: true });
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CompressionMessage {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Compressing chat history');
|
||||
@@ -49,28 +43,56 @@ describe('<CompressionMessage />', () => {
|
||||
it('renders success message when tokens are reduced', async () => {
|
||||
const props = createCompressionProps({
|
||||
isPending: false,
|
||||
beforePercentage: 22,
|
||||
afterPercentage: 6,
|
||||
originalTokenCount: 100,
|
||||
newTokenCount: 50,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
thresholdPercentage: 50,
|
||||
});
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CompressionMessage {...props} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('✦');
|
||||
expect(output).toContain('✦');
|
||||
expect(output).toContain(
|
||||
'Context compressed (22% → 6%). Adjust threshold (50%) in /settings.',
|
||||
'Chat history compressed from 100 to 50 tokens.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ original: 50000, newTokens: 25000 }, // Large compression
|
||||
{ original: 700000, newTokens: 350000 }, // Very large compression
|
||||
])(
|
||||
'renders success message for large successful compression (from $original to $newTokens)',
|
||||
async ({ original, newTokens }) => {
|
||||
const props = createCompressionProps({
|
||||
isPending: false,
|
||||
originalTokenCount: original,
|
||||
newTokenCount: newTokens,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
});
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CompressionMessage {...props} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('✦');
|
||||
expect(output).toContain(
|
||||
`compressed from ${original} to ${newTokens} tokens`,
|
||||
);
|
||||
expect(output).not.toContain('Skipping compression');
|
||||
expect(output).not.toContain('did not reduce size');
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('skipped compression (tokens increased or same)', () => {
|
||||
it('renders skip message when compression would increase token count', async () => {
|
||||
const props = createCompressionProps({
|
||||
isPending: false,
|
||||
originalTokenCount: 50,
|
||||
newTokenCount: 75,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
});
|
||||
@@ -79,12 +101,121 @@ describe('<CompressionMessage />', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('✦');
|
||||
expect(output).toContain('✦');
|
||||
expect(output).toContain(
|
||||
'Compression was not beneficial for this history size.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders skip message when token counts are equal', async () => {
|
||||
const props = createCompressionProps({
|
||||
isPending: false,
|
||||
originalTokenCount: 50,
|
||||
newTokenCount: 50,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
});
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CompressionMessage {...props} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain(
|
||||
'Compression was not beneficial for this history size.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message content validation', () => {
|
||||
it.each([
|
||||
{
|
||||
original: 200,
|
||||
newTokens: 80,
|
||||
expected: 'compressed from 200 to 80 tokens',
|
||||
},
|
||||
{
|
||||
original: 500,
|
||||
newTokens: 150,
|
||||
expected: 'compressed from 500 to 150 tokens',
|
||||
},
|
||||
{
|
||||
original: 1500,
|
||||
newTokens: 400,
|
||||
expected: 'compressed from 1500 to 400 tokens',
|
||||
},
|
||||
])(
|
||||
'displays correct compression statistics (from $original to $newTokens)',
|
||||
async ({ original, newTokens, expected }) => {
|
||||
const props = createCompressionProps({
|
||||
isPending: false,
|
||||
originalTokenCount: original,
|
||||
newTokenCount: newTokens,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
});
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CompressionMessage {...props} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain(expected);
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ original: 50, newTokens: 60 }, // Increased
|
||||
{ original: 100, newTokens: 100 }, // Same
|
||||
{ original: 49999, newTokens: 50000 }, // Just under 50k threshold
|
||||
])(
|
||||
'shows skip message for small histories when new tokens >= original tokens ($original -> $newTokens)',
|
||||
async ({ original, newTokens }) => {
|
||||
const props = createCompressionProps({
|
||||
isPending: false,
|
||||
originalTokenCount: original,
|
||||
newTokenCount: newTokens,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
});
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CompressionMessage {...props} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain(
|
||||
'Compression was not beneficial for this history size.',
|
||||
);
|
||||
expect(output).not.toContain('compressed from');
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ original: 50000, newTokens: 50100 }, // At 50k threshold
|
||||
{ original: 700000, newTokens: 710000 }, // Large history case
|
||||
{ original: 100000, newTokens: 100000 }, // Large history, same count
|
||||
])(
|
||||
'shows compression failure message for large histories when new tokens >= original tokens ($original -> $newTokens)',
|
||||
async ({ original, newTokens }) => {
|
||||
const props = createCompressionProps({
|
||||
isPending: false,
|
||||
originalTokenCount: original,
|
||||
newTokenCount: newTokens,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
});
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CompressionMessage {...props} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('compression did not reduce size');
|
||||
expect(output).not.toContain('compressed from');
|
||||
expect(output).not.toContain('Compression was not beneficial');
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('failure states', () => {
|
||||
@@ -98,9 +229,9 @@ describe('<CompressionMessage />', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('✦');
|
||||
expect(output).toContain('✦');
|
||||
expect(output).toContain(
|
||||
'Chat history compression failed: empty summary.',
|
||||
'Chat history compression failed: the model returned an empty summary.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -22,13 +22,11 @@ export interface CompressionDisplayProps {
|
||||
export function CompressionMessage({
|
||||
compression,
|
||||
}: CompressionDisplayProps): React.JSX.Element {
|
||||
const {
|
||||
isPending,
|
||||
beforePercentage,
|
||||
afterPercentage,
|
||||
compressionStatus,
|
||||
thresholdPercentage,
|
||||
} = compression;
|
||||
const { isPending, originalTokenCount, newTokenCount, compressionStatus } =
|
||||
compression;
|
||||
|
||||
const originalTokens = originalTokenCount ?? 0;
|
||||
const newTokens = newTokenCount ?? 0;
|
||||
|
||||
const getCompressionText = () => {
|
||||
if (isPending) {
|
||||
@@ -36,19 +34,20 @@ export function CompressionMessage({
|
||||
}
|
||||
|
||||
switch (compressionStatus) {
|
||||
case CompressionStatus.COMPRESSED: {
|
||||
let text = `Context compressed (${beforePercentage}% → ${afterPercentage}%).`;
|
||||
if (thresholdPercentage != null) {
|
||||
text += ` Adjust threshold (${thresholdPercentage}%) in /settings.`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
case CompressionStatus.COMPRESSED:
|
||||
return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`;
|
||||
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
|
||||
return 'Compression was not beneficial for this history size.';
|
||||
// For smaller histories (< 50k tokens), compression overhead likely exceeds benefits
|
||||
if (originalTokens < 50000) {
|
||||
return 'Compression was not beneficial for this history size.';
|
||||
}
|
||||
// For larger histories where compression should work but didn't,
|
||||
// this suggests an issue with the compression process itself
|
||||
return 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.';
|
||||
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
|
||||
return 'Could not compress chat history due to a token counting error.';
|
||||
case CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY:
|
||||
return 'Chat history compression failed: empty summary.';
|
||||
return 'Chat history compression failed: the model returned an empty summary.';
|
||||
case CompressionStatus.NOOP:
|
||||
return 'Nothing to compress.';
|
||||
default:
|
||||
@@ -59,13 +58,20 @@ export function CompressionMessage({
|
||||
const text = getCompressionText();
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" paddingLeft={1} marginBottom={1}>
|
||||
<Box marginRight={1}>{isPending && <CliSpinner type="dots" />}</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box marginRight={1}>
|
||||
{isPending ? (
|
||||
<CliSpinner type="dots" />
|
||||
) : (
|
||||
<Text color={theme.text.accent}>✦</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
color={theme.text.secondary}
|
||||
color={
|
||||
compression.isPending ? theme.text.accent : theme.status.success
|
||||
}
|
||||
aria-label={SCREEN_READER_MODEL_PREFIX}
|
||||
italic
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('ToolConfirmationMessage Redirection', () => {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
} as unknown as Config;
|
||||
|
||||
it('should display redirection warning and tip for redirected commands', async () => {
|
||||
|
||||
@@ -153,7 +153,7 @@ export const SubagentProgressDisplay: React.FC<
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{progress.state === 'completed' && progress.result && (
|
||||
{progress.result && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{progress.terminateReason && progress.terminateReason !== 'GOAL' && (
|
||||
<Box marginBottom={1}>
|
||||
@@ -164,7 +164,7 @@ export const SubagentProgressDisplay: React.FC<
|
||||
)}
|
||||
<MarkdownDisplay
|
||||
text={safeJsonToMarkdown(progress.result)}
|
||||
isPending={false}
|
||||
isPending={progress.state !== 'completed'}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
} as unknown as Config;
|
||||
|
||||
it('should not display urls if prompt and url are the same', async () => {
|
||||
@@ -324,6 +325,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
@@ -345,6 +347,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
isTrustedFolder: () => false,
|
||||
getIdeMode: () => false,
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
@@ -380,6 +383,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
@@ -406,6 +410,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
@@ -447,6 +452,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
} as unknown as Config;
|
||||
vi.mocked(useToolActions).mockReturnValue({
|
||||
confirm: vi.fn(),
|
||||
@@ -473,6 +479,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => true,
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
} as unknown as Config;
|
||||
vi.mocked(useToolActions).mockReturnValue({
|
||||
confirm: vi.fn(),
|
||||
@@ -499,6 +506,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => true,
|
||||
getDisableAlwaysAllow: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
} as unknown as Config;
|
||||
vi.mocked(useToolActions).mockReturnValue({
|
||||
confirm: vi.fn(),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ToolConfirmationPayload,
|
||||
ToolConfirmationOutcome,
|
||||
type EditorType,
|
||||
ApprovalMode,
|
||||
hasRedirection,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -314,6 +315,31 @@ export const ToolConfirmationMessage: React.FC<
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
}
|
||||
} else if (confirmationDetails.type === 'sandbox_expansion') {
|
||||
options.push({
|
||||
label: 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Allow for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Allow for this session',
|
||||
});
|
||||
if (allowPermanentApproval) {
|
||||
options.push({
|
||||
label: 'Allow for all future sessions',
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: 'Allow for all future sessions',
|
||||
});
|
||||
}
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
options.push({
|
||||
label: 'Allow once',
|
||||
@@ -546,6 +572,8 @@ export const ToolConfirmationMessage: React.FC<
|
||||
if (!confirmationDetails.isModifying) {
|
||||
question = `Apply this change?`;
|
||||
}
|
||||
} else if (confirmationDetails.type === 'sandbox_expansion') {
|
||||
question = `Allow sandbox expansion for: '${sanitizeForDisplay(confirmationDetails.rootCommand)}'?`;
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps = confirmationDetails;
|
||||
|
||||
@@ -573,6 +601,52 @@ export const ToolConfirmationMessage: React.FC<
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (confirmationDetails.type === 'sandbox_expansion') {
|
||||
const { additionalPermissions } = confirmationDetails;
|
||||
const readPaths = additionalPermissions?.fileSystem?.read || [];
|
||||
const writePaths = additionalPermissions?.fileSystem?.write || [];
|
||||
const network = additionalPermissions?.network;
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
The agent is requesting additional sandbox permissions to execute
|
||||
this command:
|
||||
</Text>
|
||||
<Box paddingY={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{sanitizeForDisplay(confirmationDetails.command)}
|
||||
</Text>
|
||||
</Box>
|
||||
{network && (
|
||||
<Box>
|
||||
<Text color={theme.status.warning}>• Network Access</Text>
|
||||
</Box>
|
||||
)}
|
||||
{readPaths.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.status.success}>• Read Access:</Text>
|
||||
{readPaths.map((p, i) => (
|
||||
<Text key={i} color={theme.text.secondary}>
|
||||
{' '}
|
||||
{sanitizeForDisplay(p)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{writePaths.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.status.error}>• Write Access:</Text>
|
||||
{writePaths.map((p, i) => (
|
||||
<Text key={i} color={theme.text.secondary}>
|
||||
{' '}
|
||||
{sanitizeForDisplay(p)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps = confirmationDetails;
|
||||
|
||||
@@ -587,7 +661,8 @@ export const ToolConfirmationMessage: React.FC<
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
let warnings: React.ReactNode = null;
|
||||
|
||||
if (containsRedirection) {
|
||||
const isAutoEdit = config.getApprovalMode() === ApprovalMode.AUTO_EDIT;
|
||||
if (containsRedirection && !isAutoEdit) {
|
||||
// Calculate lines needed for Note and Tip
|
||||
const safeWidth = Math.max(terminalWidth, 1);
|
||||
const noteLength =
|
||||
@@ -737,6 +812,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
isTrustedFolder,
|
||||
allowPermanentApproval,
|
||||
settings,
|
||||
config,
|
||||
]);
|
||||
|
||||
const bodyOverflowDirection: 'top' | 'bottom' =
|
||||
|
||||
@@ -180,6 +180,7 @@ export interface UIState {
|
||||
contextFileNames: string[];
|
||||
errorCount: number;
|
||||
availableTerminalHeight: number | undefined;
|
||||
stableControlsHeight: number;
|
||||
mainAreaWidth: number;
|
||||
staticAreaMaxItemHeight: number;
|
||||
staticExtraHeight: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
useCommandCompletion,
|
||||
CompletionMode,
|
||||
} from './useCommandCompletion.js';
|
||||
import type { CommandContext } from '../commands/types.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
@@ -72,7 +72,11 @@ const setupMocks = ({
|
||||
shellSuggestions = [],
|
||||
isLoading = false,
|
||||
isPerfectMatch = false,
|
||||
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
|
||||
slashCompletionRange = {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
getCommandFromSuggestion: () => undefined,
|
||||
},
|
||||
shellCompletionRange = {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
@@ -85,7 +89,13 @@ const setupMocks = ({
|
||||
shellSuggestions?: Suggestion[];
|
||||
isLoading?: boolean;
|
||||
isPerfectMatch?: boolean;
|
||||
slashCompletionRange?: { completionStart: number; completionEnd: number };
|
||||
slashCompletionRange?: {
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
getCommandFromSuggestion: (
|
||||
suggestion: Suggestion,
|
||||
) => SlashCommand | undefined;
|
||||
};
|
||||
shellCompletionRange?: {
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
@@ -471,10 +481,15 @@ describe('useCommandCompletion', () => {
|
||||
});
|
||||
|
||||
describe('handleAutocomplete', () => {
|
||||
it('should complete a partial command', async () => {
|
||||
it('should complete a partial command and NOT add a space if it has an action', async () => {
|
||||
setupMocks({
|
||||
slashSuggestions: [{ label: 'memory', value: 'memory' }],
|
||||
slashCompletionRange: { completionStart: 1, completionEnd: 4 },
|
||||
slashCompletionRange: {
|
||||
completionStart: 1,
|
||||
completionEnd: 4,
|
||||
getCommandFromSuggestion: () =>
|
||||
({ action: vi.fn() }) as unknown as SlashCommand,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook('/mem');
|
||||
@@ -487,12 +502,40 @@ describe('useCommandCompletion', () => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('/memory ');
|
||||
expect(result.current.textBuffer.text).toBe('/memory');
|
||||
});
|
||||
|
||||
it('should complete a partial command and ADD a space if it has NO action (e.g. just a parent)', async () => {
|
||||
setupMocks({
|
||||
slashSuggestions: [{ label: 'chat', value: 'chat' }],
|
||||
slashCompletionRange: {
|
||||
completionStart: 1,
|
||||
completionEnd: 5,
|
||||
getCommandFromSuggestion: () => ({}) as unknown as SlashCommand, // No action
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook('/chat');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('/chat ');
|
||||
});
|
||||
|
||||
it('should complete a file path', async () => {
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }],
|
||||
slashCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
getCommandFromSuggestion: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook('@src/fi');
|
||||
@@ -517,7 +560,11 @@ describe('useCommandCompletion', () => {
|
||||
insertValue: 'resume list',
|
||||
},
|
||||
],
|
||||
slashCompletionRange: { completionStart: 1, completionEnd: 5 },
|
||||
slashCompletionRange: {
|
||||
completionStart: 1,
|
||||
completionEnd: 5,
|
||||
getCommandFromSuggestion: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook('/resu');
|
||||
@@ -539,6 +586,11 @@ describe('useCommandCompletion', () => {
|
||||
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }],
|
||||
slashCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
getCommandFromSuggestion: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook(text, cursorOffset);
|
||||
@@ -559,6 +611,11 @@ describe('useCommandCompletion', () => {
|
||||
it('should complete a directory path ending with / without a trailing space', async () => {
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'src/components/', value: 'src/components/' }],
|
||||
slashCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
getCommandFromSuggestion: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook('@src/comp');
|
||||
@@ -579,6 +636,11 @@ describe('useCommandCompletion', () => {
|
||||
atSuggestions: [
|
||||
{ label: 'src\\components\\', value: 'src\\components\\' },
|
||||
],
|
||||
slashCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
getCommandFromSuggestion: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook('@src\\comp');
|
||||
@@ -594,6 +656,33 @@ describe('useCommandCompletion', () => {
|
||||
expect(result.current.textBuffer.text).toBe('@src\\components\\');
|
||||
});
|
||||
|
||||
it('should ADD a space for AT completion even if name matches a command with an action', async () => {
|
||||
// Setup a mock where getCommandFromSuggestion WOULD return a command with an action
|
||||
// if it were in SLASH mode.
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'memory', value: 'memory' }],
|
||||
slashCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
getCommandFromSuggestion: () =>
|
||||
({ action: vi.fn() }) as unknown as SlashCommand,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook('@mem');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
// Should have a space because it's AT mode, not SLASH mode
|
||||
expect(result.current.textBuffer.text).toBe('@memory ');
|
||||
});
|
||||
|
||||
it('should show ghost text for a single shell completion', async () => {
|
||||
const text = 'l';
|
||||
setupMocks({
|
||||
@@ -905,6 +994,11 @@ describe('useCommandCompletion', () => {
|
||||
it('should complete file path and add trailing space', async () => {
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }],
|
||||
slashCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
getCommandFromSuggestion: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook('/cmd @src/fi');
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useEffect, useState } from 'react';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { logicalPosToOffset } from '../components/shared/text-buffer.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
import { useShellCompletion } from './useShellCompletion.js';
|
||||
@@ -436,10 +437,23 @@ export function useCommandCompletion({
|
||||
|
||||
const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || '');
|
||||
const charAfterCompletion = lineCodePoints[end];
|
||||
|
||||
let shouldAddSpace = true;
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
const command =
|
||||
slashCompletionRange.getCommandFromSuggestion(suggestion);
|
||||
// Don't add a space if the command has an action (can be executed)
|
||||
// and doesn't have a completion function (doesn't REQUIRE more arguments)
|
||||
const isExecutableCommand = !!(command && command.action);
|
||||
const requiresArguments = !!(command && command.completion);
|
||||
shouldAddSpace = !isExecutableCommand || requiresArguments;
|
||||
}
|
||||
|
||||
if (
|
||||
charAfterCompletion !== ' ' &&
|
||||
!suggestionText.endsWith('/') &&
|
||||
!suggestionText.endsWith('\\')
|
||||
!suggestionText.endsWith('\\') &&
|
||||
shouldAddSpace
|
||||
) {
|
||||
suggestionText += ' ';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- TODO: Refactor to remove any usage */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
@@ -32,7 +32,10 @@ import type {
|
||||
Config,
|
||||
EditorType,
|
||||
AnyToolInvocation,
|
||||
AnyDeclarativeTool,
|
||||
SpanMetadata,
|
||||
CompletedToolCall,
|
||||
ToolCallRequestInfo,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
@@ -49,15 +52,19 @@ import {
|
||||
MCPDiscoveryState,
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
CompressionStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type { SlashCommandProcessorResult } from '../types.js';
|
||||
import type {
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItem,
|
||||
} from '../types.js';
|
||||
import { MessageType, StreamingState } from '../types.js';
|
||||
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
// --- MOCKS ---
|
||||
const mockSendMessageStream = vi
|
||||
@@ -138,7 +145,6 @@ const mockRunInDevTraceSpan = vi.hoisted(() =>
|
||||
};
|
||||
return await fn({
|
||||
metadata,
|
||||
endSpan: vi.fn(),
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -243,8 +249,10 @@ describe('useGeminiStream', () => {
|
||||
let mockMarkToolsAsSubmitted: Mock;
|
||||
let handleAtCommandSpy: MockInstance;
|
||||
|
||||
const emptyHistory: any[] = [];
|
||||
let capturedOnComplete: any = null;
|
||||
const emptyHistory: HistoryItem[] = [];
|
||||
let capturedOnComplete:
|
||||
| ((tools: CompletedToolCall[]) => Promise<void>)
|
||||
| null = null;
|
||||
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
|
||||
const mockOnAuthError = vi.fn();
|
||||
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
|
||||
@@ -324,9 +332,6 @@ describe('useGeminiStream', () => {
|
||||
})),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getEnableHooks: vi.fn(() => false),
|
||||
getShowContextWindowWarning: vi.fn(() => false),
|
||||
getShowContextCompression: vi.fn(() => false),
|
||||
getContextWindowCompressionThreshold: vi.fn(() => 0.2),
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -406,13 +411,17 @@ describe('useGeminiStream', () => {
|
||||
lastToolCalls,
|
||||
mockScheduleToolCalls,
|
||||
mockMarkToolsAsSubmitted,
|
||||
(updater: any) => {
|
||||
(
|
||||
updater:
|
||||
| TrackedToolCall[]
|
||||
| ((prev: TrackedToolCall[]) => TrackedToolCall[]),
|
||||
) => {
|
||||
lastToolCalls =
|
||||
typeof updater === 'function' ? updater(lastToolCalls) : updater;
|
||||
rerender({ ...initialProps, toolCalls: lastToolCalls });
|
||||
},
|
||||
(...args: any[]) => {
|
||||
mockCancelAllToolCalls(...args);
|
||||
(signal: AbortSignal) => {
|
||||
mockCancelAllToolCalls(signal);
|
||||
lastToolCalls = lastToolCalls.map((tc) => {
|
||||
if (
|
||||
tc.status === CoreToolCallStatus.AwaitingApproval ||
|
||||
@@ -879,7 +888,7 @@ describe('useGeminiStream', () => {
|
||||
const fn = spanArgs[1];
|
||||
const metadata = { attributes: {} };
|
||||
await act(async () => {
|
||||
await fn({ metadata, endSpan: vi.fn() });
|
||||
await fn({ metadata });
|
||||
});
|
||||
expect(metadata).toMatchObject({
|
||||
input: sentParts,
|
||||
@@ -973,7 +982,7 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
|
||||
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
|
||||
const stopExecutionToolCalls: TrackedToolCall[] = [
|
||||
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
callId: 'stop-call',
|
||||
@@ -1039,13 +1048,13 @@ describe('useGeminiStream', () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
infoTexts.some((text) =>
|
||||
text.includes('This request failed. Press F12 for diagnostics'),
|
||||
text.includes('Request failed. Press F12 for diagnostics'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
|
||||
const stopExecutionToolCalls: TrackedToolCall[] = [
|
||||
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
callId: 'stop-call',
|
||||
@@ -1072,9 +1081,10 @@ describe('useGeminiStream', () => {
|
||||
} as unknown as TrackedCompletedToolCall,
|
||||
];
|
||||
const lowVerbositySettings = {
|
||||
...(mockLoadedSettings as any),
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockLoadedSettings,
|
||||
merged: {
|
||||
...(mockLoadedSettings.merged as any),
|
||||
...mockLoadedSettings.merged,
|
||||
ui: { errorVerbosity: 'low' },
|
||||
},
|
||||
} as LoadedSettings;
|
||||
@@ -1098,15 +1108,13 @@ describe('useGeminiStream', () => {
|
||||
([item]) => (item as { text?: string }).text ?? '',
|
||||
);
|
||||
const noteIndex = infoTexts.findIndex((text) =>
|
||||
text.includes(
|
||||
'Some internal tool attempts failed before this final error',
|
||||
),
|
||||
text.includes('Previous tool attempts failed'),
|
||||
);
|
||||
const stopIndex = infoTexts.findIndex((text) =>
|
||||
text.includes('Agent execution stopped: Stop reason from hook'),
|
||||
);
|
||||
const failureHintIndex = infoTexts.findIndex((text) =>
|
||||
text.includes('This request failed. Press F12 for diagnostics'),
|
||||
text.includes('Request failed. Press F12 for diagnostics'),
|
||||
);
|
||||
expect(noteIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(stopIndex).toBeGreaterThanOrEqual(0);
|
||||
@@ -1925,6 +1933,120 @@ describe('useGeminiStream', () => {
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should record client-initiated tool calls in GeminiChat history', async () => {
|
||||
const { result, client: mockGeminiClient } = await renderTestHook();
|
||||
|
||||
mockHandleSlashCommand.mockResolvedValue({
|
||||
type: 'schedule_tool',
|
||||
toolName: 'activate_skill',
|
||||
toolArgs: { name: 'test-skill' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/test-skill');
|
||||
});
|
||||
|
||||
// Simulate tool completion
|
||||
const completedTool = {
|
||||
request: {
|
||||
callId: 'test-call-id',
|
||||
name: 'activate_skill',
|
||||
args: { name: 'test-skill' },
|
||||
isClientInitiated: true,
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
invocation: {
|
||||
getDescription: () => 'Activating skill test-skill',
|
||||
},
|
||||
tool: {
|
||||
isOutputMarkdown: true,
|
||||
},
|
||||
response: {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'activate_skill',
|
||||
response: { content: 'skill instructions' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TrackedCompletedToolCall;
|
||||
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([completedTool]);
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that the tool call and response were added to GeminiChat history
|
||||
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'activate_skill',
|
||||
args: { name: 'test-skill' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
parts: completedTool.response.responseParts,
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
|
||||
const { result, client: mockGeminiClient } = await renderTestHook();
|
||||
|
||||
mockHandleSlashCommand.mockResolvedValue({
|
||||
type: 'schedule_tool',
|
||||
toolName: 'save_memory',
|
||||
toolArgs: { fact: 'test fact' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/memory add "test fact"');
|
||||
});
|
||||
|
||||
// Simulate tool completion
|
||||
const completedTool = {
|
||||
request: {
|
||||
callId: 'test-call-id',
|
||||
name: 'save_memory',
|
||||
args: { fact: 'test fact' },
|
||||
isClientInitiated: true,
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
invocation: {
|
||||
getDescription: () => 'Saving memory',
|
||||
},
|
||||
tool: {
|
||||
isOutputMarkdown: true,
|
||||
},
|
||||
response: {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'save_memory',
|
||||
response: { success: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TrackedCompletedToolCall;
|
||||
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([completedTool]);
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that addHistory was NOT called
|
||||
expect(mockGeminiClient.addHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Refresh on save_memory', () => {
|
||||
@@ -1952,7 +2074,7 @@ describe('useGeminiStream', () => {
|
||||
displayName: 'save_memory',
|
||||
description: 'Saves memory',
|
||||
build: vi.fn(),
|
||||
} as any,
|
||||
} as unknown as AnyDeclarativeTool,
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
} as unknown as AnyToolInvocation,
|
||||
@@ -2026,7 +2148,8 @@ describe('useGeminiStream', () => {
|
||||
);
|
||||
|
||||
const testConfig = {
|
||||
...(mockConfig as any),
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getContentGenerator: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
authType: mockAuthType,
|
||||
@@ -2191,7 +2314,7 @@ describe('useGeminiStream', () => {
|
||||
displayName: 'replace',
|
||||
description: 'Replace text',
|
||||
build: vi.fn(),
|
||||
} as any,
|
||||
} as unknown as AnyDeclarativeTool,
|
||||
invocation: {
|
||||
getDescription: () => 'Mock description',
|
||||
} as unknown as AnyToolInvocation,
|
||||
@@ -2232,7 +2355,7 @@ describe('useGeminiStream', () => {
|
||||
displayName: 'write_file',
|
||||
description: 'Write file',
|
||||
build: vi.fn(),
|
||||
} as any,
|
||||
} as unknown as AnyDeclarativeTool,
|
||||
invocation: {
|
||||
getDescription: () => 'Mock description',
|
||||
} as unknown as AnyToolInvocation,
|
||||
@@ -2349,32 +2472,22 @@ describe('useGeminiStream', () => {
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'add a message when remaining tokens overflow, regardless of showContextWindowWarning setting',
|
||||
name: 'without suggestion when remaining tokens are > 75% of limit',
|
||||
requestTokens: 20,
|
||||
remainingTokens: 80,
|
||||
shouldShow: false, // The setting itself is false
|
||||
expectedMessage:
|
||||
'Context 20% full. Message may exceed window. Reduce size or /compress.',
|
||||
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
|
||||
},
|
||||
{
|
||||
name: 'add a message when showContextWindowWarning is true',
|
||||
name: 'with suggestion when remaining tokens are < 75% of limit',
|
||||
requestTokens: 30,
|
||||
remainingTokens: 70,
|
||||
shouldShow: true,
|
||||
expectedMessage:
|
||||
'Context 30% full. Message may exceed window. Reduce size or /compress.',
|
||||
'Sending this message (30 tokens) might exceed the context window limit (70 tokens left). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
|
||||
},
|
||||
])(
|
||||
'should $name',
|
||||
async ({
|
||||
requestTokens,
|
||||
remainingTokens,
|
||||
shouldShow,
|
||||
expectedMessage,
|
||||
}) => {
|
||||
vi.mocked(mockConfig.getShowContextWindowWarning).mockReturnValue(
|
||||
shouldShow,
|
||||
);
|
||||
'should add message $name',
|
||||
async ({ requestTokens, remainingTokens, expectedMessage }) => {
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
@@ -2394,12 +2507,10 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: expectedMessage,
|
||||
}),
|
||||
);
|
||||
expect(mockAddItem).toHaveBeenCalledWith({
|
||||
type: 'info',
|
||||
text: expectedMessage,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -2453,12 +2564,8 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should add informational messages when ChatCompressed event is received and showContextCompression is true', async () => {
|
||||
it('should add informational messages when ChatCompressed event is received', async () => {
|
||||
vi.mocked(tokenLimit).mockReturnValue(10000);
|
||||
vi.mocked(
|
||||
mockConfig.getContextWindowCompressionThreshold,
|
||||
).mockReturnValue(0.2);
|
||||
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(true);
|
||||
// Setup mock to return a stream with ChatCompressed event
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
@@ -2467,22 +2574,7 @@ describe('useGeminiStream', () => {
|
||||
value: {
|
||||
originalTokenCount: 1000,
|
||||
newTokenCount: 500,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'Response after compression',
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Finished,
|
||||
value: {
|
||||
finishReason: 'STOP',
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
},
|
||||
compressionStatus: 'compressed',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
@@ -2499,129 +2591,10 @@ describe('useGeminiStream', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'compression',
|
||||
compression: {
|
||||
isPending: false,
|
||||
beforePercentage: 10,
|
||||
afterPercentage: 5,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
isManual: false,
|
||||
thresholdPercentage: 20,
|
||||
},
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT add informational messages when ChatCompressed event is received and showContextCompression is false', async () => {
|
||||
vi.mocked(tokenLimit).mockReturnValue(10000);
|
||||
vi.mocked(
|
||||
mockConfig.getContextWindowCompressionThreshold,
|
||||
).mockReturnValue(0.2);
|
||||
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(false);
|
||||
// Setup mock to return a stream with ChatCompressed event
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.ChatCompressed,
|
||||
value: {
|
||||
originalTokenCount: 1000,
|
||||
newTokenCount: 500,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'Response after compression',
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Finished,
|
||||
value: {
|
||||
finishReason: 'STOP',
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
},
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = await renderHookWithDefaults();
|
||||
|
||||
// Submit a query
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Test compression');
|
||||
});
|
||||
|
||||
// Check that NO compression message was added
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'compression',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should add informational messages when ChatCompressed event is received with a large prompt even if showContextCompression is false', async () => {
|
||||
vi.mocked(tokenLimit).mockReturnValue(10000);
|
||||
vi.mocked(
|
||||
mockConfig.getContextWindowCompressionThreshold,
|
||||
).mockReturnValue(0.2); // 20%
|
||||
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(false);
|
||||
|
||||
// Setup mock to return a stream with ChatCompressed event and a large requestTokenCount (25%)
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.ChatCompressed,
|
||||
value: {
|
||||
originalTokenCount: 1000,
|
||||
newTokenCount: 500,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
requestTokenCount: 2500, // 25% > 20%
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'Response after compression',
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Finished,
|
||||
value: {
|
||||
finishReason: 'STOP',
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
},
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = await renderHookWithDefaults();
|
||||
|
||||
// Submit a query
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Test large prompt compression');
|
||||
});
|
||||
|
||||
// Check that compression message WAS added despite the setting
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'compression',
|
||||
compression: expect.objectContaining({
|
||||
beforePercentage: 10,
|
||||
afterPercentage: 5,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
isManual: false,
|
||||
thresholdPercentage: 20,
|
||||
}),
|
||||
type: MessageType.INFO,
|
||||
text: 'Context compressed from 10% to 5%.',
|
||||
secondaryText: 'Change threshold in /settings.',
|
||||
color: theme.status.warning,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -2727,14 +2700,14 @@ describe('useGeminiStream', () => {
|
||||
|
||||
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
|
||||
const addItemOrder: string[] = [];
|
||||
let capturedOnComplete: any;
|
||||
let capturedOnComplete: (tools: CompletedToolCall[]) => Promise<void>;
|
||||
|
||||
const mockScheduleToolCalls = vi.fn(async (requests) => {
|
||||
addItemOrder.push('scheduleToolCalls_START');
|
||||
// Simulate tools completing and triggering onComplete immediately.
|
||||
// This mimics the behavior that caused the regression where tool results
|
||||
// were added to history during the await scheduleToolCalls(...) block.
|
||||
const tools = requests.map((r: any) => ({
|
||||
const tools = requests.map((r: ToolCallRequestInfo) => ({
|
||||
request: r,
|
||||
status: CoreToolCallStatus.Success,
|
||||
tool: { displayName: r.name, name: r.name },
|
||||
@@ -2749,7 +2722,7 @@ describe('useGeminiStream', () => {
|
||||
addItemOrder.push('scheduleToolCalls_END');
|
||||
});
|
||||
|
||||
mockAddItem.mockImplementation((item: any) => {
|
||||
mockAddItem.mockImplementation((item: HistoryItemWithoutId) => {
|
||||
addItemOrder.push(`addItem:${item.type}`);
|
||||
});
|
||||
|
||||
@@ -2979,9 +2952,10 @@ describe('useGeminiStream', () => {
|
||||
describe('Thought Reset', () => {
|
||||
it('should keep full thinking entries in history when mode is full', async () => {
|
||||
const fullThinkingSettings: LoadedSettings = {
|
||||
...(mockLoadedSettings as any),
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockLoadedSettings,
|
||||
merged: {
|
||||
...(mockLoadedSettings.merged as any),
|
||||
...mockLoadedSettings.merged,
|
||||
ui: { inlineThinkingMode: 'full' },
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
@@ -4060,7 +4034,7 @@ describe('useGeminiStream', () => {
|
||||
|
||||
const spanMetadata = {} as SpanMetadata;
|
||||
await act(async () => {
|
||||
await userPromptCall });
|
||||
await userPromptCall;
|
||||
});
|
||||
expect(spanMetadata.input).toBe('telemetry test query');
|
||||
});
|
||||
|
||||
@@ -38,20 +38,22 @@ import {
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
isBackgroundExecutionData,
|
||||
type CompressionStatus,
|
||||
Kind,
|
||||
type Config,
|
||||
type EditorType,
|
||||
type GeminiClient,
|
||||
type ServerGeminiChatCompressedEvent,
|
||||
type ServerGeminiContentEvent as ContentEvent,
|
||||
type ServerGeminiFinishedEvent,
|
||||
type ServerGeminiStreamEvent as GeminiEvent,
|
||||
type ThoughtSummary,
|
||||
type ToolCallRequestInfo,
|
||||
type ToolCallResponseInfo,
|
||||
type GeminiErrorEventValue,
|
||||
type RetryAttemptPayload,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
EditorType,
|
||||
GeminiClient,
|
||||
ServerGeminiChatCompressedEvent,
|
||||
ServerGeminiContentEvent as ContentEvent,
|
||||
ServerGeminiFinishedEvent,
|
||||
ServerGeminiStreamEvent as GeminiEvent,
|
||||
ThoughtSummary,
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
GeminiErrorEventValue,
|
||||
RetryAttemptPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
||||
import type {
|
||||
@@ -59,6 +61,7 @@ import type {
|
||||
HistoryItemThinking,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItemToolGroup,
|
||||
HistoryItemInfo,
|
||||
IndividualToolCallDisplay,
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItemModel,
|
||||
@@ -107,9 +110,9 @@ enum StreamProcessingStatus {
|
||||
}
|
||||
|
||||
const SUPPRESSED_TOOL_ERRORS_NOTE =
|
||||
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for details.';
|
||||
'Previous tool attempts failed. Press F12 for diagnostics or set ui.errorVerbosity=full for details';
|
||||
const LOW_VERBOSITY_FAILURE_NOTE =
|
||||
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
|
||||
'Request failed. Press F12 for diagnostics or set ui.errorVerbosity=full for details';
|
||||
|
||||
function getBackgroundedToolInfo(
|
||||
toolCall: TrackedCompletedToolCall | TrackedCancelledToolCall,
|
||||
@@ -254,8 +257,6 @@ export const useGeminiStream = (
|
||||
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
|
||||
useStateAndRef<boolean>(true);
|
||||
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
|
||||
const handleCompletedToolsRef =
|
||||
useRef<(completedTools: TrackedToolCall[]) => Promise<void>>(undefined);
|
||||
const { startNewPrompt, getPromptCount } = useSessionStats();
|
||||
const storage = config.storage;
|
||||
const logger = useLogger(storage);
|
||||
@@ -301,23 +302,22 @@ export const useGeminiStream = (
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the live-updating display now that the final state is in history.
|
||||
setToolCallsForDisplay([]);
|
||||
|
||||
// Record tool calls with full metadata before sending responses.
|
||||
try {
|
||||
const currentModel =
|
||||
(typeof geminiClient.getCurrentSequenceModel === 'function'
|
||||
? geminiClient.getCurrentSequenceModel()
|
||||
: undefined) ?? config.getModel();
|
||||
const chat =
|
||||
typeof geminiClient.getChat === 'function'
|
||||
? geminiClient.getChat()
|
||||
: undefined;
|
||||
chat?.recordCompletedToolCalls(
|
||||
currentModel,
|
||||
completedToolCallsFromScheduler,
|
||||
);
|
||||
config.getGeminiClient().getCurrentSequenceModel() ??
|
||||
config.getModel();
|
||||
config
|
||||
.getGeminiClient()
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(
|
||||
currentModel,
|
||||
completedToolCallsFromScheduler,
|
||||
);
|
||||
|
||||
await recordToolCallInteractions(
|
||||
config,
|
||||
@@ -330,7 +330,7 @@ export const useGeminiStream = (
|
||||
}
|
||||
|
||||
// Handle tool response submission immediately when tools complete
|
||||
await handleCompletedToolsRef.current?.(
|
||||
await handleCompletedTools(
|
||||
completedToolCallsFromScheduler as TrackedToolCall[],
|
||||
);
|
||||
}
|
||||
@@ -549,11 +549,9 @@ export const useGeminiStream = (
|
||||
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
tc.status !== 'scheduled' &&
|
||||
tc.status !== 'validating' &&
|
||||
tc.status !== 'awaiting_approval'
|
||||
);
|
||||
// ToolGroupMessage now shows all non-canceled tools, so they are visible
|
||||
// in pending and we need to draw the closing border for them.
|
||||
return true;
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -1147,43 +1145,21 @@ export const useGeminiStream = (
|
||||
}
|
||||
|
||||
const limit = tokenLimit(config.getModel());
|
||||
const beforePercentage =
|
||||
eventValue?.originalTokenCount != null
|
||||
? Math.round((eventValue.originalTokenCount / limit) * 100)
|
||||
: null;
|
||||
const afterPercentage =
|
||||
eventValue?.newTokenCount != null
|
||||
? Math.round((eventValue.newTokenCount / limit) * 100)
|
||||
: null;
|
||||
|
||||
const threshold = config.getContextWindowCompressionThreshold();
|
||||
const isLargePrompt =
|
||||
eventValue?.requestTokenCount != null &&
|
||||
eventValue.requestTokenCount / limit > threshold;
|
||||
|
||||
if (!config.getShowContextCompression() && !isLargePrompt) {
|
||||
return;
|
||||
}
|
||||
const originalPercentage = Math.round(
|
||||
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
const newPercentage = Math.round(
|
||||
((eventValue?.newTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: 'compression',
|
||||
compression: {
|
||||
isPending: false,
|
||||
beforePercentage,
|
||||
afterPercentage,
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
compressionStatus: eventValue
|
||||
? (Number(
|
||||
eventValue.compressionStatus,
|
||||
) as unknown as CompressionStatus)
|
||||
: null,
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
isManual: false,
|
||||
thresholdPercentage: Math.round(threshold * 100),
|
||||
},
|
||||
timestamp: new Date(userMessageTimestamp),
|
||||
} as HistoryItemWithoutId,
|
||||
type: MessageType.INFO,
|
||||
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
|
||||
secondaryText: `Change threshold in /settings.`,
|
||||
color: theme.status.warning,
|
||||
marginBottom: 1,
|
||||
} as HistoryItemInfo,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
},
|
||||
@@ -1206,11 +1182,16 @@ export const useGeminiStream = (
|
||||
onCancelSubmit(true);
|
||||
|
||||
const limit = tokenLimit(config.getModel());
|
||||
const usedPercentage = Math.round(
|
||||
((limit - remainingTokenCount) / limit) * 100,
|
||||
);
|
||||
|
||||
const text = `Context ${usedPercentage}% full. Message may exceed window. Reduce size or /compress.`;
|
||||
const isMoreThan25PercentUsed =
|
||||
limit > 0 && remainingTokenCount < limit * 0.75;
|
||||
|
||||
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
|
||||
|
||||
if (isMoreThan25PercentUsed) {
|
||||
text +=
|
||||
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
|
||||
}
|
||||
|
||||
addItem({
|
||||
type: 'info',
|
||||
@@ -1557,7 +1538,8 @@ export const useGeminiStream = (
|
||||
setLoopDetectionConfirmationRequest(null);
|
||||
|
||||
if (result.userSelection === 'disable') {
|
||||
geminiClient
|
||||
config
|
||||
.getGeminiClient()
|
||||
.getLoopDetectionService()
|
||||
.disableForSession();
|
||||
addItem({
|
||||
@@ -1675,7 +1657,7 @@ export const useGeminiStream = (
|
||||
) {
|
||||
let awaitingApprovalCalls = toolCalls.filter(
|
||||
(call): call is TrackedWaitingToolCall =>
|
||||
call.status === 'awaiting_approval',
|
||||
call.status === 'awaiting_approval' && !call.request.forcedAsk,
|
||||
);
|
||||
|
||||
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
|
||||
@@ -1733,24 +1715,42 @@ export const useGeminiStream = (
|
||||
},
|
||||
);
|
||||
|
||||
// Check if all tools in the batch are in a terminal state
|
||||
const allTerminal = completedToolCallsFromScheduler.every(
|
||||
(tc) =>
|
||||
tc.status === 'success' ||
|
||||
tc.status === 'error' ||
|
||||
tc.status === 'cancelled',
|
||||
);
|
||||
|
||||
if (!allTerminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize any client-initiated tools as soon as they are done.
|
||||
const clientTools = completedAndReadyToSubmitTools.filter(
|
||||
(t) => t.request.isClientInitiated,
|
||||
);
|
||||
if (clientTools.length > 0) {
|
||||
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
|
||||
|
||||
if (geminiClient) {
|
||||
for (const tool of clientTools) {
|
||||
// Only manually record skill activations in the chat history.
|
||||
// Other client-initiated tools (like save_memory) update the system
|
||||
// prompt/context and don't strictly need to be in the history.
|
||||
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add both the call (model turn) and the result (user turn) to history.
|
||||
// Client-initiated calls are essentially "synthetic" turns that let
|
||||
// subsequent model calls understand what just happened in the UI.
|
||||
await geminiClient.addHistory({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: tool.request.name,
|
||||
args: tool.request.args,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: tool.response.responseParts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identify new, successful save_memory calls that we haven't processed yet.
|
||||
@@ -1906,7 +1906,6 @@ export const useGeminiStream = (
|
||||
setIsResponding,
|
||||
],
|
||||
);
|
||||
handleCompletedToolsRef.current = handleCompletedTools;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('useLoadingIndicator', () => {
|
||||
retryStatus,
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toContain('Trying to reach');
|
||||
expect(result.current.currentLoadingPhrase).toContain('Retrying');
|
||||
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
|
||||
});
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('useLoadingIndicator', () => {
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).not.toBe(
|
||||
"This is taking a bit longer, we're still on it.",
|
||||
'System busy. Retrying...',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -279,7 +279,7 @@ describe('useLoadingIndicator', () => {
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toBe(
|
||||
"This is taking a bit longer, we're still on it.",
|
||||
'System busy. Retrying...',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -82,9 +82,9 @@ export const useLoadingIndicator = ({
|
||||
const retryPhrase = retryStatus
|
||||
? errorVerbosity === 'low'
|
||||
? retryStatus.attempt >= LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD
|
||||
? "This is taking a bit longer, we're still on it."
|
||||
? 'System busy. Retrying...'
|
||||
: null
|
||||
: `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})`
|
||||
: `Retrying ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})`
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -464,7 +464,7 @@ describe('useSlashCompletion', () => {
|
||||
() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/chat',
|
||||
'/chat ',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
@@ -484,7 +484,7 @@ describe('useSlashCompletion', () => {
|
||||
() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/resume',
|
||||
'/resume ',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
@@ -513,53 +513,6 @@ describe('useSlashCompletion', () => {
|
||||
unmountResume();
|
||||
});
|
||||
|
||||
it('should show the grouped /resume menu for unique /resum prefix input', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'resume',
|
||||
description: 'Resume command',
|
||||
action: vi.fn(),
|
||||
subCommands: [
|
||||
createTestCommand({
|
||||
name: 'list',
|
||||
description: 'List checkpoints',
|
||||
suggestionGroup: 'checkpoints',
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'save',
|
||||
description: 'Save checkpoint',
|
||||
suggestionGroup: 'checkpoints',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const { result, unmount } = await renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/resum',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await resolveMatch();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions[0]).toMatchObject({
|
||||
label: 'list',
|
||||
sectionTitle: 'auto',
|
||||
submitValue: '/resume',
|
||||
});
|
||||
expect(result.current.isPerfectMatch).toBe(false);
|
||||
expect(result.current.suggestions.slice(1).map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['list', 'save']),
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should sort exact altName matches to the top', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
@@ -594,7 +547,7 @@ describe('useSlashCompletion', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should suggest subcommands when a parent command is fully typed without a trailing space', async () => {
|
||||
it('should suggest the command itself instead of subcommands when a parent command is fully typed without a trailing space', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'chat',
|
||||
@@ -618,18 +571,47 @@ describe('useSlashCompletion', () => {
|
||||
await resolveMatch();
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show the auto-session entry plus subcommands of 'chat'
|
||||
expect(result.current.suggestions).toHaveLength(3);
|
||||
expect(result.current.suggestions[0]).toMatchObject({
|
||||
label: 'list',
|
||||
sectionTitle: 'auto',
|
||||
submitValue: '/chat',
|
||||
});
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['list', 'save']),
|
||||
);
|
||||
// completionStart should be at the end of '/chat' to append subcommands
|
||||
expect(result.current.completionStart).toBe(5);
|
||||
// Should show 'chat' as the suggestion, NOT its subcommands
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('chat');
|
||||
// completionStart should be at 1 (to replace 'chat')
|
||||
expect(result.current.completionStart).toBe(1);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT suggest subcommands when a parent command is fully typed without a trailing space (fix for over-eager completion)', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'stats',
|
||||
description: 'Check session stats',
|
||||
action: vi.fn(), // Has action
|
||||
subCommands: [
|
||||
createTestCommand({
|
||||
name: 'session',
|
||||
description: 'Show session-specific usage statistics',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const { result, unmount } = await renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/stats',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await resolveMatch();
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show 'stats' as the suggestion, NOT 'session'
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('stats');
|
||||
// isPerfectMatch should be true because it has an action
|
||||
expect(result.current.isPerfectMatch).toBe(true);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -54,8 +54,6 @@ interface CommandParserResult {
|
||||
partial: string;
|
||||
currentLevel: readonly SlashCommand[] | undefined;
|
||||
leafCommand: SlashCommand | null;
|
||||
exactMatchAsParent: SlashCommand | undefined;
|
||||
usedPrefixParentDescent: boolean;
|
||||
isArgumentCompletion: boolean;
|
||||
}
|
||||
|
||||
@@ -71,8 +69,6 @@ function useCommandParser(
|
||||
partial: '',
|
||||
currentLevel: slashCommands,
|
||||
leafCommand: null,
|
||||
exactMatchAsParent: undefined,
|
||||
usedPrefixParentDescent: false,
|
||||
isArgumentCompletion: false,
|
||||
};
|
||||
}
|
||||
@@ -90,7 +86,6 @@ function useCommandParser(
|
||||
|
||||
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
|
||||
let leafCommand: SlashCommand | null = null;
|
||||
let usedPrefixParentDescent = false;
|
||||
|
||||
for (const part of commandPathParts) {
|
||||
if (!currentLevel) {
|
||||
@@ -115,60 +110,6 @@ function useCommandParser(
|
||||
}
|
||||
}
|
||||
|
||||
let exactMatchAsParent: SlashCommand | undefined;
|
||||
if (!hasTrailingSpace && currentLevel) {
|
||||
exactMatchAsParent = currentLevel.find(
|
||||
(cmd) => matchesCommand(cmd, partial) && cmd.subCommands,
|
||||
);
|
||||
|
||||
if (exactMatchAsParent) {
|
||||
// Only descend if there are NO other matches for the partial at this level.
|
||||
// This ensures that typing "/memory" still shows "/memory-leak" if it exists.
|
||||
const otherMatches = currentLevel.filter(
|
||||
(cmd) =>
|
||||
cmd !== exactMatchAsParent &&
|
||||
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
|
||||
cmd.altNames?.some((alt) =>
|
||||
alt.toLowerCase().startsWith(partial.toLowerCase()),
|
||||
)),
|
||||
);
|
||||
|
||||
if (otherMatches.length === 0) {
|
||||
leafCommand = exactMatchAsParent;
|
||||
currentLevel = exactMatchAsParent.subCommands as
|
||||
| readonly SlashCommand[]
|
||||
| undefined;
|
||||
partial = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Phase-one alias UX: allow unique prefix descent for /chat and /resume
|
||||
// so `/cha` and `/resum` expose the same grouped menu immediately.
|
||||
if (!exactMatchAsParent && partial && currentLevel) {
|
||||
const prefixParentMatches = currentLevel.filter(
|
||||
(cmd) =>
|
||||
!!cmd.subCommands &&
|
||||
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
|
||||
cmd.altNames?.some((alt) =>
|
||||
alt.toLowerCase().startsWith(partial.toLowerCase()),
|
||||
)),
|
||||
);
|
||||
|
||||
if (prefixParentMatches.length === 1) {
|
||||
const candidate = prefixParentMatches[0];
|
||||
if (candidate.name === 'chat' || candidate.name === 'resume') {
|
||||
exactMatchAsParent = candidate;
|
||||
leafCommand = candidate;
|
||||
usedPrefixParentDescent = true;
|
||||
currentLevel = candidate.subCommands as
|
||||
| readonly SlashCommand[]
|
||||
| undefined;
|
||||
partial = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const depth = commandPathParts.length;
|
||||
const isArgumentCompletion = !!(
|
||||
leafCommand?.completion &&
|
||||
@@ -182,8 +123,6 @@ function useCommandParser(
|
||||
partial,
|
||||
currentLevel,
|
||||
leafCommand,
|
||||
exactMatchAsParent,
|
||||
usedPrefixParentDescent,
|
||||
isArgumentCompletion,
|
||||
};
|
||||
}, [query, slashCommands]);
|
||||
@@ -343,19 +282,9 @@ function useCommandSuggestions(
|
||||
});
|
||||
|
||||
const finalSuggestions = sortedSuggestions.map((cmd) => {
|
||||
const canonicalParentName =
|
||||
parserResult.usedPrefixParentDescent &&
|
||||
leafCommand &&
|
||||
(leafCommand.name === 'chat' || leafCommand.name === 'resume')
|
||||
? leafCommand.name
|
||||
: undefined;
|
||||
|
||||
const suggestion: Suggestion = {
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
insertValue: canonicalParentName
|
||||
? `${canonicalParentName} ${cmd.name}`
|
||||
: undefined,
|
||||
description: cmd.description,
|
||||
commandKind: cmd.kind,
|
||||
};
|
||||
@@ -384,7 +313,7 @@ function useCommandSuggestions(
|
||||
description: 'Browse auto-saved chats',
|
||||
commandKind: CommandKind.BUILT_IN,
|
||||
sectionTitle: 'auto',
|
||||
submitValue: `/${leafCommand.name}`,
|
||||
submitValue: `/${canonicalParentName}`,
|
||||
};
|
||||
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
|
||||
return;
|
||||
@@ -427,12 +356,10 @@ function useCompletionPositions(
|
||||
return { start: -1, end: -1 };
|
||||
}
|
||||
|
||||
const { hasTrailingSpace, partial, exactMatchAsParent } = parserResult;
|
||||
const { hasTrailingSpace, partial } = parserResult;
|
||||
|
||||
// Set completion start/end positions
|
||||
if (parserResult.usedPrefixParentDescent) {
|
||||
return { start: 1, end: query.length };
|
||||
} else if (hasTrailingSpace || exactMatchAsParent) {
|
||||
if (hasTrailingSpace) {
|
||||
return { start: query.length, end: query.length };
|
||||
} else if (partial) {
|
||||
if (parserResult.isArgumentCompletion) {
|
||||
@@ -461,12 +388,7 @@ function usePerfectMatch(
|
||||
return { isPerfectMatch: false };
|
||||
}
|
||||
|
||||
if (
|
||||
leafCommand &&
|
||||
partial === '' &&
|
||||
leafCommand.action &&
|
||||
!parserResult.usedPrefixParentDescent
|
||||
) {
|
||||
if (leafCommand && partial === '' && leafCommand.action) {
|
||||
return { isPerfectMatch: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
flexDirection="column"
|
||||
width={uiState.terminalWidth}
|
||||
height={isAlternateBuffer ? terminalHeight : undefined}
|
||||
paddingBottom={isAlternateBuffer ? 1 : undefined}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
@@ -62,6 +63,9 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
width={uiState.terminalWidth}
|
||||
height={
|
||||
uiState.copyModeEnabled ? uiState.stableControlsHeight : undefined
|
||||
}
|
||||
>
|
||||
<Notifications />
|
||||
<CopyModeWarning />
|
||||
|
||||
@@ -138,11 +138,9 @@ export interface IndividualToolCallDisplay {
|
||||
|
||||
export interface CompressionProps {
|
||||
isPending: boolean;
|
||||
beforePercentage: number | null;
|
||||
afterPercentage: number | null;
|
||||
originalTokenCount: number | null;
|
||||
newTokenCount: number | null;
|
||||
compressionStatus: CompressionStatus | null;
|
||||
isManual: boolean;
|
||||
thresholdPercentage?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface UpdateInfo {
|
||||
export interface UpdateObject {
|
||||
message: string;
|
||||
update: UpdateInfo;
|
||||
isUpdating?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -197,7 +197,9 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
|
||||
...mockUpdateInfo,
|
||||
message: 'An update is available!\nPlease update manually.',
|
||||
isUpdating: false,
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -236,7 +238,9 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
|
||||
...mockUpdateInfo,
|
||||
message: 'An update is available!\nCannot determine update command.',
|
||||
isUpdating: false,
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -253,7 +257,9 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
|
||||
...mockUpdateInfo,
|
||||
message: 'An update is available!\nThis is an additional message.',
|
||||
isUpdating: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -102,17 +102,22 @@ export function handleAutoUpdate(
|
||||
combinedMessage += `\n${installationInfo.updateMessage}`;
|
||||
}
|
||||
|
||||
updateEventEmitter.emit('update-received', {
|
||||
message: combinedMessage,
|
||||
});
|
||||
|
||||
if (
|
||||
!installationInfo.updateCommand ||
|
||||
!settings.merged.general.enableAutoUpdate
|
||||
) {
|
||||
updateEventEmitter.emit('update-received', {
|
||||
...info,
|
||||
message: combinedMessage,
|
||||
isUpdating: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateEventEmitter.emit('update-received', {
|
||||
...info,
|
||||
message: combinedMessage,
|
||||
isUpdating: true,
|
||||
});
|
||||
if (_updateInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,10 @@ beforeEach(() => {
|
||||
? stackLines.slice(lastReactFrameIndex + 1).join('\n')
|
||||
: stackLines.slice(1).join('\n');
|
||||
|
||||
if (relevantStack.includes('OverflowContext.tsx')) {
|
||||
if (
|
||||
relevantStack.includes('OverflowContext.tsx') ||
|
||||
relevantStack.includes('useTimedMessage.ts')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,15 +26,15 @@ function compileWindowsSandbox() {
|
||||
|
||||
const srcHelperPath = path.resolve(
|
||||
__dirname,
|
||||
'../src/services/scripts/GeminiSandbox.exe',
|
||||
'../src/sandbox/windows/GeminiSandbox.exe',
|
||||
);
|
||||
const distHelperPath = path.resolve(
|
||||
__dirname,
|
||||
'../dist/src/services/scripts/GeminiSandbox.exe',
|
||||
'../dist/src/sandbox/windows/GeminiSandbox.exe',
|
||||
);
|
||||
const sourcePath = path.resolve(
|
||||
__dirname,
|
||||
'../src/services/scripts/GeminiSandbox.cs',
|
||||
'../src/sandbox/windows/GeminiSandbox.cs',
|
||||
);
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
|
||||
@@ -128,7 +128,10 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('getInstance / dispatcher initialization', () => {
|
||||
it('should use UndiciAgent when no proxy is configured', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
@@ -153,7 +156,10 @@ describe('A2AClientManager', () => {
|
||||
} as Config;
|
||||
|
||||
manager = new A2AClientManager(mockConfigWithProxy);
|
||||
await manager.loadAgent('TestProxyAgent', 'http://test.proxy.agent/card');
|
||||
await manager.loadAgent('TestProxyAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.proxy.agent/card',
|
||||
});
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
@@ -172,28 +178,40 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('loadAgent', () => {
|
||||
it('should create and cache an A2AClient', async () => {
|
||||
const agentCard = await manager.loadAgent(
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
);
|
||||
const agentCard = await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
expect(manager.getAgentCard('TestAgent')).toBe(agentCard);
|
||||
expect(manager.getClient('TestAgent')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
expect(ClientFactoryOptions.createFrom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if an agent with the same name is already loaded', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
await expect(
|
||||
manager.loadAgent('TestAgent', 'http://test.agent/card'),
|
||||
manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
}),
|
||||
).rejects.toThrow("Agent with name 'TestAgent' is already loaded.");
|
||||
});
|
||||
|
||||
it('should use native fetch by default', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
expect(createAuthenticatingFetchWithRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -204,7 +222,7 @@ describe('A2AClientManager', () => {
|
||||
};
|
||||
await manager.loadAgent(
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
{ type: 'url', url: 'http://test.agent/card' },
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
@@ -221,7 +239,7 @@ describe('A2AClientManager', () => {
|
||||
};
|
||||
await manager.loadAgent(
|
||||
'AuthCardAgent',
|
||||
'http://authcard.agent/card',
|
||||
{ type: 'url', url: 'http://authcard.agent/card' },
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
@@ -252,7 +270,7 @@ describe('A2AClientManager', () => {
|
||||
|
||||
await manager.loadAgent(
|
||||
'AuthCardAgent401',
|
||||
'http://authcard.agent/card',
|
||||
{ type: 'url', url: 'http://authcard.agent/card' },
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
@@ -267,19 +285,65 @@ describe('A2AClientManager', () => {
|
||||
});
|
||||
|
||||
it('should log a debug message upon loading an agent', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Loaded agent 'TestAgent'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear the cache', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
manager.clearCache();
|
||||
expect(manager.getAgentCard('TestAgent')).toBeUndefined();
|
||||
expect(manager.getClient('TestAgent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load an agent from inline JSON without calling resolver', async () => {
|
||||
const inlineJson = JSON.stringify(mockAgentCard);
|
||||
const agentCard = await manager.loadAgent('JsonAgent', {
|
||||
type: 'json',
|
||||
json: inlineJson,
|
||||
});
|
||||
expect(agentCard).toBeDefined();
|
||||
expect(agentCard.name).toBe('test-agent');
|
||||
expect(manager.getAgentCard('JsonAgent')).toBe(agentCard);
|
||||
expect(manager.getClient('JsonAgent')).toBeDefined();
|
||||
// Resolver should not have been called for inline JSON
|
||||
const resolverInstance = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.results[0]?.value;
|
||||
if (resolverInstance) {
|
||||
expect(resolverInstance.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw a descriptive error for invalid inline JSON', async () => {
|
||||
await expect(
|
||||
manager.loadAgent('BadJsonAgent', {
|
||||
type: 'json',
|
||||
json: 'not valid json {{',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Failed to parse inline agent card JSON for agent 'BadJsonAgent'/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should log "inline JSON" for JSON-loaded agents', async () => {
|
||||
const inlineJson = JSON.stringify(mockAgentCard);
|
||||
await manager.loadAgent('JsonLogAgent', {
|
||||
type: 'json',
|
||||
json: inlineJson,
|
||||
});
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('inline JSON'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if resolveAgentCard fails', async () => {
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')),
|
||||
@@ -289,7 +353,10 @@ describe('A2AClientManager', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
manager.loadAgent('FailAgent', {
|
||||
type: 'url',
|
||||
url: 'http://fail.agent',
|
||||
}),
|
||||
).rejects.toThrow('Resolution failed');
|
||||
});
|
||||
|
||||
@@ -304,7 +371,10 @@ describe('A2AClientManager', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
manager.loadAgent('FailAgent', {
|
||||
type: 'url',
|
||||
url: 'http://fail.agent',
|
||||
}),
|
||||
).rejects.toThrow('Factory failed');
|
||||
});
|
||||
});
|
||||
@@ -318,7 +388,10 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('sendMessageStream', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
});
|
||||
|
||||
it('should send a message and return a stream', async () => {
|
||||
@@ -433,7 +506,10 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('getTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get a task from the correct agent', async () => {
|
||||
@@ -462,7 +538,10 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('cancelTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
});
|
||||
|
||||
it('should cancel a task on the correct agent', async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import * as grpc from '@grpc/grpc-js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
|
||||
import { normalizeAgentCard } from './a2aUtils.js';
|
||||
import type { AgentCardLoadOptions } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { classifyAgentError } from './a2a-errors.js';
|
||||
@@ -85,7 +86,7 @@ export class A2AClientManager {
|
||||
*/
|
||||
async loadAgent(
|
||||
name: string,
|
||||
agentCardUrl: string,
|
||||
options: AgentCardLoadOptions,
|
||||
authHandler?: AuthenticationHandler,
|
||||
): Promise<AgentCard> {
|
||||
if (this.clients.has(name) && this.agentCards.has(name)) {
|
||||
@@ -119,7 +120,24 @@ export class A2AClientManager {
|
||||
};
|
||||
|
||||
const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch });
|
||||
const rawCard = await resolver.resolve(agentCardUrl, '');
|
||||
|
||||
let rawCard: unknown;
|
||||
let urlIdentifier = 'inline JSON';
|
||||
|
||||
if (options.type === 'json') {
|
||||
try {
|
||||
rawCard = JSON.parse(options.json);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to parse inline agent card JSON for agent '${name}': ${msg}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
urlIdentifier = options.url;
|
||||
rawCard = await resolver.resolve(options.url, '');
|
||||
}
|
||||
|
||||
// TODO: Remove normalizeAgentCard once @a2a-js/sdk handles
|
||||
// proto field name aliases (supportedInterfaces → additionalInterfaces,
|
||||
// protocolBinding → transport).
|
||||
@@ -153,12 +171,12 @@ export class A2AClientManager {
|
||||
this.agentCards.set(name, agentCard);
|
||||
|
||||
debugLogger.debug(
|
||||
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
|
||||
`[A2AClientManager] Loaded agent '${name}' from ${urlIdentifier}`,
|
||||
);
|
||||
|
||||
return agentCard;
|
||||
} catch (error: unknown) {
|
||||
throw classifyAgentError(name, agentCardUrl, error);
|
||||
throw classifyAgentError(name, urlIdentifier, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ describe('a2aUtils', () => {
|
||||
|
||||
const output = reassembler.toString();
|
||||
expect(output).toBe(
|
||||
'Analyzing...\n\nProcessing...\n\nArtifact (Code):\nprint("Done")',
|
||||
'Analyzing...Processing...\n\nArtifact (Code):\nprint("Done")',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AgentInterface,
|
||||
} from '@a2a-js/sdk';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
import type { SubagentActivityItem } from './types.js';
|
||||
|
||||
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
|
||||
|
||||
@@ -123,17 +124,39 @@ export class A2AResultReassembler {
|
||||
|
||||
private pushMessage(message: Message | undefined) {
|
||||
if (!message) return;
|
||||
const text = extractPartsText(message.parts, '\n');
|
||||
const text = extractPartsText(message.parts, '');
|
||||
if (text && this.messageLog[this.messageLog.length - 1] !== text) {
|
||||
this.messageLog.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of activity items representing the current reassembled state.
|
||||
*/
|
||||
toActivityItems(): SubagentActivityItem[] {
|
||||
const isAuthRequired = this.messageLog.includes(AUTH_REQUIRED_MSG);
|
||||
return [
|
||||
isAuthRequired
|
||||
? {
|
||||
id: 'auth-required',
|
||||
type: 'thought',
|
||||
content: AUTH_REQUIRED_MSG,
|
||||
status: 'running',
|
||||
}
|
||||
: {
|
||||
id: 'pending',
|
||||
type: 'thought',
|
||||
content: 'Working...',
|
||||
status: 'running',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable string representation of the current reassembled state.
|
||||
*/
|
||||
toString(): string {
|
||||
const joinedMessages = this.messageLog.join('\n\n');
|
||||
const joinedMessages = this.messageLog.join('');
|
||||
|
||||
const artifactsOutput = Array.from(this.artifacts.keys())
|
||||
.map((id) => {
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
DEFAULT_MAX_TURNS,
|
||||
type LocalAgentDefinition,
|
||||
type RemoteAgentDefinition,
|
||||
getAgentCardLoadOptions,
|
||||
getRemoteAgentTargetUrl,
|
||||
} from './types.js';
|
||||
|
||||
describe('loader', () => {
|
||||
@@ -232,6 +235,75 @@ agent_card_url: https://example.com/card
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse a remote agent with agent_card_json', async () => {
|
||||
const cardJson = JSON.stringify({
|
||||
name: 'json-agent',
|
||||
url: 'https://example.com/agent',
|
||||
version: '1.0',
|
||||
});
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: json-remote
|
||||
description: A JSON-based remote agent
|
||||
agent_card_json: '${cardJson}'
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'json-remote',
|
||||
description: 'A JSON-based remote agent',
|
||||
agent_card_json: cardJson,
|
||||
});
|
||||
// Should NOT have agent_card_url
|
||||
expect(result[0]).not.toHaveProperty('agent_card_url');
|
||||
});
|
||||
|
||||
it('should reject agent_card_json that is not valid JSON', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-json-remote
|
||||
agent_card_json: "not valid json {{"
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/agent_card_json must be valid JSON/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a remote agent with both agent_card_url and agent_card_json', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: both-fields
|
||||
agent_card_url: https://example.com/card
|
||||
agent_card_json: '{"name":"test"}'
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/Validation failed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should infer remote kind from agent_card_json', async () => {
|
||||
const cardJson = JSON.stringify({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: inferred-json-remote
|
||||
agent_card_json: '${cardJson}'
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'inferred-json-remote',
|
||||
agent_card_json: cardJson,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw AgentLoadError if agent name is not a valid slug', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: Invalid Name With Spaces
|
||||
@@ -242,6 +314,99 @@ Body`);
|
||||
/Name must be a valid slug/,
|
||||
);
|
||||
});
|
||||
|
||||
describe('error formatting and kind inference', () => {
|
||||
it('should only show local agent errors when kind is inferred as local (via kind field)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: local
|
||||
name: invalid-local
|
||||
# missing description
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('description: Required');
|
||||
expect(error.message).not.toContain('Remote Agent');
|
||||
});
|
||||
|
||||
it('should only show local agent errors when kind is inferred as local (via local-specific keys)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: invalid-local
|
||||
# missing description
|
||||
tools:
|
||||
- run_shell_command
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('description: Required');
|
||||
expect(error.message).not.toContain('Remote Agent');
|
||||
});
|
||||
|
||||
it('should only show remote agent errors when kind is inferred as remote (via kind field)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-remote
|
||||
# missing agent_card_url
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('agent_card_url: Required');
|
||||
expect(error.message).not.toContain('Local Agent');
|
||||
});
|
||||
|
||||
it('should only show remote agent errors when kind is inferred as remote (via remote-specific keys)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: invalid-remote
|
||||
auth:
|
||||
type: apiKey
|
||||
key: my_key
|
||||
# missing agent_card_url
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('agent_card_url: Required');
|
||||
expect(error.message).not.toContain('Local Agent');
|
||||
});
|
||||
|
||||
it('should show errors for both types when kind cannot be inferred', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: invalid-unknown
|
||||
# missing description and missing agent_card_url, no specific keys
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('(Local Agent)');
|
||||
expect(error.message).toContain('(Remote Agent)');
|
||||
expect(error.message).toContain('description: Required');
|
||||
expect(error.message).toContain('agent_card_url: Required');
|
||||
});
|
||||
|
||||
it('should format errors without a stray colon when the path is empty (e.g. strict object with unknown keys)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: local
|
||||
name: my-agent
|
||||
description: test
|
||||
unknown_field: true
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain(
|
||||
"Unrecognized key(s) in object: 'unknown_field'",
|
||||
);
|
||||
expect(error.message).not.toContain(': Unrecognized key(s)');
|
||||
expect(error.message).not.toContain('Required');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdownToAgentDefinition', () => {
|
||||
@@ -372,6 +537,40 @@ Body`);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert remote agent definition with agent_card_json', () => {
|
||||
const cardJson = JSON.stringify({
|
||||
name: 'json-agent',
|
||||
url: 'https://example.com/agent',
|
||||
});
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
name: 'json-remote',
|
||||
description: 'A JSON remote agent',
|
||||
agent_card_json: cardJson,
|
||||
};
|
||||
|
||||
const result = markdownToAgentDefinition(
|
||||
markdown,
|
||||
) as RemoteAgentDefinition;
|
||||
expect(result.kind).toBe('remote');
|
||||
expect(result.name).toBe('json-remote');
|
||||
expect(result.agentCardJson).toBe(cardJson);
|
||||
expect(result.agentCardUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw for remote agent with neither agent_card_url nor agent_card_json', () => {
|
||||
// Cast to bypass compile-time check — this tests the runtime guard
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
name: 'no-card-agent',
|
||||
description: 'Missing card info',
|
||||
} as Parameters<typeof markdownToAgentDefinition>[0];
|
||||
|
||||
expect(() => markdownToAgentDefinition(markdown)).toThrow(
|
||||
/neither agent_card_json nor agent_card_url/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAgentsFromDirectory', () => {
|
||||
@@ -617,7 +816,7 @@ kind: remote
|
||||
name: oauth2-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: oauth2
|
||||
type: oauth
|
||||
client_id: $MY_OAUTH_CLIENT_ID
|
||||
scopes:
|
||||
- read
|
||||
@@ -630,7 +829,7 @@ auth:
|
||||
kind: 'remote',
|
||||
name: 'oauth2-agent',
|
||||
auth: {
|
||||
type: 'oauth2',
|
||||
type: 'oauth',
|
||||
client_id: '$MY_OAUTH_CLIENT_ID',
|
||||
scopes: ['read', 'write'],
|
||||
},
|
||||
@@ -643,7 +842,7 @@ kind: remote
|
||||
name: oauth2-full-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: oauth2
|
||||
type: oauth
|
||||
client_id: my-client-id
|
||||
client_secret: my-client-secret
|
||||
scopes:
|
||||
@@ -659,7 +858,7 @@ auth:
|
||||
kind: 'remote',
|
||||
name: 'oauth2-full-agent',
|
||||
auth: {
|
||||
type: 'oauth2',
|
||||
type: 'oauth',
|
||||
client_id: 'my-client-id',
|
||||
client_secret: 'my-client-secret',
|
||||
scopes: ['openid', 'profile'],
|
||||
@@ -675,7 +874,7 @@ kind: remote
|
||||
name: oauth2-minimal-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: oauth2
|
||||
type: oauth
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
@@ -684,7 +883,7 @@ auth:
|
||||
kind: 'remote',
|
||||
name: 'oauth2-minimal-agent',
|
||||
auth: {
|
||||
type: 'oauth2',
|
||||
type: 'oauth',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -695,7 +894,7 @@ kind: remote
|
||||
name: invalid-oauth2-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: oauth2
|
||||
type: oauth
|
||||
client_id: my-client
|
||||
authorization_url: not-a-valid-url
|
||||
---
|
||||
@@ -709,7 +908,7 @@ kind: remote
|
||||
name: invalid-oauth2-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: oauth2
|
||||
type: oauth
|
||||
client_id: my-client
|
||||
token_url: not-a-valid-url
|
||||
---
|
||||
@@ -723,7 +922,7 @@ auth:
|
||||
name: 'oauth2-convert-agent',
|
||||
agent_card_url: 'https://example.com/card',
|
||||
auth: {
|
||||
type: 'oauth2' as const,
|
||||
type: 'oauth' as const,
|
||||
client_id: '$MY_CLIENT_ID',
|
||||
scopes: ['read'],
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
@@ -744,5 +943,103 @@ auth:
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error for an unknown auth type in markdownToAgentDefinition', () => {
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
name: 'unknown-auth-agent',
|
||||
agent_card_url: 'https://example.com/card',
|
||||
auth: {
|
||||
type: 'apiKey' as const,
|
||||
key: 'some-key',
|
||||
},
|
||||
};
|
||||
|
||||
// Mutate the object at runtime to bypass TypeScript compile-time checks cleanly
|
||||
Object.assign(markdown.auth, { type: 'some-unknown-type' });
|
||||
|
||||
expect(() => markdownToAgentDefinition(markdown)).toThrow(
|
||||
/Unknown auth type: some-unknown-type/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentCardLoadOptions', () => {
|
||||
it('should return json options when agentCardJson is present', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: '{"url":"http://x"}',
|
||||
} as RemoteAgentDefinition;
|
||||
const opts = getAgentCardLoadOptions(def);
|
||||
expect(opts).toEqual({ type: 'json', json: '{"url":"http://x"}' });
|
||||
});
|
||||
|
||||
it('should return url options when agentCardUrl is present', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardUrl: 'http://x/card',
|
||||
} as RemoteAgentDefinition;
|
||||
const opts = getAgentCardLoadOptions(def);
|
||||
expect(opts).toEqual({ type: 'url', url: 'http://x/card' });
|
||||
});
|
||||
|
||||
it('should prefer agentCardJson over agentCardUrl when both present', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: '{"url":"http://x"}',
|
||||
agentCardUrl: 'http://x/card',
|
||||
} as RemoteAgentDefinition;
|
||||
const opts = getAgentCardLoadOptions(def);
|
||||
expect(opts.type).toBe('json');
|
||||
});
|
||||
|
||||
it('should throw when neither is present', () => {
|
||||
const def = { name: 'orphan' } as RemoteAgentDefinition;
|
||||
expect(() => getAgentCardLoadOptions(def)).toThrow(
|
||||
/Remote agent 'orphan' has neither agentCardUrl nor agentCardJson/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteAgentTargetUrl', () => {
|
||||
it('should return agentCardUrl when present', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardUrl: 'http://x/card',
|
||||
} as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBe('http://x/card');
|
||||
});
|
||||
|
||||
it('should extract url from agentCardJson when agentCardUrl is absent', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: JSON.stringify({
|
||||
name: 'agent',
|
||||
url: 'https://example.com/agent',
|
||||
}),
|
||||
} as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBe('https://example.com/agent');
|
||||
});
|
||||
|
||||
it('should return undefined when JSON has no url field', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: JSON.stringify({ name: 'agent' }),
|
||||
} as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when agentCardJson is invalid JSON', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: 'not json',
|
||||
} as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when neither field is present', () => {
|
||||
const def = { name: 'test' } as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as crypto from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
type AgentDefinition,
|
||||
type RemoteAgentDefinition,
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
@@ -21,79 +22,6 @@ import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* DTO for Markdown parsing - represents the structure from frontmatter.
|
||||
*/
|
||||
interface FrontmatterBaseAgentDefinition {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterMCPServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
http_url?: string;
|
||||
headers?: Record<string, string>;
|
||||
tcp?: string;
|
||||
type?: 'sse' | 'http';
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
include_tools?: string[];
|
||||
exclude_tools?: string[];
|
||||
}
|
||||
|
||||
interface FrontmatterLocalAgentDefinition
|
||||
extends FrontmatterBaseAgentDefinition {
|
||||
kind: 'local';
|
||||
description: string;
|
||||
tools?: string[];
|
||||
mcp_servers?: Record<string, FrontmatterMCPServerConfig>;
|
||||
system_prompt: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
max_turns?: number;
|
||||
timeout_mins?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication configuration for remote agents in frontmatter format.
|
||||
*/
|
||||
interface FrontmatterAuthConfig {
|
||||
type: 'apiKey' | 'http' | 'google-credentials' | 'oauth2';
|
||||
// API Key
|
||||
key?: string;
|
||||
name?: string;
|
||||
// HTTP
|
||||
scheme?: string;
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
value?: string;
|
||||
// Google Credentials
|
||||
scopes?: string[];
|
||||
// OAuth2
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
authorization_url?: string;
|
||||
token_url?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterRemoteAgentDefinition
|
||||
extends FrontmatterBaseAgentDefinition {
|
||||
kind: 'remote';
|
||||
description?: string;
|
||||
agent_card_url: string;
|
||||
auth?: FrontmatterAuthConfig;
|
||||
}
|
||||
|
||||
type FrontmatterAgentDefinition =
|
||||
| FrontmatterLocalAgentDefinition
|
||||
| FrontmatterRemoteAgentDefinition;
|
||||
|
||||
/**
|
||||
* Error thrown when an agent definition is invalid or cannot be loaded.
|
||||
*/
|
||||
@@ -159,15 +87,13 @@ const localAgentSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Base fields shared by all auth configs.
|
||||
*/
|
||||
type FrontmatterLocalAgentDefinition = z.infer<typeof localAgentSchema> & {
|
||||
system_prompt: string;
|
||||
};
|
||||
|
||||
// Base fields shared by all auth configs.
|
||||
const baseAuthFields = {};
|
||||
|
||||
/**
|
||||
* API Key auth schema.
|
||||
* Supports sending key in header, query parameter, or cookie.
|
||||
*/
|
||||
const apiKeyAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('apiKey'),
|
||||
@@ -175,11 +101,6 @@ const apiKeyAuthSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* HTTP auth schema (Bearer or Basic).
|
||||
* Note: Validation for scheme-specific fields is applied in authConfigSchema
|
||||
* since discriminatedUnion doesn't support refined schemas directly.
|
||||
*/
|
||||
const httpAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('http'),
|
||||
@@ -190,22 +111,15 @@ const httpAuthSchema = z.object({
|
||||
value: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Google Credentials auth schema.
|
||||
*/
|
||||
const googleCredentialsAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('google-credentials'),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* OAuth2 auth schema.
|
||||
* authorization_url and token_url can be discovered from the agent card if omitted.
|
||||
*/
|
||||
const oauth2AuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('oauth2'),
|
||||
type: z.literal('oauth'),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
@@ -222,18 +136,16 @@ const authConfigSchema = z
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type === 'http') {
|
||||
if (data.value) {
|
||||
// Raw mode - only scheme and value are needed
|
||||
return;
|
||||
}
|
||||
if (data.scheme === 'Bearer' && !data.token) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Bearer scheme requires "token"',
|
||||
path: ['token'],
|
||||
});
|
||||
}
|
||||
if (data.scheme === 'Basic') {
|
||||
if (data.value) return;
|
||||
if (data.scheme === 'Bearer') {
|
||||
if (!data.token) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Bearer scheme requires "token"',
|
||||
path: ['token'],
|
||||
});
|
||||
}
|
||||
} else if (data.scheme === 'Basic') {
|
||||
if (!data.username) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -248,55 +160,129 @@ const authConfigSchema = z
|
||||
path: ['password'],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `HTTP scheme "${data.scheme}" requires "value"`,
|
||||
path: ['value'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const remoteAgentSchema = z
|
||||
.object({
|
||||
kind: z.literal('remote').optional().default('remote'),
|
||||
name: nameSchema,
|
||||
description: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
type FrontmatterAuthConfig = z.infer<typeof authConfigSchema>;
|
||||
|
||||
const baseRemoteAgentSchema = z.object({
|
||||
kind: z.literal('remote').optional().default('remote'),
|
||||
name: nameSchema,
|
||||
description: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
auth: authConfigSchema.optional(),
|
||||
});
|
||||
|
||||
const remoteAgentUrlSchema = baseRemoteAgentSchema
|
||||
.extend({
|
||||
agent_card_url: z.string().url(),
|
||||
auth: authConfigSchema.optional(),
|
||||
agent_card_json: z.undefined().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Use a Zod union to automatically discriminate between local and remote
|
||||
// agent types.
|
||||
const remoteAgentJsonSchema = baseRemoteAgentSchema
|
||||
.extend({
|
||||
agent_card_url: z.undefined().optional(),
|
||||
agent_card_json: z.string().refine(
|
||||
(val) => {
|
||||
try {
|
||||
JSON.parse(val);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'agent_card_json must be valid JSON' },
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const remoteAgentSchema = z.union([
|
||||
remoteAgentUrlSchema,
|
||||
remoteAgentJsonSchema,
|
||||
]);
|
||||
|
||||
type FrontmatterRemoteAgentDefinition = z.infer<typeof remoteAgentSchema>;
|
||||
|
||||
type FrontmatterAgentDefinition =
|
||||
| FrontmatterLocalAgentDefinition
|
||||
| FrontmatterRemoteAgentDefinition;
|
||||
|
||||
const agentUnionOptions = [
|
||||
{ schema: localAgentSchema, label: 'Local Agent' },
|
||||
{ schema: remoteAgentSchema, label: 'Remote Agent' },
|
||||
] as const;
|
||||
{ label: 'Local Agent' },
|
||||
{ label: 'Remote Agent' },
|
||||
{ label: 'Remote Agent' },
|
||||
];
|
||||
|
||||
const remoteAgentsListSchema = z.array(remoteAgentSchema);
|
||||
|
||||
const markdownFrontmatterSchema = z.union([
|
||||
agentUnionOptions[0].schema,
|
||||
agentUnionOptions[1].schema,
|
||||
localAgentSchema,
|
||||
remoteAgentUrlSchema,
|
||||
remoteAgentJsonSchema,
|
||||
]);
|
||||
|
||||
function formatZodError(error: z.ZodError, context: string): string {
|
||||
const issues = error.issues
|
||||
.map((i) => {
|
||||
function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
|
||||
if (typeof rawInput !== 'object' || rawInput === null) return undefined;
|
||||
const input = rawInput as Partial<FrontmatterLocalAgentDefinition> &
|
||||
Partial<FrontmatterRemoteAgentDefinition>;
|
||||
|
||||
if (input.kind === 'local') return 'local';
|
||||
if (input.kind === 'remote') return 'remote';
|
||||
|
||||
const hasLocalKeys =
|
||||
'tools' in input ||
|
||||
'mcp_servers' in input ||
|
||||
'model' in input ||
|
||||
'temperature' in input ||
|
||||
'max_turns' in input ||
|
||||
'timeout_mins' in input;
|
||||
const hasRemoteKeys =
|
||||
'agent_card_url' in input || 'auth' in input || 'agent_card_json' in input;
|
||||
|
||||
if (hasLocalKeys && !hasRemoteKeys) return 'local';
|
||||
if (hasRemoteKeys && !hasLocalKeys) return 'remote';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatZodError(
|
||||
error: z.ZodError,
|
||||
context: string,
|
||||
rawInput?: unknown,
|
||||
): string {
|
||||
const intendedKind = rawInput ? guessIntendedKind(rawInput) : undefined;
|
||||
|
||||
const formatIssues = (issues: z.ZodIssue[], unionPrefix?: string): string[] =>
|
||||
issues.flatMap((i) => {
|
||||
// Handle union errors specifically to give better context
|
||||
if (i.code === z.ZodIssueCode.invalid_union) {
|
||||
return i.unionErrors
|
||||
.map((unionError, index) => {
|
||||
const label =
|
||||
agentUnionOptions[index]?.label ?? `Agent type #${index + 1}`;
|
||||
const unionIssues = unionError.issues
|
||||
.map((u) => `${u.path.join('.')}: ${u.message}`)
|
||||
.join(', ');
|
||||
return `(${label}) ${unionIssues}`;
|
||||
})
|
||||
.join('\n');
|
||||
return i.unionErrors.flatMap((unionError, index) => {
|
||||
const label = unionPrefix
|
||||
? unionPrefix
|
||||
: ((agentUnionOptions[index] as { label?: string })?.label ??
|
||||
`Branch #${index + 1}`);
|
||||
|
||||
if (intendedKind === 'local' && label === 'Remote Agent') return [];
|
||||
if (intendedKind === 'remote' && label === 'Local Agent') return [];
|
||||
|
||||
return formatIssues(unionError.issues, label);
|
||||
});
|
||||
}
|
||||
return `${i.path.join('.')}: ${i.message}`;
|
||||
})
|
||||
.join('\n');
|
||||
return `${context}:\n${issues}`;
|
||||
const prefix = unionPrefix ? `(${unionPrefix}) ` : '';
|
||||
const path = i.path.length > 0 ? `${i.path.join('.')}: ` : '';
|
||||
return `${prefix}${path}${i.message}`;
|
||||
});
|
||||
|
||||
const formatted = Array.from(new Set(formatIssues(error.issues))).join('\n');
|
||||
return `${context}:\n${formatted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,8 +329,7 @@ export async function parseAgentMarkdown(
|
||||
} catch (error) {
|
||||
throw new AgentLoadError(
|
||||
filePath,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
`YAML frontmatter parsing failed: ${(error as Error).message}`,
|
||||
`YAML frontmatter parsing failed: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,7 +353,7 @@ export async function parseAgentMarkdown(
|
||||
if (!result.success) {
|
||||
throw new AgentLoadError(
|
||||
filePath,
|
||||
`Validation failed: ${formatZodError(result.error, 'Agent Definition')}`,
|
||||
`Validation failed: ${formatZodError(result.error, 'Agent Definition', rawFrontmatter)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,17 +368,14 @@ export async function parseAgentMarkdown(
|
||||
];
|
||||
}
|
||||
|
||||
// Local agent validation
|
||||
// Validate tools
|
||||
|
||||
// Construct the local agent definition
|
||||
const agentDef: FrontmatterLocalAgentDefinition = {
|
||||
...frontmatter,
|
||||
kind: 'local',
|
||||
system_prompt: body.trim(),
|
||||
};
|
||||
|
||||
return [agentDef];
|
||||
return [
|
||||
{
|
||||
...frontmatter,
|
||||
kind: 'local',
|
||||
system_prompt: body.trim(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,15 +385,9 @@ export async function parseAgentMarkdown(
|
||||
function convertFrontmatterAuthToConfig(
|
||||
frontmatter: FrontmatterAuthConfig,
|
||||
): A2AAuthConfig {
|
||||
const base = {};
|
||||
|
||||
switch (frontmatter.type) {
|
||||
case 'apiKey':
|
||||
if (!frontmatter.key) {
|
||||
throw new Error('Internal error: API key missing after validation.');
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'apiKey',
|
||||
key: frontmatter.key,
|
||||
name: frontmatter.name,
|
||||
@@ -419,20 +395,13 @@ function convertFrontmatterAuthToConfig(
|
||||
|
||||
case 'google-credentials':
|
||||
return {
|
||||
...base,
|
||||
type: 'google-credentials',
|
||||
scopes: frontmatter.scopes,
|
||||
};
|
||||
|
||||
case 'http': {
|
||||
if (!frontmatter.scheme) {
|
||||
throw new Error(
|
||||
'Internal error: HTTP scheme missing after validation.',
|
||||
);
|
||||
}
|
||||
case 'http':
|
||||
if (frontmatter.value) {
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: frontmatter.scheme,
|
||||
value: frontmatter.value,
|
||||
@@ -440,40 +409,27 @@ function convertFrontmatterAuthToConfig(
|
||||
}
|
||||
switch (frontmatter.scheme) {
|
||||
case 'Bearer':
|
||||
if (!frontmatter.token) {
|
||||
throw new Error(
|
||||
'Internal error: Bearer token missing after validation.',
|
||||
);
|
||||
}
|
||||
// Token is required by schema validation
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: frontmatter.token,
|
||||
|
||||
token: frontmatter.token!,
|
||||
};
|
||||
case 'Basic':
|
||||
if (!frontmatter.username || !frontmatter.password) {
|
||||
throw new Error(
|
||||
'Internal error: Basic auth credentials missing after validation.',
|
||||
);
|
||||
}
|
||||
// Username/password are required by schema validation
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: frontmatter.username,
|
||||
password: frontmatter.password,
|
||||
username: frontmatter.username!,
|
||||
password: frontmatter.password!,
|
||||
};
|
||||
default: {
|
||||
// Other IANA schemes without a value should not reach here after validation
|
||||
default:
|
||||
throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'oauth2':
|
||||
case 'oauth':
|
||||
return {
|
||||
...base,
|
||||
type: 'oauth2',
|
||||
client_id: frontmatter.client_id,
|
||||
client_secret: frontmatter.client_secret,
|
||||
@@ -483,8 +439,12 @@ function convertFrontmatterAuthToConfig(
|
||||
};
|
||||
|
||||
default: {
|
||||
const exhaustive: never = frontmatter.type;
|
||||
throw new Error(`Unknown auth type: ${exhaustive}`);
|
||||
const exhaustive: never = frontmatter;
|
||||
const raw: unknown = exhaustive;
|
||||
if (typeof raw === 'object' && raw !== null && 'type' in raw) {
|
||||
throw new Error(`Unknown auth type: ${String(raw['type'])}`);
|
||||
}
|
||||
throw new Error('Unknown auth type');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,25 +475,41 @@ export function markdownToAgentDefinition(
|
||||
};
|
||||
|
||||
if (markdown.kind === 'remote') {
|
||||
return {
|
||||
const base: RemoteAgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: markdown.name,
|
||||
description: markdown.description || '',
|
||||
displayName: markdown.display_name,
|
||||
agentCardUrl: markdown.agent_card_url,
|
||||
auth: markdown.auth
|
||||
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||
: undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (
|
||||
'agent_card_json' in markdown &&
|
||||
markdown.agent_card_json !== undefined
|
||||
) {
|
||||
base.agentCardJson = markdown.agent_card_json;
|
||||
return base;
|
||||
}
|
||||
if ('agent_card_url' in markdown && markdown.agent_card_url !== undefined) {
|
||||
base.agentCardUrl = markdown.agent_card_url;
|
||||
return base;
|
||||
}
|
||||
|
||||
throw new AgentLoadError(
|
||||
metadata?.filePath || 'unknown',
|
||||
'Unexpected state: neither agent_card_json nor agent_card_url present on remote agent',
|
||||
);
|
||||
}
|
||||
|
||||
// If a model is specified, use it. Otherwise, inherit
|
||||
const modelName = markdown.model || 'inherit';
|
||||
|
||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||
if (markdown.kind === 'local' && markdown.mcp_servers) {
|
||||
if (markdown.mcp_servers) {
|
||||
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
|
||||
mcpServers[name] = new MCPServerConfig(
|
||||
config.command,
|
||||
@@ -606,15 +582,13 @@ export async function loadAgentsFromDirectory(
|
||||
dirEntries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
// If directory doesn't exist, just return empty
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
return result;
|
||||
}
|
||||
result.errors.push(
|
||||
new AgentLoadError(
|
||||
dir,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
`Could not list directory: ${(error as Error).message}`,
|
||||
`Could not list directory: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
return result;
|
||||
@@ -644,8 +618,7 @@ export async function loadAgentsFromDirectory(
|
||||
result.errors.push(
|
||||
new AgentLoadError(
|
||||
filePath,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
`Unexpected error: ${(error as Error).message}`,
|
||||
`Unexpected error: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ Some errors are unrecoverable and retrying will never help. When you see ANY of
|
||||
- "Could not connect to Chrome" or "Failed to connect to Chrome" or "Timed out connecting to Chrome" — Include the full error message with its remediation steps in your summary verbatim. Do NOT paraphrase or omit instructions.
|
||||
- "Browser closed" or "Target closed" or "Session closed" — The browser process has terminated. Include the error and tell the user to try again.
|
||||
- "net::ERR_" network errors on the SAME URL after 2 retries — the site is unreachable. Report the URL and error.
|
||||
- "reached maximum action limit" — You have performed too many actions in this task. Stop immediately and report this limit to the user.
|
||||
- Any error that appears IDENTICALLY 3+ times in a row — it will not resolve by retrying.
|
||||
Do NOT keep retrying terminal errors. Report them with actionable remediation steps and exit immediately.
|
||||
|
||||
|
||||
@@ -697,4 +697,28 @@ describe('BrowserManager', () => {
|
||||
expect(injectAutomationOverlay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate limiting', () => {
|
||||
it('should terminate task when maxActionsPerTask is reached', async () => {
|
||||
const limitedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
maxActionsPerTask: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(limitedConfig);
|
||||
|
||||
// First 3 calls should succeed
|
||||
await manager.callTool('take_snapshot', {});
|
||||
await manager.callTool('take_snapshot', { some: 'args' });
|
||||
await manager.callTool('take_snapshot', { other: 'args' });
|
||||
await manager.callTool('take_snapshot', { other: 'new args' });
|
||||
|
||||
// 4th call should throw
|
||||
await expect(manager.callTool('take_snapshot', {})).rejects.toThrow(
|
||||
/maximum action limit \(3\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,10 @@ export class BrowserManager {
|
||||
private mcpTransport: StdioClientTransport | undefined;
|
||||
private discoveredTools: McpTool[] = [];
|
||||
|
||||
/** State for action rate limiting */
|
||||
private actionCounter = 0;
|
||||
private readonly maxActionsPerTask: number;
|
||||
|
||||
/**
|
||||
* Whether to inject the automation overlay.
|
||||
* Always false in headless mode (no visible window to decorate).
|
||||
@@ -108,6 +112,8 @@ export class BrowserManager {
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
this.shouldInjectOverlay = !browserConfig?.customConfig?.headless;
|
||||
this.shouldDisableInput = config.shouldDisableBrowserUserInput();
|
||||
this.maxActionsPerTask =
|
||||
browserConfig?.customConfig.maxActionsPerTask ?? 100;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +157,16 @@ export class BrowserManager {
|
||||
throw signal.reason ?? new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
// Hard enforcement of per-action rate limit
|
||||
if (this.actionCounter > this.maxActionsPerTask) {
|
||||
const error = new Error(
|
||||
`Browser agent reached maximum action limit (${this.maxActionsPerTask}). ` +
|
||||
`Task terminated to prevent runaway execution. To config the limit, use maxActionsPerTask in the settings.`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
this.actionCounter++;
|
||||
|
||||
const errorMessage = this.checkNavigationRestrictions(toolName, args);
|
||||
if (errorMessage) {
|
||||
return {
|
||||
|
||||
@@ -596,7 +596,7 @@ describe('AgentRegistry', () => {
|
||||
});
|
||||
expect(loadAgentSpy).toHaveBeenCalledWith(
|
||||
'RemoteAgentWithAuth',
|
||||
'https://example.com/card',
|
||||
{ type: 'url', url: 'https://example.com/card' },
|
||||
mockHandler,
|
||||
);
|
||||
expect(registry.getDefinition('RemoteAgentWithAuth')).toEqual(
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { CoreEvent, coreEvents } from '../utils/events.js';
|
||||
import type { AgentOverride, Config } from '../config/config.js';
|
||||
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
|
||||
import { getAgentCardLoadOptions, getRemoteAgentTargetUrl } from './types.js';
|
||||
import { loadAgentsFromDirectory } from './agentLoader.js';
|
||||
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
|
||||
import { CliHelpAgent } from './cli-help-agent.js';
|
||||
@@ -162,7 +164,14 @@ export class AgentRegistry {
|
||||
if (!agent.metadata) {
|
||||
agent.metadata = {};
|
||||
}
|
||||
agent.metadata.hash = agent.agentCardUrl;
|
||||
agent.metadata.hash =
|
||||
agent.agentCardUrl ??
|
||||
(agent.agentCardJson
|
||||
? crypto
|
||||
.createHash('sha256')
|
||||
.update(agent.agentCardJson)
|
||||
.digest('hex')
|
||||
: undefined);
|
||||
}
|
||||
|
||||
if (!agent.metadata?.hash) {
|
||||
@@ -443,12 +452,13 @@ export class AgentRegistry {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const targetUrl = getRemoteAgentTargetUrl(remoteDef);
|
||||
let authHandler: AuthenticationHandler | undefined;
|
||||
if (definition.auth) {
|
||||
const provider = await A2AAuthProviderFactory.create({
|
||||
authConfig: definition.auth,
|
||||
agentName: definition.name,
|
||||
targetUrl: definition.agentCardUrl,
|
||||
targetUrl,
|
||||
agentCardUrl: remoteDef.agentCardUrl,
|
||||
});
|
||||
if (!provider) {
|
||||
@@ -461,7 +471,7 @@ export class AgentRegistry {
|
||||
|
||||
const agentCard = await clientManager.loadAgent(
|
||||
remoteDef.name,
|
||||
remoteDef.agentCardUrl,
|
||||
getAgentCardLoadOptions(remoteDef),
|
||||
authHandler,
|
||||
);
|
||||
|
||||
@@ -515,7 +525,7 @@ export class AgentRegistry {
|
||||
|
||||
if (this.config.getDebugMode()) {
|
||||
debugLogger.log(
|
||||
`[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl}`,
|
||||
`[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl ?? 'inline JSON'}`,
|
||||
);
|
||||
}
|
||||
this.agents.set(definition.name, definition);
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type A2AClientManager,
|
||||
} from './a2a-client-manager.js';
|
||||
|
||||
import type { RemoteAgentDefinition } from './types.js';
|
||||
import type { RemoteAgentDefinition, SubagentProgress } from './types.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import type { A2AAuthProvider } from './auth-provider/types.js';
|
||||
@@ -189,7 +189,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
|
||||
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
|
||||
'test-agent',
|
||||
'http://test-agent/card',
|
||||
{ type: 'url', url: 'http://test-agent/card' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -240,7 +240,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
});
|
||||
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
|
||||
'test-agent',
|
||||
'http://test-agent/card',
|
||||
{ type: 'url', url: 'http://test-agent/card' },
|
||||
mockHandler,
|
||||
);
|
||||
});
|
||||
@@ -266,7 +266,8 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error?.message).toContain(
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
"Failed to create auth provider for agent 'test-agent'",
|
||||
);
|
||||
});
|
||||
@@ -325,7 +326,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
|
||||
// Execute first time
|
||||
const result1 = await invocation1.execute(new AbortController().signal);
|
||||
expect(result1.returnDisplay).toBe('Response 1');
|
||||
expect(result1.returnDisplay).toMatchObject({
|
||||
result: 'Response 1',
|
||||
});
|
||||
expect(mockClientManager.sendMessageStream).toHaveBeenLastCalledWith(
|
||||
'test-agent',
|
||||
'first',
|
||||
@@ -355,7 +358,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
const result2 = await invocation2.execute(new AbortController().signal);
|
||||
expect(result2.returnDisplay).toBe('Response 2');
|
||||
expect((result2.returnDisplay as SubagentProgress).result).toBe(
|
||||
'Response 2',
|
||||
);
|
||||
|
||||
expect(mockClientManager.sendMessageStream).toHaveBeenLastCalledWith(
|
||||
'test-agent',
|
||||
@@ -444,8 +449,22 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
await invocation.execute(new AbortController().signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledWith('Hello');
|
||||
expect(updateOutput).toHaveBeenCalledWith('Hello\n\nHello World');
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: 'running',
|
||||
recentActivity: expect.arrayContaining([
|
||||
expect.objectContaining({ content: 'Working...' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: 'completed',
|
||||
result: 'HelloHello World',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should abort when signal is aborted during streaming', async () => {
|
||||
@@ -478,8 +497,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
const result = await invocation.execute(controller.signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toContain('Operation aborted');
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
@@ -501,9 +519,10 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toContain('Network error');
|
||||
expect(result.returnDisplay).toContain('Network error');
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
state: 'error',
|
||||
result: expect.stringContaining('Network error'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a2a helpers for extracting text', async () => {
|
||||
@@ -534,7 +553,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
// Just check that text is present, exact formatting depends on helper
|
||||
expect(result.returnDisplay).toContain('Extracted text');
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
'Extracted text',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed response types during streaming (TaskStatusUpdateEvent + Message)', async () => {
|
||||
@@ -577,9 +598,25 @@ describe('RemoteAgentInvocation', () => {
|
||||
updateOutput,
|
||||
);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledWith('Thinking...');
|
||||
expect(updateOutput).toHaveBeenCalledWith('Thinking...\n\nFinal Answer');
|
||||
expect(result.returnDisplay).toBe('Thinking...\n\nFinal Answer');
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: 'running',
|
||||
recentActivity: expect.arrayContaining([
|
||||
expect.objectContaining({ content: 'Working...' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: 'completed',
|
||||
result: 'Thinking...Final Answer',
|
||||
}),
|
||||
);
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
result: 'Thinking...Final Answer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle artifact reassembly with append: true', async () => {
|
||||
@@ -635,12 +672,21 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
await invocation.execute(new AbortController().signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledWith('Generating...');
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
'Generating...\n\nArtifact (Result):\nPart 1',
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: 'running',
|
||||
recentActivity: expect.arrayContaining([
|
||||
expect.objectContaining({ content: 'Working...' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
'Generating...\n\nArtifact (Result):\nPart 1 Part 2',
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: 'completed',
|
||||
result: 'Generating...\n\nArtifact (Result):\nPart 1 Part 2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -694,8 +740,10 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.returnDisplay).toContain(a2aError.userMessage);
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
a2aError.userMessage,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use generic message for non-A2AAgentError errors', async () => {
|
||||
@@ -712,8 +760,8 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.returnDisplay).toContain(
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
'Error calling remote agent: something unexpected',
|
||||
);
|
||||
});
|
||||
@@ -741,10 +789,14 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
// Should contain both the partial output and the error message
|
||||
expect(result.returnDisplay).toContain('Partial response');
|
||||
expect(result.returnDisplay).toContain('connection reset');
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
result: expect.stringContaining('Partial response'),
|
||||
});
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
result: expect.stringContaining('connection reset'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
type RemoteAgentInputs,
|
||||
type RemoteAgentDefinition,
|
||||
type AgentInputs,
|
||||
type SubagentProgress,
|
||||
getAgentCardLoadOptions,
|
||||
getRemoteAgentTargetUrl,
|
||||
} from './types.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -25,7 +28,6 @@ import type {
|
||||
import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
|
||||
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import { A2AAgentError } from './a2a-errors.js';
|
||||
@@ -92,10 +94,11 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
if (this.definition.auth) {
|
||||
const targetUrl = getRemoteAgentTargetUrl(this.definition);
|
||||
const provider = await A2AAuthProviderFactory.create({
|
||||
authConfig: this.definition.auth,
|
||||
agentName: this.definition.name,
|
||||
targetUrl: this.definition.agentCardUrl,
|
||||
targetUrl,
|
||||
agentCardUrl: this.definition.agentCardUrl,
|
||||
});
|
||||
if (!provider) {
|
||||
@@ -125,13 +128,30 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
|
||||
async execute(
|
||||
_signal: AbortSignal,
|
||||
updateOutput?: (output: string | AnsiOutput) => void,
|
||||
updateOutput?: (output: string | AnsiOutput | SubagentProgress) => void,
|
||||
): Promise<ToolResult> {
|
||||
// 1. Ensure the agent is loaded (cached by manager)
|
||||
// We assume the user has provided an access token via some mechanism (TODO),
|
||||
// or we rely on ADC.
|
||||
const reassembler = new A2AResultReassembler();
|
||||
const agentName = this.definition.displayName ?? this.definition.name;
|
||||
try {
|
||||
if (updateOutput) {
|
||||
updateOutput({
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: 'running',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'pending',
|
||||
type: 'thought',
|
||||
content: 'Working...',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const priorState = RemoteAgentInvocation.sessionState.get(
|
||||
this.definition.name,
|
||||
);
|
||||
@@ -145,7 +165,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
if (!this.clientManager.getClient(this.definition.name)) {
|
||||
await this.clientManager.loadAgent(
|
||||
this.definition.name,
|
||||
this.definition.agentCardUrl,
|
||||
getAgentCardLoadOptions(this.definition),
|
||||
authHandler,
|
||||
);
|
||||
}
|
||||
@@ -172,7 +192,13 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
reassembler.update(chunk);
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(reassembler.toString());
|
||||
updateOutput({
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: 'running',
|
||||
recentActivity: reassembler.toActivityItems(),
|
||||
result: reassembler.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -198,9 +224,21 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
`[RemoteAgent] Final response from ${this.definition.name}:\n${JSON.stringify(finalResponse, null, 2)}`,
|
||||
);
|
||||
|
||||
const finalProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: 'completed',
|
||||
result: finalOutput,
|
||||
recentActivity: reassembler.toActivityItems(),
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(finalProgress);
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: [{ text: finalOutput }],
|
||||
returnDisplay: safeJsonToMarkdown(finalOutput),
|
||||
returnDisplay: finalProgress,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const partialOutput = reassembler.toString();
|
||||
@@ -209,10 +247,22 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
const fullDisplay = partialOutput
|
||||
? `${partialOutput}\n\n${errorMessage}`
|
||||
: errorMessage;
|
||||
|
||||
const errorProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: 'error',
|
||||
result: fullDisplay,
|
||||
recentActivity: reassembler.toActivityItems(),
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(errorProgress);
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: [{ text: fullDisplay }],
|
||||
returnDisplay: fullDisplay,
|
||||
error: { message: errorMessage },
|
||||
returnDisplay: errorProgress,
|
||||
};
|
||||
} finally {
|
||||
// Persist state even on partial failures or aborts to maintain conversational continuity.
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { type z } from 'zod';
|
||||
import type { ModelConfig } from '../services/modelConfigService.js';
|
||||
import type { AnySchema } from 'ajv';
|
||||
import type { AgentCard } from '@a2a-js/sdk';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import type { MCPServerConfig } from '../config/config.js';
|
||||
|
||||
@@ -128,6 +129,62 @@ export function isToolActivityError(data: unknown): boolean {
|
||||
* The base definition for an agent.
|
||||
* @template TOutput The specific Zod schema for the agent's final output object.
|
||||
*/
|
||||
export type AgentCardLoadOptions =
|
||||
| { type: 'url'; url: string }
|
||||
| { type: 'json'; json: string };
|
||||
|
||||
/** Minimal shape needed by helper functions, avoids generic TOutput constraints. */
|
||||
interface RemoteAgentRef {
|
||||
name: string;
|
||||
agentCardUrl?: string;
|
||||
agentCardJson?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the AgentCardLoadOptions from a RemoteAgentDefinition.
|
||||
* Throws if neither agentCardUrl nor agentCardJson is present.
|
||||
*/
|
||||
export function getAgentCardLoadOptions(
|
||||
def: RemoteAgentRef,
|
||||
): AgentCardLoadOptions {
|
||||
if (def.agentCardJson) {
|
||||
return { type: 'json', json: def.agentCardJson };
|
||||
}
|
||||
if (def.agentCardUrl) {
|
||||
return { type: 'url', url: def.agentCardUrl };
|
||||
}
|
||||
throw new Error(
|
||||
`Remote agent '${def.name}' has neither agentCardUrl nor agentCardJson`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a target URL for auth providers from a RemoteAgentDefinition.
|
||||
* For URL-based agents, returns the agentCardUrl.
|
||||
* For JSON-based agents, attempts to parse the URL from the inline card JSON.
|
||||
* Returns undefined if no URL can be determined.
|
||||
*/
|
||||
export function getRemoteAgentTargetUrl(
|
||||
def: RemoteAgentRef,
|
||||
): string | undefined {
|
||||
if (def.agentCardUrl) {
|
||||
return def.agentCardUrl;
|
||||
}
|
||||
if (def.agentCardJson) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(def.agentCardJson);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const card = parsed as AgentCard;
|
||||
if (card.url) {
|
||||
return card.url;
|
||||
}
|
||||
} catch {
|
||||
// JSON parse will fail properly later in loadAgent
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface BaseAgentDefinition<
|
||||
TOutput extends z.ZodTypeAny = z.ZodUnknown,
|
||||
> {
|
||||
@@ -172,11 +229,10 @@ export interface LocalAgentDefinition<
|
||||
processOutput?: (output: z.infer<TOutput>) => string;
|
||||
}
|
||||
|
||||
export interface RemoteAgentDefinition<
|
||||
export interface BaseRemoteAgentDefinition<
|
||||
TOutput extends z.ZodTypeAny = z.ZodUnknown,
|
||||
> extends BaseAgentDefinition<TOutput> {
|
||||
kind: 'remote';
|
||||
agentCardUrl: string;
|
||||
/** The user-provided description, before any remote card merging. */
|
||||
originalDescription?: string;
|
||||
/**
|
||||
@@ -187,6 +243,13 @@ export interface RemoteAgentDefinition<
|
||||
auth?: A2AAuthConfig;
|
||||
}
|
||||
|
||||
export interface RemoteAgentDefinition<
|
||||
TOutput extends z.ZodTypeAny = z.ZodUnknown,
|
||||
> extends BaseRemoteAgentDefinition<TOutput> {
|
||||
agentCardUrl?: string;
|
||||
agentCardJson?: string;
|
||||
}
|
||||
|
||||
export type AgentDefinition<TOutput extends z.ZodTypeAny = z.ZodUnknown> =
|
||||
| LocalAgentDefinition<TOutput>
|
||||
| RemoteAgentDefinition<TOutput>;
|
||||
|
||||
@@ -15,8 +15,20 @@ import { CodeAssistServer } from '../code_assist/server.js';
|
||||
import type { OAuth2Client } from 'google-auth-library';
|
||||
import { UserTierId, type GeminiUserTier } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
logOnboardingSuccess,
|
||||
OnboardingSuccessEvent,
|
||||
} from '../telemetry/index.js';
|
||||
|
||||
vi.mock('../code_assist/server.js');
|
||||
vi.mock('../telemetry/index.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../telemetry/index.js')>();
|
||||
return {
|
||||
...actual,
|
||||
logOnboardingStart: vi.fn(),
|
||||
logOnboardingSuccess: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockPaidTier: GeminiUserTier = {
|
||||
id: UserTierId.STANDARD,
|
||||
@@ -214,7 +226,20 @@ describe('setupUser', () => {
|
||||
mockLoad.mockResolvedValue({
|
||||
allowedTiers: [mockPaidTier],
|
||||
});
|
||||
const userData = await setupUser({} as OAuth2Client, mockConfig);
|
||||
mockOnboardUser.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return {
|
||||
done: true,
|
||||
response: {
|
||||
cloudaicompanionProject: {
|
||||
id: 'server-project',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const userDataPromise = setupUser({} as OAuth2Client, mockConfig);
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
const userData = await userDataPromise;
|
||||
expect(mockOnboardUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tierId: UserTierId.STANDARD,
|
||||
@@ -227,6 +252,13 @@ describe('setupUser', () => {
|
||||
userTierName: 'paid',
|
||||
hasOnboardedPreviously: false,
|
||||
});
|
||||
expect(logOnboardingSuccess).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.any(OnboardingSuccessEvent),
|
||||
);
|
||||
const event = vi.mocked(logOnboardingSuccess).mock.calls[0][1];
|
||||
expect(event.userTier).toBe('paid');
|
||||
expect(event.duration_ms).toBeGreaterThanOrEqual(1500);
|
||||
});
|
||||
|
||||
it('should onboard a new free user when project ID is not set', async () => {
|
||||
|
||||
@@ -251,6 +251,7 @@ async function _doSetupUser(
|
||||
}
|
||||
|
||||
logOnboardingStart(config, new OnboardingStartEvent());
|
||||
const onboardingStartTime = Date.now();
|
||||
|
||||
let lroRes = await caServer.onboardUser(onboardReq);
|
||||
if (!lroRes.done && lroRes.name) {
|
||||
@@ -261,8 +262,10 @@ async function _doSetupUser(
|
||||
}
|
||||
}
|
||||
|
||||
const userTier = tier.id ?? UserTierId.STANDARD;
|
||||
logOnboardingSuccess(config, new OnboardingSuccessEvent(userTier));
|
||||
logOnboardingSuccess(
|
||||
config,
|
||||
new OnboardingSuccessEvent(tier.name, Date.now() - onboardingStartTime),
|
||||
);
|
||||
|
||||
if (!lroRes.response?.cloudaicompanionProject?.id) {
|
||||
if (projectId) {
|
||||
|
||||
@@ -1474,6 +1474,22 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(browserConfig.customConfig.visualModel).toBe(
|
||||
'custom-visual-model',
|
||||
);
|
||||
expect(browserConfig.customConfig.maxActionsPerTask).toBe(100); // default
|
||||
});
|
||||
|
||||
it('should return custom maxActionsPerTask', () => {
|
||||
const params: ConfigParameters = {
|
||||
...baseParams,
|
||||
agents: {
|
||||
browser: {
|
||||
maxActionsPerTask: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = new Config(params);
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
|
||||
expect(browserConfig.customConfig.maxActionsPerTask).toBe(50);
|
||||
});
|
||||
|
||||
it('should apply defaults for partial custom config', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
import { inspect } from 'node:util';
|
||||
import process from 'node:process';
|
||||
import { z } from 'zod';
|
||||
@@ -330,6 +331,8 @@ export interface BrowserAgentCustomConfig {
|
||||
allowedDomains?: string[];
|
||||
/** Disable user input on the browser window during automation. Default: true in non-headless mode */
|
||||
disableUserInput?: boolean;
|
||||
/** Maximum number of actions (tool calls) allowed per task. Default: 100 */
|
||||
maxActionsPerTask?: number;
|
||||
/** Whether to confirm sensitive actions (e.g., fill_form, evaluate_script). */
|
||||
confirmSensitiveActions?: boolean;
|
||||
/** Whether to block file uploads. */
|
||||
@@ -673,8 +676,6 @@ export interface ConfigParameters {
|
||||
agents?: AgentSettings;
|
||||
}>;
|
||||
enableConseca?: boolean;
|
||||
showContextWindowWarning?: boolean;
|
||||
showContextCompression?: boolean;
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
@@ -710,8 +711,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly question: string | undefined;
|
||||
private readonly worktreeSettings: WorktreeSettings | undefined;
|
||||
readonly enableConseca: boolean;
|
||||
private readonly showContextWindowWarning: boolean;
|
||||
private readonly showContextCompression: boolean;
|
||||
|
||||
private readonly coreTools: string[] | undefined;
|
||||
private readonly mainAgentTools: string[] | undefined;
|
||||
@@ -734,7 +733,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly telemetrySettings: TelemetrySettings;
|
||||
private readonly usageStatisticsEnabled: boolean;
|
||||
private _geminiClient!: GeminiClient;
|
||||
private readonly _sandboxManager: SandboxManager;
|
||||
private _sandboxManager: SandboxManager;
|
||||
private readonly _sandboxPolicyManager: SandboxPolicyManager;
|
||||
private baseLlmClient!: BaseLlmClient;
|
||||
private localLiteRtLmClient?: LocalLiteRtLmClient;
|
||||
private modelRouterService: ModelRouterService;
|
||||
@@ -909,14 +909,14 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
|
||||
this.sandbox = params.sandbox
|
||||
? {
|
||||
enabled: params.sandbox.enabled ?? false,
|
||||
enabled: params.sandbox.enabled || params.toolSandboxing || false,
|
||||
allowedPaths: params.sandbox.allowedPaths ?? [],
|
||||
networkAccess: params.sandbox.networkAccess ?? false,
|
||||
command: params.sandbox.command,
|
||||
image: params.sandbox.image,
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
enabled: params.toolSandboxing || false,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
};
|
||||
@@ -935,6 +935,30 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.fileSystemService = new StandardFileSystemService();
|
||||
}
|
||||
|
||||
this._sandboxPolicyManager = new SandboxPolicyManager();
|
||||
const initialApprovalMode =
|
||||
params.approvalMode ??
|
||||
params.policyEngineConfig?.approvalMode ??
|
||||
'default';
|
||||
this._sandboxManager = createSandboxManager(
|
||||
this.sandbox,
|
||||
params.targetDir,
|
||||
this._sandboxPolicyManager,
|
||||
initialApprovalMode,
|
||||
);
|
||||
|
||||
if (
|
||||
!(this._sandboxManager instanceof NoopSandboxManager) &&
|
||||
this.sandbox?.enabled
|
||||
) {
|
||||
this.fileSystemService = new SandboxedFileSystemService(
|
||||
this._sandboxManager,
|
||||
params.targetDir,
|
||||
);
|
||||
} else {
|
||||
this.fileSystemService = new StandardFileSystemService();
|
||||
}
|
||||
|
||||
this.targetDir = path.resolve(params.targetDir);
|
||||
this.folderTrust = params.folderTrust ?? false;
|
||||
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
|
||||
@@ -1005,7 +1029,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.model = params.model;
|
||||
this.disableLoopDetection = params.disableLoopDetection ?? false;
|
||||
this._activeModel = params.model;
|
||||
this.enableAgents = params.enableAgents ?? false;
|
||||
this.enableAgents = params.enableAgents ?? true;
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? true;
|
||||
@@ -1150,8 +1174,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.fileExclusions = new FileExclusions(this);
|
||||
this.eventEmitter = params.eventEmitter;
|
||||
this.enableConseca = params.enableConseca ?? false;
|
||||
this.showContextWindowWarning = params.showContextWindowWarning ?? false;
|
||||
this.showContextCompression = params.showContextCompression ?? false;
|
||||
|
||||
// Initialize Safety Infrastructure
|
||||
const contextBuilder = new ContextBuilder(this);
|
||||
@@ -1166,12 +1188,19 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
params.policyUpdateConfirmationRequest;
|
||||
|
||||
this.disableAlwaysAllow = params.disableAlwaysAllow ?? false;
|
||||
const engineApprovalMode =
|
||||
params.approvalMode ??
|
||||
params.policyEngineConfig?.approvalMode ??
|
||||
ApprovalMode.DEFAULT;
|
||||
this.policyEngine = new PolicyEngine(
|
||||
{
|
||||
...params.policyEngineConfig,
|
||||
approvalMode:
|
||||
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
|
||||
approvalMode: engineApprovalMode,
|
||||
disableAlwaysAllow: this.disableAlwaysAllow,
|
||||
toolSandboxEnabled: this.getSandboxEnabled(),
|
||||
sandboxApprovedTools:
|
||||
this.sandboxPolicyManager?.getModeConfig(engineApprovalMode)
|
||||
?.approvedTools ?? [],
|
||||
},
|
||||
checkerRunner,
|
||||
);
|
||||
@@ -1566,6 +1595,20 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this._geminiClient;
|
||||
}
|
||||
|
||||
private refreshSandboxManager(): void {
|
||||
this._sandboxManager = createSandboxManager(
|
||||
this.sandbox,
|
||||
this.targetDir,
|
||||
this._sandboxPolicyManager,
|
||||
this.getApprovalMode(),
|
||||
);
|
||||
this.shellExecutionConfig.sandboxManager = this._sandboxManager;
|
||||
}
|
||||
|
||||
get sandboxPolicyManager() {
|
||||
return this._sandboxPolicyManager;
|
||||
}
|
||||
|
||||
get sandboxManager(): SandboxManager {
|
||||
return this._sandboxManager;
|
||||
}
|
||||
@@ -2059,18 +2102,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.mcpEnabled;
|
||||
}
|
||||
|
||||
getShowContextWindowWarning(): boolean {
|
||||
return this.showContextWindowWarning;
|
||||
}
|
||||
|
||||
getShowContextCompression(): boolean {
|
||||
return this.showContextCompression;
|
||||
}
|
||||
|
||||
getContextWindowCompressionThreshold(): number {
|
||||
return this.compressionThreshold ?? 0.5;
|
||||
}
|
||||
|
||||
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
||||
return this.mcpEnablementCallbacks;
|
||||
}
|
||||
@@ -2306,6 +2337,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.policyEngine.getApprovalMode();
|
||||
}
|
||||
|
||||
isPlanMode(): boolean {
|
||||
return this.getApprovalMode() === ApprovalMode.PLAN;
|
||||
}
|
||||
|
||||
getPolicyUpdateConfirmationRequest():
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined {
|
||||
@@ -2357,7 +2392,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
);
|
||||
}
|
||||
|
||||
this.policyEngine.setApprovalMode(mode);
|
||||
this.policyEngine.setApprovalMode(
|
||||
mode,
|
||||
this.sandboxPolicyManager?.getModeConfig(mode)?.approvedTools ?? [],
|
||||
);
|
||||
this.refreshSandboxManager();
|
||||
|
||||
const isPlanModeTransition =
|
||||
currentMode !== mode &&
|
||||
@@ -3157,6 +3196,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
visualModel: customConfig.visualModel,
|
||||
allowedDomains: customConfig.allowedDomains,
|
||||
disableUserInput: customConfig.disableUserInput,
|
||||
maxActionsPerTask: customConfig.maxActionsPerTask ?? 100,
|
||||
confirmSensitiveActions: customConfig.confirmSensitiveActions,
|
||||
blockFileUploads: customConfig.blockFileUploads,
|
||||
},
|
||||
|
||||
@@ -356,6 +356,10 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
default: 'gemini-3.1-pro-preview',
|
||||
contexts: [
|
||||
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
|
||||
{
|
||||
condition: { useCustomTools: true },
|
||||
target: 'gemini-3.1-pro-preview-customtools',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gemini-3.1-pro-preview-customtools': {
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock('../confirmation-bus/message-bus.js', () => ({
|
||||
vi.mock('../policy/policy-engine.js', () => ({
|
||||
PolicyEngine: vi.fn().mockImplementation(() => ({
|
||||
getExcludedTools: vi.fn().mockReturnValue(new Set()),
|
||||
getApprovalMode: vi.fn().mockReturnValue('yolo'),
|
||||
})),
|
||||
}));
|
||||
vi.mock('../skills/skillManager.js', () => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DiffStat,
|
||||
} from '../tools/tools.js';
|
||||
import type { ToolCall } from '../scheduler/types.js';
|
||||
import type { SandboxPermissions } from '../services/sandboxManager.js';
|
||||
|
||||
export enum MessageBusType {
|
||||
TOOL_CONFIRMATION_REQUEST = 'tool-confirmation-request',
|
||||
@@ -78,6 +79,14 @@ export interface ToolConfirmationResponse {
|
||||
* Data-only versions of ToolCallConfirmationDetails for bus transmission.
|
||||
*/
|
||||
export type SerializableConfirmationDetails =
|
||||
| {
|
||||
type: 'sandbox_expansion';
|
||||
title: string;
|
||||
command: string;
|
||||
rootCommand: string;
|
||||
additionalPermissions: SandboxPermissions;
|
||||
systemMessage?: string;
|
||||
}
|
||||
| {
|
||||
type: 'info';
|
||||
title: string;
|
||||
|
||||
@@ -42,6 +42,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -213,6 +214,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -503,6 +505,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -674,6 +677,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -845,6 +849,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. 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.
|
||||
@@ -968,6 +973,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. 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.
|
||||
@@ -1564,6 +1570,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -1731,6 +1738,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -1889,6 +1897,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -2047,6 +2056,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -2201,6 +2211,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -2355,6 +2366,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -2503,6 +2515,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -2656,6 +2669,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -2934,6 +2948,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -3340,6 +3355,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -3494,6 +3510,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -3760,6 +3777,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
@@ -3914,6 +3932,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
- **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.
|
||||
- **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. For Directives, only clarify if critically underspecified; otherwise, work autonomously. 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.
|
||||
|
||||
@@ -248,9 +248,6 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getEnableHooks: vi.fn().mockReturnValue(false),
|
||||
getChatCompression: vi.fn().mockReturnValue(undefined),
|
||||
getCompressionThreshold: vi.fn().mockReturnValue(undefined),
|
||||
getShowContextWindowWarning: vi.fn().mockReturnValue(false),
|
||||
getShowContextCompression: vi.fn().mockReturnValue(false),
|
||||
getContextWindowCompressionThreshold: vi.fn().mockReturnValue(0.2),
|
||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||
getShowModelInfoInChat: vi.fn().mockReturnValue(false),
|
||||
getContinueOnFailedApiCall: vi.fn(),
|
||||
@@ -1620,7 +1617,6 @@ ${JSON.stringify(
|
||||
originalTokenCount: initialTokenCount,
|
||||
newTokenCount: 400,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
requestTokenCount: 50, // Added to match updated interface
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1647,13 +1643,10 @@ ${JSON.stringify(
|
||||
}),
|
||||
);
|
||||
|
||||
// 2. Should contain compression event with requestTokenCount
|
||||
// 2. Should contain compression event
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: GeminiEventType.ChatCompressed,
|
||||
value: expect.objectContaining({
|
||||
requestTokenCount: expect.any(Number),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -608,6 +608,17 @@ export class GeminiClient {
|
||||
// Check for context window overflow
|
||||
const modelForLimitCheck = this._getActiveModelForCurrentTurn();
|
||||
|
||||
const compressed = await this.tryCompressChat(prompt_id, false);
|
||||
|
||||
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||
yield { type: GeminiEventType.ChatCompressed, value: compressed };
|
||||
}
|
||||
|
||||
const remainingTokenCount =
|
||||
tokenLimit(modelForLimitCheck) - this.getChat().getLastPromptTokenCount();
|
||||
|
||||
await this.tryMaskToolOutputs(this.getHistory());
|
||||
|
||||
// Estimate tokens. For text-only requests, we estimate based on character length.
|
||||
// For requests with non-text parts (like images, tools), we use the countTokens API.
|
||||
const estimatedRequestTokenCount = await calculateRequestTokenCount(
|
||||
@@ -616,48 +627,12 @@ export class GeminiClient {
|
||||
modelForLimitCheck,
|
||||
);
|
||||
|
||||
const compressed = await this.tryCompressChat(
|
||||
prompt_id,
|
||||
false,
|
||||
estimatedRequestTokenCount,
|
||||
);
|
||||
|
||||
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||
yield { type: GeminiEventType.ChatCompressed, value: compressed };
|
||||
}
|
||||
|
||||
let remainingTokenCount =
|
||||
tokenLimit(modelForLimitCheck) - this.getChat().getLastPromptTokenCount();
|
||||
|
||||
await this.tryMaskToolOutputs(this.getHistory());
|
||||
|
||||
if (estimatedRequestTokenCount > remainingTokenCount) {
|
||||
if (!this.config.getShowContextWindowWarning()) {
|
||||
const forcedCompressed = await this.tryCompressChat(
|
||||
prompt_id,
|
||||
true,
|
||||
estimatedRequestTokenCount,
|
||||
);
|
||||
if (
|
||||
forcedCompressed.compressionStatus === CompressionStatus.COMPRESSED
|
||||
) {
|
||||
yield {
|
||||
type: GeminiEventType.ChatCompressed,
|
||||
value: forcedCompressed,
|
||||
};
|
||||
remainingTokenCount =
|
||||
tokenLimit(modelForLimitCheck) -
|
||||
this.getChat().getLastPromptTokenCount();
|
||||
}
|
||||
}
|
||||
|
||||
if (estimatedRequestTokenCount > remainingTokenCount) {
|
||||
yield {
|
||||
type: GeminiEventType.ContextWindowWillOverflow,
|
||||
value: { estimatedRequestTokenCount, remainingTokenCount },
|
||||
};
|
||||
return turn;
|
||||
}
|
||||
yield {
|
||||
type: GeminiEventType.ContextWindowWillOverflow,
|
||||
value: { estimatedRequestTokenCount, remainingTokenCount },
|
||||
};
|
||||
return turn;
|
||||
}
|
||||
|
||||
// Prevent context updates from being sent while a tool call is
|
||||
@@ -1183,7 +1158,6 @@ export class GeminiClient {
|
||||
async tryCompressChat(
|
||||
prompt_id: string,
|
||||
force: boolean = false,
|
||||
requestTokenCount?: number,
|
||||
): Promise<ChatCompressionInfo> {
|
||||
// If the model is 'auto', we will use a placeholder model to check.
|
||||
// Compression occurs before we choose a model, so calling `count_tokens`
|
||||
@@ -1199,11 +1173,6 @@ export class GeminiClient {
|
||||
this.hasFailedCompressionAttempt,
|
||||
);
|
||||
|
||||
const resultInfo = {
|
||||
...info,
|
||||
requestTokenCount,
|
||||
};
|
||||
|
||||
if (
|
||||
info.compressionStatus ===
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
|
||||
@@ -1239,7 +1208,7 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
|
||||
return resultInfo;
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,6 +89,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
mockConfig = {
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
|
||||
@@ -418,6 +419,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
const testConfig = {
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
},
|
||||
|
||||
@@ -188,7 +188,6 @@ export interface ChatCompressionInfo {
|
||||
originalTokenCount: number;
|
||||
newTokenCount: number;
|
||||
compressionStatus: CompressionStatus;
|
||||
requestTokenCount?: number;
|
||||
}
|
||||
|
||||
export type ServerGeminiChatCompressedEvent = {
|
||||
|
||||
@@ -88,7 +88,11 @@ export * from './utils/approvalModeUtils.js';
|
||||
export * from './utils/fileDiffUtils.js';
|
||||
export * from './utils/retry.js';
|
||||
export * from './utils/shell-utils.js';
|
||||
export { PolicyDecision, ApprovalMode } from './policy/types.js';
|
||||
export {
|
||||
PolicyDecision,
|
||||
ApprovalMode,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
} from './policy/types.js';
|
||||
export * from './utils/tool-utils.js';
|
||||
export * from './utils/terminalSerializer.js';
|
||||
export * from './utils/systemEncoding.js';
|
||||
@@ -126,7 +130,7 @@ export * from './services/FolderTrustDiscoveryService.js';
|
||||
export * from './services/chatRecordingService.js';
|
||||
export * from './services/fileSystemService.js';
|
||||
export * from './services/sandboxedFileSystemService.js';
|
||||
export * from './services/windowsSandboxManager.js';
|
||||
export * from './sandbox/windows/WindowsSandboxManager.js';
|
||||
export * from './services/sessionSummaryUtils.js';
|
||||
export * from './services/contextManager.js';
|
||||
export * from './services/trackerService.js';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[modes.plan]
|
||||
network = false
|
||||
readonly = true
|
||||
approvedTools = []
|
||||
allowOverrides = false
|
||||
|
||||
[modes.default]
|
||||
network = false
|
||||
readonly = true
|
||||
approvedTools = []
|
||||
allowOverrides = true
|
||||
|
||||
[modes.accepting_edits]
|
||||
network = false
|
||||
readonly = false
|
||||
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo']
|
||||
allowOverrides = true
|
||||
|
||||
[commands]
|
||||
@@ -329,7 +329,11 @@ describe('PolicyEngine', () => {
|
||||
);
|
||||
|
||||
// Switch to autoEdit mode
|
||||
engine.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||
engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.AUTO_EDIT,
|
||||
toolSandboxEnabled: true,
|
||||
});
|
||||
expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
@@ -1427,14 +1431,14 @@ describe('PolicyEngine', () => {
|
||||
|
||||
engine = new PolicyEngine({ rules });
|
||||
|
||||
// Atomic command "whoami" matches the wildcard rule (ASK_USER).
|
||||
// Atomic command "unknown_command" matches the wildcard rule (ASK_USER).
|
||||
// It should NOT be upgraded to ALLOW.
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'whoami' },
|
||||
args: { command: 'unknown_command' },
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
@@ -1572,7 +1576,7 @@ describe('PolicyEngine', () => {
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({ rules });
|
||||
engine = new PolicyEngine({ rules, toolSandboxEnabled: true });
|
||||
engine.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||
|
||||
const result = await engine.check(
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
*/
|
||||
|
||||
import { type FunctionCall } from '@google/genai';
|
||||
import {
|
||||
isDangerousCommand,
|
||||
isKnownSafeCommand,
|
||||
} from '../sandbox/macos/commandSafety.js';
|
||||
import { parse as shellParse } from 'shell-quote';
|
||||
import {
|
||||
PolicyDecision,
|
||||
type PolicyEngineConfig,
|
||||
@@ -192,6 +197,8 @@ export class PolicyEngine {
|
||||
private readonly disableAlwaysAllow: boolean;
|
||||
private readonly checkerRunner?: CheckerRunner;
|
||||
private approvalMode: ApprovalMode;
|
||||
private toolSandboxEnabled: boolean;
|
||||
private sandboxApprovedTools: string[];
|
||||
|
||||
constructor(config: PolicyEngineConfig = {}, checkerRunner?: CheckerRunner) {
|
||||
this.rules = (config.rules ?? []).sort(
|
||||
@@ -242,13 +249,18 @@ export class PolicyEngine {
|
||||
this.disableAlwaysAllow = config.disableAlwaysAllow ?? false;
|
||||
this.checkerRunner = checkerRunner;
|
||||
this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT;
|
||||
this.toolSandboxEnabled = config.toolSandboxEnabled ?? false;
|
||||
this.sandboxApprovedTools = config.sandboxApprovedTools ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current approval mode.
|
||||
*/
|
||||
setApprovalMode(mode: ApprovalMode): void {
|
||||
setApprovalMode(mode: ApprovalMode, sandboxApprovedTools?: string[]): void {
|
||||
this.approvalMode = mode;
|
||||
if (sandboxApprovedTools !== undefined) {
|
||||
this.sandboxApprovedTools = sandboxApprovedTools;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,17 +281,58 @@ export class PolicyEngine {
|
||||
command: string,
|
||||
allowRedirection?: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!allowRedirection &&
|
||||
hasRedirection(command) &&
|
||||
this.approvalMode !== ApprovalMode.AUTO_EDIT &&
|
||||
this.approvalMode !== ApprovalMode.YOLO
|
||||
);
|
||||
if (allowRedirection) return false;
|
||||
if (!hasRedirection(command)) return false;
|
||||
|
||||
// Do not downgrade (do not ask user) if sandboxing is enabled and in AUTO_EDIT or YOLO
|
||||
if (
|
||||
this.toolSandboxEnabled &&
|
||||
(this.approvalMode === ApprovalMode.AUTO_EDIT ||
|
||||
this.approvalMode === ApprovalMode.YOLO)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a shell command is allowed.
|
||||
*/
|
||||
|
||||
private async applyShellHeuristics(
|
||||
command: string,
|
||||
decision: PolicyDecision,
|
||||
): Promise<PolicyDecision> {
|
||||
await initializeShellParsers();
|
||||
try {
|
||||
const parsedObjArgs = shellParse(command);
|
||||
if (parsedObjArgs.some((arg) => typeof arg === 'object')) return decision;
|
||||
const parsedArgs = parsedObjArgs.map(String);
|
||||
if (isDangerousCommand(parsedArgs)) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command evaluated as dangerous, forcing ASK_USER: ${command}`,
|
||||
);
|
||||
return PolicyDecision.ASK_USER;
|
||||
}
|
||||
const isApprovedBySandbox =
|
||||
this.toolSandboxEnabled &&
|
||||
this.sandboxApprovedTools.includes(parsedArgs[0]);
|
||||
if (
|
||||
(isKnownSafeCommand(parsedArgs) || isApprovedBySandbox) &&
|
||||
decision === PolicyDecision.ASK_USER
|
||||
) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command evaluated as known safe, overriding ASK_USER to ALLOW: ${command}`,
|
||||
);
|
||||
return PolicyDecision.ALLOW;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
private async checkShellCommand(
|
||||
toolName: string,
|
||||
command: string | undefined,
|
||||
@@ -522,11 +575,21 @@ export class PolicyEngine {
|
||||
`[PolicyEngine.check] MATCHED rule: toolName=${rule.toolName}, decision=${rule.decision}, priority=${rule.priority}, argsPattern=${rule.argsPattern?.source || 'none'}`,
|
||||
);
|
||||
|
||||
let ruleDecision = rule.decision;
|
||||
if (
|
||||
isShellCommand &&
|
||||
command &&
|
||||
!('commandPrefix' in rule) &&
|
||||
!rule.argsPattern
|
||||
) {
|
||||
ruleDecision = await this.applyShellHeuristics(command, ruleDecision);
|
||||
}
|
||||
|
||||
if (isShellCommand && toolName) {
|
||||
const shellResult = await this.checkShellCommand(
|
||||
toolName,
|
||||
command,
|
||||
rule.decision,
|
||||
ruleDecision,
|
||||
serverName,
|
||||
shellDirPath,
|
||||
rule.allowRedirection,
|
||||
@@ -562,10 +625,18 @@ export class PolicyEngine {
|
||||
`[PolicyEngine.check] NO MATCH - using default decision: ${this.defaultDecision}`,
|
||||
);
|
||||
if (toolName && SHELL_TOOL_NAMES.includes(toolName)) {
|
||||
let heuristicDecision = this.defaultDecision;
|
||||
if (command) {
|
||||
heuristicDecision = await this.applyShellHeuristics(
|
||||
command,
|
||||
heuristicDecision,
|
||||
);
|
||||
}
|
||||
|
||||
const shellResult = await this.checkShellCommand(
|
||||
toolName,
|
||||
command,
|
||||
this.defaultDecision,
|
||||
heuristicDecision,
|
||||
serverName,
|
||||
shellDirPath,
|
||||
false,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import toml from '@iarna/toml';
|
||||
import { z } from 'zod';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { type SandboxPermissions } from '../services/sandboxManager.js';
|
||||
import { sanitizePaths } from '../services/sandboxManager.js';
|
||||
|
||||
export const SandboxModeConfigSchema = z.object({
|
||||
network: z.boolean(),
|
||||
readonly: z.boolean(),
|
||||
approvedTools: z.array(z.string()),
|
||||
allowOverrides: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const PersistentCommandConfigSchema = z.object({
|
||||
allowed_paths: z.array(z.string()).optional(),
|
||||
allow_network: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const SandboxTomlSchema = z.object({
|
||||
modes: z.object({
|
||||
plan: SandboxModeConfigSchema,
|
||||
default: SandboxModeConfigSchema,
|
||||
accepting_edits: SandboxModeConfigSchema,
|
||||
}),
|
||||
commands: z.record(z.string(), PersistentCommandConfigSchema).default({}),
|
||||
});
|
||||
|
||||
export type SandboxModeConfig = z.infer<typeof SandboxModeConfigSchema>;
|
||||
export type PersistentCommandConfig = z.infer<
|
||||
typeof PersistentCommandConfigSchema
|
||||
>;
|
||||
export type SandboxTomlSchemaType = z.infer<typeof SandboxTomlSchema>;
|
||||
|
||||
export class SandboxPolicyManager {
|
||||
private static _DEFAULT_CONFIG: SandboxTomlSchemaType | null = null;
|
||||
|
||||
private static get DEFAULT_CONFIG(): SandboxTomlSchemaType {
|
||||
if (!SandboxPolicyManager._DEFAULT_CONFIG) {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const defaultPath = path.join(
|
||||
__dirname,
|
||||
'policies',
|
||||
'sandbox-default.toml',
|
||||
);
|
||||
try {
|
||||
const content = fs.readFileSync(defaultPath, 'utf8');
|
||||
if (typeof content !== 'string') {
|
||||
SandboxPolicyManager._DEFAULT_CONFIG = {
|
||||
modes: {
|
||||
plan: {
|
||||
network: false,
|
||||
readonly: true,
|
||||
approvedTools: [],
|
||||
allowOverrides: false,
|
||||
},
|
||||
default: {
|
||||
network: false,
|
||||
readonly: true,
|
||||
approvedTools: [],
|
||||
allowOverrides: true,
|
||||
},
|
||||
accepting_edits: {
|
||||
network: false,
|
||||
readonly: false,
|
||||
approvedTools: ['sed', 'grep', 'awk', 'perl', 'cat', 'echo'],
|
||||
allowOverrides: true,
|
||||
},
|
||||
},
|
||||
commands: {},
|
||||
};
|
||||
return SandboxPolicyManager._DEFAULT_CONFIG;
|
||||
}
|
||||
SandboxPolicyManager._DEFAULT_CONFIG = SandboxTomlSchema.parse(
|
||||
toml.parse(content),
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Failed to parse default sandbox policy: ${e}`);
|
||||
throw new Error(`Failed to parse default sandbox policy: ${e}`);
|
||||
}
|
||||
}
|
||||
return SandboxPolicyManager._DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
private config: SandboxTomlSchemaType;
|
||||
private readonly configPath: string;
|
||||
private sessionApprovals: Record<string, SandboxPermissions> = {};
|
||||
|
||||
constructor(customConfigPath?: string) {
|
||||
this.configPath =
|
||||
customConfigPath ??
|
||||
path.join(os.homedir(), '.gemini', 'policies', 'sandbox.toml');
|
||||
this.config = this.loadConfig();
|
||||
}
|
||||
|
||||
private loadConfig(): SandboxTomlSchemaType {
|
||||
if (!fs.existsSync(this.configPath)) {
|
||||
return SandboxPolicyManager.DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(this.configPath, 'utf8');
|
||||
return SandboxTomlSchema.parse(toml.parse(content));
|
||||
} catch (e) {
|
||||
debugLogger.error(`Failed to parse sandbox.toml: ${e}`);
|
||||
return SandboxPolicyManager.DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
private saveConfig(): void {
|
||||
try {
|
||||
const dir = path.dirname(this.configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const content = toml.stringify(this.config as unknown as toml.JsonMap);
|
||||
fs.writeFileSync(this.configPath, content);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Failed to save sandbox.toml: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
getModeConfig(
|
||||
mode: 'plan' | 'accepting_edits' | 'default' | string,
|
||||
): SandboxModeConfig {
|
||||
if (mode === 'plan') return this.config.modes.plan;
|
||||
if (mode === 'accepting_edits' || mode === 'autoEdit')
|
||||
return this.config.modes.accepting_edits;
|
||||
if (mode === 'default') return this.config.modes.default;
|
||||
|
||||
// Default fallback
|
||||
return this.config.modes.default ?? this.config.modes.plan;
|
||||
}
|
||||
|
||||
getCommandPermissions(commandName: string): SandboxPermissions {
|
||||
const persistent = this.config.commands[commandName];
|
||||
const session = this.sessionApprovals[commandName];
|
||||
|
||||
return {
|
||||
fileSystem: {
|
||||
read: [
|
||||
...(persistent?.allowed_paths ?? []),
|
||||
...(session?.fileSystem?.read ?? []),
|
||||
],
|
||||
write: [
|
||||
...(persistent?.allowed_paths ?? []),
|
||||
...(session?.fileSystem?.write ?? []),
|
||||
],
|
||||
},
|
||||
network: persistent?.allow_network || session?.network || false,
|
||||
};
|
||||
}
|
||||
|
||||
addSessionApproval(
|
||||
commandName: string,
|
||||
permissions: SandboxPermissions,
|
||||
): void {
|
||||
const existing = this.sessionApprovals[commandName] || {
|
||||
fileSystem: { read: [], write: [] },
|
||||
network: false,
|
||||
};
|
||||
|
||||
this.sessionApprovals[commandName] = {
|
||||
fileSystem: {
|
||||
read: Array.from(
|
||||
new Set([
|
||||
...(existing.fileSystem?.read ?? []),
|
||||
...(permissions.fileSystem?.read ?? []),
|
||||
]),
|
||||
),
|
||||
write: Array.from(
|
||||
new Set([
|
||||
...(existing.fileSystem?.write ?? []),
|
||||
...(permissions.fileSystem?.write ?? []),
|
||||
]),
|
||||
),
|
||||
},
|
||||
network: existing.network || permissions.network || false,
|
||||
};
|
||||
}
|
||||
|
||||
addPersistentApproval(
|
||||
commandName: string,
|
||||
permissions: SandboxPermissions,
|
||||
): void {
|
||||
const existing = this.config.commands[commandName] || {
|
||||
allowed_paths: [],
|
||||
allow_network: false,
|
||||
};
|
||||
|
||||
const newPathsArray: string[] = [
|
||||
...(existing.allowed_paths ?? []),
|
||||
...(permissions.fileSystem?.read ?? []),
|
||||
...(permissions.fileSystem?.write ?? []),
|
||||
];
|
||||
const newPaths = new Set(sanitizePaths(newPathsArray));
|
||||
|
||||
this.config.commands[commandName] = {
|
||||
allowed_paths: Array.from(newPaths),
|
||||
allow_network: existing.allow_network || permissions.network || false,
|
||||
};
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user