mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-31 20:21:01 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f32ff7d6 | |||
| 1693afe027 | |||
| d179e3673b | |||
| e4d6a5cc7c | |||
| fc4fdc3cbe | |||
| bdbb996a2e |
@@ -1,45 +0,0 @@
|
||||
---
|
||||
name: async-pr-review
|
||||
description: Trigger this skill when the user wants to start an asynchronous PR review, run background checks on a PR, or check the status of a previously started async PR review.
|
||||
---
|
||||
|
||||
# Async PR Review
|
||||
|
||||
This skill provides a set of tools to asynchronously review a Pull Request. It will create a background job to run the project's preflight checks, execute Gemini-powered test plans, and perform a comprehensive code review using custom prompts.
|
||||
|
||||
This skill is designed to showcase an advanced "Agentic Asynchronous Pattern":
|
||||
1. **Native Background Shells vs Headless Inference**: While Gemini CLI can natively spawn and detach background shell commands (using the `run_shell_command` tool with `is_background: true`), a standard bash background job cannot perform LLM inference. To conduct AI-driven code reviews and test generation in the background, the shell script *must* invoke the `gemini` executable headlessly using `-p`. This offloads the AI tasks to independent worker agents.
|
||||
2. **Dynamic Git Scoping**: The review scripts avoid hardcoded paths. They use `git rev-parse --show-toplevel` to automatically resolve the root of the user's current project.
|
||||
3. **Ephemeral Worktrees**: Instead of checking out branches in the user's main workspace, the skill provisions temporary git worktrees in `.gemini/tmp/async-reviews/pr-<number>`. This prevents git lock conflicts and namespace pollution.
|
||||
4. **Agentic Evaluation (`check-async-review.sh`)**: The check script outputs clean JSON/text statuses for the main agent to parse. The interactive agent itself synthesizes the final assessment dynamically from the generated log files.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Determine Action**: Establish whether the user wants to start a new async review or check the status of an existing one.
|
||||
* If the user says "start an async review for PR #123" or similar, proceed to **Start Review**.
|
||||
* If the user says "check the status of my async review for PR #123" or similar, proceed to **Check Status**.
|
||||
|
||||
### Start Review
|
||||
|
||||
If the user wants to start a new async PR review:
|
||||
|
||||
1. Ask the user for the PR number if they haven't provided it.
|
||||
2. Execute the `async-review.sh` script, passing the PR number as the first argument. Be sure to run it with the `is_background` flag set to true to ensure it immediately detaches.
|
||||
```bash
|
||||
.gemini/skills/async-pr-review/scripts/async-review.sh <PR_NUMBER>
|
||||
```
|
||||
3. Inform the user that the tasks have started successfully and they can check the status later.
|
||||
|
||||
### Check Status
|
||||
|
||||
If the user wants to check the status or view the final assessment of a previously started async review:
|
||||
|
||||
1. Ask the user for the PR number if they haven't provided it.
|
||||
2. Execute the `check-async-review.sh` script, passing the PR number as the first argument:
|
||||
```bash
|
||||
.gemini/skills/async-pr-review/scripts/check-async-review.sh <PR_NUMBER>
|
||||
```
|
||||
3. **Evaluate Output**: Read the output from the script.
|
||||
* If the output contains `STATUS: IN_PROGRESS`, tell the user which tasks are still running.
|
||||
* If the output contains `STATUS: COMPLETE`, use your file reading tools (`read_file`) to retrieve the contents of `final-assessment.md`, `review.md`, `pr-diff.diff`, `npm-test.log`, and `test-execution.log` files from the `LOG_DIR` specified in the output.
|
||||
* **Final Assessment**: Read those files, synthesize their results, and give the user a concise recommendation on whether the PR builds successfully, passes tests, and if you recommend they approve it based on the review.
|
||||
@@ -1,148 +0,0 @@
|
||||
# --- CORE TOOLS ---
|
||||
[[rule]]
|
||||
toolName = "read_file"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "write_file"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "grep_search"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "glob"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "list_directory"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# --- SHELL COMMANDS ---
|
||||
|
||||
# Git (Safe/Read-only)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"git blame",
|
||||
"git show",
|
||||
"git grep",
|
||||
"git show-ref",
|
||||
"git ls-tree",
|
||||
"git ls-remote",
|
||||
"git reflog",
|
||||
"git remote -v",
|
||||
"git diff",
|
||||
"git rev-list",
|
||||
"git rev-parse",
|
||||
"git merge-base",
|
||||
"git cherry",
|
||||
"git fetch",
|
||||
"git status",
|
||||
"git st",
|
||||
"git branch",
|
||||
"git br",
|
||||
"git log",
|
||||
"git --version"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# GitHub CLI (Read-only)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"gh workflow list",
|
||||
"gh auth status",
|
||||
"gh checkout view",
|
||||
"gh run view",
|
||||
"gh run job view",
|
||||
"gh run list",
|
||||
"gh run --help",
|
||||
"gh issue view",
|
||||
"gh issue list",
|
||||
"gh label list",
|
||||
"gh pr diff",
|
||||
"gh pr check",
|
||||
"gh pr checks",
|
||||
"gh pr view",
|
||||
"gh pr list",
|
||||
"gh pr status",
|
||||
"gh repo view",
|
||||
"gh job view",
|
||||
"gh api",
|
||||
"gh log"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# Node.js/NPM (Generic Tests, Checks, and Build)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"npm run start",
|
||||
"npm install",
|
||||
"npm run",
|
||||
"npm test",
|
||||
"npm ci",
|
||||
"npm list",
|
||||
"npm --version"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# Core Utilities (Safe)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"sleep",
|
||||
"env",
|
||||
"break",
|
||||
"xargs",
|
||||
"base64",
|
||||
"uniq",
|
||||
"sort",
|
||||
"echo",
|
||||
"which",
|
||||
"ls",
|
||||
"find",
|
||||
"tail",
|
||||
"head",
|
||||
"cat",
|
||||
"cd",
|
||||
"grep",
|
||||
"ps",
|
||||
"pwd",
|
||||
"wc",
|
||||
"file",
|
||||
"stat",
|
||||
"diff",
|
||||
"lsof",
|
||||
"date",
|
||||
"whoami",
|
||||
"uname",
|
||||
"du",
|
||||
"cut",
|
||||
"true",
|
||||
"false",
|
||||
"readlink",
|
||||
"awk",
|
||||
"jq",
|
||||
"rg",
|
||||
"less",
|
||||
"more",
|
||||
"tree"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
@@ -1,256 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
notify() {
|
||||
local title="${1}"
|
||||
local message="${2}"
|
||||
local pr="${3}"
|
||||
# Terminal escape sequence
|
||||
printf "\e]9;%s | PR #%s | %s\a" "${title}" "${pr}" "${message}"
|
||||
# Native macOS notification
|
||||
OS_TYPE=$(uname)
|
||||
if [[ "${OS_TYPE}" == "Darwin" ]]; then
|
||||
osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\""
|
||||
fi
|
||||
}
|
||||
|
||||
pr_number="${1}"
|
||||
if [[ -z "${pr_number}" ]]; then
|
||||
echo "Usage: async-review <pr_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "${base_dir}" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
|
||||
pr_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}"
|
||||
target_dir="${pr_dir}/worktree"
|
||||
log_dir="${pr_dir}/logs"
|
||||
|
||||
cd "${base_dir}" || exit 1
|
||||
|
||||
mkdir -p "${log_dir}"
|
||||
rm -f "${log_dir}/setup.exit" "${log_dir}/final-assessment.exit" "${log_dir}/final-assessment.md"
|
||||
|
||||
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "${log_dir}/setup.log"
|
||||
git worktree remove -f "${target_dir}" >> "${log_dir}/setup.log" 2>&1 || true
|
||||
git branch -D "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1 || true
|
||||
git worktree prune >> "${log_dir}/setup.log" 2>&1 || true
|
||||
|
||||
echo "📡 Fetching PR #${pr_number}..." | tee -a "${log_dir}/setup.log"
|
||||
if ! git fetch origin -f "pull/${pr_number}/head:gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
|
||||
echo 1 > "${log_dir}/setup.exit"
|
||||
echo "❌ Fetch failed. Check ${log_dir}/setup.log"
|
||||
notify "Async Review Failed" "Fetch failed." "${pr_number}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "${target_dir}" ]]; then
|
||||
echo "🧹 Pruning missing worktrees..." | tee -a "${log_dir}/setup.log"
|
||||
git worktree prune >> "${log_dir}/setup.log" 2>&1
|
||||
echo "🌿 Creating worktree in ${target_dir}..." | tee -a "${log_dir}/setup.log"
|
||||
if ! git worktree add "${target_dir}" "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
|
||||
echo 1 > "${log_dir}/setup.exit"
|
||||
echo "❌ Worktree creation failed. Check ${log_dir}/setup.log"
|
||||
notify "Async Review Failed" "Worktree creation failed." "${pr_number}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "🌿 Worktree already exists." | tee -a "${log_dir}/setup.log"
|
||||
fi
|
||||
echo 0 > "${log_dir}/setup.exit"
|
||||
|
||||
cd "${target_dir}" || exit 1
|
||||
|
||||
echo "🚀 Launching background tasks. Logs saving to: ${log_dir}"
|
||||
|
||||
echo " ↳ [1/5] Grabbing PR diff..."
|
||||
rm -f "${log_dir}/pr-diff.exit"
|
||||
{ gh pr diff "${pr_number}" > "${log_dir}/pr-diff.diff" 2>&1; echo $? > "${log_dir}/pr-diff.exit"; } &
|
||||
|
||||
echo " ↳ [2/5] Starting build and lint..."
|
||||
rm -f "${log_dir}/build-and-lint.exit"
|
||||
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "${log_dir}/build-and-lint.log" 2>&1; echo $? > "${log_dir}/build-and-lint.exit"; } &
|
||||
|
||||
# Dynamically resolve gemini binary (fallback to your nightly path)
|
||||
GEMINI_CMD=$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")
|
||||
BASH_SOURCE_DIR=$(dirname "${BASH_SOURCE[0]}")
|
||||
POLICY_PATH="$(cd "${BASH_SOURCE_DIR}/.." && pwd)/policy.toml"
|
||||
|
||||
echo " ↳ [3/5] Starting Gemini code review..."
|
||||
rm -f "${log_dir}/review.exit"
|
||||
{ "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "/review-frontend ${pr_number}" > "${log_dir}/review.md" 2>&1; echo $? > "${log_dir}/review.exit"; } &
|
||||
|
||||
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
|
||||
rm -f "${log_dir}/npm-test.exit"
|
||||
{
|
||||
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
|
||||
BUILD_LINT_EXIT_VAL=$(cat "${log_dir}/build-and-lint.exit")
|
||||
if [[ "${BUILD_LINT_EXIT_VAL}" == "0" ]]; then
|
||||
gh pr checks "${pr_number}" > "${log_dir}/ci-checks.log" 2>&1
|
||||
ci_status=$?
|
||||
|
||||
if [[ "${ci_status}" -eq 0 ]]; then
|
||||
echo "CI checks passed. Skipping local npm tests." > "${log_dir}/npm-test.log"
|
||||
echo 0 > "${log_dir}/npm-test.exit"
|
||||
elif [[ "${ci_status}" -eq 8 ]]; then
|
||||
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "${log_dir}/npm-test.log"
|
||||
echo 0 > "${log_dir}/npm-test.exit"
|
||||
else
|
||||
echo "CI checks failed. Failing checks:" > "${log_dir}/npm-test.log"
|
||||
gh pr checks "${pr_number}" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "${log_dir}/npm-test.log" 2>&1
|
||||
|
||||
echo "Attempting to extract failing test files from CI logs..." >> "${log_dir}/npm-test.log"
|
||||
PR_VIEW_DATA=$(gh pr view "${pr_number}" --json headRefName 2>/dev/null)
|
||||
pr_branch=$(echo "${PR_VIEW_DATA}" | sed -n 's/.*"headRefName":"\([^"]*\)".*/\1/p')
|
||||
RUN_LIST_DATA=$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId 2>/dev/null)
|
||||
run_id_list=$(echo "${RUN_LIST_DATA}" | sed -n 's/.*"databaseId":\([0-9]*\).*/\1/p')
|
||||
read -r run_id <<< "${run_id_list}"
|
||||
|
||||
failed_files=""
|
||||
if [[ -n "${run_id}" ]]; then
|
||||
RUN_VIEW_OUTPUT=$(gh run view "${run_id}" --log-failed 2>/dev/null)
|
||||
# Split the pipeline to avoid SC2312
|
||||
failed_files_raw=$(echo "${RUN_VIEW_OUTPUT}" | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?')
|
||||
failed_files_sorted=$(echo "${failed_files_raw}" | sort)
|
||||
failed_files=$(echo "${failed_files_sorted}" | uniq)
|
||||
fi
|
||||
|
||||
if [[ -n "${failed_files}" ]]; then
|
||||
echo "Found failing test files from CI:" >> "${log_dir}/npm-test.log"
|
||||
for f in ${failed_files}; do echo " - ${f}" >> "${log_dir}/npm-test.log"; done
|
||||
echo "Running ONLY failing tests locally..." >> "${log_dir}/npm-test.log"
|
||||
|
||||
exit_code=0
|
||||
for file in ${failed_files}; do
|
||||
if [[ "${file}" == packages/* ]]; then
|
||||
ws_dir=$(echo "${file}" | cut -d'/' -f1,2)
|
||||
else
|
||||
ws_dir=$(echo "${file}" | cut -d'/' -f1)
|
||||
fi
|
||||
rel_file=${file#"${ws_dir}/"}
|
||||
|
||||
echo "--- Running ${rel_file} in workspace ${ws_dir} ---" >> "${log_dir}/npm-test.log"
|
||||
if ! npm run test:ci -w "${ws_dir}" -- "${rel_file}" >> "${log_dir}/npm-test.log" 2>&1; then
|
||||
exit_code=1
|
||||
fi
|
||||
done
|
||||
echo "${exit_code}" > "${log_dir}/npm-test.exit"
|
||||
else
|
||||
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "${log_dir}/npm-test.log"
|
||||
echo 1 > "${log_dir}/npm-test.exit"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Skipped due to build-and-lint failure" > "${log_dir}/npm-test.log"
|
||||
echo 1 > "${log_dir}/npm-test.exit"
|
||||
fi
|
||||
} &
|
||||
|
||||
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
|
||||
rm -f "${log_dir}/test-execution.exit"
|
||||
{
|
||||
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
|
||||
BUILD_LINT_EXIT_CHECK=$(cat "${log_dir}/build-and-lint.exit")
|
||||
if [[ "${BUILD_LINT_EXIT_CHECK}" == "0" ]]; then
|
||||
"${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Analyze the diff for PR ${pr_number} using 'gh pr diff ${pr_number}'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "${log_dir}/test-execution.log" 2>&1; echo $? > "${log_dir}/test-execution.exit"
|
||||
else
|
||||
echo "Skipped due to build-and-lint failure" > "${log_dir}/test-execution.log"
|
||||
echo 1 > "${log_dir}/test-execution.exit"
|
||||
fi
|
||||
} &
|
||||
|
||||
echo "✅ All tasks dispatched!"
|
||||
echo "You can monitor progress with: tail -f ${log_dir}/*.log"
|
||||
echo "Read your review later at: ${log_dir}/review.md"
|
||||
|
||||
# Polling loop to wait for all background tasks to finish
|
||||
declare -a ASYNC_TASKS=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
|
||||
declare -a ASYNC_LOGS=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
|
||||
|
||||
declare -A task_done
|
||||
for t in "${ASYNC_TASKS[@]}"; do task_done["${t}"]=0; done
|
||||
|
||||
all_done=0
|
||||
while [[ "${all_done}" -eq 0 ]]; do
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #${pr_number}"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
|
||||
all_done=1
|
||||
for i in "${!ASYNC_TASKS[@]}"; do
|
||||
t="${ASYNC_TASKS[${i}]}"
|
||||
|
||||
EXIT_FILE_POLL="${log_dir}/${t}.exit"
|
||||
if [[ -f "${EXIT_FILE_POLL}" ]]; then
|
||||
EXIT_CODE_POLL=$(cat "${EXIT_FILE_POLL}")
|
||||
if [[ "${EXIT_CODE_POLL}" == "0" ]]; then
|
||||
echo " ✅ ${t}: SUCCESS"
|
||||
else
|
||||
echo " ❌ ${t}: FAILED (exit code ${EXIT_CODE_POLL})"
|
||||
fi
|
||||
task_done["${t}"]=1
|
||||
else
|
||||
echo " ⏳ ${t}: RUNNING"
|
||||
all_done=0
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo "📝 Live Logs (Last 5 lines of running tasks)"
|
||||
echo "=================================================="
|
||||
|
||||
for i in "${!ASYNC_TASKS[@]}"; do
|
||||
t="${ASYNC_TASKS[${i}]}"
|
||||
log_file="${ASYNC_LOGS[${i}]}"
|
||||
|
||||
if [[ ${task_done["${t}"]} -eq 0 ]]; then
|
||||
LOG_PATH_POLL="${log_dir}/${log_file}"
|
||||
if [[ -f "${LOG_PATH_POLL}" ]]; then
|
||||
echo ""
|
||||
echo "--- ${t} ---"
|
||||
tail -n 5 "${LOG_PATH_POLL}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${all_done}" -eq 0 ]]; then
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #${pr_number}"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
for t in "${ASYNC_TASKS[@]}"; do
|
||||
EXIT_FILE_FINAL="${log_dir}/${t}.exit"
|
||||
EXIT_CODE_FINAL=$(cat "${EXIT_FILE_FINAL}")
|
||||
if [[ "${EXIT_CODE_FINAL}" == "0" ]]; then
|
||||
echo " ✅ ${t}: SUCCESS"
|
||||
else
|
||||
echo " ❌ ${t}: FAILED (exit code ${EXIT_CODE_FINAL})"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "⏳ Tasks complete! Synthesizing final assessment..."
|
||||
if ! "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Read the review at ${log_dir}/review.md, the automated test logs at ${log_dir}/npm-test.log, and the manual test execution logs at ${log_dir}/test-execution.log. Summarize the results, state whether the build and tests passed based on ${log_dir}/build-and-lint.exit and ${log_dir}/npm-test.exit, and give a final recommendation for PR ${pr_number}." > "${log_dir}/final-assessment.md" 2>&1; then
|
||||
EXIT_SYNTHESIS=$?
|
||||
echo "${EXIT_SYNTHESIS}" > "${log_dir}/final-assessment.exit"
|
||||
echo "❌ Final assessment synthesis failed!"
|
||||
echo "Check ${log_dir}/final-assessment.md for details."
|
||||
notify "Async Review Failed" "Final assessment synthesis failed." "${pr_number}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 0 > "${log_dir}/final-assessment.exit"
|
||||
echo "✅ Final assessment complete! Check ${log_dir}/final-assessment.md"
|
||||
notify "Async Review Complete" "Review and test execution finished successfully." "${pr_number}"
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/bin/bash
|
||||
pr_number="${1}"
|
||||
|
||||
if [[ -z "${pr_number}" ]]; then
|
||||
echo "Usage: check-async-review <pr_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "${base_dir}" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs"
|
||||
|
||||
if [[ ! -d "${log_dir}" ]]; then
|
||||
echo "STATUS: NOT_FOUND"
|
||||
echo "❌ No logs found for PR #${pr_number} in ${log_dir}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tasks=(
|
||||
"setup|setup.log"
|
||||
"pr-diff|pr-diff.diff"
|
||||
"build-and-lint|build-and-lint.log"
|
||||
"review|review.md"
|
||||
"npm-test|npm-test.log"
|
||||
"test-execution|test-execution.log"
|
||||
"final-assessment|final-assessment.md"
|
||||
)
|
||||
|
||||
all_done=true
|
||||
echo "STATUS: CHECKING"
|
||||
|
||||
for task_info in "${tasks[@]}"; do
|
||||
IFS="|" read -r task_name log_file <<< "${task_info}"
|
||||
|
||||
file_path="${log_dir}/${log_file}"
|
||||
exit_file="${log_dir}/${task_name}.exit"
|
||||
|
||||
if [[ -f "${exit_file}" ]]; then
|
||||
EXIT_CODE_VAL=$(cat "${exit_file}")
|
||||
if [[ "${EXIT_CODE_VAL}" == "0" ]]; then
|
||||
echo "✅ ${task_name}: SUCCESS"
|
||||
else
|
||||
echo "❌ ${task_name}: FAILED (exit code ${EXIT_CODE_VAL})"
|
||||
echo " Last lines of ${file_path}:"
|
||||
tail -n 3 "${file_path}" | sed 's/^/ /' || true
|
||||
fi
|
||||
elif [[ -f "${file_path}" ]]; then
|
||||
echo "⏳ ${task_name}: RUNNING"
|
||||
all_done=false
|
||||
else
|
||||
echo "➖ ${task_name}: NOT STARTED"
|
||||
all_done=false
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${all_done}" == "true" ]]; then
|
||||
echo "STATUS: COMPLETE"
|
||||
echo "LOG_DIR: ${log_dir}"
|
||||
else
|
||||
echo "STATUS: IN_PROGRESS"
|
||||
fi
|
||||
@@ -25,20 +25,6 @@ To use remote subagents, you must explicitly enable them in your
|
||||
}
|
||||
```
|
||||
|
||||
## Proxy support
|
||||
|
||||
Gemini CLI routes traffic to remote agents through an HTTP/HTTPS proxy if one is
|
||||
configured. It uses the `general.proxy` setting in your `settings.json` file or
|
||||
standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`).
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"proxy": "http://my-proxy:8080"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Defining remote subagents
|
||||
|
||||
Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter.
|
||||
@@ -54,7 +40,6 @@ You can place them in:
|
||||
| `kind` | string | Yes | Must be `remote`. |
|
||||
| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). |
|
||||
| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. |
|
||||
| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). |
|
||||
|
||||
### Single-subagent example
|
||||
|
||||
@@ -85,273 +70,6 @@ Markdown file.
|
||||
> **Note:** Mixed local and remote agents, or multiple local agents, are not
|
||||
> supported in a single file; the list format is currently remote-only.
|
||||
|
||||
## Authentication
|
||||
|
||||
Many remote agents require authentication. Gemini CLI supports several
|
||||
authentication methods aligned with the
|
||||
[A2A security specification](https://a2a-protocol.org/latest/specification/#451-securityscheme).
|
||||
Add an `auth` block to your agent's frontmatter to configure credentials.
|
||||
|
||||
### Supported auth types
|
||||
|
||||
Gemini CLI supports the following authentication types:
|
||||
|
||||
| Type | Description |
|
||||
| :------------------- | :--------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
|
||||
### Dynamic values
|
||||
|
||||
For `apiKey` and `http` auth types, secret values (`key`, `token`, `username`,
|
||||
`password`, `value`) support dynamic resolution:
|
||||
|
||||
| Format | Description | Example |
|
||||
| :---------- | :-------------------------------------------------- | :------------------------- |
|
||||
| `$ENV_VAR` | Read from an environment variable. | `$MY_API_KEY` |
|
||||
| `!command` | Execute a shell command and use the trimmed output. | `!gcloud auth print-token` |
|
||||
| literal | Use the string as-is. | `sk-abc123` |
|
||||
| `$$` / `!!` | Escape prefix. `$$FOO` becomes the literal `$FOO`. | `$$NOT_AN_ENV_VAR` |
|
||||
|
||||
> **Security tip:** Prefer `$ENV_VAR` or `!command` over embedding secrets
|
||||
> directly in agent files, especially for project-level agents checked into
|
||||
> version control.
|
||||
|
||||
### API key (`apiKey`)
|
||||
|
||||
Sends an API key as an HTTP header on every request.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :----- | :----- | :------- | :---------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `apiKey`. |
|
||||
| `key` | string | Yes | The API key value. Supports dynamic values. |
|
||||
| `name` | string | No | Header name to send the key in. Default: `X-API-Key`. |
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: my-agent
|
||||
agent_card_url: https://example.com/agent-card
|
||||
auth:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
---
|
||||
```
|
||||
|
||||
### HTTP authentication (`http`)
|
||||
|
||||
Supports Bearer tokens, Basic auth, and arbitrary IANA-registered HTTP
|
||||
authentication schemes.
|
||||
|
||||
#### Bearer token
|
||||
|
||||
Use the following fields to configure a Bearer token:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :----- | :------- | :----------------------------------------- |
|
||||
| `type` | string | Yes | Must be `http`. |
|
||||
| `scheme` | string | Yes | Must be `Bearer`. |
|
||||
| `token` | string | Yes | The bearer token. Supports dynamic values. |
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: http
|
||||
scheme: Bearer
|
||||
token: $MY_BEARER_TOKEN
|
||||
```
|
||||
|
||||
#### Basic authentication
|
||||
|
||||
Use the following fields to configure Basic authentication:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :--------- | :----- | :------- | :------------------------------------- |
|
||||
| `type` | string | Yes | Must be `http`. |
|
||||
| `scheme` | string | Yes | Must be `Basic`. |
|
||||
| `username` | string | Yes | The username. Supports dynamic values. |
|
||||
| `password` | string | Yes | The password. Supports dynamic values. |
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: http
|
||||
scheme: Basic
|
||||
username: $MY_USERNAME
|
||||
password: $MY_PASSWORD
|
||||
```
|
||||
|
||||
#### Raw scheme
|
||||
|
||||
For any other IANA-registered scheme (for example, Digest, HOBA), provide the
|
||||
raw authorization value.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :----- | :------- | :---------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `http`. |
|
||||
| `scheme` | string | Yes | The scheme name (for example, `Digest`). |
|
||||
| `value` | string | Yes | Raw value sent as `Authorization: <scheme> <value>`. Supports dynamic values. |
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: http
|
||||
scheme: Digest
|
||||
value: $MY_DIGEST_VALUE
|
||||
```
|
||||
|
||||
### Google Application Default Credentials (`google-credentials`)
|
||||
|
||||
Uses
|
||||
[Google Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials)
|
||||
to authenticate with Google Cloud services and Cloud Run endpoints. This is the
|
||||
recommended auth method for agents hosted on Google Cloud infrastructure.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :------- | :------- | :-------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `google-credentials`. |
|
||||
| `scopes` | string[] | No | OAuth scopes. Defaults to `https://www.googleapis.com/auth/cloud-platform`. |
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: my-gcp-agent
|
||||
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
|
||||
auth:
|
||||
type: google-credentials
|
||||
---
|
||||
```
|
||||
|
||||
#### How token selection works
|
||||
|
||||
The provider automatically selects the correct token type based on the agent's
|
||||
host:
|
||||
|
||||
| Host pattern | Token type | Use case |
|
||||
| :----------------- | :----------------- | :------------------------------------------ |
|
||||
| `*.googleapis.com` | **Access token** | Google APIs (Agent Engine, Vertex AI, etc.) |
|
||||
| `*.run.app` | **Identity token** | Cloud Run services |
|
||||
|
||||
- **Access tokens** authorize API calls to Google services. They are scoped
|
||||
(default: `cloud-platform`) and fetched via `GoogleAuth.getClient()`.
|
||||
- **Identity tokens** prove the caller's identity to a service that validates
|
||||
the token's audience. The audience is set to the target host. These are
|
||||
fetched via `GoogleAuth.getIdTokenClient()`.
|
||||
|
||||
Both token types are cached and automatically refreshed before expiry.
|
||||
|
||||
#### Setup
|
||||
|
||||
`google-credentials` relies on ADC, which means your environment must have
|
||||
credentials configured. Common setups:
|
||||
|
||||
- **Local development:** Run `gcloud auth application-default login` to
|
||||
authenticate with your Google account.
|
||||
- **CI / Cloud environments:** Use a service account. Set the
|
||||
`GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your
|
||||
service account key file, or use workload identity on GKE / Cloud Run.
|
||||
|
||||
#### Allowed hosts
|
||||
|
||||
For security, `google-credentials` only sends tokens to known Google-owned
|
||||
hosts:
|
||||
|
||||
- `*.googleapis.com`
|
||||
- `*.run.app`
|
||||
|
||||
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`).
|
||||
|
||||
#### Examples
|
||||
|
||||
The following examples demonstrate how to configure Google Application Default
|
||||
Credentials.
|
||||
|
||||
**Cloud Run agent:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: cloud-run-agent
|
||||
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
|
||||
auth:
|
||||
type: google-credentials
|
||||
---
|
||||
```
|
||||
|
||||
**Google API with custom scopes:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: vertex-agent
|
||||
agent_card_url: https://us-central1-aiplatform.googleapis.com/.well-known/agent.json
|
||||
auth:
|
||||
type: google-credentials
|
||||
scopes:
|
||||
- https://www.googleapis.com/auth/cloud-platform
|
||||
- https://www.googleapis.com/auth/compute
|
||||
---
|
||||
```
|
||||
|
||||
### OAuth 2.0 (`oauth2`)
|
||||
|
||||
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
|
||||
for subsequent requests.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `oauth2`. |
|
||||
| `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. |
|
||||
| `authorization_url` | string | No | Authorization endpoint. Discovered from the agent card if omitted. |
|
||||
| `token_url` | string | No | Token endpoint. Discovered from the agent card if omitted. |
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: oauth-agent
|
||||
agent_card_url: https://example.com/.well-known/agent.json
|
||||
auth:
|
||||
type: oauth2
|
||||
client_id: my-client-id.apps.example.com
|
||||
---
|
||||
```
|
||||
|
||||
If the agent card advertises an `oauth2` security scheme with
|
||||
`authorizationCode` flow, the `authorization_url`, `token_url`, and `scopes` are
|
||||
automatically discovered. You only need to provide `client_id` (and
|
||||
`client_secret` if required).
|
||||
|
||||
Tokens are persisted to disk and refreshed automatically when they expire.
|
||||
|
||||
### Auth validation
|
||||
|
||||
When Gemini CLI loads a remote agent, it validates your auth configuration
|
||||
against the agent card's declared `securitySchemes`. If the agent requires
|
||||
authentication that you haven't configured, you'll see an error describing
|
||||
what's needed.
|
||||
|
||||
`google-credentials` is treated as compatible with `http` Bearer security
|
||||
schemes, since it produces Bearer tokens.
|
||||
|
||||
### Auth retry behavior
|
||||
|
||||
All auth providers automatically retry on `401` and `403` responses by
|
||||
re-fetching credentials (up to 2 retries). This handles cases like expired
|
||||
tokens or rotated credentials. For `apiKey` with `!command` values, the command
|
||||
is re-executed on retry to fetch a fresh key.
|
||||
|
||||
### Agent card fetching and auth
|
||||
|
||||
When connecting to a remote agent, Gemini CLI first fetches the agent card
|
||||
**without** authentication. If the card endpoint returns a `401` or `403`, it
|
||||
retries the fetch **with** the configured auth headers. This lets agents have
|
||||
publicly accessible cards while protecting their task endpoints, or to protect
|
||||
both behind auth.
|
||||
|
||||
## Managing Subagents
|
||||
|
||||
Users can manage subagents using the following commands within the Gemini CLI:
|
||||
|
||||
+19
-135
@@ -38,34 +38,6 @@ main agent calls the tool, it delegates the task to the subagent. Once the
|
||||
subagent completes its task, it reports back to the main agent with its
|
||||
findings.
|
||||
|
||||
## How to use subagents
|
||||
|
||||
You can use subagents through automatic delegation or by explicitly forcing them
|
||||
in your prompt.
|
||||
|
||||
### Automatic delegation
|
||||
|
||||
Gemini CLI's main agent is instructed to use specialized subagents when a task
|
||||
matches their expertise. For example, if you ask "How does the auth system
|
||||
work?", the main agent may decide to call the `codebase_investigator` subagent
|
||||
to perform the research.
|
||||
|
||||
### Forcing a subagent (@ syntax)
|
||||
|
||||
You can explicitly direct a task to a specific subagent by using the `@` symbol
|
||||
followed by the subagent's name at the beginning of your prompt. This is useful
|
||||
when you want to bypass the main agent's decision-making and go straight to a
|
||||
specialist.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
@codebase_investigator Map out the relationship between the AgentRegistry and the LocalAgentExecutor.
|
||||
```
|
||||
|
||||
When you use the `@` syntax, the CLI injects a system note that nudges the
|
||||
primary model to use that specific subagent tool immediately.
|
||||
|
||||
## Built-in subagents
|
||||
|
||||
Gemini CLI comes with the following built-in subagents:
|
||||
@@ -77,17 +49,15 @@ Gemini CLI comes with the following built-in subagents:
|
||||
dependencies.
|
||||
- **When to use:** "How does the authentication system work?", "Map out the
|
||||
dependencies of the `AgentRegistry` class."
|
||||
- **Configuration:** Enabled by default. You can override its settings in
|
||||
`settings.json` under `agents.overrides`. Example (forcing a specific model
|
||||
and increasing turns):
|
||||
- **Configuration:** Enabled by default. You can configure it in
|
||||
`settings.json`. Example (forcing a specific model):
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"codebase_investigator": {
|
||||
"modelConfig": { "model": "gemini-3-flash-preview" },
|
||||
"runConfig": { "maxTurns": 50 }
|
||||
}
|
||||
"experimental": {
|
||||
"codebaseInvestigatorSettings": {
|
||||
"enabled": true,
|
||||
"maxNumTurns": 20,
|
||||
"model": "gemini-2.5-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,7 +233,7 @@ kind: local
|
||||
tools:
|
||||
- read_file
|
||||
- grep_search
|
||||
model: gemini-3-flash-preview
|
||||
model: gemini-2.5-pro
|
||||
temperature: 0.2
|
||||
max_turns: 10
|
||||
---
|
||||
@@ -284,102 +254,16 @@ it yourself; just report it.
|
||||
|
||||
### Configuration schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
|
||||
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
|
||||
| `kind` | string | No | `local` (default) or `remote`. |
|
||||
| `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. |
|
||||
|
||||
### Tool wildcards
|
||||
|
||||
When defining `tools` for a subagent, you can use wildcards to quickly grant
|
||||
access to groups of tools:
|
||||
|
||||
- `*`: Grant access to all available built-in and discovered tools.
|
||||
- `mcp_*`: Grant access to all tools from all connected MCP servers.
|
||||
- `mcp_my-server_*`: Grant access to all tools from a specific MCP server named
|
||||
`my-server`.
|
||||
|
||||
### Isolation and recursion protection
|
||||
|
||||
Each subagent runs in its own isolated context loop. This means:
|
||||
|
||||
- **Independent history:** The subagent's conversation history does not bloat
|
||||
the main agent's context.
|
||||
- **Isolated tools:** The subagent only has access to the tools you explicitly
|
||||
grant it.
|
||||
- **Recursion protection:** To prevent infinite loops and excessive token usage,
|
||||
subagents **cannot** call other subagents. If a subagent is granted the `*`
|
||||
tool wildcard, it will still be unable to see or invoke other agents.
|
||||
|
||||
## Managing subagents
|
||||
|
||||
You can manage subagents interactively using the `/agents` command or
|
||||
persistently via `settings.json`.
|
||||
|
||||
### Interactive management (/agents)
|
||||
|
||||
If you are in an interactive CLI session, you can use the `/agents` command to
|
||||
manage subagents without editing configuration files manually. This is the
|
||||
recommended way to quickly enable, disable, or re-configure agents on the fly.
|
||||
|
||||
For a full list of sub-commands and usage, see the
|
||||
[`/agents` command reference](../reference/commands.md#agents).
|
||||
|
||||
### Persistent configuration (settings.json)
|
||||
|
||||
While the `/agents` command and agent definition files provide a starting point,
|
||||
you can use `settings.json` for global, persistent overrides. This is useful for
|
||||
enforcing specific models or execution limits across all sessions.
|
||||
|
||||
#### `agents.overrides`
|
||||
|
||||
Use this to enable or disable specific agents or override their run
|
||||
configurations.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"security-auditor": {
|
||||
"enabled": false,
|
||||
"runConfig": {
|
||||
"maxTurns": 20,
|
||||
"maxTimeMinutes": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `modelConfigs.overrides`
|
||||
|
||||
You can target specific subagents with custom model settings (like system
|
||||
instruction prefixes or specific safety settings) using the `overrideScope`
|
||||
field.
|
||||
|
||||
```json
|
||||
{
|
||||
"modelConfigs": {
|
||||
"overrides": [
|
||||
{
|
||||
"match": { "overrideScope": "security-auditor" },
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
| Field | Type | Required | Description |
|
||||
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
|
||||
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
|
||||
| `kind` | string | No | `local` (default) or `remote`. |
|
||||
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
|
||||
|
||||
### Optimizing your subagent
|
||||
|
||||
@@ -414,7 +298,7 @@ Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
|
||||
> **Note: Remote subagents are currently an experimental feature.**
|
||||
|
||||
See the [Remote Subagents documentation](remote-agents) for detailed
|
||||
configuration, authentication, and usage instructions.
|
||||
configuration and usage instructions.
|
||||
|
||||
## Extension subagents
|
||||
|
||||
|
||||
@@ -14,31 +14,6 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
- **Description:** Show version info. Share this information when filing issues.
|
||||
|
||||
### `/agents`
|
||||
|
||||
- **Description:** Manage local and remote subagents.
|
||||
- **Note:** This command is experimental and requires
|
||||
`experimental.enableAgents: true` in your `settings.json`.
|
||||
- **Sub-commands:**
|
||||
- **`list`**:
|
||||
- **Description:** Lists all discovered agents, including built-in, local,
|
||||
and remote agents.
|
||||
- **Usage:** `/agents list`
|
||||
- **`reload`** (alias: `refresh`):
|
||||
- **Description:** Rescans agent directories (`~/.gemini/agents` and
|
||||
`.gemini/agents`) and reloads the registry.
|
||||
- **Usage:** `/agents reload`
|
||||
- **`enable`**:
|
||||
- **Description:** Enables a specific subagent.
|
||||
- **Usage:** `/agents enable <agent-name>`
|
||||
- **`disable`**:
|
||||
- **Description:** Disables a specific subagent.
|
||||
- **Usage:** `/agents disable <agent-name>`
|
||||
- **`config`**:
|
||||
- **Description:** Opens a configuration dialog for the specified agent to
|
||||
adjust its model, temperature, or execution limits.
|
||||
- **Usage:** `/agents config <agent-name>`
|
||||
|
||||
### `/auth`
|
||||
|
||||
- **Description:** Open a dialog that lets you change the authentication method.
|
||||
|
||||
@@ -677,6 +677,141 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
used.
|
||||
- **Default:** `[]`
|
||||
|
||||
- **`modelConfigs.modelDefinitions`** (object):
|
||||
- **Description:** Registry of model metadata, including tier, family, and
|
||||
features.
|
||||
- **Default:**
|
||||
|
||||
```json
|
||||
{
|
||||
"gemini-3.1-pro-preview": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3.1-pro-preview-customtools": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3-pro-preview": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
"tier": "flash",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-2.5",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
"tier": "flash",
|
||||
"family": "gemini-2.5",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemini-2.5-flash-lite": {
|
||||
"tier": "flash-lite",
|
||||
"family": "gemini-2.5",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
"tier": "auto",
|
||||
"isPreview": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"tier": "pro",
|
||||
"isPreview": false,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"flash": {
|
||||
"tier": "flash",
|
||||
"isPreview": false,
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"flash-lite": {
|
||||
"tier": "flash-lite",
|
||||
"isPreview": false,
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto-gemini-3": {
|
||||
"displayName": "Auto (Gemini 3)",
|
||||
"tier": "auto",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "main",
|
||||
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash",
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto-gemini-2.5": {
|
||||
"displayName": "Auto (Gemini 2.5)",
|
||||
"tier": "auto",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "main",
|
||||
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `agents`
|
||||
|
||||
- **`agents.overrides`** (object):
|
||||
@@ -1068,6 +1203,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.dynamicModelConfiguration`** (boolean):
|
||||
- **Description:** Enable dynamic model configuration (definitions,
|
||||
resolutions, and chains) via settings.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.gemmaModelRouter.enabled`** (boolean):
|
||||
- **Description:** Enable the Gemma Model Router (experimental). Requires a
|
||||
local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.
|
||||
|
||||
+33
-72
@@ -5,62 +5,31 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { appEvalTest, AppEvalCase } from './app-test-helper.js';
|
||||
import { EvalPolicy } from './test-helper.js';
|
||||
|
||||
function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
return appEvalTest(policy, {
|
||||
...evalCase,
|
||||
configOverrides: {
|
||||
...evalCase.configOverrides,
|
||||
general: {
|
||||
...evalCase.configOverrides?.general,
|
||||
approvalMode: 'default',
|
||||
enableAutoUpdate: false,
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
},
|
||||
files: {
|
||||
...evalCase.files,
|
||||
},
|
||||
});
|
||||
}
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('ask_user', () => {
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool to present multiple choice options',
|
||||
prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for ask_user tool',
|
||||
).toBeDefined();
|
||||
const wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool to clarify ambiguous requirements',
|
||||
files: {
|
||||
'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
|
||||
},
|
||||
prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for ask_user tool',
|
||||
).toBeDefined();
|
||||
const wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool before performing significant ambiguous rework',
|
||||
files: {
|
||||
'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
|
||||
@@ -70,37 +39,28 @@ describe('ask_user', () => {
|
||||
}),
|
||||
'README.md': '# Gemini CLI',
|
||||
},
|
||||
prompt: `I want to completely rewrite the core package to support the upcoming V2 architecture, but I haven't decided what that looks like yet. We need to figure out the requirements first. Can you ask me some questions to help nail down the design?`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['enter_plan_mode', 'ask_user']);
|
||||
},
|
||||
prompt: `Refactor the entire core package to be better.`,
|
||||
assert: async (rig) => {
|
||||
// It might call enter_plan_mode first.
|
||||
let confirmation = await rig.waitForPendingConfirmation([
|
||||
'enter_plan_mode',
|
||||
'ask_user',
|
||||
]);
|
||||
expect(confirmation, 'Expected a tool call confirmation').toBeDefined();
|
||||
|
||||
if (confirmation?.name === 'enter_plan_mode') {
|
||||
rig.acceptConfirmation('enter_plan_mode');
|
||||
confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
}
|
||||
const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const wasAskUserCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(
|
||||
confirmation?.toolName,
|
||||
'Expected ask_user to be called to clarify the significant rework',
|
||||
).toBe('ask_user');
|
||||
wasAskUserCalled,
|
||||
'Expected ask_user tool to be called to clarify the significant rework',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Regression Tests for Recent Fixes ---
|
||||
|
||||
// Regression test for issue #20177: Ensure the agent does not use \`ask_user\` to
|
||||
// Regression test for issue #20177: Ensure the agent does not use `ask_user` to
|
||||
// confirm shell commands. Fixed via prompt refinements and tool definition
|
||||
// updates to clarify that shell command confirmation is handled by the UI.
|
||||
// See fix: https://github.com/google-gemini/gemini-cli/pull/20504
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent does NOT use AskUser to confirm shell commands',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
@@ -108,24 +68,25 @@ describe('ask_user', () => {
|
||||
}),
|
||||
},
|
||||
prompt: `Run 'npm run build' in the current directory.`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['run_shell_command', 'ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const confirmation = await rig.waitForPendingConfirmation([
|
||||
'run_shell_command',
|
||||
'ask_user',
|
||||
]);
|
||||
await rig.waitForTelemetryReady();
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const wasShellCalled = toolLogs.some(
|
||||
(log) => log.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
const wasAskUserCalled = toolLogs.some(
|
||||
(log) => log.toolRequest.name === 'ask_user',
|
||||
);
|
||||
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for a tool',
|
||||
).toBeDefined();
|
||||
|
||||
wasShellCalled,
|
||||
'Expected run_shell_command tool to be called',
|
||||
).toBe(true);
|
||||
expect(
|
||||
confirmation?.toolName,
|
||||
wasAskUserCalled,
|
||||
'ask_user should not be called to confirm shell commands',
|
||||
).toBe('run_shell_command');
|
||||
).toBe(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":"hello"}}},{"text":"I've successfully written \"hello\" to test.txt. The file has been created with the specified content."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
|
||||
@@ -203,33 +203,4 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
|
||||
// Should successfully complete all operations
|
||||
assertModelHasOutput(result);
|
||||
});
|
||||
|
||||
it('should handle tool confirmation for write_file without crashing', async () => {
|
||||
rig.setup('tool-confirmation', {
|
||||
fakeResponsesPath: join(
|
||||
__dirname,
|
||||
'browser-agent.confirmation.responses',
|
||||
),
|
||||
settings: {
|
||||
agents: {
|
||||
browser_agent: {
|
||||
headless: true,
|
||||
sessionMode: 'isolated',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const run = await rig.runInteractive({ approvalMode: 'default' });
|
||||
|
||||
await run.type('Write hello to test.txt');
|
||||
await run.type('\r');
|
||||
|
||||
await run.expectText('Allow', 15000);
|
||||
|
||||
await run.type('y');
|
||||
await run.type('\r');
|
||||
|
||||
await run.expectText('successfully written', 15000);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
-1269
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,6 @@
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@tktco/node-actionlint": "^1.6.0",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
@@ -127,13 +126,11 @@
|
||||
"react-devtools-core": "^6.1.2",
|
||||
"react-dom": "^19.2.0",
|
||||
"semver": "^7.7.2",
|
||||
"shellcheck": "^4.1.0",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"ts-prune": "^0.10.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yaml-lint": "^1.7.0",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,16 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
listExtensions,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { listExtensions, type Config } from '@google/gemini-cli-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import type {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
} from '../../config/settings.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
// Mock dependencies
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
@@ -44,12 +44,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
debugLogger,
|
||||
FolderTrustDiscoveryService,
|
||||
getRealPath,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
promptForConsentNonInteractive,
|
||||
|
||||
@@ -13,24 +13,26 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { type Argv } from 'yargs';
|
||||
import { handleLink, linkCommand } from './link.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: true });
|
||||
return { ...mocked, getErrorMessage: vi.fn() };
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{ stripAnsi: true },
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -8,10 +8,10 @@ import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
requestConsentNonInteractive,
|
||||
|
||||
@@ -5,23 +5,27 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { handleList, listCommand } from './list.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: false });
|
||||
return { ...mocked, getErrorMessage: vi.fn() };
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{
|
||||
stripAnsi: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
@@ -18,7 +18,7 @@ import { type Argv } from 'yargs';
|
||||
import { handleUninstall, uninstallCommand } from './uninstall.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential
|
||||
// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining).
|
||||
@@ -66,11 +66,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
@@ -12,12 +12,9 @@ import {
|
||||
updateExtension,
|
||||
} from '../../config/extensions/update.js';
|
||||
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import {
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import type { ExtensionConfig } from '../../config/extension.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
|
||||
@@ -28,9 +28,6 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
}));
|
||||
|
||||
import { handleInstall, installCommand } from './install.js';
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
debugLogger,
|
||||
type SkillDefinition,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { installSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -24,9 +24,6 @@ const { debugLogger } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
|
||||
@@ -21,9 +21,6 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
}));
|
||||
|
||||
import { handleUninstall, uninstallCommand } from './uninstall.js';
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { uninstallSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -857,6 +857,7 @@ export async function loadCliConfig(
|
||||
disableLLMCorrection: settings.tools?.disableLLMCorrection,
|
||||
rawOutput: argv.rawOutput,
|
||||
acceptRawOutputRisk: argv.acceptRawOutputRisk,
|
||||
dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration,
|
||||
modelConfigServiceConfig: settings.modelConfigs,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks: settings.hooksConfig.enabled,
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
*/
|
||||
|
||||
import { simpleGit } from 'simple-git';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
@@ -11,12 +11,9 @@ import {
|
||||
} from '../../ui/state/extensions.js';
|
||||
import { loadInstallMetadata } from '../extension.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { copyExtension, type ExtensionManager } from '../extension-manager.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
|
||||
|
||||
@@ -2594,7 +2594,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to save settings: Write failed',
|
||||
'There was an error saving your latest settings changes.',
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
getErrorMessage,
|
||||
getFsErrorMessage,
|
||||
Storage,
|
||||
coreEvents,
|
||||
homedir,
|
||||
@@ -1073,10 +1072,9 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
settingsToSave as Record<string, unknown>,
|
||||
);
|
||||
} catch (error) {
|
||||
const detailedErrorMessage = getFsErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to save settings: ${detailedErrorMessage}`,
|
||||
'There was an error saving your latest settings changes.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -1089,10 +1087,9 @@ export function saveModelChange(
|
||||
try {
|
||||
loadedSettings.setValue(SettingScope.User, 'model.name', model);
|
||||
} catch (error) {
|
||||
const detailedErrorMessage = getFsErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to save preferred model: ${detailedErrorMessage}`,
|
||||
'There was an error saving your preferred model.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1039,6 +1039,20 @@ const SETTINGS_SCHEMA = {
|
||||
'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.',
|
||||
showInDialog: false,
|
||||
},
|
||||
modelDefinitions: {
|
||||
type: 'object',
|
||||
label: 'Model Definitions',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_MODEL_CONFIGS.modelDefinitions,
|
||||
description:
|
||||
'Registry of model metadata, including tier, family, and features.',
|
||||
showInDialog: false,
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
ref: 'ModelDefinition',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1910,6 +1924,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable web fetch behavior that bypasses LLM summarization.',
|
||||
showInDialog: true,
|
||||
},
|
||||
dynamicModelConfiguration: {
|
||||
type: 'boolean',
|
||||
label: 'Dynamic Model Configuration',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
gemmaModelRouter: {
|
||||
type: 'object',
|
||||
label: 'Gemma Model Router',
|
||||
@@ -2726,6 +2750,25 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
},
|
||||
},
|
||||
},
|
||||
ModelDefinition: {
|
||||
type: 'object',
|
||||
description: 'Model metadata registry entry.',
|
||||
properties: {
|
||||
displayName: { type: 'string' },
|
||||
tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] },
|
||||
family: { type: 'string' },
|
||||
isPreview: { type: 'boolean' },
|
||||
dialogLocation: { enum: ['main', 'manual'] },
|
||||
dialogDescription: { type: 'string' },
|
||||
features: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
thinking: { type: 'boolean' },
|
||||
multimodalToolUse: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getSettingsSchema(): SettingsSchemaType {
|
||||
|
||||
@@ -487,7 +487,7 @@ export class AppRig {
|
||||
}
|
||||
|
||||
async waitForPendingConfirmation(
|
||||
toolNameOrDisplayName?: string | RegExp | string[],
|
||||
toolNameOrDisplayName?: string | RegExp,
|
||||
timeout = 30000,
|
||||
): Promise<PendingConfirmation> {
|
||||
const matches = (p: PendingConfirmation) => {
|
||||
@@ -498,12 +498,6 @@ export class AppRig {
|
||||
p.toolDisplayName === toolNameOrDisplayName
|
||||
);
|
||||
}
|
||||
if (Array.isArray(toolNameOrDisplayName)) {
|
||||
return (
|
||||
toolNameOrDisplayName.includes(p.toolName) ||
|
||||
toolNameOrDisplayName.includes(p.toolDisplayName || '')
|
||||
);
|
||||
}
|
||||
return (
|
||||
toolNameOrDisplayName.test(p.toolName) ||
|
||||
toolNameOrDisplayName.test(p.toolDisplayName || '')
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
import {
|
||||
debugLogger,
|
||||
listExtensions,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ExtensionUpdateInfo } from '../../config/extension.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
emptyIcon,
|
||||
MessageType,
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
MessageType,
|
||||
} from '../types.js';
|
||||
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getAdminErrorMessage } from '@google/gemini-cli-core';
|
||||
import {
|
||||
linkSkill,
|
||||
renderSkillActionFeedback,
|
||||
|
||||
@@ -116,9 +116,38 @@ const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
|
||||
</>
|
||||
);
|
||||
|
||||
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
|
||||
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
|
||||
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
|
||||
/**
|
||||
* Loading state component displayed while sessions are being loaded.
|
||||
*/
|
||||
const SessionBrowserLoading = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>Loading sessions…</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Error state component displayed when session loading fails.
|
||||
*/
|
||||
const SessionBrowserError = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Empty state component displayed when no sessions are found.
|
||||
*/
|
||||
const SessionBrowserEmpty = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
/**
|
||||
* Empty state component displayed when no sessions are found.
|
||||
*/
|
||||
export const SessionBrowserEmpty = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
/**
|
||||
* Error state component displayed when session loading fails.
|
||||
*/
|
||||
export const SessionBrowserError = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
/**
|
||||
* Loading state component displayed while sessions are being loaded.
|
||||
*/
|
||||
export const SessionBrowserLoading = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>Loading sessions…</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionBrowserLoading } from './SessionBrowserLoading.js';
|
||||
import { SessionBrowserError } from './SessionBrowserError.js';
|
||||
import { SessionBrowserEmpty } from './SessionBrowserEmpty.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
describe('SessionBrowser UI States', () => {
|
||||
it('SessionBrowserLoading renders correctly', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(<SessionBrowserLoading />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionBrowserError renders correctly', async () => {
|
||||
const mockState = { error: 'Test error message' } as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SessionBrowserError state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionBrowserEmpty renders correctly', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(<SessionBrowserEmpty />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SessionBrowser UI States > SessionBrowserEmpty renders correctly 1`] = `
|
||||
" No auto-saved conversations found.
|
||||
Press q to exit
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser UI States > SessionBrowserError renders correctly 1`] = `
|
||||
" Error: Test error message
|
||||
Press q to exit
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser UI States > SessionBrowserLoading renders correctly 1`] = `
|
||||
" Loading sessions…
|
||||
"
|
||||
`;
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../utils/displayUtils.js';
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import {
|
||||
type Config,
|
||||
type RetrieveUserQuotaResponse,
|
||||
isActiveModel,
|
||||
getDisplayString,
|
||||
@@ -88,13 +89,16 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
|
||||
// Logic for building the unified list of table rows
|
||||
const buildModelRows = (
|
||||
models: Record<string, ModelMetrics>,
|
||||
config: Config,
|
||||
quotas?: RetrieveUserQuotaResponse,
|
||||
useGemini3_1 = false,
|
||||
useCustomToolModel = false,
|
||||
) => {
|
||||
const getBaseModelName = (name: string) => name.replace('-001', '');
|
||||
const usedModelNames = new Set(
|
||||
Object.keys(models).map(getBaseModelName).map(getDisplayString),
|
||||
Object.keys(models)
|
||||
.map(getBaseModelName)
|
||||
.map((name) => getDisplayString(name, config)),
|
||||
);
|
||||
|
||||
// 1. Models with active usage
|
||||
@@ -104,7 +108,7 @@ const buildModelRows = (
|
||||
const inputTokens = metrics.tokens.input;
|
||||
return {
|
||||
key: name,
|
||||
modelName: getDisplayString(modelName),
|
||||
modelName: getDisplayString(modelName, config),
|
||||
requests: metrics.api.totalRequests,
|
||||
cachedTokens: cachedTokens.toLocaleString(),
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
@@ -121,11 +125,11 @@ const buildModelRows = (
|
||||
(b) =>
|
||||
b.modelId &&
|
||||
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
|
||||
!usedModelNames.has(getDisplayString(b.modelId)),
|
||||
!usedModelNames.has(getDisplayString(b.modelId, config)),
|
||||
)
|
||||
.map((bucket) => ({
|
||||
key: bucket.modelId!,
|
||||
modelName: getDisplayString(bucket.modelId!),
|
||||
modelName: getDisplayString(bucket.modelId!, config),
|
||||
requests: '-',
|
||||
cachedTokens: '-',
|
||||
inputTokens: '-',
|
||||
@@ -139,6 +143,7 @@ const buildModelRows = (
|
||||
|
||||
const ModelUsageTable: React.FC<{
|
||||
models: Record<string, ModelMetrics>;
|
||||
config: Config;
|
||||
quotas?: RetrieveUserQuotaResponse;
|
||||
cacheEfficiency: number;
|
||||
totalCachedTokens: number;
|
||||
@@ -150,6 +155,7 @@ const ModelUsageTable: React.FC<{
|
||||
useCustomToolModel?: boolean;
|
||||
}> = ({
|
||||
models,
|
||||
config,
|
||||
quotas,
|
||||
cacheEfficiency,
|
||||
totalCachedTokens,
|
||||
@@ -162,7 +168,13 @@ const ModelUsageTable: React.FC<{
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns ?? 84;
|
||||
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
|
||||
const rows = buildModelRows(
|
||||
models,
|
||||
config,
|
||||
quotas,
|
||||
useGemini3_1,
|
||||
useCustomToolModel,
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
@@ -676,6 +688,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
</Section>
|
||||
<ModelUsageTable
|
||||
models={models}
|
||||
config={config}
|
||||
quotas={quotas}
|
||||
cacheEfficiency={computed.cacheEfficiency}
|
||||
totalCachedTokens={computed.totalCachedTokens}
|
||||
|
||||
@@ -760,48 +760,6 @@ describe('BaseSettingsDialog', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow j and k characters to be typed in string edit fields without triggering navigation', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [stringItem],
|
||||
});
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Type 'j' - should appear in field, NOT trigger navigation
|
||||
await act(async () => {
|
||||
stdin.write('j');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Type 'k' - should appear in field, NOT trigger navigation
|
||||
await act(async () => {
|
||||
stdin.write('k');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Commit with Enter
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// j and k should be typed into the field
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
'string-setting',
|
||||
'test-valuejk', // entered value + j and k
|
||||
expect.objectContaining({ type: 'string' }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom key handling', () => {
|
||||
|
||||
@@ -325,18 +325,13 @@ export function BaseSettingsDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down in edit mode - commit and navigate.
|
||||
// Only trigger on non-insertable keys (arrow keys) so that typing
|
||||
// j/k characters into the edit buffer is not intercepted.
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key) && !key.insertable) {
|
||||
// Up/Down in edit mode - commit and navigate
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
commitEdit();
|
||||
moveUp();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) &&
|
||||
!key.insertable
|
||||
) {
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
commitEdit();
|
||||
moveDown();
|
||||
return;
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
import {
|
||||
debugLogger,
|
||||
checkExhaustive,
|
||||
getErrorMessage,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
ExtensionUpdateState,
|
||||
extensionUpdatesReducer,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
getErrorMessage,
|
||||
handleError,
|
||||
handleToolError,
|
||||
handleCancellationError,
|
||||
@@ -151,6 +152,25 @@ describe('errors', () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('should return error message for Error instances', () => {
|
||||
const error = new Error('Test error message');
|
||||
expect(getErrorMessage(error)).toBe('Test error message');
|
||||
});
|
||||
|
||||
it('should convert non-Error values to strings', () => {
|
||||
expect(getErrorMessage('string error')).toBe('string error');
|
||||
expect(getErrorMessage(123)).toBe('123');
|
||||
expect(getErrorMessage(null)).toBe('null');
|
||||
expect(getErrorMessage(undefined)).toBe('undefined');
|
||||
});
|
||||
|
||||
it('should handle objects', () => {
|
||||
const obj = { message: 'test' };
|
||||
expect(getErrorMessage(obj)).toBe('[object Object]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleError', () => {
|
||||
describe('in text mode', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -18,10 +18,16 @@ import {
|
||||
isFatalToolError,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runSyncCleanup } from './cleanup.js';
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
interface ErrorWithCode extends Error {
|
||||
exitCode?: number;
|
||||
code?: string | number;
|
||||
|
||||
@@ -109,7 +109,7 @@ export const BrowserAgentDefinition = (
|
||||
): LocalAgentDefinition<typeof BrowserTaskResultSchema> => {
|
||||
// Use Preview Flash model if the main model is any of the preview models.
|
||||
// If the main model is not a preview model, use the default flash model.
|
||||
const model = isPreviewModel(config.getModel())
|
||||
const model = isPreviewModel(config.getModel(), config)
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ToolInvocation,
|
||||
type ToolResult,
|
||||
} from '../tools/tools.js';
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { AgentDefinition, AgentInputs } from './types.js';
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
@@ -54,6 +54,10 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
|
||||
);
|
||||
}
|
||||
|
||||
private get config(): Config {
|
||||
return this.context.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an invocation instance for executing the subagent.
|
||||
*
|
||||
@@ -85,7 +89,7 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
|
||||
// Special handling for browser agent - needs async MCP setup
|
||||
if (definition.name === BROWSER_AGENT_NAME) {
|
||||
return new BrowserAgentInvocation(
|
||||
this.context,
|
||||
this.config,
|
||||
params,
|
||||
effectiveMessageBus,
|
||||
_toolName,
|
||||
|
||||
@@ -54,19 +54,21 @@ export function resolvePolicyChain(
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
);
|
||||
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel);
|
||||
const isAutoPreferred = preferredModel
|
||||
? isAutoModel(preferredModel, config)
|
||||
: false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel, config);
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (
|
||||
isGemini3Model(resolvedModel) ||
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel) ||
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
|
||||
@@ -601,6 +601,7 @@ export interface ConfigParameters {
|
||||
disableYoloMode?: boolean;
|
||||
rawOutput?: boolean;
|
||||
acceptRawOutputRisk?: boolean;
|
||||
dynamicModelConfiguration?: boolean;
|
||||
modelConfigServiceConfig?: ModelConfigServiceConfig;
|
||||
enableHooks?: boolean;
|
||||
enableHooksUI?: boolean;
|
||||
@@ -799,6 +800,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly disableYoloMode: boolean;
|
||||
private readonly rawOutput: boolean;
|
||||
private readonly acceptRawOutputRisk: boolean;
|
||||
private readonly dynamicModelConfiguration: boolean;
|
||||
private pendingIncludeDirectories: string[];
|
||||
private readonly enableHooks: boolean;
|
||||
private readonly enableHooksUI: boolean;
|
||||
@@ -987,7 +989,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.truncateToolOutputThreshold =
|
||||
params.truncateToolOutputThreshold ??
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
|
||||
this.useWriteTodos = isPreviewModel(this.model)
|
||||
this.useWriteTodos = isPreviewModel(this.model, this)
|
||||
? false
|
||||
: (params.useWriteTodos ?? true);
|
||||
this.workspacePoliciesDir = params.workspacePoliciesDir;
|
||||
@@ -1062,6 +1064,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.disableYoloMode = params.disableYoloMode ?? false;
|
||||
this.rawOutput = params.rawOutput ?? false;
|
||||
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
|
||||
this.dynamicModelConfiguration = params.dynamicModelConfiguration ?? false;
|
||||
|
||||
if (params.hooks) {
|
||||
this.hooks = params.hooks;
|
||||
@@ -1111,18 +1114,23 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
// remove this hack.
|
||||
let modelConfigServiceConfig = params.modelConfigServiceConfig;
|
||||
if (modelConfigServiceConfig) {
|
||||
if (!modelConfigServiceConfig.aliases) {
|
||||
modelConfigServiceConfig = {
|
||||
...modelConfigServiceConfig,
|
||||
aliases: DEFAULT_MODEL_CONFIGS.aliases,
|
||||
};
|
||||
}
|
||||
if (!modelConfigServiceConfig.overrides) {
|
||||
modelConfigServiceConfig = {
|
||||
...modelConfigServiceConfig,
|
||||
overrides: DEFAULT_MODEL_CONFIGS.overrides,
|
||||
};
|
||||
}
|
||||
// Ensure user-defined model definitions augment, not replace, the defaults.
|
||||
const mergedModelDefinitions = {
|
||||
...DEFAULT_MODEL_CONFIGS.modelDefinitions,
|
||||
...modelConfigServiceConfig.modelDefinitions,
|
||||
};
|
||||
|
||||
modelConfigServiceConfig = {
|
||||
// Preserve other user settings like customAliases
|
||||
...modelConfigServiceConfig,
|
||||
// Apply defaults for aliases and overrides if they are not provided
|
||||
aliases:
|
||||
modelConfigServiceConfig.aliases ?? DEFAULT_MODEL_CONFIGS.aliases,
|
||||
overrides:
|
||||
modelConfigServiceConfig.overrides ?? DEFAULT_MODEL_CONFIGS.overrides,
|
||||
// Use the merged model definitions
|
||||
modelDefinitions: mergedModelDefinitions,
|
||||
};
|
||||
}
|
||||
|
||||
this.modelConfigService = new ModelConfigService(
|
||||
@@ -1325,7 +1333,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
// Only reset when we have explicit "no access" (hasAccessToPreviewModel === false).
|
||||
// When null (quota not fetched) or true, we preserve the saved model.
|
||||
if (isPreviewModel(this.model) && this.hasAccessToPreviewModel === false) {
|
||||
if (
|
||||
isPreviewModel(this.model, this) &&
|
||||
this.hasAccessToPreviewModel === false
|
||||
) {
|
||||
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
}
|
||||
|
||||
@@ -1593,7 +1604,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
const isPreview =
|
||||
model === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
isPreviewModel(this.getActiveModel());
|
||||
isPreviewModel(this.getActiveModel(), this);
|
||||
const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL;
|
||||
const flashModel = isPreview
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
@@ -1791,8 +1802,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
const hasAccess =
|
||||
quota.buckets?.some((b) => b.modelId && isPreviewModel(b.modelId)) ??
|
||||
false;
|
||||
quota.buckets?.some(
|
||||
(b) => b.modelId && isPreviewModel(b.modelId, this),
|
||||
) ?? false;
|
||||
this.setHasAccessToPreviewModel(hasAccess);
|
||||
return quota;
|
||||
} catch (e) {
|
||||
@@ -2184,6 +2196,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.acceptRawOutputRisk;
|
||||
}
|
||||
|
||||
getExperimentalDynamicModelConfiguration(): boolean {
|
||||
return this.dynamicModelConfiguration;
|
||||
}
|
||||
|
||||
getPendingIncludeDirectories(): string[] {
|
||||
return this.pendingIncludeDirectories;
|
||||
}
|
||||
|
||||
@@ -249,4 +249,94 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
modelDefinitions: {
|
||||
// Concrete Models
|
||||
'gemini-3.1-pro-preview': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3.1-pro-preview-customtools': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3-pro-preview': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3-flash-preview': {
|
||||
tier: 'flash',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-2.5',
|
||||
isPreview: false,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
tier: 'flash',
|
||||
family: 'gemini-2.5',
|
||||
isPreview: false,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'gemini-2.5-flash-lite': {
|
||||
tier: 'flash-lite',
|
||||
family: 'gemini-2.5',
|
||||
isPreview: false,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
// Aliases
|
||||
auto: {
|
||||
tier: 'auto',
|
||||
isPreview: true,
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
pro: {
|
||||
tier: 'pro',
|
||||
isPreview: false,
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
flash: {
|
||||
tier: 'flash',
|
||||
isPreview: false,
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'flash-lite': {
|
||||
tier: 'flash-lite',
|
||||
isPreview: false,
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'auto-gemini-3': {
|
||||
displayName: 'Auto (Gemini 3)',
|
||||
tier: 'auto',
|
||||
isPreview: true,
|
||||
dialogLocation: 'main',
|
||||
dialogDescription:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash',
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
'auto-gemini-2.5': {
|
||||
displayName: 'Auto (Gemini 2.5)',
|
||||
tier: 'auto',
|
||||
isPreview: false,
|
||||
dialogLocation: 'main',
|
||||
dialogDescription:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,6 +36,121 @@ import {
|
||||
VALID_GEMINI_MODELS,
|
||||
VALID_ALIASES,
|
||||
} from './models.js';
|
||||
import type { Config } from './config.js';
|
||||
import { ModelConfigService } from '../services/modelConfigService.js';
|
||||
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
|
||||
const modelConfigService = new ModelConfigService(DEFAULT_MODEL_CONFIGS);
|
||||
|
||||
const dynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
const legacyConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => false,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
describe('Dynamic Configuration Parity', () => {
|
||||
const modelsToTest = [
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
'custom-model',
|
||||
];
|
||||
|
||||
it('getDisplayString should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = getDisplayString(model, legacyConfig);
|
||||
const dynamic = getDisplayString(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isPreviewModel should match legacy behavior', () => {
|
||||
const allModels = [
|
||||
...modelsToTest,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
];
|
||||
for (const model of allModels) {
|
||||
const legacy = isPreviewModel(model, legacyConfig);
|
||||
const dynamic = isPreviewModel(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isProModel should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isProModel(model, legacyConfig);
|
||||
const dynamic = isProModel(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isGemini3Model should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isGemini3Model(model, legacyConfig);
|
||||
const dynamic = isGemini3Model(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isGemini2Model should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isGemini2Model(model, legacyConfig);
|
||||
const dynamic = isGemini2Model(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isCustomModel should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isCustomModel(model, legacyConfig);
|
||||
const dynamic = isCustomModel(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('supportsModernFeatures should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = supportsModernFeatures(model, legacyConfig);
|
||||
const dynamic = supportsModernFeatures(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isActiveModel should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isActiveModel(model, false, false, legacyConfig);
|
||||
const dynamic = isActiveModel(model, false, false, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isValidModelOrAlias should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isValidModelOrAlias(model, legacyConfig);
|
||||
const dynamic = isValidModelOrAlias(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('supportsMultimodalFunctionResponse should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = supportsMultimodalFunctionResponse(model, legacyConfig);
|
||||
const dynamic = supportsMultimodalFunctionResponse(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPreviewModel', () => {
|
||||
it('should return true for preview models', () => {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ModelConfigService } from '../services/modelConfigService.js';
|
||||
|
||||
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
|
||||
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
|
||||
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
|
||||
@@ -46,6 +48,15 @@ export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
|
||||
// Cap the thinking at 8192 to prevent run-away thinking loops.
|
||||
export const DEFAULT_THINKING_MODE = 8192;
|
||||
|
||||
/**
|
||||
* Represents the minimal configuration needed to resolve models dynamically.
|
||||
* This breaks circular dependencies by avoiding an import of the full Config class.
|
||||
*/
|
||||
export interface ModelResolutionContext {
|
||||
getExperimentalDynamicModelConfiguration?: () => boolean;
|
||||
modelConfigService: ModelConfigService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the requested model alias (e.g., 'auto-gemini-3', 'pro', 'flash', 'flash-lite')
|
||||
* to a concrete model name.
|
||||
@@ -148,7 +159,17 @@ export function resolveClassifierModel(
|
||||
}
|
||||
return resolveModel(requestedModel, useGemini3_1, useCustomToolModel);
|
||||
}
|
||||
export function getDisplayString(model: string) {
|
||||
export function getDisplayString(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
) {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const definition = config.modelConfigService.getModelDefinition(model);
|
||||
if (definition?.displayName) {
|
||||
return definition.displayName;
|
||||
}
|
||||
}
|
||||
|
||||
switch (model) {
|
||||
case PREVIEW_GEMINI_MODEL_AUTO:
|
||||
return 'Auto (Gemini 3)';
|
||||
@@ -169,9 +190,19 @@ export function getDisplayString(model: string) {
|
||||
* Checks if the model is a preview model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a preview model.
|
||||
*/
|
||||
export function isPreviewModel(model: string): boolean {
|
||||
export function isPreviewModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(model)?.isPreview === true
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
model === PREVIEW_GEMINI_MODEL ||
|
||||
model === PREVIEW_GEMINI_3_1_MODEL ||
|
||||
@@ -186,9 +217,16 @@ export function isPreviewModel(model: string): boolean {
|
||||
* Checks if the model is a Pro model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a Pro model.
|
||||
*/
|
||||
export function isProModel(model: string): boolean {
|
||||
export function isProModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return config.modelConfigService.getModelDefinition(model)?.tier === 'pro';
|
||||
}
|
||||
return model.toLowerCase().includes('pro');
|
||||
}
|
||||
|
||||
@@ -196,9 +234,22 @@ export function isProModel(model: string): boolean {
|
||||
* Checks if the model is a Gemini 3 model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a Gemini 3 model.
|
||||
*/
|
||||
export function isGemini3Model(model: string): boolean {
|
||||
export function isGemini3Model(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
// Legacy behavior resolves the model first.
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.family ===
|
||||
'gemini-3'
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = resolveModel(model);
|
||||
return /^gemini-3(\.|-|$)/.test(resolved);
|
||||
}
|
||||
@@ -207,9 +258,20 @@ export function isGemini3Model(model: string): boolean {
|
||||
* Checks if the model is a Gemini 2.x model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a Gemini-2.x model.
|
||||
*/
|
||||
export function isGemini2Model(model: string): boolean {
|
||||
export function isGemini2Model(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
// Legacy behavior does NOT resolve the model first for Gemini 2 check.
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(model)?.family ===
|
||||
'gemini-2.5'
|
||||
);
|
||||
}
|
||||
return /^gemini-2(\.|$)/.test(model);
|
||||
}
|
||||
|
||||
@@ -217,9 +279,20 @@ export function isGemini2Model(model: string): boolean {
|
||||
* Checks if the model is a "custom" model (not Gemini branded).
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is not a Gemini branded model.
|
||||
*/
|
||||
export function isCustomModel(model: string): boolean {
|
||||
export function isCustomModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.tier ===
|
||||
'custom' || !resolved.startsWith('gemini-')
|
||||
);
|
||||
}
|
||||
const resolved = resolveModel(model);
|
||||
return !resolved.startsWith('gemini-');
|
||||
}
|
||||
@@ -229,20 +302,31 @@ export function isCustomModel(model: string): boolean {
|
||||
* This includes Gemini 3 models and any custom models.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model supports modern features like thoughts.
|
||||
*/
|
||||
export function supportsModernFeatures(model: string): boolean {
|
||||
if (isGemini3Model(model)) return true;
|
||||
return isCustomModel(model);
|
||||
export function supportsModernFeatures(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (isGemini3Model(model, config)) return true;
|
||||
return isCustomModel(model, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model is an auto model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is an auto model.
|
||||
*/
|
||||
export function isAutoModel(model: string): boolean {
|
||||
export function isAutoModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return config.modelConfigService.getModelDefinition(model)?.tier === 'auto';
|
||||
}
|
||||
return (
|
||||
model === GEMINI_MODEL_ALIAS_AUTO ||
|
||||
model === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
@@ -255,9 +339,23 @@ export function isAutoModel(model: string): boolean {
|
||||
* This is supported in Gemini 3.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model supports multimodal function responses.
|
||||
*/
|
||||
export function supportsMultimodalFunctionResponse(model: string): boolean {
|
||||
export function supportsMultimodalFunctionResponse(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
if (VALID_ALIASES.has(model)) {
|
||||
return false;
|
||||
}
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.features
|
||||
?.multimodalToolUse === true
|
||||
);
|
||||
}
|
||||
return model.startsWith('gemini-3-');
|
||||
}
|
||||
|
||||
@@ -266,16 +364,32 @@ export function supportsMultimodalFunctionResponse(model: string): boolean {
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is active.
|
||||
*/
|
||||
export function isActiveModel(
|
||||
model: string,
|
||||
useGemini3_1: boolean = false,
|
||||
useCustomToolModel: boolean = false,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (!VALID_GEMINI_MODELS.has(model)) {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
if (VALID_ALIASES.has(model)) {
|
||||
return false;
|
||||
}
|
||||
const definition = config.modelConfigService.getModelDefinition(model);
|
||||
if (!definition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Legacy logic only considers explicit Gemini models as "active".
|
||||
if (definition.tier === 'custom' && !model.startsWith('gemini-')) {
|
||||
return false;
|
||||
}
|
||||
} else if (!VALID_GEMINI_MODELS.has(model)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (useGemini3_1) {
|
||||
if (model === PREVIEW_GEMINI_MODEL) {
|
||||
return false;
|
||||
@@ -297,9 +411,19 @@ export function isActiveModel(
|
||||
* Checks if the model name is valid (either a valid model or a valid alias).
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is valid.
|
||||
*/
|
||||
export function isValidModelOrAlias(model: string): boolean {
|
||||
export function isValidModelOrAlias(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
if (config.modelConfigService.getModelDefinition(model)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a valid alias
|
||||
if (VALID_ALIASES.has(model)) {
|
||||
return true;
|
||||
|
||||
@@ -421,7 +421,7 @@ export class GeminiChat {
|
||||
: getRetryErrorType(error);
|
||||
|
||||
if (
|
||||
(isContentError && isGemini2Model(model)) ||
|
||||
(isContentError && isGemini2Model(model, this.context.config)) ||
|
||||
(isRetryable && !signal.aborted)
|
||||
) {
|
||||
// The issue requests exactly 3 retries (4 attempts) for API errors during stream iteration.
|
||||
@@ -551,7 +551,10 @@ export class GeminiChat {
|
||||
abortSignal,
|
||||
};
|
||||
|
||||
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
|
||||
let contentsToUse: Content[] = supportsModernFeatures(
|
||||
modelToUse,
|
||||
this.context.config,
|
||||
)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ export * from './utils/checks.js';
|
||||
export * from './utils/headless.js';
|
||||
export * from './utils/schemaValidator.js';
|
||||
export * from './utils/errors.js';
|
||||
export * from './utils/fsErrorMessages.js';
|
||||
export * from './utils/exitCodes.js';
|
||||
export * from './utils/getFolderStructure.js';
|
||||
export * from './utils/memoryDiscovery.js';
|
||||
|
||||
@@ -63,7 +63,7 @@ export class PromptProvider {
|
||||
context.config.getActiveModel(),
|
||||
context.config.getGemini31LaunchedSync?.() ?? false,
|
||||
);
|
||||
const isModernModel = supportsModernFeatures(desiredModel);
|
||||
const isModernModel = supportsModernFeatures(desiredModel, context.config);
|
||||
const activeSnippets = isModernModel ? snippets : legacySnippets;
|
||||
const contextFilenames = getAllGeminiMdFilenames();
|
||||
|
||||
@@ -235,7 +235,7 @@ export class PromptProvider {
|
||||
context.config.getActiveModel(),
|
||||
context.config.getGemini31LaunchedSync?.() ?? false,
|
||||
);
|
||||
const isModernModel = supportsModernFeatures(desiredModel);
|
||||
const isModernModel = supportsModernFeatures(desiredModel, context.config);
|
||||
const activeSnippets = isModernModel ? snippets : legacySnippets;
|
||||
return activeSnippets.getCompressionPrompt(
|
||||
context.config.getApprovedPlanPath(),
|
||||
|
||||
@@ -36,7 +36,7 @@ export class ApprovalModeStrategy implements RoutingStrategy {
|
||||
const model = context.requestedModel ?? config.getModel();
|
||||
|
||||
// This strategy only applies to "auto" models.
|
||||
if (!isAutoModel(model)) {
|
||||
if (!isAutoModel(model, config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export class ClassifierStrategy implements RoutingStrategy {
|
||||
const model = context.requestedModel ?? config.getModel();
|
||||
if (
|
||||
(await config.getNumericalRoutingEnabled()) &&
|
||||
isGemini3Model(model)
|
||||
isGemini3Model(model, config)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isGemini3Model(model)) {
|
||||
if (!isGemini3Model(model, config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export class OverrideStrategy implements RoutingStrategy {
|
||||
const overrideModel = context.requestedModel ?? config.getModel();
|
||||
|
||||
// If the model is 'auto' we should pass to the next strategy.
|
||||
if (isAutoModel(overrideModel)) {
|
||||
if (isAutoModel(overrideModel, config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -676,43 +676,6 @@ describe('policy.ts', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work when context is created via Object.create (prototype chain)', async () => {
|
||||
const mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
} as unknown as Mocked<Config>;
|
||||
const mockMessageBus = {
|
||||
publish: vi.fn(),
|
||||
} as unknown as Mocked<MessageBus>;
|
||||
|
||||
const baseContext = {
|
||||
config: mockConfig,
|
||||
messageBus: mockMessageBus,
|
||||
};
|
||||
const protoContext: AgentLoopContext = Object.create(baseContext);
|
||||
|
||||
expect(Object.keys(protoContext)).toHaveLength(0);
|
||||
expect(protoContext.config).toBe(mockConfig);
|
||||
expect(protoContext.messageBus).toBe(mockMessageBus);
|
||||
|
||||
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
|
||||
|
||||
await updatePolicy(
|
||||
tool,
|
||||
ToolConfirmationOutcome.ProceedAlways,
|
||||
undefined,
|
||||
protoContext,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test-tool',
|
||||
persist: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPolicyDenialError', () => {
|
||||
|
||||
@@ -51,11 +51,34 @@ export interface ModelConfigAlias {
|
||||
modelConfig: ModelConfig;
|
||||
}
|
||||
|
||||
// A model definition is a mapping from a model name to a list of features
|
||||
// that the model supports. Model names can be either direct model IDs
|
||||
// (gemini-2.5-pro) or aliases (auto).
|
||||
export interface ModelDefinition {
|
||||
displayName?: string;
|
||||
tier?: string; // 'pro' | 'flash' | 'flash-lite' | 'custom' | 'auto'
|
||||
family?: string; // The gemini family, e.g. 'gemini-3' | 'gemini-2'
|
||||
isPreview?: boolean;
|
||||
// Specifies which view the model should appear in. If unset, the model will
|
||||
// not appear in the dialog.
|
||||
dialogLocation?: 'main' | 'manual';
|
||||
/** A short description of the model for the dialog. */
|
||||
dialogDescription?: string;
|
||||
features?: {
|
||||
// Whether the model supports thinking.
|
||||
thinking?: boolean;
|
||||
// Whether the model supports mutlimodal function responses. This is
|
||||
// supported in Gemini 3.
|
||||
multimodalToolUse?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelConfigServiceConfig {
|
||||
aliases?: Record<string, ModelConfigAlias>;
|
||||
customAliases?: Record<string, ModelConfigAlias>;
|
||||
overrides?: ModelConfigOverride[];
|
||||
customOverrides?: ModelConfigOverride[];
|
||||
modelDefinitions?: Record<string, ModelDefinition>;
|
||||
}
|
||||
|
||||
const MAX_ALIAS_CHAIN_DEPTH = 100;
|
||||
@@ -76,6 +99,28 @@ export class ModelConfigService {
|
||||
// TODO(12597): Process config to build a typed alias hierarchy.
|
||||
constructor(private readonly config: ModelConfigServiceConfig) {}
|
||||
|
||||
getModelDefinition(modelId: string): ModelDefinition | undefined {
|
||||
const definition = this.config.modelDefinitions?.[modelId];
|
||||
if (definition) {
|
||||
return definition;
|
||||
}
|
||||
|
||||
// For unknown models, return an implicit custom definition to match legacy behavior.
|
||||
if (!modelId.startsWith('gemini-')) {
|
||||
return {
|
||||
tier: 'custom',
|
||||
family: 'custom',
|
||||
features: {},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getModelDefinitions(): Record<string, ModelDefinition> {
|
||||
return this.config.modelDefinitions ?? {};
|
||||
}
|
||||
|
||||
registerRuntimeModelConfig(aliasName: string, alias: ModelConfigAlias): void {
|
||||
this.runtimeAliases[aliasName] = alias;
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getFsErrorMessage } from './fsErrorMessages.js';
|
||||
|
||||
/**
|
||||
* Helper to create a mock NodeJS.ErrnoException
|
||||
*/
|
||||
function createNodeError(
|
||||
code: string,
|
||||
message: string,
|
||||
path?: string,
|
||||
): NodeJS.ErrnoException {
|
||||
const error = new Error(message) as NodeJS.ErrnoException;
|
||||
error.code = code;
|
||||
if (path) {
|
||||
error.path = path;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
interface FsErrorCase {
|
||||
code: string;
|
||||
message: string;
|
||||
path?: string;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
interface FallbackErrorCase {
|
||||
value: unknown;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
describe('getFsErrorMessage', () => {
|
||||
describe('known filesystem error codes', () => {
|
||||
const testCases: FsErrorCase[] = [
|
||||
{
|
||||
code: 'EACCES',
|
||||
message: 'EACCES: permission denied',
|
||||
path: '/etc/gemini-cli/settings.json',
|
||||
expected:
|
||||
"Permission denied: cannot access '/etc/gemini-cli/settings.json'. Check file permissions or run with elevated privileges.",
|
||||
},
|
||||
{
|
||||
code: 'EACCES',
|
||||
message: 'EACCES: permission denied',
|
||||
expected:
|
||||
'Permission denied. Check file permissions or run with elevated privileges.',
|
||||
},
|
||||
{
|
||||
code: 'ENOENT',
|
||||
message: 'ENOENT: no such file or directory',
|
||||
path: '/nonexistent/file.txt',
|
||||
expected:
|
||||
"File or directory not found: '/nonexistent/file.txt'. Check if the path exists and is spelled correctly.",
|
||||
},
|
||||
{
|
||||
code: 'ENOENT',
|
||||
message: 'ENOENT: no such file or directory',
|
||||
expected:
|
||||
'File or directory not found. Check if the path exists and is spelled correctly.',
|
||||
},
|
||||
{
|
||||
code: 'ENOSPC',
|
||||
message: 'ENOSPC: no space left on device',
|
||||
expected:
|
||||
'No space left on device. Free up some disk space and try again.',
|
||||
},
|
||||
{
|
||||
code: 'EISDIR',
|
||||
message: 'EISDIR: illegal operation on a directory',
|
||||
path: '/some/directory',
|
||||
expected:
|
||||
"Path is a directory, not a file: '/some/directory'. Please provide a path to a file instead.",
|
||||
},
|
||||
{
|
||||
code: 'EISDIR',
|
||||
message: 'EISDIR: illegal operation on a directory',
|
||||
expected:
|
||||
'Path is a directory, not a file. Please provide a path to a file instead.',
|
||||
},
|
||||
{
|
||||
code: 'EROFS',
|
||||
message: 'EROFS: read-only file system',
|
||||
expected:
|
||||
'Read-only file system. Ensure the file system allows write operations.',
|
||||
},
|
||||
{
|
||||
code: 'EPERM',
|
||||
message: 'EPERM: operation not permitted',
|
||||
path: '/protected/file',
|
||||
expected:
|
||||
"Operation not permitted: '/protected/file'. Ensure you have the required permissions for this action.",
|
||||
},
|
||||
{
|
||||
code: 'EPERM',
|
||||
message: 'EPERM: operation not permitted',
|
||||
expected:
|
||||
'Operation not permitted. Ensure you have the required permissions for this action.',
|
||||
},
|
||||
{
|
||||
code: 'EEXIST',
|
||||
message: 'EEXIST: file already exists',
|
||||
path: '/existing/file',
|
||||
expected:
|
||||
"File or directory already exists: '/existing/file'. Try using a different name or path.",
|
||||
},
|
||||
{
|
||||
code: 'EEXIST',
|
||||
message: 'EEXIST: file already exists',
|
||||
expected:
|
||||
'File or directory already exists. Try using a different name or path.',
|
||||
},
|
||||
{
|
||||
code: 'EBUSY',
|
||||
message: 'EBUSY: resource busy or locked',
|
||||
path: '/locked/file',
|
||||
expected:
|
||||
"Resource busy or locked: '/locked/file'. Close any programs that might be using the file.",
|
||||
},
|
||||
{
|
||||
code: 'EBUSY',
|
||||
message: 'EBUSY: resource busy or locked',
|
||||
expected:
|
||||
'Resource busy or locked. Close any programs that might be using the file.',
|
||||
},
|
||||
{
|
||||
code: 'EMFILE',
|
||||
message: 'EMFILE: too many open files',
|
||||
expected:
|
||||
'Too many open files. Close some unused files or applications.',
|
||||
},
|
||||
{
|
||||
code: 'ENFILE',
|
||||
message: 'ENFILE: file table overflow',
|
||||
expected:
|
||||
'Too many open files in system. Close some unused files or applications.',
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'returns friendly message for $code (path: $path)',
|
||||
({ code, message, path, expected }) => {
|
||||
const error = createNodeError(code, message, path);
|
||||
expect(getFsErrorMessage(error)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('unknown node error codes', () => {
|
||||
const testCases: FsErrorCase[] = [
|
||||
{
|
||||
code: 'EUNKNOWN',
|
||||
message: 'Some unknown error occurred',
|
||||
expected: 'Some unknown error occurred (EUNKNOWN)',
|
||||
},
|
||||
{
|
||||
code: 'toString',
|
||||
message: 'Unexpected error',
|
||||
path: '/some/path',
|
||||
expected: 'Unexpected error (toString)',
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'includes code in fallback message for $code',
|
||||
({ code, message, path, expected }) => {
|
||||
const error = createNodeError(code, message, path);
|
||||
expect(getFsErrorMessage(error)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('non-node and nullish errors', () => {
|
||||
const fallbackCases: FallbackErrorCase[] = [
|
||||
{
|
||||
value: new Error('Something went wrong'),
|
||||
expected: 'Something went wrong',
|
||||
},
|
||||
{ value: 'string error', expected: 'string error' },
|
||||
{ value: 12345, expected: '12345' },
|
||||
{ value: null, expected: 'An unknown error occurred' },
|
||||
{ value: undefined, expected: 'An unknown error occurred' },
|
||||
];
|
||||
|
||||
it.each(fallbackCases)(
|
||||
'returns a message for $value',
|
||||
({ value, expected }) => {
|
||||
expect(getFsErrorMessage(value)).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, undefined] as const)(
|
||||
'uses custom default for %s',
|
||||
(value) => {
|
||||
expect(getFsErrorMessage(value, 'Custom default')).toBe(
|
||||
'Custom default',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { isNodeError, getErrorMessage } from './errors.js';
|
||||
|
||||
/**
|
||||
* Map of Node.js filesystem error codes to user-friendly message generators.
|
||||
* Each function takes the path (if available) and returns a descriptive message.
|
||||
*/
|
||||
const errorMessageGenerators: Record<string, (path?: string) => string> = {
|
||||
EACCES: (path) =>
|
||||
(path
|
||||
? `Permission denied: cannot access '${path}'. `
|
||||
: 'Permission denied. ') +
|
||||
'Check file permissions or run with elevated privileges.',
|
||||
ENOENT: (path) =>
|
||||
(path
|
||||
? `File or directory not found: '${path}'. `
|
||||
: 'File or directory not found. ') +
|
||||
'Check if the path exists and is spelled correctly.',
|
||||
ENOSPC: () =>
|
||||
'No space left on device. Free up some disk space and try again.',
|
||||
EISDIR: (path) =>
|
||||
(path
|
||||
? `Path is a directory, not a file: '${path}'. `
|
||||
: 'Path is a directory, not a file. ') +
|
||||
'Please provide a path to a file instead.',
|
||||
EROFS: () =>
|
||||
'Read-only file system. Ensure the file system allows write operations.',
|
||||
EPERM: (path) =>
|
||||
(path
|
||||
? `Operation not permitted: '${path}'. `
|
||||
: 'Operation not permitted. ') +
|
||||
'Ensure you have the required permissions for this action.',
|
||||
EEXIST: (path) =>
|
||||
(path
|
||||
? `File or directory already exists: '${path}'. `
|
||||
: 'File or directory already exists. ') +
|
||||
'Try using a different name or path.',
|
||||
EBUSY: (path) =>
|
||||
(path
|
||||
? `Resource busy or locked: '${path}'. `
|
||||
: 'Resource busy or locked. ') +
|
||||
'Close any programs that might be using the file.',
|
||||
EMFILE: () => 'Too many open files. Close some unused files or applications.',
|
||||
ENFILE: () =>
|
||||
'Too many open files in system. Close some unused files or applications.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Node.js filesystem error to a user-friendly message.
|
||||
*
|
||||
* @param error - The error to convert
|
||||
* @param defaultMessage - Optional default message if error cannot be interpreted
|
||||
* @returns A user-friendly error message
|
||||
*/
|
||||
export function getFsErrorMessage(
|
||||
error: unknown,
|
||||
defaultMessage = 'An unknown error occurred',
|
||||
): string {
|
||||
if (error == null) {
|
||||
return defaultMessage;
|
||||
}
|
||||
|
||||
if (isNodeError(error)) {
|
||||
const code = error.code;
|
||||
const path = error.path;
|
||||
|
||||
if (code && Object.hasOwn(errorMessageGenerators, code)) {
|
||||
return errorMessageGenerators[code](path);
|
||||
}
|
||||
|
||||
// For unknown error codes, include the code in the message
|
||||
if (code) {
|
||||
const baseMessage = error.message || defaultMessage;
|
||||
return `${baseMessage} (${code})`;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-Node errors, return the error message or string representation
|
||||
return getErrorMessage(error);
|
||||
}
|
||||
@@ -353,7 +353,6 @@ export class TestRig {
|
||||
testName: string,
|
||||
options: {
|
||||
settings?: Record<string, unknown>;
|
||||
state?: Record<string, unknown>;
|
||||
fakeResponsesPath?: string;
|
||||
} = {},
|
||||
) {
|
||||
@@ -383,9 +382,6 @@ export class TestRig {
|
||||
|
||||
// Create a settings file to point the CLI to the local collector
|
||||
this._createSettingsFile(options.settings);
|
||||
|
||||
// Create persistent state file
|
||||
this._createStateFile(options.state);
|
||||
}
|
||||
|
||||
private _cleanDir(dir: string) {
|
||||
@@ -477,24 +473,6 @@ export class TestRig {
|
||||
);
|
||||
}
|
||||
|
||||
private _createStateFile(overrideState?: Record<string, unknown>) {
|
||||
if (!this.homeDir) throw new Error('TestRig homeDir is not initialized');
|
||||
const userGeminiDir = join(this.homeDir, GEMINI_DIR);
|
||||
mkdirSync(userGeminiDir, { recursive: true });
|
||||
|
||||
const state = deepMerge(
|
||||
{
|
||||
terminalSetupPromptShown: true, // Default to true in tests to avoid blocking prompts
|
||||
},
|
||||
overrideState ?? {},
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(userGeminiDir, 'state.json'),
|
||||
JSON.stringify(state, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
createFile(fileName: string, content: string) {
|
||||
const filePath = join(this.testDir!, fileName);
|
||||
writeFileSync(filePath, content);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+10
-7
@@ -24,15 +24,15 @@ import { fileURLToPath } from 'node:url';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
||||
const RMRF_OPTIONS = {
|
||||
// remove npm install/build artifacts
|
||||
rmSync(join(root, 'node_modules'), { recursive: true, force: true });
|
||||
rmSync(join(root, 'bundle'), { recursive: true, force: true });
|
||||
rmSync(join(root, 'packages/cli/src/generated/'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 100,
|
||||
};
|
||||
rmSync(join(root, 'node_modules'), RMRF_OPTIONS);
|
||||
});
|
||||
const RMRF_OPTIONS = { recursive: true, force: true };
|
||||
rmSync(join(root, 'bundle'), RMRF_OPTIONS);
|
||||
rmSync(join(root, 'packages/cli/src/generated/'), RMRF_OPTIONS);
|
||||
// Dynamically clean dist directories in all workspaces
|
||||
const rootPackageJson = JSON.parse(
|
||||
readFileSync(join(root, 'package.json'), 'utf-8'),
|
||||
@@ -57,7 +57,10 @@ for (const workspace of rootPackageJson.workspaces) {
|
||||
}
|
||||
|
||||
// Clean up vscode-ide-companion package
|
||||
rmSync(join(root, 'packages/vscode-ide-companion/node_modules'), RMRF_OPTIONS);
|
||||
rmSync(join(root, 'packages/vscode-ide-companion/node_modules'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const vscodeCompanionDir = join(root, 'packages/vscode-ide-companion');
|
||||
try {
|
||||
|
||||
+167
-126
@@ -6,16 +6,129 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ACTIONLINT_VERSION = '1.7.7';
|
||||
const SHELLCHECK_VERSION = '0.11.0';
|
||||
const YAMLLINT_VERSION = '1.35.1';
|
||||
|
||||
const TEMP_DIR =
|
||||
process.env.GEMINI_LINT_TEMP_DIR || join(tmpdir(), 'gemini-cli-linters');
|
||||
|
||||
function getPlatformArch() {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
if (platform === 'linux' && arch === 'x64') {
|
||||
return {
|
||||
actionlint: 'linux_amd64',
|
||||
shellcheck: 'linux.x86_64',
|
||||
};
|
||||
}
|
||||
if (platform === 'darwin' && arch === 'x64') {
|
||||
return {
|
||||
actionlint: 'darwin_amd64',
|
||||
shellcheck: 'darwin.x86_64',
|
||||
};
|
||||
}
|
||||
if (platform === 'darwin' && arch === 'arm64') {
|
||||
return {
|
||||
actionlint: 'darwin_arm64',
|
||||
shellcheck: 'darwin.aarch64',
|
||||
};
|
||||
}
|
||||
if (platform === 'win32' && arch === 'x64') {
|
||||
return {
|
||||
actionlint: 'windows_amd64',
|
||||
// shellcheck is not used for Windows since it uses the .zip release
|
||||
// which has a consistent name across architectures
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported platform/architecture: ${platform}/${arch}`);
|
||||
}
|
||||
|
||||
const platformArch = getPlatformArch();
|
||||
|
||||
const PYTHON_VENV_PATH = join(TEMP_DIR, 'python_venv');
|
||||
|
||||
const pythonVenvPythonPath = join(
|
||||
PYTHON_VENV_PATH,
|
||||
process.platform === 'win32' ? 'Scripts' : 'bin',
|
||||
process.platform === 'win32' ? 'python.exe' : 'python',
|
||||
);
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const actionlintCheck = isWindows
|
||||
? `where actionlint 2>nul`
|
||||
: 'command -v actionlint';
|
||||
|
||||
const actionlintInstaller = isWindows
|
||||
? `powershell -Command "` +
|
||||
`New-Item -ItemType Directory -Force -Path '${TEMP_DIR}/actionlint' | Out-Null; ` +
|
||||
`Invoke-WebRequest -Uri 'https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.zip' -OutFile '${TEMP_DIR}/.actionlint.zip'; ` +
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.actionlint.zip', '${TEMP_DIR}/actionlint')"`
|
||||
: `
|
||||
mkdir -p "${TEMP_DIR}/actionlint"
|
||||
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
|
||||
tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
|
||||
`;
|
||||
|
||||
const shellcheckCheck = isWindows
|
||||
? `where shellcheck 2>nul`
|
||||
: 'command -v shellcheck';
|
||||
|
||||
const shellcheckInstaller = isWindows
|
||||
? `powershell -Command "` +
|
||||
`Invoke-WebRequest -Uri 'https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.zip' -OutFile '${TEMP_DIR}/.shellcheck.zip'; ` +
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.shellcheck.zip', '${TEMP_DIR}/shellcheck')"`
|
||||
: `
|
||||
mkdir -p "${TEMP_DIR}/shellcheck"
|
||||
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
|
||||
tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
|
||||
`;
|
||||
|
||||
const yamllintCheck = isWindows
|
||||
? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)`
|
||||
: `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`;
|
||||
|
||||
const yamllintInstaller = isWindows
|
||||
? `python -m venv "${PYTHON_VENV_PATH}" && ` +
|
||||
`"${pythonVenvPythonPath}" -m pip install --upgrade pip && ` +
|
||||
`"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple`
|
||||
: `
|
||||
python3 -m venv "${PYTHON_VENV_PATH}" && \
|
||||
"${pythonVenvPythonPath}" -m pip install --upgrade pip && \
|
||||
"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple
|
||||
`;
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* check: string;
|
||||
* installer: string;
|
||||
* run: string;
|
||||
* }}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {{[linterName: string]: Linter}}
|
||||
*/
|
||||
const LINTERS = {
|
||||
actionlint: {
|
||||
check: 'npm list @tktco/node-actionlint --depth=0',
|
||||
installer: 'npm install --save-dev @tktco/node-actionlint',
|
||||
check: actionlintCheck,
|
||||
installer: actionlintInstaller,
|
||||
run: `
|
||||
npx node-actionlint \
|
||||
actionlint \
|
||||
-color \
|
||||
-ignore 'SC2002:' \
|
||||
-ignore 'SC2016:' \
|
||||
@@ -24,59 +137,45 @@ const LINTERS = {
|
||||
`,
|
||||
},
|
||||
shellcheck: {
|
||||
check: 'npm list shellcheck --depth=0',
|
||||
installer: 'npm install --save-dev shellcheck',
|
||||
check: shellcheckCheck,
|
||||
installer: shellcheckInstaller,
|
||||
run: `
|
||||
git ls-files | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \
|
||||
| grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \
|
||||
| xargs shellcheck \
|
||||
--check-sourced \
|
||||
--enable=all \
|
||||
--exclude=SC2002,SC2129,SC2310 \
|
||||
--severity=style \
|
||||
--format=gcc \
|
||||
--color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g'
|
||||
`,
|
||||
},
|
||||
yamllint: {
|
||||
check: 'npm list yaml-lint --depth=0',
|
||||
installer: 'npm install --save-dev yaml-lint',
|
||||
check: yamllintCheck,
|
||||
installer: yamllintInstaller,
|
||||
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
|
||||
},
|
||||
};
|
||||
|
||||
function getShellScripts() {
|
||||
const allFiles = execSync('git ls-files', { encoding: 'utf-8' })
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
|
||||
return allFiles.filter((file) => {
|
||||
if (
|
||||
file.endsWith('.sh') ||
|
||||
file.endsWith('.zsh') ||
|
||||
file.endsWith('.bash')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!file.includes('.') &&
|
||||
existsSync(file) &&
|
||||
!lstatSync(file).isDirectory()
|
||||
) {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const firstLine = content.split('\n')[0];
|
||||
return (
|
||||
firstLine.startsWith('#!') &&
|
||||
(firstLine.includes('sh') ||
|
||||
firstLine.includes('bash') ||
|
||||
firstLine.includes('zsh'))
|
||||
);
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function getYamlFiles() {
|
||||
return execSync('git ls-files', { encoding: 'utf-8' })
|
||||
.split('\n')
|
||||
.filter((file) => file.endsWith('.yaml') || file.endsWith('.yml'));
|
||||
}
|
||||
|
||||
function runCommand(command, stdio = 'inherit') {
|
||||
try {
|
||||
execSync(command, { stdio, env: process.env, shell: true });
|
||||
const env = { ...process.env };
|
||||
const nodeBin = join(process.cwd(), 'node_modules', '.bin');
|
||||
const sep = isWindows ? ';' : ':';
|
||||
const pythonBin = isWindows
|
||||
? join(PYTHON_VENV_PATH, 'Scripts')
|
||||
: join(PYTHON_VENV_PATH, 'bin');
|
||||
// Windows sometimes uses 'Path' instead of 'PATH'
|
||||
const pathKey = 'Path' in env ? 'Path' : 'PATH';
|
||||
env[pathKey] = [
|
||||
nodeBin,
|
||||
join(TEMP_DIR, 'actionlint'),
|
||||
join(TEMP_DIR, 'shellcheck'),
|
||||
pythonBin,
|
||||
env[pathKey],
|
||||
].join(sep);
|
||||
execSync(command, { stdio, env, shell: true });
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
@@ -85,6 +184,10 @@ function runCommand(command, stdio = 'inherit') {
|
||||
|
||||
export function setupLinters() {
|
||||
console.log('Setting up linters...');
|
||||
if (!process.env.GEMINI_LINT_TEMP_DIR) {
|
||||
rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TEMP_DIR, { recursive: true });
|
||||
|
||||
for (const linter in LINTERS) {
|
||||
const { check, installer } = LINTERS[linter];
|
||||
@@ -117,67 +220,21 @@ export function runActionlint() {
|
||||
|
||||
export function runShellcheck() {
|
||||
console.log('\nRunning shellcheck...');
|
||||
const files = getShellScripts();
|
||||
if (files.length === 0) {
|
||||
console.log('No shell scripts found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const args = [
|
||||
'shellcheck',
|
||||
'--check-sourced',
|
||||
'--enable=all',
|
||||
'--exclude=SC2002,SC2129,SC2310',
|
||||
'--severity=style',
|
||||
'--format=gcc',
|
||||
'--color=never',
|
||||
...files,
|
||||
];
|
||||
|
||||
const result = spawnSync('npx', args, {
|
||||
env: process.env,
|
||||
encoding: 'utf-8',
|
||||
shell: true, // Needed for npx on Windows and some Unix environments
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
console.log(
|
||||
result.stdout
|
||||
.replace(/note:/g, 'warning:')
|
||||
.replace(/style:/g, 'warning:'),
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.stderr) {
|
||||
console.error(result.stderr);
|
||||
}
|
||||
if (!runCommand(LINTERS.shellcheck.run)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runYamllint() {
|
||||
console.log('\nRunning yamllint...');
|
||||
const files = getYamlFiles();
|
||||
if (files.length === 0) {
|
||||
console.log('No YAML files found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync('npx', ['yaml-lint', ...files], {
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (!runCommand(LINTERS.yamllint.run)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runPrettier() {
|
||||
console.log('\nRunning Prettier...');
|
||||
if (!runCommand('npx prettier --check .')) {
|
||||
if (!runCommand('prettier --check .')) {
|
||||
console.log(
|
||||
'Prettier check failed. Please run "npm run format" to fix formatting issues.',
|
||||
);
|
||||
@@ -201,43 +258,27 @@ export function runSensitiveKeywordLinter() {
|
||||
function getChangedFiles() {
|
||||
const baseRef = process.env.GITHUB_BASE_REF || 'main';
|
||||
try {
|
||||
// In CI, we often have the remote main already fetched or can diff against it directly
|
||||
// Try to find merge base if possible
|
||||
const mergeBase = execSync(
|
||||
`git merge-base HEAD origin/${baseRef} 2>/dev/null || git merge-base HEAD ${baseRef} 2>/dev/null`,
|
||||
)
|
||||
execSync(`git fetch origin ${baseRef}`);
|
||||
const mergeBase = execSync(`git merge-base HEAD origin/${baseRef}`)
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
if (mergeBase) {
|
||||
return execSync(`git diff --name-only ${mergeBase}..HEAD`)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Fall through to other methods
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Falling back to diff against HEAD~1');
|
||||
return execSync(`git diff --name-only HEAD~1..HEAD`)
|
||||
return execSync(`git diff --name-only ${mergeBase}..HEAD`)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
} catch (_fallbackError) {
|
||||
console.log('Falling back to all tracked files (slowest)');
|
||||
} catch (_error) {
|
||||
console.error(`Could not get changed files against origin/${baseRef}.`);
|
||||
try {
|
||||
return execSync('git ls-files')
|
||||
console.log('Falling back to diff against HEAD~1');
|
||||
return execSync(`git diff --name-only HEAD~1..HEAD`)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
} catch (_ultimateError) {
|
||||
console.error('Could not get any files to lint.');
|
||||
return [];
|
||||
} catch (_fallbackError) {
|
||||
console.error('Could not get changed files against HEAD~1 either.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
-46
@@ -5,17 +5,17 @@
|
||||
|
||||
set -e
|
||||
|
||||
if [[ -z "${1}" ]]; then
|
||||
echo "Usage: ${0} <pr#> [model]"
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 <pr#> [model]"
|
||||
exit 1
|
||||
fi
|
||||
pr="${1}"
|
||||
pr="$1"
|
||||
model="${2:-gemini-3.1-pro-preview}"
|
||||
REPO="google-gemini/gemini-cli"
|
||||
REVIEW_DIR="${HOME}/git/review/gemini-cli"
|
||||
REVIEW_DIR="$HOME/git/review/gemini-cli"
|
||||
|
||||
if [[ ! -d "${REVIEW_DIR}" ]]; then
|
||||
echo "ERROR: Directory ${REVIEW_DIR} does not exist."
|
||||
if [ ! -d "$REVIEW_DIR" ]; then
|
||||
echo "ERROR: Directory $REVIEW_DIR does not exist."
|
||||
echo ""
|
||||
echo "Please create a new gemini-cli clone at that directory to use for reviews."
|
||||
echo "Instructions:"
|
||||
@@ -26,56 +26,54 @@ if [[ ! -d "${REVIEW_DIR}" ]]; then
|
||||
fi
|
||||
|
||||
# 1. Check if the PR exists before doing anything else
|
||||
echo "review: Validating PR ${pr} on ${REPO}..."
|
||||
if ! gh pr view "${pr}" -R "${REPO}" > /dev/null 2>&1; then
|
||||
echo "ERROR: Could not find PR #${pr} in ${REPO}."
|
||||
echo "Are you sure ${pr} is a Pull Request number and not an Issue number?"
|
||||
echo "review: Validating PR $pr on $REPO..."
|
||||
if ! gh pr view "$pr" -R "$REPO" > /dev/null 2>&1; then
|
||||
echo "ERROR: Could not find PR #$pr in $REPO."
|
||||
echo "Are you sure $pr is a Pull Request number and not an Issue number?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "review: Opening PR ${pr} in browser..."
|
||||
OS_TYPE=$(uname)
|
||||
if [[ "${OS_TYPE}" == "Darwin" ]]; then
|
||||
open "https://github.com/${REPO}/pull/${pr}" || true
|
||||
echo "review: Opening PR $pr in browser..."
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
open "https://github.com/$REPO/pull/$pr" || true
|
||||
else
|
||||
xdg-open "https://github.com/${REPO}/pull/${pr}" || true
|
||||
xdg-open "https://github.com/$REPO/pull/$pr" || true
|
||||
fi
|
||||
|
||||
echo "review: Changing directory to ${REVIEW_DIR}"
|
||||
cd "${REVIEW_DIR}" || exit 1
|
||||
echo "review: Changing directory to $REVIEW_DIR"
|
||||
cd "$REVIEW_DIR" || exit 1
|
||||
|
||||
# 2. Fetch latest main to ensure we have a clean starting point
|
||||
echo "review: Fetching latest from origin..."
|
||||
git fetch origin main
|
||||
|
||||
# 3. Handle worktree creation
|
||||
WORKTREE_PATH="pr_${pr}"
|
||||
if [[ -d "${WORKTREE_PATH}" ]]; then
|
||||
echo "review: Worktree directory ${WORKTREE_PATH} already exists."
|
||||
WORKTREE_PATH="pr_$pr"
|
||||
if [ -d "$WORKTREE_PATH" ]; then
|
||||
echo "review: Worktree directory $WORKTREE_PATH already exists."
|
||||
# Check if it's actually a registered worktree
|
||||
WORKTREE_LIST=$(git worktree list)
|
||||
if echo "${WORKTREE_LIST}" | grep -q "${WORKTREE_PATH}"; then
|
||||
if git worktree list | grep -q "$WORKTREE_PATH"; then
|
||||
echo "review: Reusing existing worktree..."
|
||||
else
|
||||
echo "review: Directory exists but is not a worktree. Cleaning up..."
|
||||
rm -rf "${WORKTREE_PATH}"
|
||||
rm -rf "$WORKTREE_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -d "${WORKTREE_PATH}" ]]; then
|
||||
echo "review: Adding new worktree at ${WORKTREE_PATH}..."
|
||||
if [ ! -d "$WORKTREE_PATH" ]; then
|
||||
echo "review: Adding new worktree at $WORKTREE_PATH..."
|
||||
# Create a detached worktree from origin/main
|
||||
git worktree add --detach "${WORKTREE_PATH}" origin/main
|
||||
git worktree add --detach "$WORKTREE_PATH" origin/main
|
||||
fi
|
||||
|
||||
echo "review: Changing directory to ${WORKTREE_PATH}"
|
||||
cd "${WORKTREE_PATH}" || exit 1
|
||||
echo "review: Changing directory to $WORKTREE_PATH"
|
||||
cd "$WORKTREE_PATH" || exit 1
|
||||
|
||||
# 4. Checkout the PR
|
||||
echo "review: Cleaning worktree and checking out PR ${pr}..."
|
||||
echo "review: Cleaning worktree and checking out PR $pr..."
|
||||
git reset --hard
|
||||
git clean -fd
|
||||
gh pr checkout "${pr}" --branch "review-${pr}" -f -R "${REPO}"
|
||||
gh pr checkout "$pr" --branch "review-$pr" -f -R "$REPO"
|
||||
|
||||
# 5. Clean and Build
|
||||
echo "review: Clearing possibly stale node_modules..."
|
||||
@@ -89,48 +87,48 @@ npm install
|
||||
|
||||
echo "--- build ---"
|
||||
temp_dir_base="${TMPDIR:-/tmp}"
|
||||
if ! build_log_file=$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX"); then
|
||||
build_log_file=$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX") || {
|
||||
echo "Attempting to create temporary file in current directory as a fallback." >&2
|
||||
if ! build_log_file=$(mktemp "./npm_build_log_fallback.XXXXXX"); then
|
||||
build_log_file=$(mktemp "./npm_build_log_fallback.XXXXXX")
|
||||
if [ $? -ne 0 ] || [ -z "$build_log_file" ]; then
|
||||
echo "ERROR: Critical - Failed to create any temporary build log file. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
build_status=0
|
||||
build_command_to_run="FORCE_COLOR=1 CLICOLOR_FORCE=1 npm run build"
|
||||
|
||||
echo "Running build. Output (with colors) will be shown below and saved to: ${build_log_file}"
|
||||
echo "Build command: ${build_command_to_run}"
|
||||
echo "Running build. Output (with colors) will be shown below and saved to: $build_log_file"
|
||||
echo "Build command: $build_command_to_run"
|
||||
|
||||
OS_TYPE=$(uname)
|
||||
if [[ "${OS_TYPE}" == "Darwin" ]]; then
|
||||
script -q "${build_log_file}" /bin/sh -c "${build_command_to_run}"
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
script -q "$build_log_file" /bin/sh -c "$build_command_to_run"
|
||||
build_status=$?
|
||||
else
|
||||
if script -q -e -c "${build_command_to_run}" "${build_log_file}"; then
|
||||
if script -q -e -c "$build_command_to_run" "$build_log_file"; then
|
||||
build_status=0
|
||||
else
|
||||
build_status=$?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${build_status}" -ne 0 ]]; then
|
||||
echo "ERROR: npm build failed with exit status ${build_status}." >&2
|
||||
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
|
||||
if [ $build_status -ne 0 ]; then
|
||||
echo "ERROR: npm build failed with exit status $build_status." >&2
|
||||
echo "Review output above. Full log (with color codes) was in $build_log_file." >&2
|
||||
exit 1
|
||||
else
|
||||
if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "${build_log_file}"; then
|
||||
if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "$build_log_file"; then
|
||||
echo "ERROR: npm build completed with exit status 0, but suspicious error patterns were found in the build output." >&2
|
||||
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
|
||||
echo "Review output above. Full log (with color codes) was in $build_log_file." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "npm build completed successfully (exit status 0, no critical error patterns found in log)."
|
||||
rm -f "${build_log_file}"
|
||||
rm -f "$build_log_file"
|
||||
fi
|
||||
|
||||
echo "-- running ---"
|
||||
if ! npm start -- -m "${model}" -i="/review-frontend ${pr}"; then
|
||||
if ! npm start -- -m "$model" -i="/review-frontend $pr"; then
|
||||
echo "ERROR: npm start failed. Please check its output for details." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user