mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52860fd53a | |||
| 947eff6a05 | |||
| e279fb7917 | |||
| 388af256d5 | |||
| c3b88a61b1 | |||
| 2e85d48bdc | |||
| d7bf0c3a13 | |||
| d368997ca3 | |||
| 7bde726067 | |||
| bbd80c9393 | |||
| 3b601b3d90 | |||
| 6d6fc9dcf3 | |||
| b4bcd1a015 | |||
| aa000d7d30 | |||
| 2a7e602356 | |||
| 8d0b2d7f1b | |||
| c156bac5f7 | |||
| 263b8cd3b3 | |||
| 2d05396dd2 | |||
| 7b4a822b0e | |||
| d44615ac2f | |||
| de656f01d7 | |||
| 1d2585dba6 | |||
| 97bc3f28c5 | |||
| 3038fdce2e | |||
| bb060d7a98 | |||
| 9a73aa4072 | |||
| d7d53981f3 | |||
| 19e0b1ff7d | |||
| 4d393f9dca | |||
| 5abc170b08 | |||
| ceb4c5f6a7 | |||
| b6beab9480 | |||
| c2691f44b6 | |||
| 663d9c0537 | |||
| 4863816b81 | |||
| 829c532703 | |||
| c68303c553 | |||
| 7242d71c01 | |||
| 391715c33c | |||
| 8a537d85e9 | |||
| cd7dced951 | |||
| 73c589f9e3 | |||
| e700a9220b | |||
| 4b76fe0061 | |||
| 86080cc72b | |||
| e49430f744 |
@@ -0,0 +1,45 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,148 @@
|
||||
# --- 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
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
#!/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}"
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/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
|
||||
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.33.0
|
||||
# Latest stable release: v0.33.1
|
||||
|
||||
Released: March 11, 2026
|
||||
Released: March 12, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -29,6 +29,9 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version
|
||||
v0.33.0 and create version 0.33.1 by @gemini-cli-robot in
|
||||
[#22206](https://github.com/google-gemini/gemini-cli/pull/22206)
|
||||
- Docs: Update model docs to remove Preview Features. by @jkcinouye in
|
||||
[#20084](https://github.com/google-gemini/gemini-cli/pull/20084)
|
||||
- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in
|
||||
@@ -228,4 +231,4 @@ npm install -g @google/gemini-cli
|
||||
[#21952](https://github.com/google-gemini/gemini-cli/pull/21952)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.34.0-preview.0
|
||||
# Preview release: v0.34.0-preview.1
|
||||
|
||||
Released: March 11, 2026
|
||||
Released: March 12, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -28,6 +28,9 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#22174](https://github.com/google-gemini/gemini-cli/pull/22174)
|
||||
- feat(cli): add chat resume footer on session quit by @lordshashank in
|
||||
[#20667](https://github.com/google-gemini/gemini-cli/pull/20667)
|
||||
- Support bold and other styles in svg snapshots by @jacob314 in
|
||||
@@ -465,4 +468,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.1
|
||||
|
||||
@@ -26,6 +26,20 @@ policies.
|
||||
the CLI will use an available fallback model for the current turn or the
|
||||
remainder of the session.
|
||||
|
||||
### Local Model Routing (Experimental)
|
||||
|
||||
Gemini CLI supports using a local model for routing decisions. When configured,
|
||||
Gemini CLI will use a locally-running **Gemma** model to make routing decisions
|
||||
(instead of sending routing decisions to a hosted model). This feature can help
|
||||
reduce costs associated with hosted model usage while offering similar routing
|
||||
decision latency and quality.
|
||||
|
||||
In order to use this feature, the local Gemma model **must** be served behind a
|
||||
Gemini API and accessible via HTTP at an endpoint configured in `settings.json`.
|
||||
|
||||
For more details on how to configure local model routing, see
|
||||
[Local Model Routing](../core/local-model-routing.md).
|
||||
|
||||
### Model selection precedence
|
||||
|
||||
The model used by Gemini CLI is determined by the following order of precedence:
|
||||
@@ -38,5 +52,8 @@ The model used by Gemini CLI is determined by the following order of precedence:
|
||||
3. **`model.name` in `settings.json`:** If neither of the above are set, the
|
||||
model specified in the `model.name` property of your `settings.json` file
|
||||
will be used.
|
||||
4. **Default model:** If none of the above are set, the default model will be
|
||||
4. **Local model (experimental):** If the Gemma local model router is enabled
|
||||
in your `settings.json` file, the CLI will use the local Gemma model
|
||||
(instead of Gemini models) to route the request to an appropriate model.
|
||||
5. **Default model:** If none of the above are set, the default model will be
|
||||
used. The default model is `auto`
|
||||
|
||||
+71
-10
@@ -109,16 +109,6 @@ switch back to another mode.
|
||||
- **Keyboard shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
- **Natural language:** Ask Gemini CLI to "exit plan mode" or "stop planning."
|
||||
|
||||
## Customization and best practices
|
||||
|
||||
Plan Mode is secure by default, but you can adapt it to fit your specific
|
||||
workflows. You can customize how Gemini CLI plans by using skills, adjusting
|
||||
safety policies, or changing where plans are stored.
|
||||
|
||||
## Commands
|
||||
|
||||
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
Plan Mode enforces strict safety policies to prevent accidental changes.
|
||||
@@ -146,6 +136,12 @@ These are the only allowed tools:
|
||||
- **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized
|
||||
instructions and resources in a read-only manner)
|
||||
|
||||
## Customization and best practices
|
||||
|
||||
Plan Mode is secure by default, but you can adapt it to fit your specific
|
||||
workflows. You can customize how Gemini CLI plans by using skills, adjusting
|
||||
safety policies, changing where plans are stored, or adding hooks.
|
||||
|
||||
### Custom planning with skills
|
||||
|
||||
You can use [Agent Skills](../cli/skills.md) to customize how Gemini CLI
|
||||
@@ -294,6 +290,71 @@ modes = ["plan"]
|
||||
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
|
||||
```
|
||||
|
||||
### Using hooks with Plan Mode
|
||||
|
||||
You can use the [hook system](../hooks/writing-hooks.md) to automate parts of
|
||||
the planning workflow or enforce additional checks when Gemini CLI transitions
|
||||
into or out of Plan Mode.
|
||||
|
||||
Hooks such as `BeforeTool` or `AfterTool` can be configured to intercept the
|
||||
`enter_plan_mode` and `exit_plan_mode` tool calls.
|
||||
|
||||
> [!WARNING] When hooks are triggered by **tool executions**, they do **not**
|
||||
> run when you manually toggle Plan Mode using the `/plan` command or the
|
||||
> `Shift+Tab` keyboard shortcut. If you need hooks to execute on mode changes,
|
||||
> ensure the transition is initiated by the agent (e.g., by asking "start a plan
|
||||
> for...").
|
||||
|
||||
#### Example: Archive approved plans to GCS (`AfterTool`)
|
||||
|
||||
If your organizational policy requires a record of all execution plans, you can
|
||||
use an `AfterTool` hook to securely copy the plan artifact to Google Cloud
|
||||
Storage whenever Gemini CLI exits Plan Mode to start the implementation.
|
||||
|
||||
**`.gemini/hooks/archive-plan.sh`:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Extract the plan path from the tool input JSON
|
||||
plan_path=$(jq -r '.tool_input.plan_path // empty')
|
||||
|
||||
if [ -f "$plan_path" ]; then
|
||||
# Generate a unique filename using a timestamp
|
||||
filename="$(date +%s)_$(basename "$plan_path")"
|
||||
|
||||
# Upload the plan to GCS in the background so it doesn't block the CLI
|
||||
gsutil cp "$plan_path" "gs://my-audit-bucket/gemini-plans/$filename" > /dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
# AfterTool hooks should generally allow the flow to continue
|
||||
echo '{"decision": "allow"}'
|
||||
```
|
||||
|
||||
To register this `AfterTool` hook, add it to your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"AfterTool": [
|
||||
{
|
||||
"matcher": "exit_plan_mode",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "archive-plan",
|
||||
"type": "command",
|
||||
"command": "./.gemini/hooks/archive-plan.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
|
||||
|
||||
## Planning workflows
|
||||
|
||||
Plan Mode provides building blocks for structured research and design. These are
|
||||
|
||||
@@ -55,6 +55,7 @@ they appear in the UI.
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
|
||||
@@ -15,6 +15,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
|
||||
modular GEMINI.md import feature using @file.md syntax.
|
||||
- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for
|
||||
fine-grained control over tool execution.
|
||||
- **[Local Model Routing (experimental)](./local-model-routing.md):** Learn how
|
||||
to enable use of a local Gemma model for model routing decisions.
|
||||
|
||||
## Role of the core
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# Local Model Routing (experimental)
|
||||
|
||||
Gemini CLI supports using a local model for
|
||||
[routing decisions](../cli/model-routing.md). When configured, Gemini CLI will
|
||||
use a locally-running **Gemma** model to make routing decisions (instead of
|
||||
sending routing decisions to a hosted model).
|
||||
|
||||
This feature can help reduce costs associated with hosted model usage while
|
||||
offering similar routing decision latency and quality.
|
||||
|
||||
> **Note: Local model routing is currently an experimental feature.**
|
||||
|
||||
## Setup
|
||||
|
||||
Using a Gemma model for routing decisions requires that an implementation of a
|
||||
Gemma model be running locally on your machine, served behind an HTTP endpoint
|
||||
and accessed via the Gemini API.
|
||||
|
||||
To serve the Gemma model, follow these steps:
|
||||
|
||||
### Download the LiteRT-LM runtime
|
||||
|
||||
The [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) runtime offers
|
||||
pre-built binaries for locally-serving models. Download the binary appropriate
|
||||
for your system.
|
||||
|
||||
#### Windows
|
||||
|
||||
1. Download
|
||||
[lit.windows_x86_64.exe](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.windows_x86_64.exe).
|
||||
2. Using GPU on Windows requires the DirectXShaderCompiler. Download the
|
||||
[dxc zip from the latest release](https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.8.2505.1/dxc_2025_07_14.zip).
|
||||
Unzip the archive and from the architecture-appropriate `bin\` directory, and
|
||||
copy the `dxil.dll` and `dxcompiler.dll` into the same location as you saved
|
||||
`lit.windows_x86_64.exe`.
|
||||
3. (Optional) Test starting the runtime:
|
||||
`.\lit.windows_x86_64.exe serve --verbose`
|
||||
|
||||
#### Linux
|
||||
|
||||
1. Download
|
||||
[lit.linux_x86_64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.linux_x86_64).
|
||||
2. Ensure the binary is executable: `chmod a+x lit.linux_x86_64`
|
||||
3. (Optional) Test starting the runtime: `./lit.linux_x86_64 serve --verbose`
|
||||
|
||||
#### MacOS
|
||||
|
||||
1. Download
|
||||
[lit-macos-arm64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.macos_arm64).
|
||||
2. Ensure the binary is executable: `chmod a+x lit.macos_arm64`
|
||||
3. (Optional) Test starting the runtime: `./lit.macos_arm64 serve --verbose`
|
||||
|
||||
> **Note**: MacOS can be configured to only allows binaries from "App Store &
|
||||
> Known Developers". If you encounter an error message when attempting to run
|
||||
> the binary, you will need to allow the application. One option is to visit
|
||||
> `System Settings -> Privacy & Security`, scroll to `Security`, and click
|
||||
> `"Allow Anyway"` for `"lit.macos_arm64"`. Another option is to run
|
||||
> `xattr -d com.apple.quarantine lit.macos_arm64` from the commandline.
|
||||
|
||||
### Download the Gemma Model
|
||||
|
||||
Before using Gemma, you will need to download the model (and agree to the Terms
|
||||
of Service).
|
||||
|
||||
This can be done via the LiteRT-LM runtime.
|
||||
|
||||
#### Windows
|
||||
|
||||
```bash
|
||||
$ .\lit.windows_x86_64.exe pull gemma3-1b-gpu-custom
|
||||
|
||||
[Legal] The model you are about to download is governed by
|
||||
the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing.
|
||||
|
||||
Full Terms: https://ai.google.dev/gemma/terms
|
||||
Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
|
||||
|
||||
Do you accept these terms? (Y/N): Y
|
||||
|
||||
Terms accepted.
|
||||
Downloading model 'gemma3-1b-gpu-custom' ...
|
||||
Downloading... 968.6 MB
|
||||
Download complete.
|
||||
```
|
||||
|
||||
#### Linux
|
||||
|
||||
```bash
|
||||
$ ./lit.linux_x86_64 pull gemma3-1b-gpu-custom
|
||||
|
||||
[Legal] The model you are about to download is governed by
|
||||
the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing.
|
||||
|
||||
Full Terms: https://ai.google.dev/gemma/terms
|
||||
Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
|
||||
|
||||
Do you accept these terms? (Y/N): Y
|
||||
|
||||
Terms accepted.
|
||||
Downloading model 'gemma3-1b-gpu-custom' ...
|
||||
Downloading... 968.6 MB
|
||||
Download complete.
|
||||
```
|
||||
|
||||
#### MacOS
|
||||
|
||||
```bash
|
||||
$ ./lit.lit.macos_arm64 pull gemma3-1b-gpu-custom
|
||||
|
||||
[Legal] The model you are about to download is governed by
|
||||
the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing.
|
||||
|
||||
Full Terms: https://ai.google.dev/gemma/terms
|
||||
Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
|
||||
|
||||
Do you accept these terms? (Y/N): Y
|
||||
|
||||
Terms accepted.
|
||||
Downloading model 'gemma3-1b-gpu-custom' ...
|
||||
Downloading... 968.6 MB
|
||||
Download complete.
|
||||
```
|
||||
|
||||
### Start LiteRT-LM Runtime
|
||||
|
||||
Using the command appropriate to your system, start the LiteRT-LM runtime.
|
||||
Configure the port that you want to use for your Gemma model. For the purposes
|
||||
of this document, we will use port `9379`.
|
||||
|
||||
Example command for MacOS: `./lit.macos_arm64 serve --port=9379 --verbose`
|
||||
|
||||
### (Optional) Verify Model Serving
|
||||
|
||||
Send a quick prompt to the model via HTTP to validate successful model serving.
|
||||
This will cause the runtime to download the model and run it once.
|
||||
|
||||
You should see a short joke in the server output as an indicator of success.
|
||||
|
||||
#### Windows
|
||||
|
||||
```
|
||||
# Run this in PowerShell to send a request to the server
|
||||
|
||||
$uri = "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent"
|
||||
$body = @{contents = @( @{
|
||||
role = "user"
|
||||
parts = @( @{ text = "Tell me a joke." } )
|
||||
})} | ConvertTo-Json -Depth 10
|
||||
|
||||
Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType "application/json"
|
||||
```
|
||||
|
||||
#### Linux/MacOS
|
||||
|
||||
```bash
|
||||
$ curl "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d '{"contents":[{"role":"user","parts":[{"text":"Tell me a joke."}]}]}'
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
To use a local Gemma model for routing, you must explicitly enable it in your
|
||||
`settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"gemmaModelRouter": {
|
||||
"enabled": true,
|
||||
"classifier": {
|
||||
"host": "http://localhost:9379",
|
||||
"model": "gemma3-1b-gpu-custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Use the port you started your LiteRT-LM runtime on in the setup steps.
|
||||
|
||||
### Configuration schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :----------------- | :------ | :------- | :----------------------------------------------------------------------------------------- |
|
||||
| `enabled` | boolean | Yes | Must be `true` to enable the feature. |
|
||||
| `classifier` | object | Yes | The configuration for the local model endpoint. It includes the host and model specifiers. |
|
||||
| `classifier.host` | string | Yes | The URL to the local model server. Should be `http://localhost:<port>`. |
|
||||
| `classifier.model` | string | Yes | The model name to use for decisions. Must be `"gemma3-1b-gpu-custom"`. |
|
||||
|
||||
> **Note: You will need to restart after configuration changes for local model
|
||||
> routing to take effect.**
|
||||
@@ -25,6 +25,20 @@ 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.
|
||||
@@ -40,6 +54,7 @@ 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
|
||||
|
||||
@@ -70,6 +85,273 @@ 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:
|
||||
|
||||
+135
-19
@@ -38,6 +38,34 @@ 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:
|
||||
@@ -49,15 +77,17 @@ 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 configure it in
|
||||
`settings.json`. Example (forcing a specific model):
|
||||
- **Configuration:** Enabled by default. You can override its settings in
|
||||
`settings.json` under `agents.overrides`. Example (forcing a specific model
|
||||
and increasing turns):
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"codebaseInvestigatorSettings": {
|
||||
"enabled": true,
|
||||
"maxNumTurns": 20,
|
||||
"model": "gemini-2.5-pro"
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"codebase_investigator": {
|
||||
"modelConfig": { "model": "gemini-3-flash-preview" },
|
||||
"runConfig": { "maxTurns": 50 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +263,7 @@ kind: local
|
||||
tools:
|
||||
- read_file
|
||||
- grep_search
|
||||
model: gemini-2.5-pro
|
||||
model: gemini-3-flash-preview
|
||||
temperature: 0.2
|
||||
max_turns: 10
|
||||
---
|
||||
@@ -254,16 +284,102 @@ 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. 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`. |
|
||||
| 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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Optimizing your subagent
|
||||
|
||||
@@ -298,7 +414,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 and usage instructions.
|
||||
configuration, authentication, and usage instructions.
|
||||
|
||||
## Extension subagents
|
||||
|
||||
|
||||
@@ -14,6 +14,31 @@ 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.
|
||||
|
||||
@@ -245,6 +245,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Hide helpful tips in the UI
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.escapePastedAtSymbols`** (boolean):
|
||||
- **Description:** When enabled, @ symbols in pasted text are escaped to
|
||||
prevent unintended @path expansion.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showShortcutsHint`** (boolean):
|
||||
- **Description:** Show the "? for shortcuts" hint above the input.
|
||||
- **Default:** `true`
|
||||
|
||||
@@ -342,7 +342,9 @@ policies, as it is much more robust than manually writing Fully Qualified Names
|
||||
|
||||
**1. Targeting a specific tool on a server**
|
||||
|
||||
Combine `mcpName` and `toolName` to target a single operation.
|
||||
Combine `mcpName` and `toolName` to target a single operation. When using
|
||||
`mcpName`, the `toolName` field should strictly be the simple name of the tool
|
||||
(e.g., `search`), **not** the Fully Qualified Name (e.g., `mcp_server_search`).
|
||||
|
||||
```toml
|
||||
# Allows the `search` tool on the `my-jira-server` MCP
|
||||
|
||||
@@ -120,6 +120,14 @@ tools to detect if they are being run from within the Gemini CLI.
|
||||
|
||||
## Command restrictions
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> The `tools.core` setting is an **allowlist for _all_ built-in
|
||||
> tools**, not just shell commands. When you set `tools.core` to any value,
|
||||
> _only_ the tools explicitly listed will be enabled. This includes all built-in
|
||||
> tools like `read_file`, `write_file`, `glob`, `grep_search`, `list_directory`,
|
||||
> `replace`, etc.
|
||||
|
||||
You can restrict the commands that can be executed by the `run_shell_command`
|
||||
tool by using the `tools.core` and `tools.exclude` settings in your
|
||||
configuration file.
|
||||
|
||||
+9
-4
@@ -82,11 +82,14 @@ const commonAliases = {
|
||||
const cliConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
|
||||
},
|
||||
entryPoints: ['packages/cli/index.ts'],
|
||||
outfile: 'bundle/gemini.js',
|
||||
entryPoints: { gemini: 'packages/cli/index.ts' },
|
||||
outdir: 'bundle',
|
||||
splitting: true,
|
||||
define: {
|
||||
__filename: '__chunk_filename',
|
||||
__dirname: '__chunk_dirname',
|
||||
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
|
||||
'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify(
|
||||
pkg.config?.sandboxImageUri,
|
||||
@@ -103,11 +106,13 @@ const cliConfig = {
|
||||
const a2aServerConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
|
||||
},
|
||||
entryPoints: ['packages/a2a-server/src/http/server.ts'],
|
||||
outfile: 'packages/a2a-server/dist/a2a-server.mjs',
|
||||
define: {
|
||||
__filename: '__chunk_filename',
|
||||
__dirname: '__chunk_dirname',
|
||||
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
|
||||
},
|
||||
plugins: createWasmPlugins(),
|
||||
|
||||
@@ -35,11 +35,6 @@ const commonRestrictedSyntaxRules = [
|
||||
message:
|
||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
||||
},
|
||||
{
|
||||
selector: 'CallExpression[callee.name="fetch"]',
|
||||
message:
|
||||
'Use safeFetch() from "@/utils/fetch" instead of the global fetch() to ensure SSRF protection. If you are implementing a custom security layer, use an eslint-disable comment and explain why.',
|
||||
},
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
|
||||
+72
-33
@@ -5,31 +5,62 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('ask_user', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
askUserEvalTest('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 wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
const confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for ask_user tool',
|
||||
).toBeDefined();
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
askUserEvalTest('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 wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
const confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for ask_user tool',
|
||||
).toBeDefined();
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
askUserEvalTest('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";',
|
||||
@@ -39,28 +70,37 @@ describe('ask_user', () => {
|
||||
}),
|
||||
'README.md': '# Gemini CLI',
|
||||
},
|
||||
prompt: `Refactor the entire core package to be better.`,
|
||||
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']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
// 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 wasAskUserCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(
|
||||
wasAskUserCalled,
|
||||
'Expected ask_user tool to be called to clarify the significant rework',
|
||||
).toBe(true);
|
||||
confirmation?.toolName,
|
||||
'Expected ask_user to be called to clarify the significant rework',
|
||||
).toBe('ask_user');
|
||||
},
|
||||
});
|
||||
|
||||
// --- 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
|
||||
evalTest('USUALLY_PASSES', {
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
name: 'Agent does NOT use AskUser to confirm shell commands',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
@@ -68,25 +108,24 @@ 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) => {
|
||||
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',
|
||||
);
|
||||
const confirmation = await rig.waitForPendingConfirmation([
|
||||
'run_shell_command',
|
||||
'ask_user',
|
||||
]);
|
||||
|
||||
expect(
|
||||
wasShellCalled,
|
||||
'Expected run_shell_command tool to be called',
|
||||
).toBe(true);
|
||||
confirmation,
|
||||
'Expected a pending confirmation for a tool',
|
||||
).toBeDefined();
|
||||
|
||||
expect(
|
||||
wasAskUserCalled,
|
||||
confirmation?.toolName,
|
||||
'ask_user should not be called to confirm shell commands',
|
||||
).toBe(false);
|
||||
).toBe('run_shell_command');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"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,4 +203,33 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
/**
|
||||
* integration test to ensure no node.js deprecation warnings are emitted.
|
||||
* must run for all supported node versions as warnings may vary by version.
|
||||
*/
|
||||
describe('deprecation-warnings', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it.each([
|
||||
{ command: '--version', description: 'running --version' },
|
||||
{ command: '--help', description: 'running with --help' },
|
||||
])(
|
||||
'should not emit any deprecation warnings when $description',
|
||||
async ({ command, description }) => {
|
||||
await rig.setup(
|
||||
`should not emit any deprecation warnings when ${description}`,
|
||||
);
|
||||
|
||||
const { stderr, exitCode } = await rig.runWithStreams([command]);
|
||||
|
||||
// node.js deprecation warnings: (node:12345) [DEP0040] DeprecationWarning: ...
|
||||
const deprecationWarningPattern = /\[DEP\d+\].*DeprecationWarning/i;
|
||||
const hasDeprecationWarning = deprecationWarningPattern.test(stderr);
|
||||
|
||||
if (hasDeprecationWarning) {
|
||||
const deprecationMatches = stderr.match(
|
||||
/\[DEP\d+\].*DeprecationWarning:.*/gi,
|
||||
);
|
||||
const warnings = deprecationMatches
|
||||
? deprecationMatches.map((m) => m.trim()).join('\n')
|
||||
: 'Unknown deprecation warning format';
|
||||
|
||||
throw new Error(
|
||||
`Deprecation warnings detected in CLI output:\n${warnings}\n\n` +
|
||||
`Full stderr:\n${stderr}\n\n` +
|
||||
`This test ensures no deprecated Node.js modules are used. ` +
|
||||
`Please update dependencies to use non-deprecated alternatives.`,
|
||||
);
|
||||
}
|
||||
|
||||
// only check exit code if no deprecation warnings found
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${exitCode} (expected 0). This may indicate a setup issue.\n` +
|
||||
`Stderr: ${stderr}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Generated
+1494
-168
File diff suppressed because it is too large
Load Diff
@@ -88,6 +88,7 @@
|
||||
"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",
|
||||
@@ -126,11 +127,13 @@
|
||||
"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": {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
mockConfig = createMockConfig({
|
||||
isEventDrivenSchedulerEnabled: () => true,
|
||||
}) as Config;
|
||||
messageBus = mockConfig.getMessageBus();
|
||||
messageBus = mockConfig.messageBus;
|
||||
mockEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
@@ -360,7 +360,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
isEventDrivenSchedulerEnabled: () => true,
|
||||
getApprovalMode: () => ApprovalMode.YOLO,
|
||||
}) as Config;
|
||||
const yoloMessageBus = yoloConfig.getMessageBus();
|
||||
const yoloMessageBus = yoloConfig.messageBus;
|
||||
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
type AgentLoopContext,
|
||||
Scheduler,
|
||||
type GeminiClient,
|
||||
GeminiEventType,
|
||||
@@ -114,7 +115,8 @@ export class Task {
|
||||
|
||||
this.scheduler = this.setupEventDrivenScheduler();
|
||||
|
||||
this.geminiClient = this.config.getGeminiClient();
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
this.geminiClient = loopContext.geminiClient;
|
||||
this.pendingToolConfirmationDetails = new Map();
|
||||
this.taskState = 'submitted';
|
||||
this.eventBus = eventBus;
|
||||
@@ -143,7 +145,8 @@ export class Task {
|
||||
// process. This is not scoped to the individual task but reflects the global connection
|
||||
// state managed within the @gemini-cli/core module.
|
||||
async getMetadata(): Promise<TaskMetadata> {
|
||||
const toolRegistry = this.config.getToolRegistry();
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
const toolRegistry = loopContext.toolRegistry;
|
||||
const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const serverStatuses = getAllMCPServerStatuses();
|
||||
const servers = Object.keys(mcpServers).map((serverName) => ({
|
||||
@@ -376,7 +379,8 @@ export class Task {
|
||||
private messageBusListener?: (message: ToolCallsUpdateMessage) => void;
|
||||
|
||||
private setupEventDrivenScheduler(): Scheduler {
|
||||
const messageBus = this.config.getMessageBus();
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
const messageBus = loopContext.messageBus;
|
||||
const scheduler = new Scheduler({
|
||||
schedulerId: this.id,
|
||||
context: this.config,
|
||||
@@ -395,9 +399,11 @@ export class Task {
|
||||
|
||||
dispose(): void {
|
||||
if (this.messageBusListener) {
|
||||
this.config
|
||||
.getMessageBus()
|
||||
.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusListener);
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
loopContext.messageBus.unsubscribe(
|
||||
MessageBusType.TOOL_CALLS_UPDATE,
|
||||
this.messageBusListener,
|
||||
);
|
||||
this.messageBusListener = undefined;
|
||||
}
|
||||
|
||||
@@ -948,7 +954,8 @@ export class Task {
|
||||
|
||||
try {
|
||||
if (correlationId) {
|
||||
await this.config.getMessageBus().publish({
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
await loopContext.messageBus.publish({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId,
|
||||
confirmed:
|
||||
|
||||
@@ -59,6 +59,9 @@ describe('a2a-server memory commands', () => {
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
mockConfig = {
|
||||
get toolRegistry() {
|
||||
return mockToolRegistry;
|
||||
},
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -168,7 +171,6 @@ describe('a2a-server memory commands', () => {
|
||||
]);
|
||||
|
||||
expect(mockAddMemory).toHaveBeenCalledWith(fact);
|
||||
expect(mockConfig.getToolRegistry).toHaveBeenCalled();
|
||||
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory');
|
||||
expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith(
|
||||
{ fact },
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
import type { AgentLoopContext } from '@google/gemini-cli-core';
|
||||
|
||||
const DEFAULT_SANITIZATION_CONFIG = {
|
||||
allowedEnvironmentVariables: [],
|
||||
@@ -95,7 +96,8 @@ export class AddMemoryCommand implements Command {
|
||||
return { name: this.name, data: result.content };
|
||||
}
|
||||
|
||||
const toolRegistry = context.config.getToolRegistry();
|
||||
const loopContext: AgentLoopContext = context.config;
|
||||
const toolRegistry = loopContext.toolRegistry;
|
||||
const tool = toolRegistry.getTool(result.toolName);
|
||||
if (tool) {
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
GeminiClient,
|
||||
HookSystem,
|
||||
type MessageBus,
|
||||
PolicyDecision,
|
||||
tmpdir,
|
||||
type Config,
|
||||
@@ -31,9 +32,27 @@ export function createMockConfig(
|
||||
const tmpDir = tmpdir();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const mockConfig = {
|
||||
get toolRegistry(): ToolRegistry {
|
||||
get config() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (this as unknown as Config).getToolRegistry();
|
||||
return this as unknown as Config;
|
||||
},
|
||||
get toolRegistry() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = this as unknown as Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return config.getToolRegistry?.() as unknown as ToolRegistry;
|
||||
},
|
||||
get messageBus() {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(this as unknown as Config).getMessageBus?.() as unknown as MessageBus
|
||||
);
|
||||
},
|
||||
get geminiClient() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = this as unknown as Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return config.getGeminiClient?.() as unknown as GeminiClient;
|
||||
},
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTool: vi.fn(),
|
||||
@@ -81,9 +100,6 @@ export function createMockConfig(
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(mockConfig as unknown as { config: Config; promptId: string }).config =
|
||||
mockConfig;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
|
||||
'test-prompt-id';
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { listExtensions, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
listExtensions,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
} 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 '../../utils/errors.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
|
||||
// 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,8 +6,7 @@
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } 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,26 +13,24 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { coreEvents, getErrorMessage } 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'
|
||||
);
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{ stripAnsi: true },
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: true });
|
||||
return { ...mocked, 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(),
|
||||
}));
|
||||
|
||||
@@ -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,27 +5,23 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { coreEvents, getErrorMessage } 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'
|
||||
);
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{
|
||||
stripAnsi: false,
|
||||
},
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: false });
|
||||
return { ...mocked, 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(),
|
||||
}));
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } 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 '../../utils/errors.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
|
||||
// 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,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } 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,9 +12,12 @@ 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 } from '@google/gemini-cli-core';
|
||||
import {
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
} 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,11 +5,10 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } 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,6 +28,9 @@ 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,8 +5,11 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
debugLogger,
|
||||
type SkillDefinition,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { installSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -24,6 +24,9 @@ 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,10 +5,9 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
|
||||
@@ -21,6 +21,9 @@ 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,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { uninstallSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type MergedSettings,
|
||||
createTestMergedSettings,
|
||||
} from './settings.js';
|
||||
import * as SettingsModule from './settings.js';
|
||||
import * as ServerConfig from '@google/gemini-cli-core';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
@@ -814,7 +813,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
});
|
||||
|
||||
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
|
||||
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings();
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
@@ -863,7 +862,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
});
|
||||
|
||||
it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
|
||||
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||
process.argv = ['node', 'script.js'];
|
||||
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
|
||||
const settings = createTestMergedSettings({
|
||||
context: {
|
||||
@@ -891,7 +890,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
});
|
||||
|
||||
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
|
||||
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
context: {
|
||||
includeDirectories: ['/path/to/include'],
|
||||
@@ -916,39 +915,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip extension, memory, and PTY discovery in bootstrap mode', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = await parseArguments(settings);
|
||||
const loadSettingsSpy = vi.spyOn(SettingsModule, 'loadSettings');
|
||||
const getPtySpy = vi.spyOn(ServerConfig, 'getPty');
|
||||
|
||||
await loadCliConfig(settings, 'session-id', argv, {
|
||||
mode: 'bootstrap',
|
||||
loadedSettings: { merged: settings } as SettingsModule.LoadedSettings,
|
||||
});
|
||||
|
||||
expect(loadSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(ExtensionManager.prototype.loadExtensions).not.toHaveBeenCalled();
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
expect(getPtySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should defer extension and memory discovery during interactive startup', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = await parseArguments(settings);
|
||||
const getPtySpy = vi.spyOn(ServerConfig, 'getPty');
|
||||
|
||||
await loadCliConfig(settings, 'session-id', argv, {
|
||||
loadedSettings: { merged: settings } as SettingsModule.LoadedSettings,
|
||||
});
|
||||
|
||||
expect(ExtensionManager.prototype.loadExtensions).not.toHaveBeenCalled();
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
expect(getPtySpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeMcpServers', () => {
|
||||
@@ -3666,6 +3632,8 @@ describe('loadCliConfig acpMode and clientName', () => {
|
||||
it('should set acpMode to true and detect clientName when --acp flag is used', async () => {
|
||||
process.argv = ['node', 'script.js', '--acp'];
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings(),
|
||||
@@ -3679,6 +3647,8 @@ describe('loadCliConfig acpMode and clientName', () => {
|
||||
it('should set acpMode to true but leave clientName undefined for generic terminals', async () => {
|
||||
process.argv = ['node', 'script.js', '--acp'];
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal
|
||||
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings(),
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
resolveToRealPath,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
startupProfiler,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
@@ -48,7 +47,6 @@ import {
|
||||
import {
|
||||
type Settings,
|
||||
type MergedSettings,
|
||||
type LoadedSettings,
|
||||
saveModelChange,
|
||||
loadSettings,
|
||||
} from './settings.js';
|
||||
@@ -419,8 +417,6 @@ export function isDebugMode(argv: CliArgs): boolean {
|
||||
|
||||
export interface LoadCliConfigOptions {
|
||||
cwd?: string;
|
||||
mode?: 'bootstrap' | 'full';
|
||||
loadedSettings?: LoadedSettings;
|
||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
|
||||
disabled?: string[];
|
||||
};
|
||||
@@ -432,15 +428,10 @@ export async function loadCliConfig(
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
mode = 'full',
|
||||
loadedSettings,
|
||||
projectHooks,
|
||||
} = options;
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
const resolvedLoadedSettings = loadedSettings ?? loadSettings(cwd);
|
||||
const isBootstrap = mode === 'bootstrap';
|
||||
|
||||
const loadedSettings = loadSettings(cwd);
|
||||
|
||||
if (argv.sandbox) {
|
||||
process.env['GEMINI_SANDBOX'] = 'true';
|
||||
@@ -448,18 +439,6 @@ export async function loadCliConfig(
|
||||
|
||||
const memoryImportFormat = settings.context?.importFormat || 'tree';
|
||||
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.acp ||
|
||||
!!argv.experimentalAcp ||
|
||||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
|
||||
!argv.isCommand);
|
||||
const shouldDeferInteractiveStartupWork =
|
||||
!isBootstrap &&
|
||||
interactive &&
|
||||
!argv.listExtensions &&
|
||||
!argv.listSessions &&
|
||||
!argv.deleteSession;
|
||||
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
@@ -501,7 +480,6 @@ export async function loadCliConfig(
|
||||
.map(resolvePath)
|
||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||
|
||||
const clientVersion = await getVersion();
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
@@ -510,15 +488,9 @@ export async function loadCliConfig(
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
if (!isBootstrap && !shouldDeferInteractiveStartupWork) {
|
||||
const loadExtensionsHandle = startupProfiler.start(
|
||||
'load_extensions_during_config',
|
||||
);
|
||||
await extensionManager.loadExtensions();
|
||||
loadExtensionsHandle?.end();
|
||||
}
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
.getExtensions()
|
||||
@@ -539,12 +511,7 @@ export async function loadCliConfig(
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
|
||||
if (
|
||||
!experimentalJitContext &&
|
||||
!isBootstrap &&
|
||||
!shouldDeferInteractiveStartupWork
|
||||
) {
|
||||
const loadMemoryHandle = startupProfiler.start('load_memory');
|
||||
if (!experimentalJitContext) {
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
@@ -561,7 +528,6 @@ export async function loadCliConfig(
|
||||
memoryContent = result.memoryContent;
|
||||
fileCount = result.fileCount;
|
||||
filePaths = result.filePaths;
|
||||
loadMemoryHandle?.end();
|
||||
}
|
||||
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
@@ -651,6 +617,15 @@ export async function loadCliConfig(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// -p/--prompt forces non-interactive (headless) mode
|
||||
// -i/--prompt-interactive forces interactive mode with an initial prompt
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.acp ||
|
||||
!!argv.experimentalAcp ||
|
||||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
|
||||
!argv.isCommand);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
@@ -720,7 +695,7 @@ export async function loadCliConfig(
|
||||
? argv.screenReader
|
||||
: (settings.ui?.accessibility?.screenReader ?? false);
|
||||
|
||||
const ptyInfo = isBootstrap ? null : await getPty();
|
||||
const ptyInfo = await getPty();
|
||||
|
||||
const mcpEnabled = settings.admin?.mcp?.enabled ?? true;
|
||||
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
|
||||
@@ -766,7 +741,7 @@ export async function loadCliConfig(
|
||||
acpMode: isAcpMode,
|
||||
clientName,
|
||||
sessionId,
|
||||
clientVersion,
|
||||
clientVersion: await getVersion(),
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: sandboxConfig,
|
||||
targetDir: cwd,
|
||||
@@ -847,8 +822,6 @@ export async function loadCliConfig(
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
deferInitialMemoryLoad:
|
||||
shouldDeferInteractiveStartupWork && !experimentalJitContext,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
@@ -891,8 +864,7 @@ export async function loadCliConfig(
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
projectHooks: projectHooks || {},
|
||||
onModelChange: (model: string) =>
|
||||
saveModelChange(resolvedLoadedSettings, model),
|
||||
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
||||
onReload: async () => {
|
||||
const refreshedSettings = loadSettings(cwd);
|
||||
return {
|
||||
|
||||
@@ -94,7 +94,7 @@ interface ExtensionManagerParams {
|
||||
/**
|
||||
* Actual implementation of an ExtensionLoader.
|
||||
*
|
||||
* Extension metadata is loaded lazily via `loadExtensions`/`ensureLoaded`.
|
||||
* You must call `loadExtensions` prior to calling other methods on this class.
|
||||
*/
|
||||
export class ExtensionManager extends ExtensionLoader {
|
||||
private extensionEnablementManager: ExtensionEnablementManager;
|
||||
@@ -146,24 +146,12 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
}
|
||||
|
||||
getExtensions(): GeminiCLIExtension[] {
|
||||
return this.loadedExtensions ?? [];
|
||||
}
|
||||
|
||||
override isLoaded(): boolean {
|
||||
return this.loadedExtensions !== undefined;
|
||||
}
|
||||
|
||||
override async ensureLoaded(): Promise<void> {
|
||||
if (this.loadedExtensions) {
|
||||
return;
|
||||
if (!this.loadedExtensions) {
|
||||
throw new Error(
|
||||
'Extensions not yet loaded, must call `loadExtensions` first',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.loadingPromise) {
|
||||
await this.loadingPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadExtensions();
|
||||
return this.loadedExtensions;
|
||||
}
|
||||
|
||||
async installOrUpdateExtension(
|
||||
|
||||
@@ -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,9 +11,12 @@ import {
|
||||
} from '../../ui/state/extensions.js';
|
||||
import { loadInstallMetadata } from '../extension.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
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',
|
||||
'There was an error saving your latest settings changes.',
|
||||
'Failed to save settings: Write failed',
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
getErrorMessage,
|
||||
getFsErrorMessage,
|
||||
Storage,
|
||||
coreEvents,
|
||||
homedir,
|
||||
@@ -289,11 +290,6 @@ export function createTestMergedSettings(
|
||||
) as MergedSettings;
|
||||
}
|
||||
|
||||
export function createDefaultMergedSettings(): MergedSettings {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return getDefaultsFromSchema() as MergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* An immutable snapshot of settings state.
|
||||
* Used with useSyncExternalStore for reactive updates.
|
||||
@@ -1077,9 +1073,10 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
settingsToSave as Record<string, unknown>,
|
||||
);
|
||||
} catch (error) {
|
||||
const detailedErrorMessage = getFsErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'There was an error saving your latest settings changes.',
|
||||
`Failed to save settings: ${detailedErrorMessage}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -1092,9 +1089,10 @@ export function saveModelChange(
|
||||
try {
|
||||
loadedSettings.setValue(SettingScope.User, 'model.name', model);
|
||||
} catch (error) {
|
||||
const detailedErrorMessage = getFsErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'There was an error saving your preferred model.',
|
||||
`Failed to save preferred model: ${detailedErrorMessage}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -540,6 +540,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
escapePastedAtSymbols: {
|
||||
type: 'boolean',
|
||||
label: 'Escape Pasted @ Symbols',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showShortcutsHint: {
|
||||
type: 'boolean',
|
||||
label: 'Show Shortcuts Hint',
|
||||
|
||||
@@ -269,7 +269,6 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalIsTTY: boolean | undefined;
|
||||
const originalArgv = [...process.argv];
|
||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||
[];
|
||||
|
||||
@@ -285,7 +284,6 @@ describe('gemini.tsx main function', () => {
|
||||
originalIsTTY = process.stdin.isTTY;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).isTTY = true;
|
||||
process.argv = ['node', 'script.js'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -298,70 +296,11 @@ describe('gemini.tsx main function', () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).isTTY = originalIsTTY;
|
||||
process.argv = [...originalArgv];
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should fast-path --version without loading settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--version'];
|
||||
|
||||
await main();
|
||||
|
||||
expect(loadSettings).not.toHaveBeenCalled();
|
||||
expect(parseArguments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fast-path --help without loading settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--help'];
|
||||
|
||||
await main();
|
||||
|
||||
expect(loadSettings).not.toHaveBeenCalled();
|
||||
expect(parseArguments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not relaunch a child process when sandboxing and memory relaunch are unnecessary', async () => {
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
prompt: 'test',
|
||||
} as unknown as CliArgs);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => 'test',
|
||||
getSandbox: () => undefined,
|
||||
}),
|
||||
);
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
try {
|
||||
await main();
|
||||
expect.fail('Should have thrown MockProcessExitError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(MockProcessExitError);
|
||||
expect((e as MockProcessExitError).code).toBe(0);
|
||||
} finally {
|
||||
processExitSpy.mockRestore();
|
||||
}
|
||||
|
||||
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
|
||||
expect(relaunchAppInChildProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log unhandled promise rejections and open debug console on first error', async () => {
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
@@ -726,42 +665,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should use bootstrap config for the pre-relaunch startup pass', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
});
|
||||
vi.mocked(loadSettings).mockReturnValue(mockSettings);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as unknown as CliArgs);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
isInteractive: () => true,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await main();
|
||||
|
||||
expect(loadCliConfig).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(loadCliConfig).mock.calls[0][3]).toMatchObject({
|
||||
mode: 'bootstrap',
|
||||
loadedSettings: mockSettings,
|
||||
});
|
||||
expect(vi.mocked(loadCliConfig).mock.calls[1][3]).toMatchObject({
|
||||
loadedSettings: mockSettings,
|
||||
});
|
||||
});
|
||||
|
||||
it('should log warning when theme is not found', async () => {
|
||||
const { themeManager } = await import('./ui/themes/theme-manager.js');
|
||||
const debugLoggerWarnSpy = vi
|
||||
|
||||
+52
-283
@@ -4,20 +4,44 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type OutputPayload,
|
||||
type ConsoleLogPayload,
|
||||
type UserFeedbackPayload,
|
||||
sessionId,
|
||||
logUserPrompt,
|
||||
AuthType,
|
||||
UserPromptEvent,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
getOauthClient,
|
||||
patchStdio,
|
||||
writeToStdout,
|
||||
writeToStderr,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
ExitCodes,
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type AdminControlsSettings,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import {
|
||||
createDefaultMergedSettings,
|
||||
loadSettings,
|
||||
SettingScope,
|
||||
type DnsResolutionOrder,
|
||||
@@ -38,47 +62,11 @@ import {
|
||||
runExitCleanup,
|
||||
registerTelemetryConfig,
|
||||
setupSignalHandlers,
|
||||
setupTtyCheck,
|
||||
} from './utils/cleanup.js';
|
||||
import {
|
||||
cleanupToolOutputFiles,
|
||||
cleanupExpiredSessions,
|
||||
} from './utils/sessionCleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type OutputPayload,
|
||||
type ConsoleLogPayload,
|
||||
type UserFeedbackPayload,
|
||||
sessionId,
|
||||
logUserPrompt,
|
||||
AuthType,
|
||||
getOauthClient,
|
||||
UserPromptEvent,
|
||||
debugLogger,
|
||||
recordSlowRender,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
createWorkingStdio,
|
||||
patchStdio,
|
||||
writeToStdout,
|
||||
writeToStderr,
|
||||
disableMouseEvents,
|
||||
enableMouseEvents,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
ExitCodes,
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
getVersion,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
@@ -86,21 +74,9 @@ import {
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { runAcpClient } from './acp/acpClient.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
||||
import { StreamingState } from './ui/types.js';
|
||||
import { computeTerminalTitle } from './utils/windowTitle.js';
|
||||
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
|
||||
import { loadKeyMatchers } from './ui/key/keyMatchers.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
@@ -108,37 +84,13 @@ import {
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { runDeferredCommand } from './deferred.js';
|
||||
import { cleanupBackgroundLogs } from './utils/logCleanup.js';
|
||||
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
const HELP_FLAGS = new Set(['--help', '-h']);
|
||||
const VERSION_FLAGS = new Set(['--version', '-v']);
|
||||
|
||||
function getStartupFastPath(args: string[]): 'help' | 'version' | null {
|
||||
if (args.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (args.every((arg) => HELP_FLAGS.has(arg))) {
|
||||
return 'help';
|
||||
}
|
||||
|
||||
if (args.every((arg) => VERSION_FLAGS.has(arg))) {
|
||||
return 'version';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
): DnsResolutionOrder {
|
||||
@@ -217,180 +169,19 @@ export async function startInteractiveUI(
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
// Never enter Ink alternate buffer mode when screen reader mode is enabled
|
||||
// as there is no benefit of alternate buffer mode when using a screen reader
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
// screen readers.
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
// Dynamically import the heavy UI module so React/Ink are only parsed when needed
|
||||
const { startInteractiveUI: doStartUI } = await import('./interactiveCli.js');
|
||||
await doStartUI(
|
||||
config,
|
||||
settings,
|
||||
startupWarnings,
|
||||
workspaceRoot,
|
||||
resumedSessionData,
|
||||
initializationResult,
|
||||
);
|
||||
const mouseEventsEnabled = useAlternateBuffer;
|
||||
if (mouseEventsEnabled) {
|
||||
enableMouseEvents();
|
||||
registerCleanup(() => {
|
||||
disableMouseEvents();
|
||||
});
|
||||
}
|
||||
|
||||
const { matchers, errors } = await loadKeyMatchers();
|
||||
errors.forEach((error) => {
|
||||
coreEvents.emitFeedback('warning', error);
|
||||
});
|
||||
|
||||
const version = await getVersion();
|
||||
setWindowTitle(basename(workspaceRoot), settings);
|
||||
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
},
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
useKittyKeyboardProtocol();
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeyMatchersProvider value={matchers}>
|
||||
<KeypressProvider
|
||||
config={config}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</OverflowProvider>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</KeyMatchersProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
if (isShpool) {
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
// shpool is a persistence tool that restores terminal state by replaying it.
|
||||
// This delay gives shpool time to finish its restoration replay and send
|
||||
// the actual terminal size (often via an immediate SIGWINCH) before we
|
||||
// render the first TUI frame. Without this, the first frame may be
|
||||
// garbled or rendered at an incorrect size, which disabling incremental
|
||||
// rendering alone cannot fix for the initial frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
<AppWrapper />
|
||||
</React.StrictMode>
|
||||
) : (
|
||||
<AppWrapper />
|
||||
),
|
||||
{
|
||||
stdout: inkStdout,
|
||||
stderr: inkStderr,
|
||||
stdin: process.stdin,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
if (renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, renderTime);
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
},
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
debugLogger.warn('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
}
|
||||
|
||||
async function runStartupCleanup(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): Promise<void> {
|
||||
const projectTempDir = config.storage.getProjectTempDir();
|
||||
try {
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(projectTempDir),
|
||||
cleanupToolOutputFiles(
|
||||
settings.merged,
|
||||
config.getDebugMode(),
|
||||
projectTempDir,
|
||||
),
|
||||
cleanupBackgroundLogs(),
|
||||
]);
|
||||
} catch (error) {
|
||||
debugLogger.warn('Deferred startup cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const fastPath = getStartupFastPath(process.argv.slice(2));
|
||||
if (fastPath === 'version') {
|
||||
writeToStdout(`${await getVersion()}\n`);
|
||||
return;
|
||||
}
|
||||
if (fastPath === 'help') {
|
||||
await parseArguments(createDefaultMergedSettings());
|
||||
return;
|
||||
}
|
||||
|
||||
const cliStartupHandle = startupProfiler.start('cli_startup');
|
||||
|
||||
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
|
||||
@@ -424,10 +215,6 @@ export async function main() {
|
||||
coreEvents.emitFeedback('warning', error.message);
|
||||
});
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argv = await parseArguments(settings.merged);
|
||||
parseArgsHandle?.end();
|
||||
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
trustedFolders.errors.forEach((error: TrustedFoldersError) => {
|
||||
coreEvents.emitFeedback(
|
||||
@@ -435,6 +222,17 @@ export async function main() {
|
||||
`Error in ${error.path}: ${error.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
cleanupBackgroundLogs(),
|
||||
]);
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argv = await parseArguments(settings.merged);
|
||||
parseArgsHandle?.end();
|
||||
|
||||
if (
|
||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
||||
(settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0)
|
||||
@@ -502,13 +300,9 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrapConfigHandle = startupProfiler.start('load_bootstrap_config');
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
mode: 'bootstrap',
|
||||
loadedSettings: settings,
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
});
|
||||
bootstrapConfigHandle?.end();
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
|
||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||
@@ -618,7 +412,9 @@ export async function main() {
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
} else if (memoryArgs.length > 0) {
|
||||
} else {
|
||||
// Relaunch app so we always have a child process that can be internally
|
||||
// restarted if needed.
|
||||
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
|
||||
}
|
||||
}
|
||||
@@ -629,7 +425,6 @@ export async function main() {
|
||||
{
|
||||
const loadConfigHandle = startupProfiler.start('load_cli_config');
|
||||
const config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
loadedSettings: settings,
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
});
|
||||
loadConfigHandle?.end();
|
||||
@@ -707,13 +502,6 @@ export async function main() {
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
const startupCleanup = runStartupCleanup(config, settings);
|
||||
if (config.isInteractive()) {
|
||||
void startupCleanup;
|
||||
} else {
|
||||
await startupCleanup;
|
||||
}
|
||||
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
||||
// Set this as early as possible to avoid spurious characters from
|
||||
@@ -897,25 +685,6 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
if (!settings.merged.ui.hideWindowTitle) {
|
||||
// Initial state before React loop starts
|
||||
const windowTitle = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: title,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
});
|
||||
writeToStdout(`\x1b]0;${windowTitle}\x07`);
|
||||
|
||||
process.on('exit', () => {
|
||||
writeToStdout(`\x1b]0;\x07`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeOutputListenersAndFlush() {
|
||||
// If there are no listeners for output, make sure we flush so output is not
|
||||
// lost.
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { basename } from 'node:path';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
coreEvents,
|
||||
createWorkingStdio,
|
||||
disableMouseEvents,
|
||||
enableMouseEvents,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
recordSlowRender,
|
||||
writeToStdout,
|
||||
getVersion,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { InitializationResult } from './core/initializer.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
||||
import { StreamingState } from './ui/types.js';
|
||||
import { computeTerminalTitle } from './utils/windowTitle.js';
|
||||
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
|
||||
import { loadKeyMatchers } from './ui/key/keyMatchers.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
export async function startInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: StartupWarning[],
|
||||
workspaceRoot: string = process.cwd(),
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
// Never enter Ink alternate buffer mode when screen reader mode is enabled
|
||||
// as there is no benefit of alternate buffer mode when using a screen reader
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
// screen readers.
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const mouseEventsEnabled = useAlternateBuffer;
|
||||
if (mouseEventsEnabled) {
|
||||
enableMouseEvents();
|
||||
registerCleanup(() => {
|
||||
disableMouseEvents();
|
||||
});
|
||||
}
|
||||
|
||||
const { matchers, errors } = await loadKeyMatchers();
|
||||
errors.forEach((error) => {
|
||||
coreEvents.emitFeedback('warning', error);
|
||||
});
|
||||
|
||||
const version = await getVersion();
|
||||
setWindowTitle(basename(workspaceRoot), settings);
|
||||
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
},
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
useKittyKeyboardProtocol();
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeyMatchersProvider value={matchers}>
|
||||
<KeypressProvider
|
||||
config={config}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</OverflowProvider>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</KeyMatchersProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
if (isShpool) {
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
<AppWrapper />
|
||||
</React.StrictMode>
|
||||
) : (
|
||||
<AppWrapper />
|
||||
),
|
||||
{
|
||||
stdout: inkStdout,
|
||||
stderr: inkStderr,
|
||||
stdin: process.stdin,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
if (renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, renderTime);
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
},
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
debugLogger.warn('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
}
|
||||
|
||||
function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
if (!settings.merged.ui.hideWindowTitle) {
|
||||
// Initial state before React loop starts
|
||||
const windowTitle = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: title,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
});
|
||||
writeToStdout(`\x1b]0;${windowTitle}\x07`);
|
||||
|
||||
process.on('exit', () => {
|
||||
writeToStdout(`\x1b]0;\x07`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -263,8 +263,8 @@ export async function runNonInteractive({
|
||||
onDebugMessage: () => {},
|
||||
messageId: Date.now(),
|
||||
signal: abortController.signal,
|
||||
escapePastedAtSymbols: false,
|
||||
});
|
||||
|
||||
if (error || !processedQuery) {
|
||||
// An error occurred during @include processing (e.g., file not found).
|
||||
// The error message is already logged by handleAtCommand.
|
||||
|
||||
@@ -487,7 +487,7 @@ export class AppRig {
|
||||
}
|
||||
|
||||
async waitForPendingConfirmation(
|
||||
toolNameOrDisplayName?: string | RegExp,
|
||||
toolNameOrDisplayName?: string | RegExp | string[],
|
||||
timeout = 30000,
|
||||
): Promise<PendingConfirmation> {
|
||||
const matches = (p: PendingConfirmation) => {
|
||||
@@ -498,6 +498,12 @@ 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 || '')
|
||||
|
||||
@@ -162,6 +162,7 @@ import {
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { parseSlashCommand } from '../utils/commands.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
||||
import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
|
||||
@@ -796,7 +797,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
Logging in with Google... Restarting Gemini CLI to continue.
|
||||
----------------------------------------------------------------
|
||||
`);
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
await relaunchApp();
|
||||
}
|
||||
}
|
||||
setAuthState(AuthState.Authenticated);
|
||||
@@ -1289,6 +1290,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
...pendingGeminiHistoryItems,
|
||||
]);
|
||||
|
||||
if (isSlash && isAgentRunning) {
|
||||
const { commandToExecute } = parseSlashCommand(
|
||||
submittedValue,
|
||||
slashCommands ?? [],
|
||||
);
|
||||
if (commandToExecute?.isSafeConcurrent) {
|
||||
void handleSlashCommand(submittedValue);
|
||||
addInput(submittedValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) {
|
||||
handleHintSubmit(submittedValue);
|
||||
addInput(submittedValue);
|
||||
@@ -1332,6 +1345,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
addMessage,
|
||||
addInput,
|
||||
submitQuery,
|
||||
handleSlashCommand,
|
||||
slashCommands,
|
||||
isMcpReady,
|
||||
streamingState,
|
||||
messageQueue.length,
|
||||
@@ -2478,7 +2493,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
onHintClear: () => {},
|
||||
onHintSubmit: () => {},
|
||||
handleRestart: async () => {
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
if (process.send) {
|
||||
const remoteSettings = config.getRemoteAdminSettings();
|
||||
if (remoteSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
await relaunchApp();
|
||||
},
|
||||
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
|
||||
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
|
||||
|
||||
@@ -22,8 +22,9 @@ import { AuthState } from '../types.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { validateAuthMethodWithSettings } from './useAuth.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { Text } from 'ink';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -35,12 +36,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./useAuth.js', () => ({
|
||||
validateAuthMethodWithSettings: vi.fn(),
|
||||
vi.mock('../../utils/cleanup.js', () => ({
|
||||
runExitCleanup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/processUtils.js', () => ({
|
||||
relaunchApp: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock('./useAuth.js', () => ({
|
||||
validateAuthMethodWithSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
@@ -63,7 +64,7 @@ vi.mock('../components/shared/RadioButtonSelect.js', () => ({
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
|
||||
const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock;
|
||||
const mockedRelaunchApp = relaunchApp as Mock;
|
||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
||||
|
||||
describe('AuthDialog', () => {
|
||||
let props: {
|
||||
@@ -84,7 +85,6 @@ describe('AuthDialog', () => {
|
||||
props = {
|
||||
config: {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config,
|
||||
settings: {
|
||||
merged: {
|
||||
@@ -351,8 +351,11 @@ describe('AuthDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('restarts for Sign in with Google when browser is suppressed', async () => {
|
||||
it('exits process for Sign in with Google when browser is suppressed', async () => {
|
||||
vi.useFakeTimers();
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
|
||||
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
@@ -368,8 +371,10 @@ describe('AuthDialog', () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(mockedRelaunchApp).toHaveBeenCalledWith(undefined);
|
||||
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
|
||||
@@ -132,9 +132,7 @@ export function AuthDialog({
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
setExiting(true);
|
||||
setTimeout(async () => {
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
}, 100);
|
||||
setTimeout(relaunchApp, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,23 +8,30 @@ import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import {
|
||||
RELAUNCH_EXIT_CODE,
|
||||
_resetRelaunchStateForTesting,
|
||||
} from '../../utils/processUtils.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/processUtils.js', () => ({
|
||||
relaunchApp: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock('../../utils/cleanup.js', () => ({
|
||||
runExitCleanup: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedRelaunchApp = relaunchApp as Mock;
|
||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
||||
|
||||
describe('LoginWithGoogleRestartDialog', () => {
|
||||
const onDismiss = vi.fn();
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
const mockConfig = {
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
@@ -32,7 +39,9 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
exitSpy.mockClear();
|
||||
vi.useRealTimers();
|
||||
_resetRelaunchStateForTesting();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
@@ -69,33 +78,36 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each(['r', 'R'])('restarts when %s is pressed', async (keyName) => {
|
||||
vi.useFakeTimers();
|
||||
it.each(['r', 'R'])(
|
||||
'calls runExitCleanup and process.exit when %s is pressed',
|
||||
async (keyName) => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: keyName,
|
||||
});
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: keyName,
|
||||
});
|
||||
|
||||
// Advance timers to trigger the setTimeout callback
|
||||
await vi.runAllTimersAsync();
|
||||
// Advance timers to trigger the setTimeout callback
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockedRelaunchApp).toHaveBeenCalledTimes(1);
|
||||
expect(mockedRelaunchApp).toHaveBeenCalledWith(undefined);
|
||||
expect(mockedRunExitCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,16 @@ export const LoginWithGoogleRestartDialog = ({
|
||||
return true;
|
||||
} else if (key.name === 'r' || key.name === 'R') {
|
||||
setTimeout(async () => {
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
if (process.send) {
|
||||
const remoteSettings = config.getRemoteAdminSettings();
|
||||
if (remoteSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
await relaunchApp();
|
||||
}, 100);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const aboutCommand: SlashCommand = {
|
||||
description: 'Show version info',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context) => {
|
||||
const osVersion = process.platform;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const settingsCommand: SlashCommand = {
|
||||
description: 'View and edit Gemini CLI settings',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
|
||||
@@ -123,7 +123,6 @@ async function downloadFiles({
|
||||
downloads.push(
|
||||
(async () => {
|
||||
const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`;
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
MessageType,
|
||||
} from '../types.js';
|
||||
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
import { getAdminErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import {
|
||||
linkSkill,
|
||||
renderSkillActionFeedback,
|
||||
|
||||
@@ -84,6 +84,7 @@ export const statsCommand: SlashCommand = {
|
||||
description: 'Check session stats. Usage: /stats [session|model|tools]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context: CommandContext) => {
|
||||
await defaultSessionView(context);
|
||||
},
|
||||
@@ -93,6 +94,7 @@ export const statsCommand: SlashCommand = {
|
||||
description: 'Show session-specific usage statistics',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context: CommandContext) => {
|
||||
await defaultSessionView(context);
|
||||
},
|
||||
@@ -102,6 +104,7 @@ export const statsCommand: SlashCommand = {
|
||||
description: 'Show model-specific usage statistics',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (context: CommandContext) => {
|
||||
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
|
||||
const currentModel = context.services.config?.getModel();
|
||||
@@ -125,6 +128,7 @@ export const statsCommand: SlashCommand = {
|
||||
description: 'Show tool-specific usage statistics',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (context: CommandContext) => {
|
||||
context.ui.addItem({
|
||||
type: MessageType.TOOL_STATS,
|
||||
|
||||
@@ -207,6 +207,11 @@ export interface SlashCommand {
|
||||
*/
|
||||
autoExecute?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this command can be safely executed while the agent is busy (e.g. streaming a response).
|
||||
*/
|
||||
isSafeConcurrent?: boolean;
|
||||
|
||||
// Optional metadata for extension commands
|
||||
extensionName?: string;
|
||||
extensionId?: string;
|
||||
|
||||
@@ -11,6 +11,7 @@ export const vimCommand: SlashCommand = {
|
||||
description: 'Toggle vim mode on/off',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context, _args) => {
|
||||
const newVimState = await context.ui.toggleVimEnabled();
|
||||
|
||||
|
||||
@@ -231,9 +231,7 @@ export const DialogManager = ({
|
||||
<Box flexDirection="column">
|
||||
<SettingsDialog
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onRestartRequest={async () => {
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
}}
|
||||
onRestartRequest={relaunchApp}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -94,6 +94,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{
|
||||
name: 'stats',
|
||||
description: 'Check stats',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
isSafeConcurrent: true,
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
@@ -3876,6 +3882,13 @@ describe('InputPrompt', () => {
|
||||
shouldSubmit: false,
|
||||
errorMessage: 'Slash commands cannot be queued',
|
||||
},
|
||||
{
|
||||
name: 'should allow concurrent-safe slash commands',
|
||||
bufferText: '/stats',
|
||||
shellMode: false,
|
||||
shouldSubmit: true,
|
||||
errorMessage: null,
|
||||
},
|
||||
{
|
||||
name: 'should prevent shell commands',
|
||||
bufferText: 'ls',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Box, Text, useStdout, type DOMElement } from 'ink';
|
||||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
type TextBuffer,
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
isAutoExecutableCommand,
|
||||
isSlashCommand,
|
||||
} from '../utils/commandUtils.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { getSafeLowColorBackground } from '../themes/color-utils.js';
|
||||
@@ -408,6 +410,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
(isSlash || isShell) &&
|
||||
streamingState === StreamingState.Responding
|
||||
) {
|
||||
if (isSlash) {
|
||||
const { commandToExecute } = parseSlashCommand(
|
||||
trimmedMessage,
|
||||
slashCommands,
|
||||
);
|
||||
if (commandToExecute?.isSafeConcurrent) {
|
||||
inputHistory.handleSubmit(trimmedMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setQueueErrorMessage(
|
||||
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||||
);
|
||||
@@ -415,7 +428,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
inputHistory.handleSubmit(trimmedMessage);
|
||||
},
|
||||
[inputHistory, shellModeActive, streamingState, setQueueErrorMessage],
|
||||
[
|
||||
inputHistory,
|
||||
shellModeActive,
|
||||
streamingState,
|
||||
setQueueErrorMessage,
|
||||
slashCommands,
|
||||
],
|
||||
);
|
||||
|
||||
// Effect to reset completion if history navigation just occurred and set the text
|
||||
@@ -497,7 +516,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
stdout.write('\x1b]52;c;?\x07');
|
||||
} else {
|
||||
const textToInsert = await clipboardy.read();
|
||||
buffer.insert(textToInsert, { paste: true });
|
||||
const escapedText = settings.ui?.escapePastedAtSymbols
|
||||
? escapeAtSymbols(textToInsert)
|
||||
: textToInsert;
|
||||
buffer.insert(escapedText, { paste: true });
|
||||
|
||||
if (isLargePaste(textToInsert)) {
|
||||
appEvents.emit(AppEvent.TransientMessage, {
|
||||
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
||||
@@ -732,8 +755,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
pasteTimeoutRef.current = null;
|
||||
}, 40);
|
||||
}
|
||||
// Ensure we never accidentally interpret paste as regular input.
|
||||
buffer.handleInput(key);
|
||||
if (settings.ui?.escapePastedAtSymbols) {
|
||||
buffer.handleInput({
|
||||
...key,
|
||||
sequence: escapeAtSymbols(key.sequence || ''),
|
||||
});
|
||||
} else {
|
||||
buffer.handleInput(key);
|
||||
}
|
||||
|
||||
if (key.sequence && isLargePaste(key.sequence)) {
|
||||
appEvents.emit(AppEvent.TransientMessage, {
|
||||
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
||||
@@ -1273,6 +1303,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
forceShowShellSuggestions,
|
||||
keyMatchers,
|
||||
isHelpDismissKey,
|
||||
settings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -13,9 +13,8 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import {
|
||||
cleanMessage,
|
||||
formatRelativeTime,
|
||||
getSessionFiles,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
@@ -117,157 +116,11 @@ const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
|
||||
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
|
||||
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
|
||||
/**
|
||||
* Sorts an array of sessions by the specified criteria.
|
||||
* @param sessions - Array of sessions to sort
|
||||
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
|
||||
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
|
||||
* @returns New sorted array of sessions
|
||||
*/
|
||||
const sortSessions = (
|
||||
sessions: SessionInfo[],
|
||||
sortBy: 'date' | 'messages' | 'name',
|
||||
reverse: boolean,
|
||||
): SessionInfo[] => {
|
||||
const sorted = [...sessions].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
return (
|
||||
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
|
||||
);
|
||||
case 'messages':
|
||||
return b.messageCount - a.messageCount;
|
||||
case 'name':
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return reverse ? sorted.reverse() : sorted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds all text matches for a search query within conversation messages.
|
||||
* Creates TextMatch objects with context (10 chars before/after) and role information.
|
||||
* @param messages - Array of messages to search through
|
||||
* @param query - Search query string (case-insensitive)
|
||||
* @returns Array of TextMatch objects containing match context and metadata
|
||||
*/
|
||||
const findTextMatches = (
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
query: string,
|
||||
): TextMatch[] => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const matches: TextMatch[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const m = cleanMessage(message.content);
|
||||
const lowerContent = m.toLowerCase();
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
|
||||
if (matchIndex === -1) break;
|
||||
|
||||
const contextStart = Math.max(0, matchIndex - 10);
|
||||
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
|
||||
|
||||
const snippet = m.slice(contextStart, contextEnd);
|
||||
const relativeMatchStart = matchIndex - contextStart;
|
||||
const relativeMatchEnd = relativeMatchStart + query.length;
|
||||
|
||||
let before = snippet.slice(0, relativeMatchStart);
|
||||
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
|
||||
let after = snippet.slice(relativeMatchEnd);
|
||||
|
||||
if (contextStart > 0) before = '…' + before;
|
||||
if (contextEnd < m.length) after = after + '…';
|
||||
|
||||
matches.push({ before, match, after, role: message.role });
|
||||
startIndex = matchIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters sessions based on a search query, checking titles, IDs, and full content.
|
||||
* Also populates matchSnippets and matchCount for sessions with content matches.
|
||||
* @param sessions - Array of sessions to filter
|
||||
* @param query - Search query string (case-insensitive)
|
||||
* @returns Filtered array of sessions that match the query
|
||||
*/
|
||||
const filterSessions = (
|
||||
sessions: SessionInfo[],
|
||||
query: string,
|
||||
): SessionInfo[] => {
|
||||
if (!query.trim()) {
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
matchSnippets: undefined,
|
||||
matchCount: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return sessions.filter((session) => {
|
||||
const titleMatch =
|
||||
session.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
session.id.toLowerCase().includes(lowerQuery) ||
|
||||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
|
||||
|
||||
const contentMatch = session.fullContent
|
||||
?.toLowerCase()
|
||||
.includes(lowerQuery);
|
||||
|
||||
if (titleMatch || contentMatch) {
|
||||
if (session.messages) {
|
||||
session.matchSnippets = findTextMatches(session.messages, query);
|
||||
session.matchCount = session.matchSnippets.length;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
|
||||
|
||||
/**
|
||||
* Search input display component.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @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
@@ -0,0 +1,18 @@
|
||||
// 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…
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sortSessions, findTextMatches, filterSessions } from './utils.js';
|
||||
import type { SessionInfo } from '../../../utils/sessionUtils.js';
|
||||
|
||||
describe('SessionBrowser utils', () => {
|
||||
const createTestSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
|
||||
id: 'test-id',
|
||||
file: 'test-file',
|
||||
fileName: 'test-file.json',
|
||||
startTime: '2025-01-01T10:00:00Z',
|
||||
lastUpdated: '2025-01-01T10:00:00Z',
|
||||
messageCount: 1,
|
||||
displayName: 'Test Session',
|
||||
firstUserMessage: 'Hello',
|
||||
isCurrentSession: false,
|
||||
index: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('sortSessions', () => {
|
||||
it('sorts by date ascending/descending', () => {
|
||||
const older = createTestSession({
|
||||
id: '1',
|
||||
lastUpdated: '2025-01-01T10:00:00Z',
|
||||
});
|
||||
const newer = createTestSession({
|
||||
id: '2',
|
||||
lastUpdated: '2025-01-02T10:00:00Z',
|
||||
});
|
||||
|
||||
const desc = sortSessions([older, newer], 'date', false);
|
||||
expect(desc[0].id).toBe('2');
|
||||
|
||||
const asc = sortSessions([older, newer], 'date', true);
|
||||
expect(asc[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('sorts by message count ascending/descending', () => {
|
||||
const more = createTestSession({ id: '1', messageCount: 10 });
|
||||
const less = createTestSession({ id: '2', messageCount: 2 });
|
||||
|
||||
const desc = sortSessions([more, less], 'messages', false);
|
||||
expect(desc[0].id).toBe('1');
|
||||
|
||||
const asc = sortSessions([more, less], 'messages', true);
|
||||
expect(asc[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('sorts by name ascending/descending', () => {
|
||||
const apple = createTestSession({ id: '1', displayName: 'Apple' });
|
||||
const banana = createTestSession({ id: '2', displayName: 'Banana' });
|
||||
|
||||
const asc = sortSessions([apple, banana], 'name', true);
|
||||
expect(asc[0].id).toBe('2'); // Reversed alpha
|
||||
|
||||
const desc = sortSessions([apple, banana], 'name', false);
|
||||
expect(desc[0].id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTextMatches', () => {
|
||||
it('returns empty array if query is practically empty', () => {
|
||||
expect(
|
||||
findTextMatches([{ role: 'user', content: 'hello world' }], ' '),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds simple matches with surrounding context', () => {
|
||||
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
||||
{ role: 'user', content: 'What is the capital of France?' },
|
||||
];
|
||||
|
||||
const matches = findTextMatches(messages, 'capital');
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0].match).toBe('capital');
|
||||
expect(matches[0].before.endsWith('the ')).toBe(true);
|
||||
expect(matches[0].after.startsWith(' of')).toBe(true);
|
||||
expect(matches[0].role).toBe('user');
|
||||
});
|
||||
|
||||
it('finds multiple matches in a single message', () => {
|
||||
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
||||
{ role: 'user', content: 'test here test there' },
|
||||
];
|
||||
|
||||
const matches = findTextMatches(messages, 'test');
|
||||
expect(matches.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSessions', () => {
|
||||
it('returns all sessions when query is blank and clears existing snippets', () => {
|
||||
const sessions = [createTestSession({ id: '1', matchCount: 5 })];
|
||||
|
||||
const result = filterSessions(sessions, ' ');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].matchCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('filters by displayName', () => {
|
||||
const session1 = createTestSession({
|
||||
id: '1',
|
||||
displayName: 'Cats and Dogs',
|
||||
});
|
||||
const session2 = createTestSession({ id: '2', displayName: 'Fish' });
|
||||
|
||||
const result = filterSessions([session1, session2], 'cat');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('populates match snippets if it matches content inside messages array', () => {
|
||||
const sessionWithMessages = createTestSession({
|
||||
id: '1',
|
||||
displayName: 'Unrelated Title',
|
||||
fullContent: 'This mentions a giraffe',
|
||||
messages: [{ role: 'user', content: 'This mentions a giraffe' }],
|
||||
});
|
||||
|
||||
const result = filterSessions([sessionWithMessages], 'giraffe');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].matchCount).toBe(1);
|
||||
expect(result[0].matchSnippets?.[0].match).toBe('giraffe');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
cleanMessage,
|
||||
type SessionInfo,
|
||||
type TextMatch,
|
||||
} from '../../../utils/sessionUtils.js';
|
||||
|
||||
/**
|
||||
* Sorts an array of sessions by the specified criteria.
|
||||
* @param sessions - Array of sessions to sort
|
||||
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
|
||||
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
|
||||
* @returns New sorted array of sessions
|
||||
*/
|
||||
export const sortSessions = (
|
||||
sessions: SessionInfo[],
|
||||
sortBy: 'date' | 'messages' | 'name',
|
||||
reverse: boolean,
|
||||
): SessionInfo[] => {
|
||||
const sorted = [...sessions].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
return (
|
||||
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
|
||||
);
|
||||
case 'messages':
|
||||
return b.messageCount - a.messageCount;
|
||||
case 'name':
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return reverse ? sorted.reverse() : sorted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds all text matches for a search query within conversation messages.
|
||||
* Creates TextMatch objects with context (10 chars before/after) and role information.
|
||||
* @param messages - Array of messages to search through
|
||||
* @param query - Search query string (case-insensitive)
|
||||
* @returns Array of TextMatch objects containing match context and metadata
|
||||
*/
|
||||
export const findTextMatches = (
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
query: string,
|
||||
): TextMatch[] => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const matches: TextMatch[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const m = cleanMessage(message.content);
|
||||
const lowerContent = m.toLowerCase();
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
|
||||
if (matchIndex === -1) break;
|
||||
|
||||
const contextStart = Math.max(0, matchIndex - 10);
|
||||
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
|
||||
|
||||
const snippet = m.slice(contextStart, contextEnd);
|
||||
const relativeMatchStart = matchIndex - contextStart;
|
||||
const relativeMatchEnd = relativeMatchStart + query.length;
|
||||
|
||||
let before = snippet.slice(0, relativeMatchStart);
|
||||
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
|
||||
let after = snippet.slice(relativeMatchEnd);
|
||||
|
||||
if (contextStart > 0) before = '…' + before;
|
||||
if (contextEnd < m.length) after = after + '…';
|
||||
|
||||
matches.push({ before, match, after, role: message.role });
|
||||
startIndex = matchIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters sessions based on a search query, checking titles, IDs, and full content.
|
||||
* Also populates matchSnippets and matchCount for sessions with content matches.
|
||||
* @param sessions - Array of sessions to filter
|
||||
* @param query - Search query string (case-insensitive)
|
||||
* @returns Filtered array of sessions that match the query
|
||||
*/
|
||||
export const filterSessions = (
|
||||
sessions: SessionInfo[],
|
||||
query: string,
|
||||
): SessionInfo[] => {
|
||||
if (!query.trim()) {
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
matchSnippets: undefined,
|
||||
matchCount: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return sessions.filter((session) => {
|
||||
const titleMatch =
|
||||
session.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
session.id.toLowerCase().includes(lowerQuery) ||
|
||||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
|
||||
|
||||
const contentMatch = session.fullContent
|
||||
?.toLowerCase()
|
||||
.includes(lowerQuery);
|
||||
|
||||
if (titleMatch || contentMatch) {
|
||||
if (session.messages) {
|
||||
session.matchSnippets = findTextMatches(session.messages, query);
|
||||
session.matchCount = session.matchSnippets.length;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
@@ -760,6 +760,48 @@ 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,13 +325,18 @@ export function BaseSettingsDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down in edit mode - commit and navigate
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
// 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) {
|
||||
commitEdit();
|
||||
moveUp();
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
if (
|
||||
keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) &&
|
||||
!key.insertable
|
||||
) {
|
||||
commitEdit();
|
||||
moveDown();
|
||||
return;
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { handleAtCommand } from './atCommandProcessor.js';
|
||||
import {
|
||||
handleAtCommand,
|
||||
escapeAtSymbols,
|
||||
unescapeLiteralAt,
|
||||
} from './atCommandProcessor.js';
|
||||
import {
|
||||
FileDiscoveryService,
|
||||
GlobTool,
|
||||
@@ -1481,3 +1485,56 @@ describe('handleAtCommand', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeAtSymbols', () => {
|
||||
it('escapes a bare @ symbol', () => {
|
||||
expect(escapeAtSymbols('test@domain.com')).toBe('test\\@domain.com');
|
||||
});
|
||||
|
||||
it('escapes a leading @ symbol', () => {
|
||||
expect(escapeAtSymbols('@scope/pkg')).toBe('\\@scope/pkg');
|
||||
});
|
||||
|
||||
it('escapes multiple @ symbols', () => {
|
||||
expect(escapeAtSymbols('a@b and c@d')).toBe('a\\@b and c\\@d');
|
||||
});
|
||||
|
||||
it('does not double-escape an already escaped @', () => {
|
||||
expect(escapeAtSymbols('test\\@domain.com')).toBe('test\\@domain.com');
|
||||
});
|
||||
|
||||
it('returns text with no @ unchanged', () => {
|
||||
expect(escapeAtSymbols('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(escapeAtSymbols('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unescapeLiteralAt', () => {
|
||||
it('unescapes \\@ to @', () => {
|
||||
expect(unescapeLiteralAt('test\\@domain.com')).toBe('test@domain.com');
|
||||
});
|
||||
|
||||
it('unescapes a leading \\@', () => {
|
||||
expect(unescapeLiteralAt('\\@scope/pkg')).toBe('@scope/pkg');
|
||||
});
|
||||
|
||||
it('unescapes multiple \\@ sequences', () => {
|
||||
expect(unescapeLiteralAt('a\\@b and c\\@d')).toBe('a@b and c@d');
|
||||
});
|
||||
|
||||
it('returns text with no \\@ unchanged', () => {
|
||||
expect(unescapeLiteralAt('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(unescapeLiteralAt('')).toBe('');
|
||||
});
|
||||
|
||||
it('roundtrips correctly with escapeAtSymbols', () => {
|
||||
const input = 'user@example.com and @scope/pkg';
|
||||
expect(unescapeLiteralAt(escapeAtSymbols(input))).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,26 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
|
||||
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
|
||||
|
||||
/**
|
||||
* Escapes unescaped @ symbols so they are not interpreted as @path commands.
|
||||
*/
|
||||
export function escapeAtSymbols(text: string): string {
|
||||
return text.replace(/(?<!\\)@/g, '\\@');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescapes \@ back to @ correctly, preserving \\@ sequences.
|
||||
*/
|
||||
export function unescapeLiteralAt(text: string): string {
|
||||
return text.replace(/\\@/g, (match, offset, full) => {
|
||||
let backslashCount = 0;
|
||||
for (let i = offset - 1; i >= 0 && full[i] === '\\'; i--) {
|
||||
backslashCount++;
|
||||
}
|
||||
return backslashCount % 2 === 0 ? '@' : '\\@';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex source for the path/command part of an @ reference.
|
||||
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
|
||||
@@ -49,6 +69,7 @@ interface HandleAtCommandParams {
|
||||
onDebugMessage: (message: string) => void;
|
||||
messageId: number;
|
||||
signal: AbortSignal;
|
||||
escapePastedAtSymbols?: boolean;
|
||||
}
|
||||
|
||||
interface HandleAtCommandResult {
|
||||
@@ -65,7 +86,10 @@ interface AtCommandPart {
|
||||
* Parses a query string to find all '@<path>' commands and text segments.
|
||||
* Handles \ escaped spaces within paths.
|
||||
*/
|
||||
function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
function parseAllAtCommands(
|
||||
query: string,
|
||||
escapePastedAtSymbols = false,
|
||||
): AtCommandPart[] {
|
||||
const parts: AtCommandPart[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
@@ -85,7 +109,9 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
if (matchIndex > lastIndex) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: query.substring(lastIndex, matchIndex),
|
||||
content: escapePastedAtSymbols
|
||||
? unescapeLiteralAt(query.substring(lastIndex, matchIndex))
|
||||
: query.substring(lastIndex, matchIndex),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,7 +124,12 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < query.length) {
|
||||
parts.push({ type: 'text', content: query.substring(lastIndex) });
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: escapePastedAtSymbols
|
||||
? unescapeLiteralAt(query.substring(lastIndex))
|
||||
: query.substring(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
|
||||
@@ -635,8 +666,9 @@ export async function handleAtCommand({
|
||||
onDebugMessage,
|
||||
messageId: userMessageTimestamp,
|
||||
signal,
|
||||
escapePastedAtSymbols = false,
|
||||
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
||||
const commandParts = parseAllAtCommands(query);
|
||||
const commandParts = parseAllAtCommands(query, escapePastedAtSymbols);
|
||||
|
||||
const { agentParts, resourceParts, fileParts } = categorizeAtCommands(
|
||||
commandParts,
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
import {
|
||||
debugLogger,
|
||||
checkExhaustive,
|
||||
getErrorMessage,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
ExtensionUpdateState,
|
||||
extensionUpdatesReducer,
|
||||
|
||||
@@ -837,8 +837,8 @@ export const useGeminiStream = (
|
||||
onDebugMessage,
|
||||
messageId: userMessageTimestamp,
|
||||
signal: abortSignal,
|
||||
escapePastedAtSymbols: settings.merged.ui?.escapePastedAtSymbols,
|
||||
});
|
||||
|
||||
if (atCommandResult.error) {
|
||||
onDebugMessage(atCommandResult.error);
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
@@ -874,6 +874,7 @@ export const useGeminiStream = (
|
||||
logger,
|
||||
shellModeActive,
|
||||
scheduleToolCalls,
|
||||
settings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export type HighlightToken = {
|
||||
// It matches any character except strict delimiters (ASCII whitespace, comma, etc.).
|
||||
// This supports URIs like `@file:///example.txt` and filenames with Unicode spaces (like NNBSP).
|
||||
const HIGHLIGHT_REGEX = new RegExp(
|
||||
`(^/[a-zA-Z0-9_-]+|@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
|
||||
`(^/[a-zA-Z0-9_-]+|(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
|
||||
@@ -103,21 +103,6 @@ async function drainStdin() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
export async function cleanupCheckpoints(projectTempDir?: string) {
|
||||
let tempDir = projectTempDir;
|
||||
if (!tempDir) {
|
||||
const storage = new Storage(process.cwd());
|
||||
await storage.initialize();
|
||||
tempDir = storage.getProjectTempDir();
|
||||
}
|
||||
const checkpointsDir = join(tempDir, 'checkpoints');
|
||||
try {
|
||||
await fs.rm(checkpointsDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors if the directory doesn't exist or fails to delete.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shuts down the process, ensuring cleanup runs exactly once.
|
||||
* Guards against concurrent shutdown from signals (SIGHUP, SIGTERM, SIGINT)
|
||||
@@ -176,3 +161,15 @@ export function setupTtyCheck(): () => void {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function cleanupCheckpoints() {
|
||||
const storage = new Storage(process.cwd());
|
||||
await storage.initialize();
|
||||
const tempDir = storage.getProjectTempDir();
|
||||
const checkpointsDir = join(tempDir, 'checkpoints');
|
||||
try {
|
||||
await fs.rm(checkpointsDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors if the directory doesn't exist or fails to delete.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
getErrorMessage,
|
||||
handleError,
|
||||
handleToolError,
|
||||
handleCancellationError,
|
||||
@@ -152,25 +151,6 @@ 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,16 +18,10 @@ 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;
|
||||
|
||||
@@ -61,7 +61,6 @@ export const getLatestGitHubRelease = async (
|
||||
|
||||
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
||||
@@ -11,56 +11,28 @@ import {
|
||||
_resetRelaunchStateForTesting,
|
||||
} from './processUtils.js';
|
||||
import * as cleanup from './cleanup.js';
|
||||
import * as relaunch from './relaunch.js';
|
||||
import * as handleAutoUpdate from './handleAutoUpdate.js';
|
||||
|
||||
vi.mock('./handleAutoUpdate.js', () => ({
|
||||
waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./relaunch.js', () => ({
|
||||
relaunchAppInChildProcess: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('processUtils', () => {
|
||||
const processExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockReturnValue(undefined as never);
|
||||
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
|
||||
const relaunchAppInChildProcess = vi.spyOn(
|
||||
relaunch,
|
||||
'relaunchAppInChildProcess',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
_resetRelaunchStateForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['SANDBOX'];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (process as any).send;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('should wait for updates, run cleanup, and exit with the relaunch code when a parent wrapper exists', async () => {
|
||||
process.env['SANDBOX'] = 'sandbox-exec';
|
||||
processExit.mockImplementationOnce(() => {
|
||||
throw new Error('PROCESS_EXIT_CALLED');
|
||||
});
|
||||
|
||||
await expect(relaunchApp()).rejects.toThrow('PROCESS_EXIT_CALLED');
|
||||
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(runExitCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
expect(relaunchAppInChildProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should spawn a child wrapper on demand when no parent wrapper exists', async () => {
|
||||
it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
|
||||
await relaunchApp();
|
||||
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(runExitCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(relaunchAppInChildProcess).toHaveBeenCalledWith([], [], undefined);
|
||||
expect(processExit).not.toHaveBeenCalled();
|
||||
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AdminControlsSettings } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './cleanup.js';
|
||||
import { waitForUpdateCompletion } from './handleAutoUpdate.js';
|
||||
|
||||
@@ -14,8 +13,7 @@ import { waitForUpdateCompletion } from './handleAutoUpdate.js';
|
||||
export const RELAUNCH_EXIT_CODE = 199;
|
||||
|
||||
/**
|
||||
* Restarts the CLI, either by signaling an existing parent wrapper or by
|
||||
* spawning one on demand.
|
||||
* Exits the process with a special code to signal that the parent process should relaunch it.
|
||||
*/
|
||||
let isRelaunching = false;
|
||||
|
||||
@@ -24,24 +22,10 @@ export function _resetRelaunchStateForTesting(): void {
|
||||
isRelaunching = false;
|
||||
}
|
||||
|
||||
export async function relaunchApp(
|
||||
remoteAdminSettings?: AdminControlsSettings,
|
||||
): Promise<void> {
|
||||
export async function relaunchApp(): Promise<void> {
|
||||
if (isRelaunching) return;
|
||||
isRelaunching = true;
|
||||
await waitForUpdateCompletion();
|
||||
await runExitCleanup();
|
||||
|
||||
if (process.send || process.env['SANDBOX']) {
|
||||
if (process.send && remoteAdminSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteAdminSettings,
|
||||
});
|
||||
}
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}
|
||||
|
||||
const { relaunchAppInChildProcess } = await import('./relaunch.js');
|
||||
await relaunchAppInChildProcess([], [], remoteAdminSettings);
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
A2AClientManager,
|
||||
type SendMessageResult,
|
||||
} from './a2a-client-manager.js';
|
||||
import type { AgentCard, Task } from '@a2a-js/sdk';
|
||||
import { A2AClientManager } from './a2a-client-manager.js';
|
||||
import type { AgentCard } from '@a2a-js/sdk';
|
||||
import {
|
||||
ClientFactory,
|
||||
DefaultAgentCardResolver,
|
||||
@@ -18,83 +15,99 @@ import {
|
||||
type AuthenticationHandler,
|
||||
type Client,
|
||||
} from '@a2a-js/sdk/client';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
interface MockClient {
|
||||
sendMessageStream: ReturnType<typeof vi.fn>;
|
||||
getTask: ReturnType<typeof vi.fn>;
|
||||
cancelTask: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
vi.mock('@a2a-js/sdk/client', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
createAuthenticatingFetchWithRetry: vi.fn(),
|
||||
ClientFactory: vi.fn(),
|
||||
DefaultAgentCardResolver: vi.fn(),
|
||||
ClientFactoryOptions: {
|
||||
createFrom: vi.fn(),
|
||||
default: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@a2a-js/sdk/client', () => {
|
||||
const ClientFactory = vi.fn();
|
||||
const DefaultAgentCardResolver = vi.fn();
|
||||
const RestTransportFactory = vi.fn();
|
||||
const JsonRpcTransportFactory = vi.fn();
|
||||
const ClientFactoryOptions = {
|
||||
default: {},
|
||||
createFrom: vi.fn(),
|
||||
};
|
||||
const createAuthenticatingFetchWithRetry = vi.fn();
|
||||
|
||||
DefaultAgentCardResolver.prototype.resolve = vi.fn();
|
||||
ClientFactory.prototype.createFromUrl = vi.fn();
|
||||
|
||||
return {
|
||||
ClientFactory,
|
||||
ClientFactoryOptions,
|
||||
DefaultAgentCardResolver,
|
||||
RestTransportFactory,
|
||||
JsonRpcTransportFactory,
|
||||
createAuthenticatingFetchWithRetry,
|
||||
};
|
||||
});
|
||||
|
||||
describe('A2AClientManager', () => {
|
||||
let manager: A2AClientManager;
|
||||
const mockAgentCard: AgentCard = {
|
||||
name: 'test-agent',
|
||||
description: 'A test agent',
|
||||
url: 'http://test.agent',
|
||||
version: '1.0.0',
|
||||
protocolVersion: '0.1.0',
|
||||
capabilities: {},
|
||||
skills: [],
|
||||
defaultInputModes: [],
|
||||
defaultOutputModes: [],
|
||||
};
|
||||
|
||||
const mockClient: MockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
};
|
||||
|
||||
// Stable mocks initialized once
|
||||
const sendMessageStreamMock = vi.fn();
|
||||
const getTaskMock = vi.fn();
|
||||
const cancelTaskMock = vi.fn();
|
||||
const getAgentCardMock = vi.fn();
|
||||
const authFetchMock = vi.fn();
|
||||
|
||||
const mockClient = {
|
||||
sendMessageStream: sendMessageStreamMock,
|
||||
getTask: getTaskMock,
|
||||
cancelTask: cancelTaskMock,
|
||||
getAgentCard: getAgentCardMock,
|
||||
} as unknown as Client;
|
||||
|
||||
const mockAgentCard: Partial<AgentCard> = { name: 'TestAgent' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
A2AClientManager.resetInstanceForTesting();
|
||||
manager = A2AClientManager.getInstance();
|
||||
|
||||
// Default mock implementations
|
||||
getAgentCardMock.mockResolvedValue({
|
||||
// Re-create the instances as plain objects that can be spied on
|
||||
const factoryInstance = {
|
||||
createFromUrl: vi.fn(),
|
||||
createFromAgentCard: vi.fn(),
|
||||
};
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(ClientFactory).mockReturnValue(
|
||||
factoryInstance as unknown as ClientFactory,
|
||||
);
|
||||
vi.mocked(DefaultAgentCardResolver).mockReturnValue(
|
||||
resolverInstance as unknown as DefaultAgentCardResolver,
|
||||
);
|
||||
|
||||
vi.spyOn(factoryInstance, 'createFromUrl').mockResolvedValue(
|
||||
mockClient as unknown as Client,
|
||||
);
|
||||
vi.spyOn(factoryInstance, 'createFromAgentCard').mockResolvedValue(
|
||||
mockClient as unknown as Client,
|
||||
);
|
||||
vi.spyOn(resolverInstance, 'resolve').mockResolvedValue({
|
||||
...mockAgentCard,
|
||||
url: 'http://test.agent/real/endpoint',
|
||||
} as AgentCard);
|
||||
|
||||
vi.mocked(ClientFactory.prototype.createFromUrl).mockResolvedValue(
|
||||
mockClient,
|
||||
vi.spyOn(ClientFactoryOptions, 'createFrom').mockImplementation(
|
||||
(_defaults, overrides) => overrides as unknown as ClientFactoryOptions,
|
||||
);
|
||||
|
||||
vi.mocked(DefaultAgentCardResolver.prototype.resolve).mockResolvedValue({
|
||||
...mockAgentCard,
|
||||
url: 'http://test.agent/real/endpoint',
|
||||
} as AgentCard);
|
||||
|
||||
vi.mocked(ClientFactoryOptions.createFrom).mockImplementation(
|
||||
(_defaults, overrides) => overrides as ClientFactoryOptions,
|
||||
);
|
||||
|
||||
vi.mocked(createAuthenticatingFetchWithRetry).mockReturnValue(
|
||||
authFetchMock,
|
||||
vi.mocked(createAuthenticatingFetchWithRetry).mockImplementation(() =>
|
||||
authFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as Response),
|
||||
);
|
||||
|
||||
vi.stubGlobal(
|
||||
@@ -117,21 +130,70 @@ describe('A2AClientManager', () => {
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
describe('getInstance / dispatcher initialization', () => {
|
||||
it('should use UndiciAgent when no proxy is configured', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
|
||||
await cardFetch('http://test.agent/card');
|
||||
|
||||
const fetchCall = vi
|
||||
.mocked(fetch)
|
||||
.mock.calls.find((call) => call[0] === 'http://test.agent/card');
|
||||
expect(fetchCall).toBeDefined();
|
||||
expect(
|
||||
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
|
||||
).toBeInstanceOf(UndiciAgent);
|
||||
expect(
|
||||
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
|
||||
).not.toBeInstanceOf(ProxyAgent);
|
||||
});
|
||||
|
||||
it('should use ProxyAgent when a proxy is configured via Config', async () => {
|
||||
A2AClientManager.resetInstanceForTesting();
|
||||
const mockConfig = {
|
||||
getProxy: () => 'http://my-proxy:8080',
|
||||
} as Config;
|
||||
|
||||
manager = A2AClientManager.getInstance(mockConfig);
|
||||
await manager.loadAgent('TestProxyAgent', 'http://test.proxy.agent/card');
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
|
||||
await cardFetch('http://test.proxy.agent/card');
|
||||
|
||||
const fetchCall = vi
|
||||
.mocked(fetch)
|
||||
.mock.calls.find((call) => call[0] === 'http://test.proxy.agent/card');
|
||||
expect(fetchCall).toBeDefined();
|
||||
expect(
|
||||
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
|
||||
).toBeInstanceOf(ProxyAgent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAgent', () => {
|
||||
it('should create and cache an A2AClient', async () => {
|
||||
const agentCard = await manager.loadAgent(
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
);
|
||||
expect(agentCard).toMatchObject(mockAgentCard);
|
||||
expect(manager.getAgentCard('TestAgent')).toBe(agentCard);
|
||||
expect(manager.getClient('TestAgent')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(ClientFactoryOptions.createFrom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if an agent with the same name is already loaded', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await expect(
|
||||
manager.loadAgent('TestAgent', 'http://another.agent/card'),
|
||||
manager.loadAgent('TestAgent', 'http://test.agent/card'),
|
||||
).rejects.toThrow("Agent with name 'TestAgent' is already loaded.");
|
||||
});
|
||||
|
||||
@@ -146,20 +208,12 @@ describe('A2AClientManager', () => {
|
||||
shouldRetryWithHeaders: vi.fn(),
|
||||
};
|
||||
await manager.loadAgent(
|
||||
'CustomAuthAgent',
|
||||
'http://custom.agent/card',
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
expect(createAuthenticatingFetchWithRetry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
customAuthHandler,
|
||||
);
|
||||
|
||||
// Card resolver should NOT use the authenticated fetch by default.
|
||||
const resolverInstance = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.instances[0];
|
||||
expect(resolverInstance).toBeDefined();
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
expect(resolverOptions?.fetchImpl).not.toBe(authFetchMock);
|
||||
@@ -220,106 +274,163 @@ describe('A2AClientManager', () => {
|
||||
it('should log a debug message upon loading an agent', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
"[A2AClientManager] Loaded agent 'TestAgent' from http://test.agent/card",
|
||||
expect.stringContaining("Loaded agent 'TestAgent'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear the cache', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(manager.getAgentCard('TestAgent')).toBeDefined();
|
||||
expect(manager.getClient('TestAgent')).toBeDefined();
|
||||
|
||||
manager.clearCache();
|
||||
|
||||
expect(manager.getAgentCard('TestAgent')).toBeUndefined();
|
||||
expect(manager.getClient('TestAgent')).toBeUndefined();
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
'[A2AClientManager] Cache cleared.',
|
||||
});
|
||||
|
||||
it('should throw if resolveAgentCard fails', async () => {
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')),
|
||||
};
|
||||
vi.mocked(DefaultAgentCardResolver).mockReturnValue(
|
||||
resolverInstance as unknown as DefaultAgentCardResolver,
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
).rejects.toThrow('Resolution failed');
|
||||
});
|
||||
|
||||
it('should throw if factory.createFromAgentCard fails', async () => {
|
||||
const factoryInstance = {
|
||||
createFromAgentCard: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Factory failed')),
|
||||
};
|
||||
vi.mocked(ClientFactory).mockReturnValue(
|
||||
factoryInstance as unknown as ClientFactory,
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
).rejects.toThrow('Factory failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentCard and getClient', () => {
|
||||
it('should return undefined if agent is not found', () => {
|
||||
expect(manager.getAgentCard('Unknown')).toBeUndefined();
|
||||
expect(manager.getClient('Unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessageStream', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
});
|
||||
|
||||
it('should send a message and return a stream', async () => {
|
||||
const mockResult = {
|
||||
kind: 'message',
|
||||
messageId: 'a',
|
||||
parts: [],
|
||||
role: 'agent',
|
||||
} as SendMessageResult;
|
||||
|
||||
sendMessageStreamMock.mockReturnValue(
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield mockResult;
|
||||
yield { kind: 'message' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
const results = [];
|
||||
for await (const res of stream) {
|
||||
results.push(res);
|
||||
for await (const result of stream) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
expect(results).toEqual([mockResult]);
|
||||
expect(sendMessageStreamMock).toHaveBeenCalledWith(
|
||||
expect(results).toHaveLength(1);
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use contextId and taskId when provided', async () => {
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { kind: 'message' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello', {
|
||||
contextId: 'ctx123',
|
||||
taskId: 'task456',
|
||||
});
|
||||
// trigger execution
|
||||
for await (const _ of stream) {
|
||||
break;
|
||||
}
|
||||
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.anything(),
|
||||
message: expect.objectContaining({
|
||||
contextId: 'ctx123',
|
||||
taskId: 'task456',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use contextId and taskId when provided', async () => {
|
||||
sendMessageStreamMock.mockReturnValue(
|
||||
it('should correctly propagate AbortSignal to the stream', async () => {
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'a',
|
||||
parts: [],
|
||||
role: 'agent',
|
||||
} as SendMessageResult;
|
||||
yield { kind: 'message' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const expectedContextId = 'user-context-id';
|
||||
const expectedTaskId = 'user-task-id';
|
||||
|
||||
const controller = new AbortController();
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello', {
|
||||
contextId: expectedContextId,
|
||||
taskId: expectedTaskId,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// trigger execution
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
break;
|
||||
}
|
||||
|
||||
const call = sendMessageStreamMock.mock.calls[0][0];
|
||||
expect(call.message.contextId).toBe(expectedContextId);
|
||||
expect(call.message.taskId).toBe(expectedTaskId);
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ signal: controller.signal }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate the original error on failure', async () => {
|
||||
sendMessageStreamMock.mockImplementationOnce(() => {
|
||||
throw new Error('Network error');
|
||||
it('should handle a multi-chunk stream with different event types', async () => {
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { kind: 'message', messageId: 'm1' };
|
||||
yield { kind: 'status-update', taskId: 't1' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
const results = [];
|
||||
for await (const result of stream) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].kind).toBe('message');
|
||||
expect(results[1].kind).toBe('status-update');
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
mockClient.sendMessageStream.mockImplementation(() => {
|
||||
throw new Error('Network failure');
|
||||
});
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
// empty
|
||||
}
|
||||
}).rejects.toThrow('Network error');
|
||||
}).rejects.toThrow(
|
||||
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network failure',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the agent is not found', async () => {
|
||||
const stream = manager.sendMessageStream('NonExistentAgent', 'Hello');
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
// empty
|
||||
}
|
||||
}).rejects.toThrow("Agent 'NonExistentAgent' not found.");
|
||||
});
|
||||
@@ -327,28 +438,23 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('getTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
});
|
||||
|
||||
it('should get a task from the correct agent', async () => {
|
||||
getTaskMock.mockResolvedValue({
|
||||
id: 'task123',
|
||||
contextId: 'a',
|
||||
kind: 'task',
|
||||
status: { state: 'completed' },
|
||||
} as Task);
|
||||
const mockTask = { id: 'task123', kind: 'task' };
|
||||
mockClient.getTask.mockResolvedValue(mockTask);
|
||||
|
||||
await manager.getTask('TestAgent', 'task123');
|
||||
expect(getTaskMock).toHaveBeenCalledWith({
|
||||
id: 'task123',
|
||||
});
|
||||
const result = await manager.getTask('TestAgent', 'task123');
|
||||
expect(result).toBe(mockTask);
|
||||
expect(mockClient.getTask).toHaveBeenCalledWith({ id: 'task123' });
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
getTaskMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
mockClient.getTask.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
await expect(manager.getTask('TestAgent', 'task123')).rejects.toThrow(
|
||||
'A2AClient getTask Error [TestAgent]: Network error',
|
||||
'A2AClient getTask Error [TestAgent]: Not found',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -361,28 +467,23 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('cancelTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
});
|
||||
|
||||
it('should cancel a task on the correct agent', async () => {
|
||||
cancelTaskMock.mockResolvedValue({
|
||||
id: 'task123',
|
||||
contextId: 'a',
|
||||
kind: 'task',
|
||||
status: { state: 'canceled' },
|
||||
} as Task);
|
||||
const mockTask = { id: 'task123', kind: 'task' };
|
||||
mockClient.cancelTask.mockResolvedValue(mockTask);
|
||||
|
||||
await manager.cancelTask('TestAgent', 'task123');
|
||||
expect(cancelTaskMock).toHaveBeenCalledWith({
|
||||
id: 'task123',
|
||||
});
|
||||
const result = await manager.cancelTask('TestAgent', 'task123');
|
||||
expect(result).toBe(mockTask);
|
||||
expect(mockClient.cancelTask).toHaveBeenCalledWith({ id: 'task123' });
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
cancelTaskMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
mockClient.cancelTask.mockRejectedValue(new Error('Cannot cancel'));
|
||||
|
||||
await expect(manager.cancelTask('TestAgent', 'task123')).rejects.toThrow(
|
||||
'A2AClient cancelTask Error [TestAgent]: Network error',
|
||||
'A2AClient cancelTask Error [TestAgent]: Cannot cancel',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user