diff --git a/.gemini/skills/async-pr-review/scripts/async-review.sh b/.gemini/skills/async-pr-review/scripts/async-review.sh index d408c5f2f1..9977a6f907 100755 --- a/.gemini/skills/async-pr-review/scripts/async-review.sh +++ b/.gemini/skills/async-pr-review/scripts/async-review.sh @@ -1,190 +1,194 @@ #!/bin/bash notify() { - local title="$1" - local message="$2" - local pr="$3" + local title="${1}" + local message="${2}" + local pr="${3}" # Terminal escape sequence - printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message" + printf "\e]9;%s | PR #%s | %s\a" "${title}" "${pr}" "${message}" # Native macOS notification - if [[ "$(uname)" == "Darwin" ]]; then - osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\"" + os_type="$(uname || true)" + 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 +pr_number="${1}" +if [[ -z "${pr_number}" ]]; then echo "Usage: async-review " exit 1 fi -base_dir=$(git rev-parse --show-toplevel 2>/dev/null) -if [[ -z "$base_dir" ]]; then +base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)" +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" +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 +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" +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 "๐Ÿงน 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" +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" +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" + echo "๐ŸŒฟ Worktree already exists." | tee -a "${log_dir}/setup.log" fi -echo 0 > "$log_dir/setup.exit" +echo 0 > "${log_dir}/setup.exit" -cd "$target_dir" || exit 1 +cd "${target_dir}" || exit 1 -echo "๐Ÿš€ Launching background tasks. Logs saving to: $log_dir" +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"; } & +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"; } & +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=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini") +GEMINI_CMD="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")" +# shellcheck disable=SC2312 POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && 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"; } & +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" +rm -f "${log_dir}/npm-test.exit" { - while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done - if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then - gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1 + while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done + read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit="" + if [[ "${build_exit}" == "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" + 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 "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_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null) - run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null) + echo "Attempting to extract failing test files from CI logs..." >> "${log_dir}/npm-test.log" + pr_branch="$(gh pr view "${pr_number}" --json headRefName -q '.headRefName' 2>/dev/null || true)" + run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)" failed_files="" - if [[ -n "$run_id" ]]; then - failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq) + if [[ -n "${run_id}" ]]; then + failed_files="$(gh run view "${run_id}" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq || true)" 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" + 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) + 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) + ws_dir="$(echo "${file}" | cut -d'/' -f1)" fi - rel_file=${file#$ws_dir/} + 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 + 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" + 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" + 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" + 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" +rm -f "${log_dir}/test-execution.exit" { - while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done - if [ "$(cat "$log_dir/build-and-lint.exit")" == "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" + while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done + read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit="" + if [[ "${build_exit}" == "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" + 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" +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 tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution") log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log") declare -A task_done -for t in "${tasks[@]}"; do task_done[$t]=0; done +for t in "${tasks[@]}"; do task_done[${t}]=0; done all_done=0 -while [[ $all_done -eq 0 ]]; do +while [[ "${all_done}" -eq 0 ]]; do clear echo "==================================================" - echo "๐Ÿš€ Async PR Review Status for PR #$pr_number" + echo "๐Ÿš€ Async PR Review Status for PR #${pr_number}" echo "==================================================" echo "" all_done=1 for i in "${!tasks[@]}"; do - t="${tasks[$i]}" + t="${tasks[${i}]}" - if [[ -f "$log_dir/$t.exit" ]]; then - exit_code=$(cat "$log_dir/$t.exit") - if [[ "$exit_code" == "0" ]]; then - echo " โœ… $t: SUCCESS" + if [[ -f "${log_dir}/${t}.exit" ]]; then + read -r task_exit < "${log_dir}/${t}.exit" || task_exit="" + if [[ "${task_exit}" == "0" ]]; then + echo " โœ… ${t}: SUCCESS" else - echo " โŒ $t: FAILED (exit code $exit_code)" + echo " โŒ ${t}: FAILED (exit code ${task_exit})" fi - task_done[$t]=1 + task_done[${t}]=1 else - echo " โณ $t: RUNNING" + echo " โณ ${t}: RUNNING" all_done=0 fi done @@ -195,47 +199,47 @@ while [[ $all_done -eq 0 ]]; do echo "==================================================" for i in "${!tasks[@]}"; do - t="${tasks[$i]}" - log_file="${log_files[$i]}" + t="${tasks[${i}]}" + log_file="${log_files[${i}]}" - if [[ ${task_done[$t]} -eq 0 ]]; then - if [[ -f "$log_dir/$log_file" ]]; then + if [[ "${task_done[${t}]}" -eq 0 ]]; then + if [[ -f "${log_dir}/${log_file}" ]]; then echo "" - echo "--- $t ---" - tail -n 5 "$log_dir/$log_file" + echo "--- ${t} ---" + tail -n 5 "${log_dir}/${log_file}" fi fi done - if [[ $all_done -eq 0 ]]; then + if [[ "${all_done}" -eq 0 ]]; then sleep 3 fi done clear echo "==================================================" -echo "๐Ÿš€ Async PR Review Status for PR #$pr_number" +echo "๐Ÿš€ Async PR Review Status for PR #${pr_number}" echo "==================================================" echo "" for t in "${tasks[@]}"; do - exit_code=$(cat "$log_dir/$t.exit") - if [[ "$exit_code" == "0" ]]; then - echo " โœ… $t: SUCCESS" + read -r task_exit < "${log_dir}/${t}.exit" || task_exit="" + if [[ "${task_exit}" == "0" ]]; then + echo " โœ… ${t}: SUCCESS" else - echo " โŒ $t: FAILED (exit code $exit_code)" + echo " โŒ ${t}: FAILED (exit code ${task_exit})" 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 - echo $? > "$log_dir/final-assessment.exit" +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 + echo $? > "${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" + 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" +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}" diff --git a/.gemini/skills/async-pr-review/scripts/check-async-review.sh b/.gemini/skills/async-pr-review/scripts/check-async-review.sh index fbb58c2b72..846f703237 100755 --- a/.gemini/skills/async-pr-review/scripts/check-async-review.sh +++ b/.gemini/skills/async-pr-review/scripts/check-async-review.sh @@ -1,22 +1,22 @@ #!/bin/bash -pr_number=$1 +pr_number="${1}" -if [[ -z "$pr_number" ]]; then +if [[ -z "${pr_number}" ]]; then echo "Usage: check-async-review " exit 1 fi -base_dir=$(git rev-parse --show-toplevel 2>/dev/null) -if [[ -z "$base_dir" ]]; then +base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)" +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" +log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs" -if [[ ! -d "$log_dir" ]]; then +if [[ ! -d "${log_dir}" ]]; then echo "STATUS: NOT_FOUND" - echo "โŒ No logs found for PR #$pr_number in $log_dir" + echo "โŒ No logs found for PR #${pr_number} in ${log_dir}" exit 0 fi @@ -34,32 +34,32 @@ all_done=true echo "STATUS: CHECKING" for task_info in "${tasks[@]}"; do - IFS="|" read -r task_name log_file <<< "$task_info" + IFS="|" read -r task_name log_file <<< "${task_info}" - file_path="$log_dir/$log_file" - exit_file="$log_dir/$task_name.exit" + file_path="${log_dir}/${log_file}" + exit_file="${log_dir}/${task_name}.exit" - if [[ -f "$exit_file" ]]; then - exit_code=$(cat "$exit_file") - if [[ "$exit_code" == "0" ]]; then - echo "โœ… $task_name: SUCCESS" + if [[ -f "${exit_file}" ]]; then + read -r exit_code < "${exit_file}" || exit_code="" + if [[ "${exit_code}" == "0" ]]; then + echo "โœ… ${task_name}: SUCCESS" else - echo "โŒ $task_name: FAILED (exit code $exit_code)" - echo " Last lines of $file_path:" - tail -n 3 "$file_path" | sed 's/^/ /' + echo "โŒ ${task_name}: FAILED (exit code ${exit_code})" + echo " Last lines of ${file_path}:" + tail -n 3 "${file_path}" | sed 's/^/ /' || true fi - elif [[ -f "$file_path" ]]; then - echo "โณ $task_name: RUNNING" + elif [[ -f "${file_path}" ]]; then + echo "โณ ${task_name}: RUNNING" all_done=false else - echo "โž– $task_name: NOT STARTED" + echo "โž– ${task_name}: NOT STARTED" all_done=false fi done -if $all_done; then +if [[ "${all_done}" == "true" ]]; then echo "STATUS: COMPLETE" - echo "LOG_DIR: $log_dir" + echo "LOG_DIR: ${log_dir}" else echo "STATUS: IN_PROGRESS" -fi \ No newline at end of file +fi diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 541c881ed2..6c31a64679 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.36.0-preview.4 +# Preview release: v0.36.0-preview.5 -Released: March 26, 2026 +Released: March 27, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -31,6 +31,11 @@ npm install -g @google/gemini-cli@preview ## What's Changed +- fix(a2a-server): A2A server should execute ask policies in interactive mode by + @kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831) +- docs(core): document agent_card_json string literal options for remote agents + by @adamfweidman in + [#23797](https://github.com/google-gemini/gemini-cli/pull/23797) - feat(core): support inline agentCardJson for remote agents by @adamfweidman in [#23743](https://github.com/google-gemini/gemini-cli/pull/23743) - fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch @@ -381,4 +386,4 @@ npm install -g @google/gemini-cli@preview [#23666](https://github.com/google-gemini/gemini-cli/pull/23666) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.4 +https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5 diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 2163e4fcd1..ad87bc591b 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -39,7 +39,9 @@ To start Plan Mode while using Gemini CLI: the rotation when Gemini CLI is actively processing or showing confirmation dialogs. -- **Command:** Type `/plan` in the input box. +- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional; + for example, `/plan implement authentication` will switch to Plan Mode and + immediately submit the prompt to the model. - **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI calls the diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 5f432b8c8d..ac1fdc98fc 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -155,17 +155,21 @@ they appear in the UI. ### Experimental -| UI Label | Setting | Description | Default | -| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | -| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | -| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | -| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | -| Plan | `experimental.plan` | Enable Plan Mode. | `true` | -| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | -| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | -| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` | -| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | +| UI Label | Setting | Description | Default | +| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | +| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | +| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | +| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | +| Plan | `experimental.plan` | Enable Plan Mode. | `true` | +| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | +| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | +| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` | +| Agent History Truncation | `experimental.agentHistoryTruncation` | Enable truncation window logic for the Agent History Provider. | `false` | +| Agent History Truncation Threshold | `experimental.agentHistoryTruncationThreshold` | The maximum number of messages before history is truncated. | `30` | +| Agent History Retained Messages | `experimental.agentHistoryRetainedMessages` | The number of recent messages to retain after truncation. | `15` | +| Agent History Summarization | `experimental.agentHistorySummarization` | Enable summarization of truncated content via a small model for the Agent History Provider. | `false` | +| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | ### Skills diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index 8e0af1a9ce..11ef1edbbb 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -1,6 +1,6 @@ # Gemini 3 Pro and Gemini 3 Flash on Gemini CLI -Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! +Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI. > [!NOTE] diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5c558899c1..8078a89a00 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -670,6 +670,11 @@ their corresponding top-level category object in your `settings.json` file. "modelConfig": { "model": "gemini-3-pro-preview" } + }, + "agent-history-provider-summarizer": { + "modelConfig": { + "model": "gemini-3-flash-preview" + } } } ``` @@ -1282,6 +1287,18 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Maximum number of directories to search for memory. - **Default:** `200` +- **`context.memoryBoundaryMarkers`** (array): + - **Description:** File or directory names that mark the boundary for + GEMINI.md discovery. The upward traversal stops at the first directory + containing any of these markers. An empty array disables parent traversal. + - **Default:** + + ```json + [".git"] + ``` + + - **Requires restart:** Yes + - **`context.includeDirectories`** (array): - **Description:** Additional directories to include in the workspace context. Missing directories will be skipped with a warning. @@ -1349,6 +1366,14 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`tools.shell.backgroundCompletionBehavior`** (enum): + - **Description:** Controls what happens when a background shell command + finishes. 'silent' (default): quietly exits in background. 'inject': + automatically returns output to agent. 'notify': shows brief message in + chat. + - **Default:** `"silent"` + - **Values:** `"silent"`, `"inject"`, `"notify"` + - **`tools.shell.pager`** (string): - **Description:** The pager command to use for shell output. Defaults to `cat`. @@ -1677,6 +1702,28 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`experimental.agentHistoryTruncation`** (boolean): + - **Description:** Enable truncation window logic for the Agent History + Provider. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.agentHistoryTruncationThreshold`** (number): + - **Description:** The maximum number of messages before history is truncated. + - **Default:** `30` + - **Requires restart:** Yes + +- **`experimental.agentHistoryRetainedMessages`** (number): + - **Description:** The number of recent messages to retain after truncation. + - **Default:** `15` + - **Requires restart:** Yes + +- **`experimental.agentHistorySummarization`** (boolean): + - **Description:** Enable summarization of truncated content via a small model + for the Agent History Provider. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.topicUpdateNarration`** (boolean): - **Description:** Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 2ca7a6bb39..58edd797c6 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -86,12 +86,13 @@ available combinations. #### Text Input -| Command | Action | Keys | -| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `input.submit` | Submit the current prompt. | `Enter` | -| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | -| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` | -| `input.paste` | Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | +| Command | Action | Keys | +| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `input.submit` | Submit the current prompt. | `Enter` | +| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` | +| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | +| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` | +| `input.paste` | Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | #### App Controls diff --git a/evals/subagents.eval.ts b/evals/subagents.eval.ts index 140925964b..7053290fba 100644 --- a/evals/subagents.eval.ts +++ b/evals/subagents.eval.ts @@ -13,8 +13,21 @@ import { evalTest, TEST_AGENTS } from './test-helper.js'; const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n'; +// A minimal package.json is used to provide a realistic workspace anchor. +// This prevents the agent from making incorrect assumptions about the environment +// and helps it properly navigate or act as if it is in a standard Node.js project. +const MOCK_PACKAGE_JSON = JSON.stringify( + { + name: 'subagent-eval-project', + version: '1.0.0', + type: 'module', + }, + null, + 2, +); + function readProjectFile( - rig: { testDir?: string }, + rig: { testDir: string | null }, relativePath: string, ): string { return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8'); @@ -117,15 +130,7 @@ describe('subagent eval test cases', () => { files: { ...TEST_AGENTS.TESTING_AGENT.asFile(), 'index.ts': INDEX_TS, - 'package.json': JSON.stringify( - { - name: 'subagent-eval-project', - version: '1.0.0', - type: 'module', - }, - null, - 2, - ), + 'package.json': MOCK_PACKAGE_JSON, }, assert: async (rig, _result) => { const toolLogs = rig.readToolLogs() as Array<{ @@ -164,15 +169,7 @@ describe('subagent eval test cases', () => { ...TEST_AGENTS.TESTING_AGENT.asFile(), 'index.ts': INDEX_TS, 'README.md': 'TODO: update the README.\n', - 'package.json': JSON.stringify( - { - name: 'subagent-eval-project', - version: '1.0.0', - type: 'module', - }, - null, - 2, - ), + 'package.json': MOCK_PACKAGE_JSON, }, assert: async (rig, _result) => { const toolLogs = rig.readToolLogs() as Array<{ @@ -190,4 +187,105 @@ describe('subagent eval test cases', () => { ); }, }); + + /** + * Checks that the main agent can correctly select the appropriate subagent + * from a large pool of available subagents (10 total). + */ + evalTest('USUALLY_PASSES', { + name: 'should select the correct subagent from a pool of 10 different agents', + prompt: 'Please add a new SQL table migration for a user profile.', + files: { + ...TEST_AGENTS.DOCS_AGENT.asFile(), + ...TEST_AGENTS.TESTING_AGENT.asFile(), + ...TEST_AGENTS.DATABASE_AGENT.asFile(), + ...TEST_AGENTS.CSS_AGENT.asFile(), + ...TEST_AGENTS.I18N_AGENT.asFile(), + ...TEST_AGENTS.SECURITY_AGENT.asFile(), + ...TEST_AGENTS.DEVOPS_AGENT.asFile(), + ...TEST_AGENTS.ANALYTICS_AGENT.asFile(), + ...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(), + ...TEST_AGENTS.MOBILE_AGENT.asFile(), + 'package.json': MOCK_PACKAGE_JSON, + }, + assert: async (rig, _result) => { + const toolLogs = rig.readToolLogs() as Array<{ + toolRequest: { name: string }; + }>; + await rig.expectToolCallSuccess(['database-agent']); + + // Ensure the generalist and other irrelevant specialists were not invoked + const uncalledAgents = [ + 'generalist', + TEST_AGENTS.DOCS_AGENT.name, + TEST_AGENTS.TESTING_AGENT.name, + TEST_AGENTS.CSS_AGENT.name, + TEST_AGENTS.I18N_AGENT.name, + TEST_AGENTS.SECURITY_AGENT.name, + TEST_AGENTS.DEVOPS_AGENT.name, + TEST_AGENTS.ANALYTICS_AGENT.name, + TEST_AGENTS.ACCESSIBILITY_AGENT.name, + TEST_AGENTS.MOBILE_AGENT.name, + ]; + + for (const agentName of uncalledAgents) { + expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe( + false, + ); + } + }, + }); + + /** + * Checks that the main agent can correctly select the appropriate subagent + * from a large pool of available subagents, even when many irrelevant MCP tools are present. + * + * This test includes stress tests the subagent delegation with ~80 tools. + */ + evalTest('USUALLY_PASSES', { + name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present', + prompt: 'Please add a new SQL table migration for a user profile.', + setup: async (rig) => { + rig.addTestMcpServer('workspace-server', 'google-workspace'); + }, + files: { + ...TEST_AGENTS.DOCS_AGENT.asFile(), + ...TEST_AGENTS.TESTING_AGENT.asFile(), + ...TEST_AGENTS.DATABASE_AGENT.asFile(), + ...TEST_AGENTS.CSS_AGENT.asFile(), + ...TEST_AGENTS.I18N_AGENT.asFile(), + ...TEST_AGENTS.SECURITY_AGENT.asFile(), + ...TEST_AGENTS.DEVOPS_AGENT.asFile(), + ...TEST_AGENTS.ANALYTICS_AGENT.asFile(), + ...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(), + ...TEST_AGENTS.MOBILE_AGENT.asFile(), + 'package.json': MOCK_PACKAGE_JSON, + }, + assert: async (rig, _result) => { + const toolLogs = rig.readToolLogs() as Array<{ + toolRequest: { name: string }; + }>; + await rig.expectToolCallSuccess(['database-agent']); + + // Ensure the generalist and other irrelevant specialists were not invoked + const uncalledAgents = [ + 'generalist', + TEST_AGENTS.DOCS_AGENT.name, + TEST_AGENTS.TESTING_AGENT.name, + TEST_AGENTS.CSS_AGENT.name, + TEST_AGENTS.I18N_AGENT.name, + TEST_AGENTS.SECURITY_AGENT.name, + TEST_AGENTS.DEVOPS_AGENT.name, + TEST_AGENTS.ANALYTICS_AGENT.name, + TEST_AGENTS.ACCESSIBILITY_AGENT.name, + TEST_AGENTS.MOBILE_AGENT.name, + ]; + + for (const agentName of uncalledAgents) { + expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe( + false, + ); + } + }, + }); }); diff --git a/evals/test-helper.ts b/evals/test-helper.ts index f79a78779a..2bf9188eee 100644 --- a/evals/test-helper.ts +++ b/evals/test-helper.ts @@ -61,6 +61,10 @@ export async function internalEvalTest(evalCase: EvalCase) { try { rig.setup(evalCase.name, evalCase.params); + if (evalCase.setup) { + await evalCase.setup(rig); + } + if (evalCase.files) { await setupTestFiles(rig, evalCase.files); } @@ -371,6 +375,7 @@ export interface EvalCase { prompt: string; timeout?: number; files?: Record; + setup?: (rig: TestRig) => Promise | void; /** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */ messages?: Record[]; /** Session ID for the resumed session. Auto-generated if not provided. */ diff --git a/package.json b/package.json index 73ebef63fd..8bb5f25e20 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests", "test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests", "test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests", - "lint": "eslint . --cache --max-warnings 0", + "lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0", "lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format", "lint:ci": "npm run lint:all", "lint:all": "node scripts/lint.js", diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index 1c553d7539..f4d5fbd330 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -424,7 +424,22 @@ describe('loadConfig', () => { }); }); - describe('authentication fallback', () => { + describe('authentication logic', () => { + const setupConfigMock = (refreshAuthMock: ReturnType) => { + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + }; + beforeEach(() => { vi.stubEnv('USE_CCPA', 'true'); vi.stubEnv('GEMINI_API_KEY', ''); @@ -434,182 +449,77 @@ describe('loadConfig', () => { vi.unstubAllEnvs(); }); - it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => { - vi.stubEnv('CLOUD_SHELL', 'true'); - vi.mocked(isHeadlessMode).mockReturnValue(false); - const refreshAuthMock = vi.fn().mockImplementation((authType) => { - if (authType === AuthType.LOGIN_WITH_GOOGLE) { - throw new FatalAuthenticationError('Non-interactive session'); - } - return Promise.resolve(); - }); - - // Update the mock implementation for this test - vi.mocked(Config).mockImplementation( - (params: unknown) => - ({ - ...(params as object), - initialize: vi.fn(), - waitForMcpInit: vi.fn(), - refreshAuth: refreshAuthMock, - getExperiments: vi.fn().mockReturnValue({ flags: {} }), - getRemoteAdminSettings: vi.fn(), - setRemoteAdminSettings: vi.fn(), - }) as unknown as Config, - ); - - await loadConfig(mockSettings, mockExtensionLoader, taskId); - - expect(refreshAuthMock).toHaveBeenCalledWith( - AuthType.LOGIN_WITH_GOOGLE, - ); - expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); - }); - - it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => { - vi.mocked(isHeadlessMode).mockReturnValue(false); - const refreshAuthMock = vi.fn().mockImplementation((authType) => { - if (authType === AuthType.LOGIN_WITH_GOOGLE) { - throw new FatalAuthenticationError('Non-interactive session'); - } - return Promise.resolve(); - }); - - vi.mocked(Config).mockImplementation( - (params: unknown) => - ({ - ...(params as object), - initialize: vi.fn(), - waitForMcpInit: vi.fn(), - refreshAuth: refreshAuthMock, - getExperiments: vi.fn().mockReturnValue({ flags: {} }), - getRemoteAdminSettings: vi.fn(), - setRemoteAdminSettings: vi.fn(), - }) as unknown as Config, - ); - - await expect( - loadConfig(mockSettings, mockExtensionLoader, taskId), - ).rejects.toThrow('Non-interactive session'); - - expect(refreshAuthMock).toHaveBeenCalledWith( - AuthType.LOGIN_WITH_GOOGLE, - ); - expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC); - }); - - it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => { - vi.stubEnv('CLOUD_SHELL', 'true'); - vi.mocked(isHeadlessMode).mockReturnValue(true); - + it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => { const refreshAuthMock = vi.fn().mockResolvedValue(undefined); - - vi.mocked(Config).mockImplementation( - (params: unknown) => - ({ - ...(params as object), - initialize: vi.fn(), - waitForMcpInit: vi.fn(), - refreshAuth: refreshAuthMock, - getExperiments: vi.fn().mockReturnValue({ flags: {} }), - getRemoteAdminSettings: vi.fn(), - setRemoteAdminSettings: vi.fn(), - }) as unknown as Config, - ); + setupConfigMock(refreshAuthMock); await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); expect(refreshAuthMock).not.toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); - expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); }); - it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => { - vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true'); - vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless - - const refreshAuthMock = vi.fn().mockResolvedValue(undefined); - - vi.mocked(Config).mockImplementation( - (params: unknown) => - ({ - ...(params as object), - initialize: vi.fn(), - waitForMcpInit: vi.fn(), - refreshAuth: refreshAuthMock, - getExperiments: vi.fn().mockReturnValue({ flags: {} }), - getRemoteAdminSettings: vi.fn(), - setRemoteAdminSettings: vi.fn(), - }) as unknown as Config, - ); + it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.COMPUTE_ADC) { + return Promise.reject(new Error('ADC failed')); + } + return Promise.resolve(); + }); + setupConfigMock(refreshAuthMock); await loadConfig(mockSettings, mockExtensionLoader, taskId); - expect(refreshAuthMock).not.toHaveBeenCalledWith( + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + expect(refreshAuthMock).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); - expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); }); - it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => { + it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => { vi.mocked(isHeadlessMode).mockReturnValue(true); - const refreshAuthMock = vi.fn().mockResolvedValue(undefined); - - vi.mocked(Config).mockImplementation( - (params: unknown) => - ({ - ...(params as object), - initialize: vi.fn(), - waitForMcpInit: vi.fn(), - refreshAuth: refreshAuthMock, - getExperiments: vi.fn().mockReturnValue({ flags: {} }), - getRemoteAdminSettings: vi.fn(), - setRemoteAdminSettings: vi.fn(), - }) as unknown as Config, - ); + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.COMPUTE_ADC) { + return Promise.reject(new Error('ADC not found')); + } + return Promise.resolve(); + }); + setupConfigMock(refreshAuthMock); await expect( loadConfig(mockSettings, mockExtensionLoader, taskId), ).rejects.toThrow( - 'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.', + 'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)', ); - expect(refreshAuthMock).not.toHaveBeenCalled(); + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + expect(refreshAuthMock).not.toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); }); - it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => { - vi.stubEnv('CLOUD_SHELL', 'true'); + it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => { vi.mocked(isHeadlessMode).mockReturnValue(false); const refreshAuthMock = vi.fn().mockImplementation((authType) => { - if (authType === AuthType.LOGIN_WITH_GOOGLE) { - throw new FatalAuthenticationError('OAuth failed'); - } if (authType === AuthType.COMPUTE_ADC) { throw new Error('ADC failed'); } + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + throw new FatalAuthenticationError('OAuth failed'); + } return Promise.resolve(); }); - - vi.mocked(Config).mockImplementation( - (params: unknown) => - ({ - ...(params as object), - initialize: vi.fn(), - waitForMcpInit: vi.fn(), - refreshAuth: refreshAuthMock, - getExperiments: vi.fn().mockReturnValue({ flags: {} }), - getRemoteAdminSettings: vi.fn(), - setRemoteAdminSettings: vi.fn(), - }) as unknown as Config, - ); + setupConfigMock(refreshAuthMock); await expect( loadConfig(mockSettings, mockExtensionLoader, taskId), ).rejects.toThrow( - 'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed', + 'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed', ); }); }); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index cd4f5df25f..3badd3ff79 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -25,7 +25,6 @@ import { ExperimentFlags, isHeadlessMode, FatalAuthenticationError, - isCloudShell, PolicyDecision, PRIORITY_YOLO_ALLOW_ALL, type TelemetryTarget, @@ -43,7 +42,6 @@ export async function loadConfig( taskId: string, ): Promise { const workspaceDir = process.cwd(); - const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS']; const folderTrust = settings.folderTrust === true || @@ -192,7 +190,7 @@ export async function loadConfig( await config.waitForMcpInit(); startupProfiler.flush(config); - await refreshAuthentication(config, adcFilePath, 'Config'); + await refreshAuthentication(config, 'Config'); return config; } @@ -263,75 +261,51 @@ function findEnvFile(startDir: string): string | null { async function refreshAuthentication( config: Config, - adcFilePath: string | undefined, logPrefix: string, ): Promise { if (process.env['USE_CCPA']) { logger.info(`[${logPrefix}] Using CCPA Auth:`); + + logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`); try { - if (adcFilePath) { - path.resolve(adcFilePath); - } - } catch (e) { - logger.error( - `[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`, + await config.refreshAuth(AuthType.COMPUTE_ADC); + logger.info(`[${logPrefix}] COMPUTE_ADC successful.`); + } catch (adcError) { + const adcMessage = + adcError instanceof Error ? adcError.message : String(adcError); + logger.info( + `[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`, ); - } - const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'; - const isHeadless = isHeadlessMode(); - const shouldSkipOauth = isHeadless || useComputeAdc; + const useComputeAdc = + process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'; + const isHeadless = isHeadlessMode(); - if (shouldSkipOauth) { - if (isCloudShell() || useComputeAdc) { - logger.info( - `[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`, - ); - try { - await config.refreshAuth(AuthType.COMPUTE_ADC); - logger.info(`[${logPrefix}] COMPUTE_ADC successful.`); - } catch (adcError) { - const adcMessage = - adcError instanceof Error ? adcError.message : String(adcError); - throw new FatalAuthenticationError( - `COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`, - ); - } - } else { + if (isHeadless || useComputeAdc) { + const reason = isHeadless + ? 'headless mode' + : 'GEMINI_CLI_USE_COMPUTE_ADC=true'; throw new FatalAuthenticationError( - `Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`, + `COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`, ); } - } else { + + logger.info( + `[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`, + ); try { await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); } catch (e) { - if ( - e instanceof FatalAuthenticationError && - (isCloudShell() || useComputeAdc) - ) { - logger.warn( - `[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`, + if (e instanceof FatalAuthenticationError) { + const originalMessage = e instanceof Error ? e.message : String(e); + throw new FatalAuthenticationError( + `${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`, ); - try { - await config.refreshAuth(AuthType.COMPUTE_ADC); - logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`); - } catch (adcError) { - logger.error( - `[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`, - ); - const originalMessage = e instanceof Error ? e.message : String(e); - const adcMessage = - adcError instanceof Error ? adcError.message : String(adcError); - throw new FatalAuthenticationError( - `${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`, - ); - } - } else { - throw e; } + throw e; } } + logger.info( `[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`, ); diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 8181f702f1..f7f1645f8c 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -109,6 +109,12 @@ export function createMockConfig( enableEnvironmentVariableRedaction: false, }, }), + isExperimentalAgentHistoryTruncationEnabled: vi.fn().mockReturnValue(false), + getExperimentalAgentHistoryTruncationThreshold: vi.fn().mockReturnValue(50), + getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(30), + isExperimentalAgentHistorySummarizationEnabled: vi + .fn() + .mockReturnValue(false), ...overrides, } as unknown as Config; diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 9e4b89ea20..14295954dd 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -28,6 +28,7 @@ import { LlmRole, type GitService, processSingleFileContent, + InvalidStreamError, } from '@google/gemini-cli-core'; import { SettingScope, @@ -785,6 +786,32 @@ describe('Session', () => { expect(result).toMatchObject({ stopReason: 'end_turn' }); }); + it('should handle prompt with empty response (InvalidStreamError)', async () => { + mockChat.sendMessageStream.mockRejectedValue( + new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'), + ); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(mockChat.sendMessageStream).toHaveBeenCalled(); + expect(result).toMatchObject({ stopReason: 'end_turn' }); + }); + + it('should handle prompt with empty response (NO_RESPONSE_TEXT anomaly)', async () => { + mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_RESPONSE_TEXT' }); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(mockChat.sendMessageStream).toHaveBeenCalled(); + expect(result).toMatchObject({ stopReason: 'end_turn' }); + }); + it('should handle /memory command', async () => { const handleCommandSpy = vi .spyOn( diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 59c6cb2b3f..6b76ffdc7a 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -48,6 +48,7 @@ import { PREVIEW_GEMINI_MODEL_AUTO, getDisplayString, processSingleFileContent, + InvalidStreamError, type AgentLoopContext, updatePolicy, } from '@google/gemini-cli-core'; @@ -851,6 +852,37 @@ export class Session { return { stopReason: CoreToolCallStatus.Cancelled }; } + if ( + error instanceof InvalidStreamError || + (error && + typeof error === 'object' && + 'type' in error && + error.type === 'NO_RESPONSE_TEXT') + ) { + // The stream ended with an empty response or malformed tool call. + // Treat this as a graceful end to the model's turn rather than a crash. + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + }, + model_usage: Array.from(modelUsageMap.entries()).map( + ([modelName, counts]) => ({ + model: modelName, + token_count: { + input_tokens: counts.input, + output_tokens: counts.output, + }, + }), + ), + }, + }, + }; + } + throw new acp.RequestError( getErrorStatus(error) || 500, getAcpErrorMessage(error), diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0d9fb8a9a0..b9401ed5eb 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -989,6 +989,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { respectGeminiIgnore: true, }), 200, // maxDirs + ['.git'], // boundaryMarkers ); }); @@ -1018,6 +1019,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { respectGeminiIgnore: true, }), 200, + ['.git'], // boundaryMarkers ); }); @@ -1046,6 +1048,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { respectGeminiIgnore: true, }), 200, + ['.git'], // boundaryMarkers ); }); }); @@ -1122,12 +1125,7 @@ describe('mergeExcludeTools', () => { ]); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); - const config = await loadCliConfig( - settings, - - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']), ); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d4e0b6b4cf..98bd8b3e42 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -649,6 +649,7 @@ export async function loadCliConfig( memoryImportFormat, memoryFileFiltering, settings.context?.discoveryMaxDirs, + settings.context?.memoryBoundaryMarkers, ); memoryContent = result.memoryContent; fileCount = result.fileCount; @@ -903,6 +904,7 @@ export async function loadCliConfig( loadMemoryFromIncludeDirectories: settings.context?.loadMemoryFromIncludeDirectories || false, discoveryMaxDirs: settings.context?.discoveryMaxDirs, + memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers, importFormat: settings.context?.importFormat, debugMode, question, @@ -982,6 +984,14 @@ export async function loadCliConfig( disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, experimentalMemoryManager: settings.experimental?.memoryManager, + experimentalAgentHistoryTruncation: + settings.experimental?.agentHistoryTruncation, + experimentalAgentHistoryTruncationThreshold: + settings.experimental?.agentHistoryTruncationThreshold, + experimentalAgentHistoryRetainedMessages: + settings.experimental?.agentHistoryRetainedMessages, + experimentalAgentHistorySummarization: + settings.experimental?.agentHistorySummarization, modelSteering: settings.experimental?.modelSteering, topicUpdateNarration: settings.experimental?.topicUpdateNarration, toolOutputMasking: settings.experimental?.toolOutputMasking, @@ -997,6 +1007,8 @@ export async function loadCliConfig( useAlternateBuffer: settings.ui?.useAlternateBuffer, useRipgrep: settings.tools?.useRipgrep, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, + shellBackgroundCompletionBehavior: settings.tools?.shell + ?.backgroundCompletionBehavior as string | undefined, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, enableShellOutputEfficiency: settings.tools?.shell?.enableShellOutputEfficiency ?? true, diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index 9358784a2f..fa5fec5bc3 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => { respectGeminiIgnore: true, }), getDiscoveryMaxDirs: () => 200, + getMemoryBoundaryMarkers: () => ['.git'], getMcpClientManager: () => ({ getMcpInstructions: () => '', startExtension: vi.fn().mockResolvedValue(undefined), diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index 3ec0e6a5bb..34c97d6ecd 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -93,7 +93,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'docker', image: 'default/image', }); @@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'lxc', image: 'default/image', }); @@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'sandbox-exec', image: 'default/image', }); @@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'sandbox-exec', image: 'default/image', }); @@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'docker', image: 'default/image', }); @@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'podman', image: 'default/image', }); @@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'podman', image: 'default/image', }); @@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'docker', image: 'env/image', }); @@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'docker', image: 'default/image', }); @@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'docker', image: 'default/image', }); @@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => { enabled: true, command: 'podman', allowedPaths: [], - networkAccess: false, + networkAccess: true, }, }, }, @@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => { enabled: true, image: 'custom/image', allowedPaths: [], - networkAccess: false, + networkAccess: true, }, }, }, @@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: false, allowedPaths: [], - networkAccess: false, + networkAccess: true, }, }, }, @@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, allowedPaths: ['/settings-path'], - networkAccess: false, + networkAccess: true, }, }, }, @@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'runsc', image: 'default/image', }); @@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'runsc', image: 'default/image', }); @@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'runsc', image: 'default/image', }); @@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => { expect(config).toEqual({ enabled: true, allowedPaths: [], - networkAccess: false, + networkAccess: true, command: 'runsc', image: 'default/image', }); diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 1a047760d3..07685e9bea 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -131,7 +131,7 @@ export async function loadSandboxConfig( let sandboxValue: boolean | string | null | undefined; let allowedPaths: string[] = []; - let networkAccess = false; + let networkAccess = true; let customImage: string | undefined; if ( @@ -142,7 +142,7 @@ export async function loadSandboxConfig( const config = sandboxOption; sandboxValue = config.enabled ? (config.command ?? true) : false; allowedPaths = config.allowedPaths ?? []; - networkAccess = config.networkAccess ?? false; + networkAccess = config.networkAccess ?? true; customImage = config.image; } else if (typeof sandboxOption !== 'object' || sandboxOption === null) { sandboxValue = sandboxOption; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index aec521317c..c40e87db18 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1291,6 +1291,19 @@ const SETTINGS_SCHEMA = { description: 'Maximum number of directories to search for memory.', showInDialog: true, }, + memoryBoundaryMarkers: { + type: 'array', + label: 'Memory Boundary Markers', + category: 'Context', + requiresRestart: true, + default: ['.git'] as string[], + description: + 'File or directory names that mark the boundary for GEMINI.md discovery. ' + + 'The upward traversal stops at the first directory containing any of these markers. ' + + 'An empty array disables parent traversal.', + showInDialog: false, + items: { type: 'string' }, + }, includeDirectories: { type: 'array', label: 'Include Directories', @@ -1445,6 +1458,21 @@ const SETTINGS_SCHEMA = { `, showInDialog: true, }, + backgroundCompletionBehavior: { + type: 'enum', + label: 'Background Completion Behavior', + category: 'Tools', + requiresRestart: false, + default: 'silent', + description: + "Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.", + showInDialog: false, + options: [ + { label: 'Silent', value: 'silent' }, + { label: 'Inject', value: 'inject' }, + { label: 'Notify', value: 'notify' }, + ], + }, pager: { type: 'string', label: 'Pager', @@ -2141,6 +2169,46 @@ const SETTINGS_SCHEMA = { 'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.', showInDialog: true, }, + agentHistoryTruncation: { + type: 'boolean', + label: 'Agent History Truncation', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable truncation window logic for the Agent History Provider.', + showInDialog: true, + }, + agentHistoryTruncationThreshold: { + type: 'number', + label: 'Agent History Truncation Threshold', + category: 'Experimental', + requiresRestart: true, + default: 30, + description: + 'The maximum number of messages before history is truncated.', + showInDialog: true, + }, + agentHistoryRetainedMessages: { + type: 'number', + label: 'Agent History Retained Messages', + category: 'Experimental', + requiresRestart: true, + default: 15, + description: + 'The number of recent messages to retain after truncation.', + showInDialog: true, + }, + agentHistorySummarization: { + type: 'boolean', + label: 'Agent History Summarization', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable summarization of truncated content via a small model for the Agent History Provider.', + showInDialog: true, + }, topicUpdateNarration: { type: 'boolean', label: 'Topic & Update Narration', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 4b43d7d81b..fa22f59267 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -671,11 +671,6 @@ export async function main() { } } - // Register SessionEnd hook for graceful exit - registerCleanup(async () => { - await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); - }); - if (!input) { debugLogger.error( `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 382ad3f81f..b2fa2139fd 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -6,7 +6,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { main } from './gemini.js'; -import { debugLogger, type Config } from '@google/gemini-cli-core'; +import { + debugLogger, + SessionEndReason, + type Config, + type HookSystem, +} from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -197,11 +202,11 @@ describe('gemini.tsx main function cleanup', () => { setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + } as unknown as ReturnType); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + } as unknown as Awaited>); vi.mocked(loadCliConfig).mockResolvedValue({ isInteractive: vi.fn(() => false), getQuestion: vi.fn(() => 'test'), @@ -238,7 +243,8 @@ describe('gemini.tsx main function cleanup', () => { setTerminalBackground: vi.fn(), refreshAuth: vi.fn(), getRemoteAdminSettings: vi.fn(() => undefined), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + getUseAlternateBuffer: vi.fn(() => false), + } as unknown as Config); await main(); @@ -248,4 +254,80 @@ describe('gemini.tsx main function cleanup', () => { expect.objectContaining({ message: 'Cleanup failed' }), ); }); + + it('should register SessionEnd hook exactly once in non-interactive mode', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { registerCleanup } = await import('./utils/cleanup.js'); + + const mockHookSystem = { + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), + } as unknown as HookSystem; + + vi.mocked(parseArguments).mockResolvedValue({ + promptInteractive: false, + } as unknown as Awaited>); + + vi.mocked(loadCliConfig).mockResolvedValue( + buildMockConfig({ + getHookSystem: vi.fn(() => mockHookSystem), + }), + ); + + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await main(); + + const registeredCallbacks = vi + .mocked(registerCleanup) + .mock.calls.map(([fn]) => fn); + for (const fn of registeredCallbacks) await fn(); + expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1); + expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( + SessionEndReason.Exit, + ); + }); + + function buildMockConfig(overrides: Partial = {}): Config { + return { + isInteractive: vi.fn(() => false), + getQuestion: vi.fn(() => 'test'), + getSandbox: vi.fn(() => false), + getDebugMode: vi.fn(() => false), + getPolicyEngine: vi.fn(), + getMessageBus: () => ({ subscribe: vi.fn() }), + getEnableHooks: vi.fn(() => true), + getHookSystem: vi.fn(() => undefined), + initialize: vi.fn(), + storage: { initialize: vi.fn().mockResolvedValue(undefined) }, + getContentGeneratorConfig: vi.fn(), + getMcpClientManager: vi.fn(), + getIdeMode: vi.fn(() => false), + getAcpMode: vi.fn(() => false), + getScreenReader: vi.fn(() => false), + getGeminiMdFileCount: vi.fn(() => 0), + getProjectRoot: vi.fn(() => '/'), + getListExtensions: vi.fn(() => false), + getListSessions: vi.fn(() => false), + getDeleteSession: vi.fn(() => undefined), + getToolRegistry: vi.fn(), + getExtensions: vi.fn(() => []), + getModel: vi.fn(() => 'gemini-pro'), + getEmbeddingModel: vi.fn(() => 'embedding-001'), + getApprovalMode: vi.fn(() => 'default'), + getCoreTools: vi.fn(() => []), + getTelemetryEnabled: vi.fn(() => false), + getTelemetryLogPromptsEnabled: vi.fn(() => false), + getFileFilteringRespectGitIgnore: vi.fn(() => true), + getOutputFormat: vi.fn(() => 'text'), + getUsageStatisticsEnabled: vi.fn(() => false), + setTerminalBackground: vi.fn(), + refreshAuth: vi.fn(), + getRemoteAdminSettings: vi.fn(() => undefined), + getUseAlternateBuffer: vi.fn(() => false), + ...overrides, + } as unknown as Config; + } }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c55ca10b1c..c8d328edb2 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -57,7 +57,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; -import { shellsCommand } from '../ui/commands/shellsCommand.js'; +import { tasksCommand } from '../ui/commands/tasksCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; @@ -223,7 +223,7 @@ export class BuiltinCommandLoader implements ICommandLoader { : [skillsCommand] : []), settingsCommand, - shellsCommand, + tasksCommand, vimCommand, setupGithubCommand, terminalSetupCommand, diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index e1505df970..260bafdf2b 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -163,6 +163,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getAdminSkillsEnabled: vi.fn().mockReturnValue(false), getDisabledSkills: vi.fn().mockReturnValue([]), getExperimentalJitContext: vi.fn().mockReturnValue(false), + getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']), getTerminalBackground: vi.fn().mockReturnValue(undefined), getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'), getQuotaErrorOccurred: vi.fn().mockReturnValue(false), diff --git a/packages/cli/src/test-utils/mockSpinner.tsx b/packages/cli/src/test-utils/mockSpinner.tsx new file mode 100644 index 0000000000..c27c7a8707 --- /dev/null +++ b/packages/cli/src/test-utils/mockSpinner.tsx @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import type { SpinnerName } from 'cli-spinners'; + +export function mockInkSpinner() { + vi.mock('ink-spinner', async () => { + const { Text } = await import('ink'); + const cliSpinners = (await import('cli-spinners')).default; + + return { + default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) { + const spinner = cliSpinners[type]; + const frame = spinner ? spinner.frames[0] : 'โ ‹'; + return {frame}; + }, + }; + }); +} diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index c4aec2e9cd..6ca30dd8b9 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -506,8 +506,8 @@ const baseMockUiState = { cleanUiDetailsVisible: false, allowPlanMode: true, activePtyId: undefined, - backgroundShells: new Map(), - backgroundShellHeight: 0, + backgroundTasks: new Map(), + backgroundTaskHeight: 0, quota: { userTier: undefined, stats: undefined, @@ -568,6 +568,7 @@ const mockUIActions: UIActions = { handleOverageMenuChoice: vi.fn(), handleEmptyWalletChoice: vi.fn(), setQueueErrorMessage: vi.fn(), + addMessage: vi.fn(), popAllMessages: vi.fn(), handleApiKeySubmit: vi.fn(), handleApiKeyCancel: vi.fn(), @@ -578,9 +579,9 @@ const mockUIActions: UIActions = { revealCleanUiDetailsTemporarily: vi.fn(), handleWarning: vi.fn(), setEmbeddedShellFocused: vi.fn(), - dismissBackgroundShell: vi.fn(), - setActiveBackgroundShellPid: vi.fn(), - setIsBackgroundShellListOpen: vi.fn(), + dismissBackgroundTask: vi.fn(), + setActiveBackgroundTaskPid: vi.fn(), + setIsBackgroundTaskListOpen: vi.fn(), setAuthContext: vi.fn(), onHintInput: vi.fn(), onHintBackspace: vi.fn(), diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index b836202eb7..3505e63452 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -88,7 +88,7 @@ describe('App', () => { defaultText: 'Mock Banner Text', warningText: '', }, - backgroundShells: new Map(), + backgroundTasks: new Map(), }; it('should render main content and composer when not quitting', async () => { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 3324505778..0e436cc645 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -328,13 +328,13 @@ describe('AppContainer State Management', () => { handleApprovalModeChange: vi.fn(), activePtyId: null, loopDetectionConfirmationRequest: null, - backgroundShellCount: 0, - isBackgroundShellVisible: false, - toggleBackgroundShell: vi.fn(), - backgroundCurrentShell: vi.fn(), - backgroundShells: new Map(), - registerBackgroundShell: vi.fn(), - dismissBackgroundShell: vi.fn(), + backgroundTaskCount: 0, + isBackgroundTaskVisible: false, + toggleBackgroundTasks: vi.fn(), + backgroundCurrentExecution: vi.fn(), + backgroundTasks: new Map(), + registerBackgroundTask: vi.fn(), + dismissBackgroundTask: vi.fn(), }; beforeEach(() => { @@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => { }); it('should focus background shell on Tab when already visible (not toggle it off)', async () => { - const mockToggleBackgroundShell = vi.fn(); + const mockToggleBackgroundTask = vi.fn(); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, - isBackgroundShellVisible: true, - backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), - toggleBackgroundShell: mockToggleBackgroundShell, + isBackgroundTaskVisible: true, + backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundTasks: mockToggleBackgroundTask, }); await setupKeypressTest(); @@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => { // Should be focused expect(capturedUIState.embeddedShellFocused).toBe(true); // Should NOT have toggled (closed) the shell - expect(mockToggleBackgroundShell).not.toHaveBeenCalled(); + expect(mockToggleBackgroundTask).not.toHaveBeenCalled(); unmount(); }); @@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => { describe('Background Shell Toggling (CTRL+B)', () => { it('should toggle background shell on Ctrl+B even if visible but not focused', async () => { - const mockToggleBackgroundShell = vi.fn(); + const mockToggleBackgroundTask = vi.fn(); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, - isBackgroundShellVisible: true, - backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), - toggleBackgroundShell: mockToggleBackgroundShell, + isBackgroundTaskVisible: true, + backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundTasks: mockToggleBackgroundTask, }); await setupKeypressTest(); @@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => { pressKey('\x02'); // Should have toggled (closed) the shell - expect(mockToggleBackgroundShell).toHaveBeenCalled(); + expect(mockToggleBackgroundTask).toHaveBeenCalled(); // Should be unfocused expect(capturedUIState.embeddedShellFocused).toBe(false); @@ -2311,28 +2311,28 @@ describe('AppContainer State Management', () => { }); it('should show and focus background shell on Ctrl+B if hidden', async () => { - const mockToggleBackgroundShell = vi.fn(); + const mockToggleBackgroundTask = vi.fn(); const geminiStreamMock = { ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, - isBackgroundShellVisible: false, - backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), - toggleBackgroundShell: mockToggleBackgroundShell, + isBackgroundTaskVisible: false, + backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundTasks: mockToggleBackgroundTask, }; mockedUseGeminiStream.mockReturnValue(geminiStreamMock); await setupKeypressTest(); // Update the mock state when toggled to simulate real behavior - mockToggleBackgroundShell.mockImplementation(() => { - geminiStreamMock.isBackgroundShellVisible = true; + mockToggleBackgroundTask.mockImplementation(() => { + geminiStreamMock.isBackgroundTaskVisible = true; }); // Press Ctrl+B pressKey('\x02'); // Should have toggled (shown) the shell - expect(mockToggleBackgroundShell).toHaveBeenCalled(); + expect(mockToggleBackgroundTask).toHaveBeenCalled(); // Should be focused expect(capturedUIState.embeddedShellFocused).toBe(true); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a2655504a6..b523cbc792 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -112,7 +112,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; -import { type BackgroundShell } from './hooks/shellCommandProcessor.js'; +import { type BackgroundTask } from './hooks/useExecutionLifecycle.js'; import { useVim } from './hooks/vim.js'; import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; @@ -153,7 +153,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useBanner } from './hooks/useBanner.js'; import { useTerminalSetupPrompt } from './utils/terminalSetup.js'; import { useHookDisplayState } from './hooks/useHookDisplayState.js'; -import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'; +import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js'; import { WARNING_PROMPT_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS, @@ -234,9 +234,9 @@ export const AppContainer = (props: AppContainerProps) => { ); const [copyModeEnabled, setCopyModeEnabled] = useState(false); const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false); - const toggleBackgroundShellRef = useRef<() => void>(() => {}); - const isBackgroundShellVisibleRef = useRef(false); - const backgroundShellsRef = useRef>(new Map()); + const toggleBackgroundTasksRef = useRef<() => void>(() => {}); + const isBackgroundTaskVisibleRef = useRef(false); + const backgroundTasksRef = useRef>(new Map()); const [adminSettingsChanged, setAdminSettingsChanged] = useState(false); @@ -456,7 +456,7 @@ export const AppContainer = (props: AppContainerProps) => { // Kill all background shells await Promise.all( - Array.from(backgroundShellsRef.current.keys()).map((pid) => + Array.from(backgroundTasksRef.current.keys()).map((pid) => ShellExecutionService.kill(pid), ), ); @@ -867,7 +867,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const { toggleVimEnabled } = useVimMode(); - const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>( + const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>( () => {}, ); const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); @@ -902,14 +902,14 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleDebugProfiler, dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, - toggleBackgroundShell: () => { - toggleBackgroundShellRef.current(); - if (!isBackgroundShellVisibleRef.current) { + toggleBackgroundTasks: () => { + toggleBackgroundTasksRef.current(); + if (!isBackgroundTaskVisibleRef.current) { setEmbeddedShellFocused(true); - if (backgroundShellsRef.current.size > 1) { - setIsBackgroundShellListOpenRef.current(true); + if (backgroundTasksRef.current.size > 1) { + setIsBackgroundTaskListOpenRef.current(true); } else { - setIsBackgroundShellListOpenRef.current(false); + setIsBackgroundTaskListOpenRef.current(false); } } }, @@ -1081,7 +1081,7 @@ Logging in with Google... Restarting Gemini CLI to continue. useEffect(() => { const hintListener = (text: string, source: InjectionSource) => { - if (source !== 'user_steering') { + if (source !== 'user_steering' && source !== 'background_completion') { return; } pendingHintsRef.current.push(text); @@ -1105,12 +1105,12 @@ Logging in with Google... Restarting Gemini CLI to continue. activePtyId, loopDetectionConfirmationRequest, lastOutputTime, - backgroundShellCount, - isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - backgroundShells, - dismissBackgroundShell, + backgroundTaskCount, + isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + backgroundTasks, + dismissBackgroundTask, retryStatus, } = useGeminiStream( config.getGeminiClient(), @@ -1144,27 +1144,27 @@ Logging in with Google... Restarting Gemini CLI to continue. [pendingHistoryItems], ); - toggleBackgroundShellRef.current = toggleBackgroundShell; - isBackgroundShellVisibleRef.current = isBackgroundShellVisible; - backgroundShellsRef.current = backgroundShells; + toggleBackgroundTasksRef.current = toggleBackgroundTasks; + isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible; + backgroundTasksRef.current = backgroundTasks; const { - activeBackgroundShellPid, - setIsBackgroundShellListOpen, - isBackgroundShellListOpen, - setActiveBackgroundShellPid, - backgroundShellHeight, - } = useBackgroundShellManager({ - backgroundShells, - backgroundShellCount, - isBackgroundShellVisible, + activeBackgroundTaskPid, + setIsBackgroundTaskListOpen, + isBackgroundTaskListOpen, + setActiveBackgroundTaskPid, + backgroundTaskHeight, + } = useBackgroundTaskManager({ + backgroundTasks, + backgroundTaskCount, + isBackgroundTaskVisible, activePtyId, embeddedShellFocused, setEmbeddedShellFocused, terminalHeight, }); - setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen; + setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen; const lastOutputTimeRef = useRef(0); @@ -1450,7 +1450,7 @@ Logging in with Google... Restarting Gemini CLI to continue. // Compute available terminal height based on stable controls measurement const availableTerminalHeight = Math.max( 0, - terminalHeight - stableControlsHeight - backgroundShellHeight - 1, + terminalHeight - stableControlsHeight - backgroundTaskHeight - 1, ); config.setShellExecutionConfig({ @@ -1806,7 +1806,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } else if ( (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) && - (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) + (activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0)) ) { if (embeddedShellFocused) { const capturedTime = lastOutputTimeRef.current; @@ -1827,12 +1827,12 @@ Logging in with Google... Restarting Gemini CLI to continue. const isIdle = Date.now() - lastOutputTimeRef.current >= 100; - if (isIdle && !activePtyId && !isBackgroundShellVisible) { + if (isIdle && !activePtyId && !isBackgroundTaskVisible) { if (tabFocusTimeoutRef.current) clearTimeout(tabFocusTimeoutRef.current); - toggleBackgroundShell(); + toggleBackgroundTasks(); setEmbeddedShellFocused(true); - if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true); + if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true); return true; } @@ -1849,15 +1849,15 @@ Logging in with Google... Restarting Gemini CLI to continue. return false; } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { if (activePtyId) { - backgroundCurrentShell(); + backgroundCurrentExecution(); // After backgrounding, we explicitly do NOT show or focus the background UI. } else { - toggleBackgroundShell(); + toggleBackgroundTasks(); // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus. - if (!isBackgroundShellVisible && backgroundShells.size > 0) { + if (!isBackgroundTaskVisible && backgroundTasks.size > 0) { setEmbeddedShellFocused(true); - if (backgroundShells.size > 1) { - setIsBackgroundShellListOpen(true); + if (backgroundTasks.size > 1) { + setIsBackgroundTaskListOpen(true); } } else { setEmbeddedShellFocused(false); @@ -1865,11 +1865,11 @@ Logging in with Google... Restarting Gemini CLI to continue. } return true; } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { - if (backgroundShells.size > 0 && isBackgroundShellVisible) { + if (backgroundTasks.size > 0 && isBackgroundTaskVisible) { if (!embeddedShellFocused) { setEmbeddedShellFocused(true); } - setIsBackgroundShellListOpen(true); + setIsBackgroundTaskListOpen(true); } return true; } @@ -1894,11 +1894,11 @@ Logging in with Google... Restarting Gemini CLI to continue. tabFocusTimeoutRef, isAlternateBuffer, shortcutsHelpVisible, - backgroundCurrentShell, - toggleBackgroundShell, - backgroundShells, - isBackgroundShellVisible, - setIsBackgroundShellListOpen, + backgroundCurrentExecution, + toggleBackgroundTasks, + backgroundTasks, + isBackgroundTaskVisible, + setIsBackgroundTaskListOpen, lastOutputTimeRef, showTransientMessage, settings.merged.general.devtools, @@ -2077,7 +2077,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all'; const showLoadingIndicator = - (!embeddedShellFocused || isBackgroundShellVisible) && + (!embeddedShellFocused || isBackgroundTaskVisible) && streamingState === StreamingState.Responding && !hasPendingActionRequired; @@ -2335,8 +2335,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isRestarting, extensionsUpdateState, activePtyId, - backgroundShellCount, - isBackgroundShellVisible, + backgroundTaskCount, + isBackgroundTaskVisible, embeddedShellFocused, showDebugProfiler, customDialog, @@ -2346,10 +2346,10 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), settingsNonce, - backgroundShells, - activeBackgroundShellPid, - backgroundShellHeight, - isBackgroundShellListOpen, + backgroundTasks, + activeBackgroundTaskPid, + backgroundTaskHeight, + isBackgroundTaskListOpen, adminSettingsChanged, newAgents, showIsExpandableHint, @@ -2458,8 +2458,8 @@ Logging in with Google... Restarting Gemini CLI to continue. currentModel, extensionsUpdateState, activePtyId, - backgroundShellCount, - isBackgroundShellVisible, + backgroundTaskCount, + isBackgroundTaskVisible, historyManager, embeddedShellFocused, showDebugProfiler, @@ -2472,10 +2472,10 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerVisible, config, settingsNonce, - backgroundShellHeight, - isBackgroundShellListOpen, - activeBackgroundShellPid, - backgroundShells, + backgroundTaskHeight, + isBackgroundTaskListOpen, + activeBackgroundTaskPid, + backgroundTasks, adminSettingsChanged, newAgents, showIsExpandableHint, @@ -2524,6 +2524,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleResumeSession, handleDeleteSession, setQueueErrorMessage, + addMessage, popAllMessages, handleApiKeySubmit, handleApiKeyCancel, @@ -2534,9 +2535,9 @@ Logging in with Google... Restarting Gemini CLI to continue. revealCleanUiDetailsTemporarily, handleWarning, setEmbeddedShellFocused, - dismissBackgroundShell, - setActiveBackgroundShellPid, - setIsBackgroundShellListOpen, + dismissBackgroundTask, + setActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen, setAuthContext, onHintInput: () => {}, onHintBackspace: () => {}, @@ -2615,6 +2616,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleResumeSession, handleDeleteSession, setQueueErrorMessage, + addMessage, popAllMessages, handleApiKeySubmit, handleApiKeyCancel, @@ -2625,9 +2627,9 @@ Logging in with Google... Restarting Gemini CLI to continue. revealCleanUiDetailsTemporarily, handleWarning, setEmbeddedShellFocused, - dismissBackgroundShell, - setActiveBackgroundShellPid, - setIsBackgroundShellListOpen, + dismissBackgroundTask, + setActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen, setAuthContext, setAccountSuspensionInfo, newAgents, diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 87aacb056b..e7a33672f3 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -75,6 +75,7 @@ const listCommand: SlashCommand = { description: 'List saved manual conversation checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: async (context): Promise => { const chatDetails = await getSavedChatTags(context, false); @@ -406,14 +407,24 @@ export const chatResumeSubCommands: SlashCommand[] = [ checkpointCompatibilityCommand, ]; +import { parseSlashCommand } from '../../utils/commands.js'; + export const chatCommand: SlashCommand = { name: 'chat', description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, - action: async () => ({ - type: 'dialog', - dialog: 'sessionBrowser', - }), + action: async (context, args) => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + } + return { + type: 'dialog', + dialog: 'sessionBrowser', + }; + }, subCommands: chatResumeSubCommands, }; diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index aed7595389..7a3ada83e0 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -789,6 +789,7 @@ const listExtensionsCommand: SlashCommand = { description: 'List active extensions', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: listAction, }; @@ -849,6 +850,7 @@ const exploreExtensionsCommand: SlashCommand = { description: 'Open extensions page in your browser', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: exploreAction, }; @@ -870,6 +872,8 @@ const configCommand: SlashCommand = { action: configAction, }; +import { parseSlashCommand } from '../../utils/commands.js'; + export function extensionsCommand( enableExtensionReloading?: boolean, ): SlashCommand { @@ -883,20 +887,29 @@ export function extensionsCommand( configCommand, ] : []; + const subCommands = [ + listExtensionsCommand, + updateExtensionsCommand, + exploreExtensionsCommand, + reloadCommand, + ...conditionalCommands, + ]; + return { name: 'extensions', description: 'Manage extensions', kind: CommandKind.BUILT_IN, autoExecute: false, - subCommands: [ - listExtensionsCommand, - updateExtensionsCommand, - exploreExtensionsCommand, - reloadCommand, - ...conditionalCommands, - ], - action: (context, args) => + subCommands, + action: async (context, args) => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, subCommands); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + } // Default to list if no subcommand is provided - listExtensionsCommand.action!(context, args), + return listExtensionsCommand.action!(context, args); + }, }; } diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 9a3254fbae..d082c4ed09 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -17,7 +17,7 @@ import { } from '@google/gemini-cli-core'; import type { CallableTool } from '@google/genai'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemMcpStatus } from '../types.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -280,5 +280,41 @@ describe('mcpCommand', () => { }), ); }); + + it('should filter servers by name when an argument is provided to list', async () => { + await mcpCommand.action!(mockContext, 'list server1'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + servers: expect.objectContaining({ + server1: expect.any(Object), + }), + }), + ); + + // Should NOT contain server2 or server3 + const call = vi.mocked(mockContext.ui.addItem).mock + .calls[0][0] as HistoryItemMcpStatus; + expect(Object.keys(call.servers)).toEqual(['server1']); + }); + + it('should filter servers by name and show descriptions when an argument is provided to desc', async () => { + await mcpCommand.action!(mockContext, 'desc server2'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + showDescriptions: true, + servers: expect.objectContaining({ + server2: expect.any(Object), + }), + }), + ); + + const call = vi.mocked(mockContext.ui.addItem).mock + .calls[0][0] as HistoryItemMcpStatus; + expect(Object.keys(call.servers)).toEqual(['server2']); + }); }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 0fb6b5a1dd..3fd214152e 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -31,6 +31,7 @@ import { canLoadServer, } from '../../config/mcp/mcpServerEnablement.js'; import { loadSettings } from '../../config/settings.js'; +import { parseSlashCommand } from '../../utils/commands.js'; const authCommand: SlashCommand = { name: 'auth', @@ -177,6 +178,7 @@ const listAction = async ( context: CommandContext, showDescriptions = false, showSchema = false, + serverNameFilter?: string, ): Promise => { const agentContext = context.services.agentContext; const config = agentContext?.config; @@ -199,11 +201,25 @@ const listAction = async ( }; } - const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; - const serverNames = Object.keys(mcpServers); + let mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; const blockedMcpServers = config.getMcpClientManager()?.getBlockedMcpServers() || []; + if (serverNameFilter) { + const filter = serverNameFilter.trim().toLowerCase(); + if (filter) { + mcpServers = Object.fromEntries( + Object.entries(mcpServers).filter( + ([name]) => + name.toLowerCase().includes(filter) || + normalizeServerId(name).includes(filter), + ), + ); + } + } + + const serverNames = Object.keys(mcpServers); + const connectingServers = serverNames.filter( (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, ); @@ -306,7 +322,7 @@ const listCommand: SlashCommand = { description: 'List configured MCP servers and tools', kind: CommandKind.BUILT_IN, autoExecute: true, - action: (context) => listAction(context), + action: (context, args) => listAction(context, false, false, args), }; const descCommand: SlashCommand = { @@ -315,7 +331,7 @@ const descCommand: SlashCommand = { description: 'List configured MCP servers and tools with descriptions', kind: CommandKind.BUILT_IN, autoExecute: true, - action: (context) => listAction(context, true), + action: (context, args) => listAction(context, true, false, args), }; const schemaCommand: SlashCommand = { @@ -324,7 +340,7 @@ const schemaCommand: SlashCommand = { 'List configured MCP servers and tools with descriptions and schemas', kind: CommandKind.BUILT_IN, autoExecute: true, - action: (context) => listAction(context, true, true), + action: (context, args) => listAction(context, true, true, args), }; const reloadCommand: SlashCommand = { @@ -333,6 +349,7 @@ const reloadCommand: SlashCommand = { description: 'Reloads MCP servers', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: async ( context: CommandContext, ): Promise => { @@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = { enableCommand, disableCommand, ], - action: async (context: CommandContext) => listAction(context), + action: async ( + context: CommandContext, + args: string, + ): Promise => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + // If no subcommand matches, treat the whole args as a filter for list + return listAction(context, false, false, args); + } + return listAction(context); + }, }; diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index 49c00ce8bd..56b6949750 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -104,6 +104,47 @@ describe('planCommand', () => { ); }); + it('should not return a submit_prompt action if arguments are empty', async () => { + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + mockContext.invocation = { + raw: '/plan', + name: 'plan', + args: '', + }; + + if (!planCommand.action) throw new Error('Action missing'); + const result = await planCommand.action(mockContext, ''); + + expect(result).toBeUndefined(); + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); + }); + + it('should return a submit_prompt action if arguments are provided', async () => { + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + mockContext.invocation = { + raw: '/plan implement auth', + name: 'plan', + args: 'implement auth', + }; + + if (!planCommand.action) throw new Error('Action missing'); + const result = await planCommand.action(mockContext, 'implement auth'); + + expect(result).toEqual({ + type: 'submit_prompt', + content: 'implement auth', + }); + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); + }); + it('should display the approved plan from config', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; vi.mocked( diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index c38d021d90..b90c74323c 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -66,6 +66,13 @@ export const planCommand: SlashCommand = { coreEvents.emitFeedback('info', 'Switched to Plan Mode.'); } + if (context.invocation?.args) { + return { + type: 'submit_prompt', + content: context.invocation.args, + }; + } + const approvedPlanPath = config.getApprovedPlanPath(); if (!approvedPlanPath) { @@ -86,12 +93,14 @@ export const planCommand: SlashCommand = { type: MessageType.GEMINI, text: partToString(content.llmContent), }); + return; } catch (error) { coreEvents.emitFeedback( 'error', `Failed to read approved plan at ${approvedPlanPath}: ${error}`, error, ); + return; } }, subCommands: [ @@ -100,6 +109,7 @@ export const planCommand: SlashCommand = { description: 'Copy the currently approved plan to your clipboard', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: copyAction, }, ], diff --git a/packages/cli/src/ui/commands/shellsCommand.test.ts b/packages/cli/src/ui/commands/shellsCommand.test.ts deleted file mode 100644 index 794d162d6e..0000000000 --- a/packages/cli/src/ui/commands/shellsCommand.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi } from 'vitest'; -import { shellsCommand } from './shellsCommand.js'; -import type { CommandContext } from './types.js'; - -describe('shellsCommand', () => { - it('should call toggleBackgroundShell', async () => { - const toggleBackgroundShell = vi.fn(); - const context = { - ui: { - toggleBackgroundShell, - }, - } as unknown as CommandContext; - - if (shellsCommand.action) { - await shellsCommand.action(context, ''); - } - - expect(toggleBackgroundShell).toHaveBeenCalled(); - }); - - it('should have correct name and altNames', () => { - expect(shellsCommand.name).toBe('shells'); - expect(shellsCommand.altNames).toContain('bashes'); - }); - - it('should auto-execute', () => { - expect(shellsCommand.autoExecute).toBe(true); - }); -}); diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index a1f9c82445..8c8db2fca5 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -357,6 +357,8 @@ function enableCompletion( .map((s) => s.name); } +import { parseSlashCommand } from '../../utils/commands.js'; + export const skillsCommand: SlashCommand = { name: 'skills', description: @@ -402,5 +404,13 @@ export const skillsCommand: SlashCommand = { action: reloadAction, }, ], - action: listAction, + action: async (context, args) => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + } + return listAction(context, args); + }, }; diff --git a/packages/cli/src/ui/commands/tasksCommand.test.ts b/packages/cli/src/ui/commands/tasksCommand.test.ts new file mode 100644 index 0000000000..b60f3f8ab3 --- /dev/null +++ b/packages/cli/src/ui/commands/tasksCommand.test.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { tasksCommand } from './tasksCommand.js'; +import type { CommandContext } from './types.js'; + +describe('tasksCommand', () => { + it('should call toggleBackgroundTasks', async () => { + const toggleBackgroundTasks = vi.fn(); + const context = { + ui: { + toggleBackgroundTasks, + }, + } as unknown as CommandContext; + + if (tasksCommand.action) { + await tasksCommand.action(context, ''); + } + + expect(toggleBackgroundTasks).toHaveBeenCalled(); + }); + + it('should have correct name and altNames', () => { + expect(tasksCommand.name).toBe('tasks'); + expect(tasksCommand.altNames).toContain('bg'); + expect(tasksCommand.altNames).toContain('background'); + }); + + it('should auto-execute', () => { + expect(tasksCommand.autoExecute).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/commands/shellsCommand.ts b/packages/cli/src/ui/commands/tasksCommand.ts similarity index 56% rename from packages/cli/src/ui/commands/shellsCommand.ts rename to packages/cli/src/ui/commands/tasksCommand.ts index 80645bbf8e..0980744e44 100644 --- a/packages/cli/src/ui/commands/shellsCommand.ts +++ b/packages/cli/src/ui/commands/tasksCommand.ts @@ -6,13 +6,13 @@ import { CommandKind, type SlashCommand } from './types.js'; -export const shellsCommand: SlashCommand = { - name: 'shells', - altNames: ['bashes'], +export const tasksCommand: SlashCommand = { + name: 'tasks', + altNames: ['bg', 'background'], kind: CommandKind.BUILT_IN, - description: 'Toggle background shells view', + description: 'Toggle background tasks view', autoExecute: true, action: async (context) => { - context.ui.toggleBackgroundShell(); + context.ui.toggleBackgroundTasks(); }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 4065e075bf..466e70c994 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -90,7 +90,7 @@ export interface CommandContext { */ setConfirmationRequest: (value: ConfirmationRequest) => void; removeComponent: () => void; - toggleBackgroundShell: () => void; + toggleBackgroundTasks: () => void; toggleShortcutsHelp: () => void; }; // Session-specific data @@ -240,5 +240,14 @@ export interface SlashCommand { */ showCompletionLoading?: boolean; + /** + * Whether the command expects arguments. + * If false, and the command is a subcommand, the command parser may treat + * any following text as arguments for the parent command instead of this subcommand, + * provided the parent command has an action. + * Defaults to true. + */ + takesArgs?: boolean; + subCommands?: SlashCommand[]; } diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx similarity index 85% rename from packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx rename to packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx index c097028a0d..6083a0e569 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx @@ -6,8 +6,8 @@ import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { BackgroundShellDisplay } from './BackgroundShellDisplay.js'; -import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; +import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js'; +import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; import { act } from 'react'; import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js'; @@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js'; import { Box } from 'ink'; // Mock dependencies -const mockDismissBackgroundShell = vi.fn(); -const mockSetActiveBackgroundShellPid = vi.fn(); -const mockSetIsBackgroundShellListOpen = vi.fn(); +const mockDismissBackgroundTask = vi.fn(); +const mockSetActiveBackgroundTaskPid = vi.fn(); +const mockSetIsBackgroundTaskListOpen = vi.fn(); vi.mock('../contexts/UIActionsContext.js', () => ({ useUIActions: () => ({ - dismissBackgroundShell: mockDismissBackgroundShell, - setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid, - setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen, + dismissBackgroundTask: mockDismissBackgroundTask, + setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen, }), })); @@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({ data, renderItem, }: { - data: BackgroundShell[]; + data: BackgroundTask[]; renderItem: (props: { - item: BackgroundShell; + item: BackgroundTask; index: number; }) => React.ReactNode; }) => ( - {data.map((item: BackgroundShell, index: number) => ( + {data.map((item: BackgroundTask, index: number) => ( {renderItem({ item, index })} ))} @@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial): Key => ({ ...overrides, }); -describe('', () => { - const mockShells = new Map(); - const shell1: BackgroundShell = { +describe('', () => { + const mockShells = new Map(); + const shell1: BackgroundTask = { pid: 1001, command: 'npm start', output: 'Starting server...', @@ -126,7 +126,7 @@ describe('', () => { binaryBytesReceived: 0, status: 'running', }; - const shell2: BackgroundShell = { + const shell2: BackgroundTask = { pid: 1002, command: 'tail -f log.txt', output: 'Log entry 1', @@ -147,7 +147,7 @@ describe('', () => { const width = 80; const { lastFrame, unmount } = await render( - ', () => { const width = 100; const { lastFrame, unmount } = await render( - ', () => { const width = 80; const { lastFrame, unmount } = await render( - ', () => { const width = 80; const { rerender, unmount } = await render( - ', () => { rerender( - ', () => { const width = 80; const { lastFrame, unmount } = await render( - ', () => { const width = 80; const { unmount } = await render( - ', () => { simulateKey({ name: 'down' }); }); - // Simulate Ctrl+L (handled by BackgroundShellDisplay) + // Simulate Ctrl+L (handled by BackgroundTaskDisplay) await act(async () => { simulateKey({ name: 'l', ctrl: true }); }); - expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); - expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); + expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid); + expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false); unmount(); }); @@ -301,7 +301,7 @@ describe('', () => { const width = 80; const { unmount } = await render( - ', () => { simulateKey({ name: 'k', ctrl: true }); }); - expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); + expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid); unmount(); }); @@ -333,7 +333,7 @@ describe('', () => { const width = 80; const { unmount } = await render( - ', () => { simulateKey({ name: 'k', ctrl: true }); }); - expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); + expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid); unmount(); }); @@ -358,7 +358,7 @@ describe('', () => { const width = 80; const { lastFrame, unmount } = await render( - ', () => { }); it('keeps exit code status color even when selected', async () => { - const exitedShell: BackgroundShell = { + const exitedShell: BackgroundTask = { pid: 1003, command: 'exit 0', output: '', @@ -389,7 +389,7 @@ describe('', () => { const width = 80; const { lastFrame, unmount } = await render( - ; +interface BackgroundTaskDisplayProps { + shells: Map; activePid: number; width: number; height: number; @@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => { : commandFirstLine; }; -export const BackgroundShellDisplay = ({ +export const BackgroundTaskDisplay = ({ shells, activePid, width, height, isFocused, isListOpenProp, -}: BackgroundShellDisplayProps) => { +}: BackgroundTaskDisplayProps) => { const keyMatchers = useKeyMatchers(); const { - dismissBackgroundShell, - setActiveBackgroundShellPid, - setIsBackgroundShellListOpen, + dismissBackgroundTask, + setActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen, } = useUIActions(); const activeShell = shells.get(activePid); const [output, setOutput] = useState( @@ -152,13 +152,13 @@ export const BackgroundShellDisplay = ({ // RadioButtonSelect handles Enter -> onSelect if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) { - setIsBackgroundShellListOpen(false); + setIsBackgroundTaskListOpen(false); return true; } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { if (highlightedPid) { - void dismissBackgroundShell(highlightedPid); + void dismissBackgroundTask(highlightedPid); // If we killed the active one, the list might update via props } return true; @@ -166,9 +166,9 @@ export const BackgroundShellDisplay = ({ if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { if (highlightedPid) { - setActiveBackgroundShellPid(highlightedPid); + setActiveBackgroundTaskPid(highlightedPid); } - setIsBackgroundShellListOpen(false); + setIsBackgroundTaskListOpen(false); return true; } return false; @@ -179,12 +179,12 @@ export const BackgroundShellDisplay = ({ } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { - void dismissBackgroundShell(activeShell.pid); + void dismissBackgroundTask(activeShell.pid); return true; } if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { - setIsBackgroundShellListOpen(true); + setIsBackgroundTaskListOpen(true); return true; } @@ -339,8 +339,8 @@ export const BackgroundShellDisplay = ({ items={items} initialIndex={initialIndex >= 0 ? initialIndex : 0} onSelect={(pid) => { - setActiveBackgroundShellPid(pid); - setIsBackgroundShellListOpen(false); + setActiveBackgroundTaskPid(pid); + setIsBackgroundTaskListOpen(false); }} onHighlight={(pid) => setHighlightedPid(pid)} isFocused={isFocused} diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 1cbb29a06c..1750536dbe 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => nightly: false, isTrustedFolder: true, activeHooks: [], - isBackgroundShellVisible: false, + isBackgroundTaskVisible: false, embeddedShellFocused: false, showIsExpandableHint: false, quota: { @@ -464,7 +464,7 @@ describe('Composer', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, embeddedShellFocused: true, - isBackgroundShellVisible: true, + isBackgroundTaskVisible: true, }); const { lastFrame } = await renderComposer(uiState); @@ -494,7 +494,7 @@ describe('Composer', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, embeddedShellFocused: true, - isBackgroundShellVisible: false, + isBackgroundTaskVisible: false, }); const { lastFrame } = await renderComposer(uiState); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 5c9850bf92..590d1e9c6b 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { vimHandleInput={uiActions.vimHandleInput} isEmbeddedShellFocused={uiState.embeddedShellFocused} popAllMessages={uiActions.popAllMessages} + onQueueMessage={uiActions.addMessage} placeholder={ vimEnabled ? vimMode === 'INSERT' diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 76147e097f..0cf6a9db9c 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -49,6 +49,7 @@ interface HistoryItemDisplayProps { isExpandable?: boolean; isFirstThinking?: boolean; isFirstAfterThinking?: boolean; + suppressNarration?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -61,6 +62,7 @@ export const HistoryItemDisplay: React.FC = ({ isExpandable, isFirstThinking = false, isFirstAfterThinking = false, + suppressNarration = false, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); @@ -69,6 +71,17 @@ export const HistoryItemDisplay: React.FC = ({ const needsTopMarginAfterThinking = isFirstAfterThinking && inlineThinkingMode !== 'off'; + // If there's a topic update in this turn, we suppress the regular narration + // and thoughts as they are being "replaced" by the update_topic tool. + if ( + suppressNarration && + (itemForDisplay.type === 'thinking' || + itemForDisplay.type === 'gemini' || + itemForDisplay.type === 'gemini_content') + ) { + return null; + } + return ( { setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible, toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible, revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily, + addMessage: vi.fn(), }; beforeEach(() => { @@ -352,6 +353,8 @@ describe('InputPrompt', () => { vi.mocked(clipboardy.read).mockResolvedValue(''); props = { + onQueueMessage: vi.fn(), + buffer: mockBuffer, onSubmit: vi.fn(), userMessages: [], @@ -1099,6 +1102,76 @@ describe('InputPrompt', () => { unmount(); }); + it('queues a message when Tab is pressed during generation', async () => { + props.buffer.setText('A new prompt'); + props.streamingState = StreamingState.Responding; + + const { stdin, unmount } = await renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\t'); + }); + + await waitFor(() => { + expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt'); + expect(props.buffer.text).toBe(''); + }); + unmount(); + }); + + it('shows an error when attempting to queue a slash command', async () => { + props.buffer.setText('/clear'); + props.streamingState = StreamingState.Responding; + + const { stdin, unmount } = await renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\t'); + }); + + await waitFor(() => { + expect(props.setQueueErrorMessage).toHaveBeenCalledWith( + 'Slash commands cannot be queued', + ); + expect(props.onQueueMessage).not.toHaveBeenCalled(); + }); + unmount(); + }); + + it('shows an error when attempting to queue a shell command', async () => { + props.shellModeActive = true; + props.buffer.setText('ls'); + props.streamingState = StreamingState.Responding; + + const { stdin, unmount } = await renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\t'); + }); + + await waitFor(() => { + expect(props.setQueueErrorMessage).toHaveBeenCalledWith( + 'Shell commands cannot be queued', + ); + expect(props.onQueueMessage).not.toHaveBeenCalled(); + }); + unmount(); + }); it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => { props.buffer.setText(' '); // Set buffer to whitespace diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index e7c221579a..f078dbc7d6 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -117,6 +117,7 @@ export interface InputPromptProps { setQueueErrorMessage: (message: string | null) => void; streamingState: StreamingState; popAllMessages?: () => string | undefined; + onQueueMessage?: (message: string) => void; suggestionsPosition?: 'above' | 'below'; setBannerVisible: (visible: boolean) => void; copyModeEnabled?: boolean; @@ -211,6 +212,7 @@ export const InputPrompt: React.FC = ({ setQueueErrorMessage, streamingState, popAllMessages, + onQueueMessage, suggestionsPosition = 'below', setBannerVisible, copyModeEnabled = false, @@ -230,8 +232,8 @@ export const InputPrompt: React.FC = ({ terminalWidth, activePtyId, history, - backgroundShells, - backgroundShellHeight, + backgroundTasks, + backgroundTaskHeight, shortcutsHelpVisible, } = useUIState(); const [suppressCompletion, setSuppressCompletion] = useState(false); @@ -690,6 +692,7 @@ export const InputPrompt: React.FC = ({ streamingState === StreamingState.Responding || streamingState === StreamingState.WaitingForConfirmation; + const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key); const isPlainTab = key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd; const hasTabCompletionInteraction = @@ -698,6 +701,29 @@ export const InputPrompt: React.FC = ({ reverseSearchActive || commandSearchActive; + if ( + isGenerating && + isQueueMessageKey && + !hasTabCompletionInteraction && + buffer.text.trim().length > 0 + ) { + const trimmedMessage = buffer.text.trim(); + const isSlash = isSlashCommand(trimmedMessage); + + if (isSlash || shellModeActive) { + setQueueErrorMessage( + `${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`, + ); + } else if (onQueueMessage) { + onQueueMessage(buffer.text); + buffer.setText(''); + resetCompletionState(); + resetReverseSearchCompletionState(); + } + resetPlainTabPress(); + return true; + } + if (isPlainTab && shellModeActive) { resetPlainTabPress(); if (!shouldShowSuggestions) { @@ -1236,7 +1262,7 @@ export const InputPrompt: React.FC = ({ if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) { if ( activePtyId || - (backgroundShells.size > 0 && backgroundShellHeight > 0) + (backgroundTasks.size > 0 && backgroundTaskHeight > 0) ) { setEmbeddedShellFocused(true); return true; @@ -1293,11 +1319,14 @@ export const InputPrompt: React.FC = ({ shortcutsHelpVisible, setShortcutsHelpVisible, tryLoadQueuedMessages, + onQueueMessage, + setQueueErrorMessage, + resetReverseSearchCompletionState, setBannerVisible, activePtyId, setEmbeddedShellFocused, - backgroundShells.size, - backgroundShellHeight, + backgroundTasks.size, + backgroundTaskHeight, streamingState, handleEscPress, registerPlainTabPress, diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index b6bc0795eb..93d77e0dfe 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({ })); import { theme } from '../semantic-colors.js'; -import { type BackgroundShell } from '../hooks/shellReducer.js'; +import { type BackgroundTask } from '../hooks/shellReducer.js'; describe('getToolGroupBorderAppearance', () => { - const mockBackgroundShells = new Map(); + const mockBackgroundTasks = new Map(); const activeShellPtyId = 123; it('returns default empty values for non-tool_group items', () => { @@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => { null, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: '', borderDimColor: false }); }); @@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => { null, false, pendingItems, - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.border.default, @@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => { null, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.border.default, @@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => { null, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.status.warning, @@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => { activeShellPtyId, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.ui.active, @@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => { activeShellPtyId, true, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.ui.focus, @@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => { activeShellPtyId, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.ui.active, @@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => { activeShellPtyId, true, [], - mockBackgroundShells, + mockBackgroundTasks, ); // Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true // so it counts as pending shell. diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index d8656a879c..c4e395c612 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -7,8 +7,10 @@ import { Box, Static } from 'ink'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; + import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { SCROLL_TO_ITEM_END, @@ -19,6 +21,7 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react'; import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; +import { isTopicTool } from './messages/TopicMessage.js'; const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); const MemoizedAppHeader = memo(AppHeader); @@ -63,12 +66,39 @@ export const MainContent = () => { return -1; }, [uiState.history]); + const settings = useSettings(); + const topicUpdateNarrationEnabled = + settings.merged.experimental?.topicUpdateNarration === true; + + const suppressNarrationFlags = useMemo(() => { + const combinedHistory = [...uiState.history, ...pendingHistoryItems]; + const flags = new Array(combinedHistory.length).fill(false); + + if (topicUpdateNarrationEnabled) { + let toolGroupInTurn = false; + for (let i = combinedHistory.length - 1; i >= 0; i--) { + const item = combinedHistory[i]; + if (item.type === 'user' || item.type === 'user_shell') { + toolGroupInTurn = false; + } else if (item.type === 'tool_group') { + toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name)); + } else if ( + (item.type === 'thinking' || + item.type === 'gemini' || + item.type === 'gemini_content') && + toolGroupInTurn + ) { + flags[i] = true; + } + } + } + return flags; + }, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]); + const augmentedHistory = useMemo( () => - uiState.history.map((item, index) => { - const isExpandable = index > lastUserPromptIndex; - const prevType = - index > 0 ? uiState.history[index - 1]?.type : undefined; + uiState.history.map((item, i) => { + const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined; const isFirstThinking = item.type === 'thinking' && prevType !== 'thinking'; const isFirstAfterThinking = @@ -76,18 +106,25 @@ export const MainContent = () => { return { item, - isExpandable, + isExpandable: i > lastUserPromptIndex, isFirstThinking, isFirstAfterThinking, + suppressNarration: suppressNarrationFlags[i] ?? false, }; }), - [uiState.history, lastUserPromptIndex], + [uiState.history, lastUserPromptIndex, suppressNarrationFlags], ); const historyItems = useMemo( () => augmentedHistory.map( - ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ( + ({ + item, + isExpandable, + isFirstThinking, + isFirstAfterThinking, + suppressNarration, + }) => ( { isExpandable={isExpandable} isFirstThinking={isFirstThinking} isFirstAfterThinking={isFirstAfterThinking} + suppressNarration={suppressNarration} /> ), ), @@ -138,6 +176,9 @@ export const MainContent = () => { const isFirstAfterThinking = item.type !== 'thinking' && prevType === 'thinking'; + const suppressNarration = + suppressNarrationFlags[uiState.history.length + i] ?? false; + return ( { isExpandable={true} isFirstThinking={isFirstThinking} isFirstAfterThinking={isFirstAfterThinking} + suppressNarration={suppressNarration} /> ); })} @@ -169,6 +211,7 @@ export const MainContent = () => { showConfirmationQueue, confirmingTool, uiState.history, + suppressNarrationFlags, ], ); @@ -176,12 +219,19 @@ export const MainContent = () => { () => [ { type: 'header' as const }, ...augmentedHistory.map( - ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({ + ({ + item, + isExpandable, + isFirstThinking, + isFirstAfterThinking, + suppressNarration, + }) => ({ type: 'history' as const, item, isExpandable, isFirstThinking, isFirstAfterThinking, + suppressNarration, }), ), { type: 'pending' as const }, @@ -216,6 +266,7 @@ export const MainContent = () => { isExpandable={item.isExpandable} isFirstThinking={item.isFirstThinking} isFirstAfterThinking={item.isFirstAfterThinking} + suppressNarration={item.suppressNarration} /> ); } else { diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index 82b439e65f..a8a369b301 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState => ideContextState: null, geminiMdFileCount: 0, contextFileNames: [], - backgroundShellCount: 0, + backgroundTaskCount: 0, buffer: { text: '' }, history: [{ id: 1, type: 'user', text: 'test' }], ...overrides, @@ -159,9 +159,9 @@ describe('StatusDisplay', () => { unmount(); }); - it('passes backgroundShellCount to ContextSummaryDisplay', async () => { + it('passes backgroundTaskCount to ContextSummaryDisplay', async () => { const uiState = createMockUIState({ - backgroundShellCount: 3, + backgroundTaskCount: 3, }); const { lastFrame, unmount } = await renderStatusDisplay( { hideContextSummary: false }, diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index 472e900b3b..7cd0656e60 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -38,7 +38,7 @@ export const StatusDisplay: React.FC = ({ config.getMcpClientManager()?.getBlockedMcpServers() ?? [] } skillCount={config.getSkillManager().getDisplayableSkills().length} - backgroundProcessCount={uiState.backgroundShellCount} + backgroundProcessCount={uiState.backgroundTaskCount} /> ); } diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx new file mode 100644 index 0000000000..b80dbacabe --- /dev/null +++ b/packages/cli/src/ui/components/StatusRow.test.tsx @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { StatusRow } from './StatusRow.js'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { useComposerStatus } from '../hooks/useComposerStatus.js'; +import { type UIState } from '../contexts/UIStateContext.js'; +import { type TextBuffer } from '../components/shared/text-buffer.js'; +import { type SessionStatsState } from '../contexts/SessionContext.js'; +import { type ThoughtSummary } from '../types.js'; +import { ApprovalMode } from '@google/gemini-cli-core'; + +vi.mock('../hooks/useComposerStatus.js', () => ({ + useComposerStatus: vi.fn(), +})); + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const defaultUiState: Partial = { + currentTip: undefined, + thought: null, + elapsedTime: 0, + currentWittyPhrase: undefined, + activeHooks: [], + buffer: { text: '' } as unknown as TextBuffer, + sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState, + shortcutsHelpVisible: false, + contextFileNames: [], + showApprovalModeIndicator: ApprovalMode.DEFAULT, + allowPlanMode: false, + shellModeActive: false, + renderMarkdown: true, + currentModel: 'gemini-3', + }; + + it('renders status and tip correctly when they both fit', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: true, + showWit: true, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + currentTip: 'Test Tip', + thought: { subject: 'Thinking...' } as unknown as ThoughtSummary, + elapsedTime: 5, + currentWittyPhrase: 'I am witty', + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Thinking...'); + expect(output).toContain('I am witty'); + expect(output).toContain('Tip: Test Tip'); + }); + + it('renders correctly when interactive shell is waiting', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: true, + showLoadingIndicator: false, + showTips: false, + showWit: false, + modeContentObj: null, + showMinimalContext: false, + }); + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState: defaultUiState, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)'); + }); + + it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: true, + showWit: true, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + currentTip: 'Test Tip', + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Tip: Test Tip'); + }); +}); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index 4585438bee..adaa339a64 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -179,7 +179,13 @@ export const StatusRow: React.FC = ({ const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { - setTipWidth(Math.round(entry.contentRect.width)); + const width = Math.round(entry.contentRect.width); + // Only update if width > 0 to prevent layout feedback loops + // when the tip is hidden. This ensures we always use the + // intrinsic width for collision detection. + if (width > 0) { + setTipWidth(width); + } } }); observer.observe(node); @@ -230,6 +236,10 @@ export const StatusRow: React.FC = ({ const showRow1 = showUiDetails || showRow1Minimal; const showRow2 = showUiDetails || showRow2Minimal; + const onStatusResize = useCallback((width: number) => { + if (width > 0) setStatusWidth(width); + }, []); + const statusNode = ( = ({ errorVerbosity={ settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined } - onResize={setStatusWidth} + onResize={onStatusResize} /> ); @@ -322,20 +332,23 @@ export const StatusRow: React.FC = ({ {/* - We always render the tip node so it can be measured by ResizeObserver, - but we control its visibility based on the collision detection. + We always render the tip node so it can be measured by ResizeObserver. + When hidden, we use absolute positioning so it can still be measured + but doesn't affect the layout of Row 1. This prevents layout loops. */} - - {!isNarrow && tipContentStr && renderTipNode()} - + {!isNarrow && tipContentStr && renderTipNode()} )} diff --git a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/BackgroundTaskDisplay.test.tsx.snap similarity index 92% rename from packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap rename to packages/cli/src/ui/components/__snapshots__/BackgroundTaskDisplay.test.tsx.snap index 0cc1f4b9f0..b9e20b490d 100644 --- a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/BackgroundTaskDisplay.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > highlights the focused state 1`] = ` +exports[` > highlights the focused state 1`] = ` "โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List โ”‚ โ”‚ (Focused) (Ctrl+L) โ”‚ @@ -10,7 +10,7 @@ exports[` > highlights the focused state 1`] = ` " `; -exports[` > keeps exit code status color even when selected 1`] = ` +exports[` > keeps exit code status color even when selected 1`] = ` "โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List โ”‚ โ”‚ (Focused) (Ctrl+L) โ”‚ @@ -25,7 +25,7 @@ exports[` > keeps exit code status color even when sel " `; -exports[` > renders tabs for multiple shells 1`] = ` +exports[` > renders tabs for multiple shells 1`] = ` "โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) โ”‚ โ”‚ Starting server... โ”‚ @@ -34,7 +34,7 @@ exports[` > renders tabs for multiple shells 1`] = ` " `; -exports[` > renders the output of the active shell 1`] = ` +exports[` > renders the output of the active shell 1`] = ` "โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) โ”‚ โ”‚ Starting server... โ”‚ @@ -43,7 +43,7 @@ exports[` > renders the output of the active shell 1`] " `; -exports[` > renders the process list when isListOpenProp is true 1`] = ` +exports[` > renders the process list when isListOpenProp is true 1`] = ` "โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List โ”‚ โ”‚ (Focused) (Ctrl+L) โ”‚ @@ -57,7 +57,7 @@ exports[` > renders the process list when isListOpenPr " `; -exports[` > scrolls to active shell when list opens 1`] = ` +exports[` > scrolls to active shell when list opens 1`] = ` "โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List โ”‚ โ”‚ (Focused) (Ctrl+L) โ”‚ diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx index caed091b2b..fcafa4ed28 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -8,11 +8,6 @@ import { render, cleanup } from '../../../test-utils/render.js'; import { SubagentProgressDisplay } from './SubagentProgressDisplay.js'; import type { SubagentProgress } from '@google/gemini-cli-core'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { Text } from 'ink'; - -vi.mock('ink-spinner', () => ({ - default: () => โ ‹, -})); describe('', () => { afterEach(() => { diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 4240bc3b86..bfc19e344f 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -7,13 +7,10 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { ToolGroupMessage } from './ToolGroupMessage.js'; -import type { - HistoryItem, - HistoryItemWithoutId, - IndividualToolCallDisplay, -} from '../../types.js'; -import { Scrollable } from '../shared/Scrollable.js'; import { + UPDATE_TOPIC_TOOL_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_STRATEGIC_INTENT, makeFakeConfig, CoreToolCallStatus, ApprovalMode, @@ -23,6 +20,12 @@ import { READ_FILE_DISPLAY_NAME, GLOB_DISPLAY_NAME, } from '@google/gemini-cli-core'; +import type { + HistoryItem, + HistoryItemWithoutId, + IndividualToolCallDisplay, +} from '../../types.js'; +import { Scrollable } from '../shared/Scrollable.js'; import os from 'node:os'; import { createMockSettings } from '../../../test-utils/settings.js'; @@ -36,6 +39,7 @@ describe('', () => { ): IndividualToolCallDisplay => ({ callId: 'tool-123', name: 'test-tool', + args: {}, description: 'A tool for testing', resultDisplay: 'Test result', status: CoreToolCallStatus.Success, @@ -253,8 +257,71 @@ describe('', () => { unmount(); }); - it('renders mixed tool calls including shell command', async () => { + it('renders update_topic tool call using TopicMessage', async () => { const toolCalls = [ + createToolCall({ + callId: 'topic-tool', + name: UPDATE_TOPIC_TOOL_NAME, + args: { + [TOPIC_PARAM_TITLE]: 'Testing Topic', + [TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description', + }, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { + config: baseMockConfig, + settings: fullVerbositySettings, + }, + ); + + const output = lastFrame(); + expect(output).toContain('Testing Topic'); + expect(output).toContain('โ€” This is the description'); + expect(output).toMatchSnapshot('update_topic_tool'); + unmount(); + }); + + it('renders update_topic tool call with summary instead of strategic_intent', async () => { + const toolCalls = [ + createToolCall({ + callId: 'topic-tool-summary', + name: UPDATE_TOPIC_TOOL_NAME, + args: { + [TOPIC_PARAM_TITLE]: 'Testing Topic', + summary: 'This is the summary', + }, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { + config: baseMockConfig, + settings: fullVerbositySettings, + }, + ); + + const output = lastFrame(); + expect(output).toContain('Testing Topic'); + expect(output).toContain('โ€” This is the summary'); + unmount(); + }); + + it('renders mixed tool calls including update_topic', async () => { + const toolCalls = [ + createToolCall({ + callId: 'topic-tool-mixed', + name: UPDATE_TOPIC_TOOL_NAME, + args: { + [TOPIC_PARAM_TITLE]: 'Testing Topic', + [TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description', + }, + }), createToolCall({ callId: 'tool-1', name: 'read_file', diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 637e8afa40..29ab48a09c 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -15,6 +15,7 @@ import type { import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; +import { TopicMessage, isTopicTool } from './TopicMessage.js'; import { SubagentGroupDisplay } from './SubagentGroupDisplay.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; @@ -81,7 +82,7 @@ export const ToolGroupMessage: React.FC = ({ const { activePtyId, embeddedShellFocused, - backgroundShells, + backgroundTasks, pendingHistoryItems, } = useUIState(); @@ -92,14 +93,14 @@ export const ToolGroupMessage: React.FC = ({ activePtyId, embeddedShellFocused, pendingHistoryItems, - backgroundShells, + backgroundTasks, ), [ item, activePtyId, embeddedShellFocused, pendingHistoryItems, - backgroundShells, + backgroundTasks, ], ); @@ -192,7 +193,20 @@ export const ToolGroupMessage: React.FC = ({ paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN} > {groupedTools.map((group, index) => { - const isFirst = index === 0; + let isFirst = index === 0; + if (!isFirst) { + // Check if all previous tools were topics + let allPreviousWereTopics = true; + for (let i = 0; i < index; i++) { + const prevGroup = groupedTools[i]; + if (Array.isArray(prevGroup) || !isTopicTool(prevGroup.name)) { + allPreviousWereTopics = false; + break; + } + } + isFirst = allPreviousWereTopics; + } + const resolvedIsFirst = borderTopOverride !== undefined ? borderTopOverride && isFirst @@ -215,6 +229,7 @@ export const ToolGroupMessage: React.FC = ({ const tool = group; const isShellToolCall = isShellTool(tool.name); + const isTopicToolCall = isTopicTool(tool.name); const commonProps = { ...tool, @@ -234,7 +249,9 @@ export const ToolGroupMessage: React.FC = ({ minHeight={1} width={contentWidth} > - {isShellToolCall ? ( + {isTopicToolCall ? ( + + ) : isShellToolCall ? ( ) : ( @@ -262,26 +279,26 @@ export const ToolGroupMessage: React.FC = ({ ); })} - { - /* - We have to keep the bottom border separate so it doesn't get - drawn over by the sticky header directly inside it. - */ - (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && - borderBottomOverride !== false && ( - - ) - } + {/* + We have to keep the bottom border separate so it doesn't get + drawn over by the sticky header directly inside it. + */} + {(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && + borderBottomOverride !== false && + (visibleToolCalls.length === 0 || + !visibleToolCalls.every((tool) => isTopicTool(tool.name))) && ( + + )} ); diff --git a/packages/cli/src/ui/components/messages/TopicMessage.tsx b/packages/cli/src/ui/components/messages/TopicMessage.tsx new file mode 100644 index 0000000000..810628606d --- /dev/null +++ b/packages/cli/src/ui/components/messages/TopicMessage.tsx @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { + UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, + TOPIC_PARAM_STRATEGIC_INTENT, +} from '@google/gemini-cli-core'; +import type { IndividualToolCallDisplay } from '../../types.js'; +import { theme } from '../../semantic-colors.js'; + +interface TopicMessageProps extends IndividualToolCallDisplay { + terminalWidth: number; +} + +export const isTopicTool = (name: string): boolean => + name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME; + +export const TopicMessage: React.FC = ({ args }) => { + const rawTitle = args?.[TOPIC_PARAM_TITLE]; + const title = typeof rawTitle === 'string' ? rawTitle : undefined; + const rawIntent = + args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY]; + const intent = typeof rawIntent === 'string' ? rawIntent : undefined; + + return ( + + + {title || 'Topic'} + + {intent && โ€” {intent}} + + ); +}; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index 98db513da8..e5a69fb2bf 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -74,8 +74,9 @@ exports[` > Golden Snapshots > renders header when scrolled " `; -exports[` > Golden Snapshots > renders mixed tool calls including shell command 1`] = ` -"โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +exports[` > Golden Snapshots > renders mixed tool calls including update_topic 1`] = ` +" Testing Topic โ€” This is the description +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โœ“ read_file Read a file โ”‚ โ”‚ โ”‚ โ”‚ Test result โ”‚ @@ -137,6 +138,11 @@ exports[` > Golden Snapshots > renders two tool groups where " `; +exports[` > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = ` +" Testing Topic โ€” This is the description +" +`; + exports[` > Golden Snapshots > renders with limited terminal height 1`] = ` "โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โœ“ tool-with-result Tool with output โ”‚ diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index db9a51a269..f1959c0173 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -70,6 +70,7 @@ export interface UIActions { handleResumeSession: (session: SessionInfo) => Promise; handleDeleteSession: (session: SessionInfo) => Promise; setQueueErrorMessage: (message: string | null) => void; + addMessage: (message: string) => void; popAllMessages: () => string | undefined; handleApiKeySubmit: (apiKey: string) => Promise; handleApiKeyCancel: () => void; @@ -80,9 +81,9 @@ export interface UIActions { revealCleanUiDetailsTemporarily: (durationMs?: number) => void; handleWarning: (message: string) => void; setEmbeddedShellFocused: (value: boolean) => void; - dismissBackgroundShell: (pid: number) => Promise; - setActiveBackgroundShellPid: (pid: number) => void; - setIsBackgroundShellListOpen: (isOpen: boolean) => void; + dismissBackgroundTask: (pid: number) => Promise; + setActiveBackgroundTaskPid: (pid: number) => void; + setIsBackgroundTaskListOpen: (isOpen: boolean) => void; setAuthContext: (context: { requiresRestart?: boolean }) => void; onHintInput: (char: string) => void; onHintBackspace: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 8447247e53..a5d10820b2 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -84,7 +84,7 @@ export interface EmptyWalletDialogRequest { import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js'; -import type { BackgroundShell } from '../hooks/shellCommandProcessor.js'; +import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js'; export interface QuotaState { userTier: UserTierId | undefined; @@ -201,8 +201,8 @@ export interface UIState { isRestarting: boolean; extensionsUpdateState: Map; activePtyId: number | undefined; - backgroundShellCount: number; - isBackgroundShellVisible: boolean; + backgroundTaskCount: number; + isBackgroundTaskVisible: boolean; embeddedShellFocused: boolean; showDebugProfiler: boolean; showFullTodos: boolean; @@ -215,10 +215,10 @@ export interface UIState { customDialog: React.ReactNode | null; terminalBackgroundColor: TerminalBackgroundColor; settingsNonce: number; - backgroundShells: Map; - activeBackgroundShellPid: number | null; - backgroundShellHeight: number; - isBackgroundShellListOpen: boolean; + backgroundTasks: Map; + activeBackgroundTaskPid: number | null; + backgroundTaskHeight: number; + isBackgroundTaskListOpen: boolean; adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; showIsExpandableHint: boolean; diff --git a/packages/cli/src/ui/hooks/shellReducer.test.ts b/packages/cli/src/ui/hooks/shellReducer.test.ts index a9d4bf6da5..a6df9e61e6 100644 --- a/packages/cli/src/ui/hooks/shellReducer.test.ts +++ b/packages/cli/src/ui/hooks/shellReducer.test.ts @@ -36,27 +36,27 @@ describe('shellReducer', () => { it('should handle SET_VISIBILITY', () => { const action: ShellAction = { type: 'SET_VISIBILITY', visible: true }; const state = shellReducer(initialState, action); - expect(state.isBackgroundShellVisible).toBe(true); + expect(state.isBackgroundTaskVisible).toBe(true); }); it('should handle TOGGLE_VISIBILITY', () => { const action: ShellAction = { type: 'TOGGLE_VISIBILITY' }; let state = shellReducer(initialState, action); - expect(state.isBackgroundShellVisible).toBe(true); + expect(state.isBackgroundTaskVisible).toBe(true); state = shellReducer(state, action); - expect(state.isBackgroundShellVisible).toBe(false); + expect(state.isBackgroundTaskVisible).toBe(false); }); - it('should handle REGISTER_SHELL', () => { + it('should handle REGISTER_TASK', () => { const action: ShellAction = { - type: 'REGISTER_SHELL', + type: 'REGISTER_TASK', pid: 1001, command: 'ls', initialOutput: 'init', }; const state = shellReducer(initialState, action); - expect(state.backgroundShells.has(1001)).toBe(true); - expect(state.backgroundShells.get(1001)).toEqual({ + expect(state.backgroundTasks.has(1001)).toBe(true); + expect(state.backgroundTasks.get(1001)).toEqual({ pid: 1001, command: 'ls', output: 'init', @@ -66,9 +66,9 @@ describe('shellReducer', () => { }); }); - it('should not REGISTER_SHELL if PID already exists', () => { + it('should not REGISTER_TASK if PID already exists', () => { const action: ShellAction = { - type: 'REGISTER_SHELL', + type: 'REGISTER_TASK', pid: 1001, command: 'ls', initialOutput: 'init', @@ -76,35 +76,35 @@ describe('shellReducer', () => { const state = shellReducer(initialState, action); const state2 = shellReducer(state, { ...action, command: 'other' }); expect(state2).toBe(state); - expect(state2.backgroundShells.get(1001)?.command).toBe('ls'); + expect(state2.backgroundTasks.get(1001)?.command).toBe('ls'); }); - it('should handle UPDATE_SHELL', () => { + it('should handle UPDATE_TASK', () => { const registeredState = shellReducer(initialState, { - type: 'REGISTER_SHELL', + type: 'REGISTER_TASK', pid: 1001, command: 'ls', initialOutput: 'init', }); const action: ShellAction = { - type: 'UPDATE_SHELL', + type: 'UPDATE_TASK', pid: 1001, update: { status: 'exited', exitCode: 0 }, }; const state = shellReducer(registeredState, action); - const shell = state.backgroundShells.get(1001); + const shell = state.backgroundTasks.get(1001); expect(shell?.status).toBe('exited'); expect(shell?.exitCode).toBe(0); // Map should be new - expect(state.backgroundShells).not.toBe(registeredState.backgroundShells); + expect(state.backgroundTasks).not.toBe(registeredState.backgroundTasks); }); - it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => { + it('should handle APPEND_TASK_OUTPUT when visible (triggers re-render)', () => { const visibleState: ShellState = { ...initialState, - isBackgroundShellVisible: true, - backgroundShells: new Map([ + isBackgroundTaskVisible: true, + backgroundTasks: new Map([ [ 1001, { @@ -120,21 +120,21 @@ describe('shellReducer', () => { }; const action: ShellAction = { - type: 'APPEND_SHELL_OUTPUT', + type: 'APPEND_TASK_OUTPUT', pid: 1001, chunk: ' + more', }; const state = shellReducer(visibleState, action); - expect(state.backgroundShells.get(1001)?.output).toBe('init + more'); + expect(state.backgroundTasks.get(1001)?.output).toBe('init + more'); // Drawer is visible, so we expect a NEW map object to trigger React re-render - expect(state.backgroundShells).not.toBe(visibleState.backgroundShells); + expect(state.backgroundTasks).not.toBe(visibleState.backgroundTasks); }); - it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => { + it('should handle APPEND_TASK_OUTPUT when hidden (no re-render optimization)', () => { const hiddenState: ShellState = { ...initialState, - isBackgroundShellVisible: false, - backgroundShells: new Map([ + isBackgroundTaskVisible: false, + backgroundTasks: new Map([ [ 1001, { @@ -150,27 +150,27 @@ describe('shellReducer', () => { }; const action: ShellAction = { - type: 'APPEND_SHELL_OUTPUT', + type: 'APPEND_TASK_OUTPUT', pid: 1001, chunk: ' + more', }; const state = shellReducer(hiddenState, action); - expect(state.backgroundShells.get(1001)?.output).toBe('init + more'); + expect(state.backgroundTasks.get(1001)?.output).toBe('init + more'); // Drawer is hidden, so we expect the SAME map object (mutation optimization) - expect(state.backgroundShells).toBe(hiddenState.backgroundShells); + expect(state.backgroundTasks).toBe(hiddenState.backgroundTasks); }); - it('should handle SYNC_BACKGROUND_SHELLS', () => { - const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' }; + it('should handle SYNC_BACKGROUND_TASKS', () => { + const action: ShellAction = { type: 'SYNC_BACKGROUND_TASKS' }; const state = shellReducer(initialState, action); - expect(state.backgroundShells).not.toBe(initialState.backgroundShells); + expect(state.backgroundTasks).not.toBe(initialState.backgroundTasks); }); - it('should handle DISMISS_SHELL', () => { + it('should handle DISMISS_TASK', () => { const registeredState: ShellState = { ...initialState, - isBackgroundShellVisible: true, - backgroundShells: new Map([ + isBackgroundTaskVisible: true, + backgroundTasks: new Map([ [ 1001, { @@ -185,9 +185,9 @@ describe('shellReducer', () => { ]), }; - const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 }; + const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 }; const state = shellReducer(registeredState, action); - expect(state.backgroundShells.has(1001)).toBe(false); - expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell + expect(state.backgroundTasks.has(1001)).toBe(false); + expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last shell }); }); diff --git a/packages/cli/src/ui/hooks/shellReducer.ts b/packages/cli/src/ui/hooks/shellReducer.ts index 7d3917c681..43f40d546c 100644 --- a/packages/cli/src/ui/hooks/shellReducer.ts +++ b/packages/cli/src/ui/hooks/shellReducer.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AnsiOutput } from '@google/gemini-cli-core'; +import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core'; -export interface BackgroundShell { +export interface BackgroundTask { pid: number; command: string; output: string | AnsiOutput; @@ -14,13 +14,14 @@ export interface BackgroundShell { binaryBytesReceived: number; status: 'running' | 'exited'; exitCode?: number; + completionBehavior?: CompletionBehavior; } export interface ShellState { activeShellPtyId: number | null; lastShellOutputTime: number; - backgroundShells: Map; - isBackgroundShellVisible: boolean; + backgroundTasks: Map; + isBackgroundTaskVisible: boolean; } export type ShellAction = @@ -29,21 +30,22 @@ export type ShellAction = | { type: 'SET_VISIBILITY'; visible: boolean } | { type: 'TOGGLE_VISIBILITY' } | { - type: 'REGISTER_SHELL'; + type: 'REGISTER_TASK'; pid: number; command: string; initialOutput: string | AnsiOutput; + completionBehavior?: CompletionBehavior; } - | { type: 'UPDATE_SHELL'; pid: number; update: Partial } - | { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput } - | { type: 'SYNC_BACKGROUND_SHELLS' } - | { type: 'DISMISS_SHELL'; pid: number }; + | { type: 'UPDATE_TASK'; pid: number; update: Partial } + | { type: 'APPEND_TASK_OUTPUT'; pid: number; chunk: string | AnsiOutput } + | { type: 'SYNC_BACKGROUND_TASKS' } + | { type: 'DISMISS_TASK'; pid: number }; export const initialState: ShellState = { activeShellPtyId: null, lastShellOutputTime: 0, - backgroundShells: new Map(), - isBackgroundShellVisible: false, + backgroundTasks: new Map(), + isBackgroundTaskVisible: false, }; export function shellReducer( @@ -56,75 +58,76 @@ export function shellReducer( case 'SET_OUTPUT_TIME': return { ...state, lastShellOutputTime: action.time }; case 'SET_VISIBILITY': - return { ...state, isBackgroundShellVisible: action.visible }; + return { ...state, isBackgroundTaskVisible: action.visible }; case 'TOGGLE_VISIBILITY': return { ...state, - isBackgroundShellVisible: !state.isBackgroundShellVisible, + isBackgroundTaskVisible: !state.isBackgroundTaskVisible, }; - case 'REGISTER_SHELL': { - if (state.backgroundShells.has(action.pid)) return state; - const nextShells = new Map(state.backgroundShells); - nextShells.set(action.pid, { + case 'REGISTER_TASK': { + if (state.backgroundTasks.has(action.pid)) return state; + const nextTasks = new Map(state.backgroundTasks); + nextTasks.set(action.pid, { pid: action.pid, command: action.command, output: action.initialOutput, isBinary: false, binaryBytesReceived: 0, status: 'running', + completionBehavior: action.completionBehavior, }); - return { ...state, backgroundShells: nextShells }; + return { ...state, backgroundTasks: nextTasks }; } - case 'UPDATE_SHELL': { - const shell = state.backgroundShells.get(action.pid); - if (!shell) return state; - const nextShells = new Map(state.backgroundShells); - const updatedShell = { ...shell, ...action.update }; + case 'UPDATE_TASK': { + const task = state.backgroundTasks.get(action.pid); + if (!task) return state; + const nextTasks = new Map(state.backgroundTasks); + const updatedTask = { ...task, ...action.update }; // Maintain insertion order, move to end if status changed to exited if (action.update.status === 'exited') { - nextShells.delete(action.pid); + nextTasks.delete(action.pid); } - nextShells.set(action.pid, updatedShell); - return { ...state, backgroundShells: nextShells }; + nextTasks.set(action.pid, updatedTask); + return { ...state, backgroundTasks: nextTasks }; } - case 'APPEND_SHELL_OUTPUT': { - const shell = state.backgroundShells.get(action.pid); - if (!shell) return state; - // Note: we mutate the shell object in the map for background updates + case 'APPEND_TASK_OUTPUT': { + const task = state.backgroundTasks.get(action.pid); + if (!task) return state; + // Note: we mutate the task object in the map for background updates // to avoid re-rendering if the drawer is not visible. // This is an intentional performance optimization for the CLI. - let newOutput = shell.output; + let newOutput = task.output; if (typeof action.chunk === 'string') { newOutput = - typeof shell.output === 'string' - ? shell.output + action.chunk + typeof task.output === 'string' + ? task.output + action.chunk : action.chunk; } else { newOutput = action.chunk; } - shell.output = newOutput; + task.output = newOutput; const nextState = { ...state, lastShellOutputTime: Date.now() }; - if (state.isBackgroundShellVisible) { + if (state.isBackgroundTaskVisible) { return { ...nextState, - backgroundShells: new Map(state.backgroundShells), + backgroundTasks: new Map(state.backgroundTasks), }; } return nextState; } - case 'SYNC_BACKGROUND_SHELLS': { - return { ...state, backgroundShells: new Map(state.backgroundShells) }; + case 'SYNC_BACKGROUND_TASKS': { + return { ...state, backgroundTasks: new Map(state.backgroundTasks) }; } - case 'DISMISS_SHELL': { - const nextShells = new Map(state.backgroundShells); - nextShells.delete(action.pid); + case 'DISMISS_TASK': { + const nextTasks = new Map(state.backgroundTasks); + nextTasks.delete(action.pid); return { ...state, - backgroundShells: nextShells, - isBackgroundShellVisible: - nextShells.size === 0 ? false : state.isBackgroundShellVisible, + backgroundTasks: nextTasks, + isBackgroundTaskVisible: + nextTasks.size === 0 ? false : state.isBackgroundTaskVisible, }; } default: diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 33df14dcce..ec4aa00677 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -213,7 +213,7 @@ describe('useSlashCommandProcessor', () => { toggleDebugProfiler: vi.fn(), dispatchExtensionStateUpdate: vi.fn(), addConfirmUpdateExtensionRequest: vi.fn(), - toggleBackgroundShell: vi.fn(), + toggleBackgroundTasks: vi.fn(), toggleShortcutsHelp: vi.fn(), setText: vi.fn(), }, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 1839670df7..f55503ad25 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -84,7 +84,7 @@ interface SlashCommandProcessorActions { toggleDebugProfiler: () => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; - toggleBackgroundShell: () => void; + toggleBackgroundTasks: () => void; toggleShortcutsHelp: () => void; setText: (text: string) => void; } @@ -242,7 +242,7 @@ export const useSlashCommandProcessor = ( actions.addConfirmUpdateExtensionRequest, setConfirmationRequest, removeComponent: () => setCustomDialog(null), - toggleBackgroundShell: actions.toggleBackgroundShell, + toggleBackgroundTasks: actions.toggleBackgroundTasks, toggleShortcutsHelp: actions.toggleShortcutsHelp, }, session: { diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index e06ebf5bb5..a23b5c3d96 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -50,6 +50,7 @@ export function mapToDisplay( callId: call.request.callId, parentCallId: call.request.parentCallId, name: displayName, + args: call.request.args, description, renderOutputAsMarkdown, }; diff --git a/packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx b/packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx deleted file mode 100644 index c6a5e9ef4e..0000000000 --- a/packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { render } from '../../test-utils/render.js'; -import { - useBackgroundShellManager, - type BackgroundShellManagerProps, -} from './useBackgroundShellManager.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { act } from 'react'; -import { type BackgroundShell } from './shellReducer.js'; - -describe('useBackgroundShellManager', () => { - const setEmbeddedShellFocused = vi.fn(); - const terminalHeight = 30; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - const renderHook = async (props: BackgroundShellManagerProps) => { - let hookResult: ReturnType; - function TestComponent({ p }: { p: BackgroundShellManagerProps }) { - hookResult = useBackgroundShellManager(p); - return null; - } - const { rerender } = await render(); - return { - result: { - get current() { - return hookResult; - }, - }, - rerender: (newProps: BackgroundShellManagerProps) => - rerender(), - }; - }; - - it('should initialize with correct default values', async () => { - const backgroundShells = new Map(); - const { result } = await renderHook({ - backgroundShells, - backgroundShellCount: 0, - isBackgroundShellVisible: false, - activePtyId: null, - embeddedShellFocused: false, - setEmbeddedShellFocused, - terminalHeight, - }); - - expect(result.current.isBackgroundShellListOpen).toBe(false); - expect(result.current.activeBackgroundShellPid).toBe(null); - expect(result.current.backgroundShellHeight).toBe(0); - }); - - it('should auto-select the first background shell when added', async () => { - const backgroundShells = new Map(); - const { result, rerender } = await renderHook({ - backgroundShells, - backgroundShellCount: 0, - isBackgroundShellVisible: false, - activePtyId: null, - embeddedShellFocused: false, - setEmbeddedShellFocused, - terminalHeight, - }); - - const newShells = new Map([ - [123, {} as BackgroundShell], - ]); - rerender({ - backgroundShells: newShells, - backgroundShellCount: 1, - isBackgroundShellVisible: false, - activePtyId: null, - embeddedShellFocused: false, - setEmbeddedShellFocused, - terminalHeight, - }); - - expect(result.current.activeBackgroundShellPid).toBe(123); - }); - - it('should reset state when all shells are removed', async () => { - const backgroundShells = new Map([ - [123, {} as BackgroundShell], - ]); - const { result, rerender } = await renderHook({ - backgroundShells, - backgroundShellCount: 1, - isBackgroundShellVisible: true, - activePtyId: null, - embeddedShellFocused: true, - setEmbeddedShellFocused, - terminalHeight, - }); - - act(() => { - result.current.setIsBackgroundShellListOpen(true); - }); - expect(result.current.isBackgroundShellListOpen).toBe(true); - - rerender({ - backgroundShells: new Map(), - backgroundShellCount: 0, - isBackgroundShellVisible: true, - activePtyId: null, - embeddedShellFocused: true, - setEmbeddedShellFocused, - terminalHeight, - }); - - expect(result.current.activeBackgroundShellPid).toBe(null); - expect(result.current.isBackgroundShellListOpen).toBe(false); - }); - - it('should unfocus embedded shell when no shells are active', async () => { - const backgroundShells = new Map([ - [123, {} as BackgroundShell], - ]); - await renderHook({ - backgroundShells, - backgroundShellCount: 1, - isBackgroundShellVisible: false, // Background shell not visible - activePtyId: null, // No foreground shell - embeddedShellFocused: true, - setEmbeddedShellFocused, - terminalHeight, - }); - - expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false); - }); - - it('should calculate backgroundShellHeight correctly when visible', async () => { - const backgroundShells = new Map([ - [123, {} as BackgroundShell], - ]); - const { result } = await renderHook({ - backgroundShells, - backgroundShellCount: 1, - isBackgroundShellVisible: true, - activePtyId: null, - embeddedShellFocused: true, - setEmbeddedShellFocused, - terminalHeight: 100, - }); - - // 100 * 0.3 = 30 - expect(result.current.backgroundShellHeight).toBe(30); - }); - - it('should maintain current active shell if it still exists', async () => { - const backgroundShells = new Map([ - [123, {} as BackgroundShell], - [456, {} as BackgroundShell], - ]); - const { result, rerender } = await renderHook({ - backgroundShells, - backgroundShellCount: 2, - isBackgroundShellVisible: true, - activePtyId: null, - embeddedShellFocused: true, - setEmbeddedShellFocused, - terminalHeight, - }); - - act(() => { - result.current.setActiveBackgroundShellPid(456); - }); - expect(result.current.activeBackgroundShellPid).toBe(456); - - // Remove the OTHER shell - const updatedShells = new Map([ - [456, {} as BackgroundShell], - ]); - rerender({ - backgroundShells: updatedShells, - backgroundShellCount: 1, - isBackgroundShellVisible: true, - activePtyId: null, - embeddedShellFocused: true, - setEmbeddedShellFocused, - terminalHeight, - }); - - expect(result.current.activeBackgroundShellPid).toBe(456); - }); -}); diff --git a/packages/cli/src/ui/hooks/useBackgroundShellManager.ts b/packages/cli/src/ui/hooks/useBackgroundShellManager.ts deleted file mode 100644 index 465e4b8e0d..0000000000 --- a/packages/cli/src/ui/hooks/useBackgroundShellManager.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useEffect, useMemo } from 'react'; -import { type BackgroundShell } from './shellCommandProcessor.js'; - -export interface BackgroundShellManagerProps { - backgroundShells: Map; - backgroundShellCount: number; - isBackgroundShellVisible: boolean; - activePtyId: number | null | undefined; - embeddedShellFocused: boolean; - setEmbeddedShellFocused: (focused: boolean) => void; - terminalHeight: number; -} - -export function useBackgroundShellManager({ - backgroundShells, - backgroundShellCount, - isBackgroundShellVisible, - activePtyId, - embeddedShellFocused, - setEmbeddedShellFocused, - terminalHeight, -}: BackgroundShellManagerProps) { - const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] = - useState(false); - const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState< - number | null - >(null); - - useEffect(() => { - if (backgroundShells.size === 0) { - if (activeBackgroundShellPid !== null) { - setActiveBackgroundShellPid(null); - } - if (isBackgroundShellListOpen) { - setIsBackgroundShellListOpen(false); - } - } else if ( - activeBackgroundShellPid === null || - !backgroundShells.has(activeBackgroundShellPid) - ) { - // If active shell is closed or none selected, select the first one (last added usually, or just first in iteration) - setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null); - } - }, [ - backgroundShells, - activeBackgroundShellPid, - backgroundShellCount, - isBackgroundShellListOpen, - ]); - - useEffect(() => { - if (embeddedShellFocused) { - const hasActiveForegroundShell = !!activePtyId; - const hasVisibleBackgroundShell = - isBackgroundShellVisible && backgroundShells.size > 0; - - if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) { - setEmbeddedShellFocused(false); - } - } - }, [ - isBackgroundShellVisible, - backgroundShells, - embeddedShellFocused, - backgroundShellCount, - activePtyId, - setEmbeddedShellFocused, - ]); - - const backgroundShellHeight = useMemo( - () => - isBackgroundShellVisible && backgroundShells.size > 0 - ? Math.max(Math.floor(terminalHeight * 0.3), 5) - : 0, - [isBackgroundShellVisible, backgroundShells.size, terminalHeight], - ); - - return { - isBackgroundShellListOpen, - setIsBackgroundShellListOpen, - activeBackgroundShellPid, - setActiveBackgroundShellPid, - backgroundShellHeight, - }; -} diff --git a/packages/cli/src/ui/hooks/useBackgroundTaskManager.test.tsx b/packages/cli/src/ui/hooks/useBackgroundTaskManager.test.tsx new file mode 100644 index 0000000000..d1c25e7de4 --- /dev/null +++ b/packages/cli/src/ui/hooks/useBackgroundTaskManager.test.tsx @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { + useBackgroundTaskManager, + type BackgroundTaskManagerProps, +} from './useBackgroundTaskManager.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { type BackgroundTask } from './shellReducer.js'; + +describe('useBackgroundTaskManager', () => { + const setEmbeddedShellFocused = vi.fn(); + const terminalHeight = 30; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderHook = async (props: BackgroundTaskManagerProps) => { + let hookResult: ReturnType; + function TestComponent({ p }: { p: BackgroundTaskManagerProps }) { + hookResult = useBackgroundTaskManager(p); + return null; + } + const { rerender } = await render(); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: (newProps: BackgroundTaskManagerProps) => + rerender(), + }; + }; + + it('should initialize with correct default values', async () => { + const backgroundTasks = new Map(); + const { result } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 0, + isBackgroundTaskVisible: false, + activePtyId: null, + embeddedShellFocused: false, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.isBackgroundTaskListOpen).toBe(false); + expect(result.current.activeBackgroundTaskPid).toBe(null); + expect(result.current.backgroundTaskHeight).toBe(0); + }); + + it('should auto-select the first background shell when added', async () => { + const backgroundTasks = new Map(); + const { result, rerender } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 0, + isBackgroundTaskVisible: false, + activePtyId: null, + embeddedShellFocused: false, + setEmbeddedShellFocused, + terminalHeight, + }); + + const newShells = new Map([ + [123, {} as BackgroundTask], + ]); + rerender({ + backgroundTasks: newShells, + backgroundTaskCount: 1, + isBackgroundTaskVisible: false, + activePtyId: null, + embeddedShellFocused: false, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.activeBackgroundTaskPid).toBe(123); + }); + + it('should reset state when all shells are removed', async () => { + const backgroundTasks = new Map([ + [123, {} as BackgroundTask], + ]); + const { result, rerender } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 1, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + act(() => { + result.current.setIsBackgroundTaskListOpen(true); + }); + expect(result.current.isBackgroundTaskListOpen).toBe(true); + + rerender({ + backgroundTasks: new Map(), + backgroundTaskCount: 0, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.activeBackgroundTaskPid).toBe(null); + expect(result.current.isBackgroundTaskListOpen).toBe(false); + }); + + it('should unfocus embedded shell when no shells are active', async () => { + const backgroundTasks = new Map([ + [123, {} as BackgroundTask], + ]); + await renderHook({ + backgroundTasks, + backgroundTaskCount: 1, + isBackgroundTaskVisible: false, // Background shell not visible + activePtyId: null, // No foreground shell + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false); + }); + + it('should calculate backgroundTaskHeight correctly when visible', async () => { + const backgroundTasks = new Map([ + [123, {} as BackgroundTask], + ]); + const { result } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 1, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight: 100, + }); + + // 100 * 0.3 = 30 + expect(result.current.backgroundTaskHeight).toBe(30); + }); + + it('should maintain current active shell if it still exists', async () => { + const backgroundTasks = new Map([ + [123, {} as BackgroundTask], + [456, {} as BackgroundTask], + ]); + const { result, rerender } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 2, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + act(() => { + result.current.setActiveBackgroundTaskPid(456); + }); + expect(result.current.activeBackgroundTaskPid).toBe(456); + + // Remove the OTHER shell + const updatedShells = new Map([ + [456, {} as BackgroundTask], + ]); + rerender({ + backgroundTasks: updatedShells, + backgroundTaskCount: 1, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.activeBackgroundTaskPid).toBe(456); + }); +}); diff --git a/packages/cli/src/ui/hooks/useBackgroundTaskManager.ts b/packages/cli/src/ui/hooks/useBackgroundTaskManager.ts new file mode 100644 index 0000000000..54b8c553fe --- /dev/null +++ b/packages/cli/src/ui/hooks/useBackgroundTaskManager.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo } from 'react'; +import { type BackgroundTask } from './useExecutionLifecycle.js'; + +export interface BackgroundTaskManagerProps { + backgroundTasks: Map; + backgroundTaskCount: number; + isBackgroundTaskVisible: boolean; + activePtyId: number | null | undefined; + embeddedShellFocused: boolean; + setEmbeddedShellFocused: (focused: boolean) => void; + terminalHeight: number; +} + +export function useBackgroundTaskManager({ + backgroundTasks, + backgroundTaskCount, + isBackgroundTaskVisible, + activePtyId, + embeddedShellFocused, + setEmbeddedShellFocused, + terminalHeight, +}: BackgroundTaskManagerProps) { + const [isBackgroundTaskListOpen, setIsBackgroundTaskListOpen] = + useState(false); + const [activeBackgroundTaskPid, setActiveBackgroundTaskPid] = useState< + number | null + >(null); + + useEffect(() => { + if (backgroundTasks.size === 0) { + if (activeBackgroundTaskPid !== null) { + setActiveBackgroundTaskPid(null); + } + if (isBackgroundTaskListOpen) { + setIsBackgroundTaskListOpen(false); + } + } else if ( + activeBackgroundTaskPid === null || + !backgroundTasks.has(activeBackgroundTaskPid) + ) { + // If active shell is closed or none selected, select the first one (last added usually, or just first in iteration) + setActiveBackgroundTaskPid(backgroundTasks.keys().next().value ?? null); + } + }, [ + backgroundTasks, + activeBackgroundTaskPid, + backgroundTaskCount, + isBackgroundTaskListOpen, + ]); + + useEffect(() => { + if (embeddedShellFocused) { + const hasActiveForegroundShell = !!activePtyId; + const hasVisibleBackgroundTask = + isBackgroundTaskVisible && backgroundTasks.size > 0; + + if (!hasActiveForegroundShell && !hasVisibleBackgroundTask) { + setEmbeddedShellFocused(false); + } + } + }, [ + isBackgroundTaskVisible, + backgroundTasks, + embeddedShellFocused, + backgroundTaskCount, + activePtyId, + setEmbeddedShellFocused, + ]); + + const backgroundTaskHeight = useMemo( + () => + isBackgroundTaskVisible && backgroundTasks.size > 0 + ? Math.max(Math.floor(terminalHeight * 0.3), 5) + : 0, + [isBackgroundTaskVisible, backgroundTasks.size, terminalHeight], + ); + + return { + isBackgroundTaskListOpen, + setIsBackgroundTaskListOpen, + activeBackgroundTaskPid, + setActiveBackgroundTaskPid, + backgroundTaskHeight, + }; +} diff --git a/packages/cli/src/ui/hooks/useComposerStatus.ts b/packages/cli/src/ui/hooks/useComposerStatus.ts index 0f82e650aa..3b9c5f0eec 100644 --- a/packages/cli/src/ui/hooks/useComposerStatus.ts +++ b/packages/cli/src/ui/hooks/useComposerStatus.ts @@ -49,7 +49,7 @@ export const useComposerStatus = () => { ); const showLoadingIndicator = - (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && + (!uiState.embeddedShellFocused || uiState.isBackgroundTaskVisible) && uiState.streamingState === StreamingState.Responding && !hasPendingActionRequired; diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx similarity index 86% rename from packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx rename to packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx index f9416d379f..743bf90c04 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx @@ -35,6 +35,23 @@ const mockShellOnExit = vi.hoisted(() => ) => () => void >(() => vi.fn()), ); +const mockLifecycleSubscribe = vi.hoisted(() => + vi.fn< + (pid: number, listener: (event: ShellOutputEvent) => void) => () => void + >(() => vi.fn()), +); +const mockLifecycleOnExit = vi.hoisted(() => + vi.fn< + ( + pid: number, + callback: (exitCode: number, signal?: number) => void, + ) => () => void + >(() => vi.fn()), +); +const mockLifecycleKill = vi.hoisted(() => vi.fn()); +const mockLifecycleBackground = vi.hoisted(() => vi.fn()); +const mockLifecycleOnBackground = vi.hoisted(() => vi.fn()); +const mockLifecycleOffBackground = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -48,6 +65,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { subscribe: mockShellSubscribe, onExit: mockShellOnExit, }, + ExecutionLifecycleService: { + subscribe: mockLifecycleSubscribe, + onExit: mockLifecycleOnExit, + kill: mockLifecycleKill, + background: mockLifecycleBackground, + onBackground: mockLifecycleOnBackground, + offBackground: mockLifecycleOffBackground, + }, isBinary: mockIsBinary, }; }); @@ -68,9 +93,9 @@ vi.mock('node:os', async (importOriginal) => { vi.mock('node:crypto'); import { - useShellCommandProcessor, + useExecutionLifecycle, OUTPUT_UPDATE_INTERVAL_MS, -} from './shellCommandProcessor.js'; +} from './useExecutionLifecycle.js'; import { type Config, type GeminiClient, @@ -83,7 +108,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as crypto from 'node:crypto'; -describe('useShellCommandProcessor', () => { +describe('useExecutionLifecycle', () => { let addItemToHistoryMock: Mock; let setPendingHistoryItemMock: Mock; let onExecMock: Mock; @@ -140,7 +165,7 @@ describe('useShellCommandProcessor', () => { }); const renderProcessorHook = async () => { - let hookResult: ReturnType; + let hookResult: ReturnType; let renderCount = 0; function TestComponent({ isWaitingForConfirmation, @@ -148,7 +173,7 @@ describe('useShellCommandProcessor', () => { isWaitingForConfirmation?: boolean; }) { renderCount++; - hookResult = useShellCommandProcessor( + hookResult = useExecutionLifecycle( addItemToHistoryMock, setPendingHistoryItemMock, onExecMock, @@ -772,11 +797,11 @@ describe('useShellCommandProcessor', () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); - expect(result.current.backgroundShellCount).toBe(1); - const shell = result.current.backgroundShells.get(1001); + expect(result.current.backgroundTaskCount).toBe(1); + const shell = result.current.backgroundTasks.get(1001); expect(shell).toEqual( expect.objectContaining({ pid: 1001, @@ -784,8 +809,11 @@ describe('useShellCommandProcessor', () => { output: 'initial', }), ); - expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function)); - expect(mockShellSubscribe).toHaveBeenCalledWith( + expect(mockLifecycleOnExit).toHaveBeenCalledWith( + 1001, + expect.any(Function), + ); + expect(mockLifecycleSubscribe).toHaveBeenCalledWith( 1001, expect.any(Function), ); @@ -795,55 +823,55 @@ describe('useShellCommandProcessor', () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); }); it('should show info message when toggling background shells if none are active', async () => { const { result } = await renderProcessorHook(); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); expect(addItemToHistoryMock).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: 'No background shells are currently active.', + text: 'No background tasks are currently active.', }), expect.any(Number), ); - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); }); it('should dismiss a background shell and remove it from state', async () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); await act(async () => { - await result.current.dismissBackgroundShell(1001); + await result.current.dismissBackgroundTask(1001); }); - expect(mockShellKill).toHaveBeenCalledWith(1001); - expect(result.current.backgroundShellCount).toBe(0); - expect(result.current.backgroundShells.has(1001)).toBe(false); + expect(mockLifecycleKill).toHaveBeenCalledWith(1001); + expect(result.current.backgroundTaskCount).toBe(0); + expect(result.current.backgroundTasks.has(1001)).toBe(false); }); it('should handle backgrounding the current shell', async () => { @@ -867,7 +895,7 @@ describe('useShellCommandProcessor', () => { expect(result.current.activeShellPtyId).toBe(555); act(() => { - result.current.backgroundCurrentShell(); + result.current.backgroundCurrentExecution(); }); expect(mockShellBackground).toHaveBeenCalledWith(555); @@ -887,19 +915,19 @@ describe('useShellCommandProcessor', () => { // Wait for promise resolution await act(async () => await onExecMock.mock.calls[0][0]); - expect(result.current.backgroundShellCount).toBe(1); + expect(result.current.backgroundTaskCount).toBe(1); expect(result.current.activeShellPtyId).toBeNull(); }); - it('should persist background shell on successful exit and mark as exited', async () => { + it('should auto-dismiss background task on successful exit', async () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(888, 'auto-exit', ''); + result.current.registerBackgroundTask(888, 'auto-exit', ''); }); // Find the exit callback registered - const exitCallback = mockShellOnExit.mock.calls.find( + const exitCallback = mockLifecycleOnExit.mock.calls.find( (call) => call[0] === 888, )?.[1]; expect(exitCallback).toBeDefined(); @@ -910,22 +938,19 @@ describe('useShellCommandProcessor', () => { }); } - // Should NOT be removed, but updated - expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0 - expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it - const shell = result.current.backgroundShells.get(888); - expect(shell?.status).toBe('exited'); - expect(shell?.exitCode).toBe(0); + // Should be auto-dismissed from the panel + expect(result.current.backgroundTaskCount).toBe(0); + expect(result.current.backgroundTasks.has(888)).toBe(false); }); - it('should persist background shell on failed exit', async () => { + it('should auto-dismiss background task on failed exit', async () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(999, 'fail-exit', ''); + result.current.registerBackgroundTask(999, 'fail-exit', ''); }); - const exitCallback = mockShellOnExit.mock.calls.find( + const exitCallback = mockLifecycleOnExit.mock.calls.find( (call) => call[0] === 999, )?.[1]; expect(exitCallback).toBeDefined(); @@ -936,34 +961,26 @@ describe('useShellCommandProcessor', () => { }); } - // Should NOT be removed, but updated - expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0 - const shell = result.current.backgroundShells.get(999); - expect(shell?.status).toBe('exited'); - expect(shell?.exitCode).toBe(1); - - // Now dismiss it - await act(async () => { - await result.current.dismissBackgroundShell(999); - }); - expect(result.current.backgroundShellCount).toBe(0); + // Should be auto-dismissed from the panel + expect(result.current.backgroundTaskCount).toBe(0); + expect(result.current.backgroundTasks.has(999)).toBe(false); }); it('should NOT trigger re-render on background shell output when visible', async () => { const { result, getRenderCount } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); // Show the background shells act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); const initialRenderCount = getRenderCount(); - const subscribeCallback = mockShellSubscribe.mock.calls.find( + const subscribeCallback = mockLifecycleSubscribe.mock.calls.find( (call) => call[0] === 1001, )?.[1]; expect(subscribeCallback).toBeDefined(); @@ -975,7 +992,7 @@ describe('useShellCommandProcessor', () => { } expect(getRenderCount()).toBeGreaterThan(initialRenderCount); - const shell = result.current.backgroundShells.get(1001); + const shell = result.current.backgroundTasks.get(1001); expect(shell?.output).toBe('initial + updated'); }); @@ -983,13 +1000,13 @@ describe('useShellCommandProcessor', () => { const { result, getRenderCount } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); // Ensure background shells are hidden (default) const initialRenderCount = getRenderCount(); - const subscribeCallback = mockShellSubscribe.mock.calls.find( + const subscribeCallback = mockLifecycleSubscribe.mock.calls.find( (call) => call[0] === 1001, )?.[1]; expect(subscribeCallback).toBeDefined(); @@ -1001,7 +1018,7 @@ describe('useShellCommandProcessor', () => { } expect(getRenderCount()).toBeGreaterThan(initialRenderCount); - const shell = result.current.backgroundShells.get(1001); + const shell = result.current.backgroundTasks.get(1001); expect(shell?.output).toBe('initial + updated'); }); @@ -1009,17 +1026,17 @@ describe('useShellCommandProcessor', () => { const { result, getRenderCount } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); // Show the background shells act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); const initialRenderCount = getRenderCount(); - const subscribeCallback = mockShellSubscribe.mock.calls.find( + const subscribeCallback = mockLifecycleSubscribe.mock.calls.find( (call) => call[0] === 1001, )?.[1]; expect(subscribeCallback).toBeDefined(); @@ -1031,7 +1048,7 @@ describe('useShellCommandProcessor', () => { } expect(getRenderCount()).toBeGreaterThan(initialRenderCount); - const shell = result.current.backgroundShells.get(1001); + const shell = result.current.backgroundTasks.get(1001); expect(shell?.isBinary).toBe(true); expect(shell?.binaryBytesReceived).toBe(1024); }); @@ -1041,12 +1058,12 @@ describe('useShellCommandProcessor', () => { // 1. Register and show background shell act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); // 2. Simulate model responding (not waiting for confirmation) act(() => { @@ -1054,7 +1071,7 @@ describe('useShellCommandProcessor', () => { }); // Should stay visible - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); }); it('should hide background shell when waiting for confirmation and restore after delay', async () => { @@ -1062,12 +1079,12 @@ describe('useShellCommandProcessor', () => { // 1. Register and show background shell act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); // 2. Simulate tool confirmation showing up act(() => { @@ -1075,7 +1092,7 @@ describe('useShellCommandProcessor', () => { }); // Should be hidden - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); // 3. Simulate confirmation accepted (waiting for PTY start) act(() => { @@ -1083,11 +1100,11 @@ describe('useShellCommandProcessor', () => { }); // Should STAY hidden during the 300ms gap - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); // 4. Wait for restore delay await waitFor(() => - expect(result.current.isBackgroundShellVisible).toBe(true), + expect(result.current.isBackgroundTaskVisible).toBe(true), ); }); @@ -1096,12 +1113,12 @@ describe('useShellCommandProcessor', () => { // 1. Register and show background shell act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); // 2. Start foreground shell act(() => { @@ -1112,7 +1129,7 @@ describe('useShellCommandProcessor', () => { await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345)); // Should be hidden automatically - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); // 3. Complete foreground shell act(() => { @@ -1123,7 +1140,7 @@ describe('useShellCommandProcessor', () => { // Should be restored automatically (after delay) await waitFor(() => - expect(result.current.isBackgroundShellVisible).toBe(true), + expect(result.current.isBackgroundTaskVisible).toBe(true), ); }); @@ -1132,25 +1149,25 @@ describe('useShellCommandProcessor', () => { // 1. Register and show background shell act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); // 2. Start foreground shell act(() => { result.current.handleShellCommand('ls', new AbortController().signal); }); await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345)); - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); // 3. Manually toggle visibility (e.g. user wants to peek) act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); // 4. Complete foreground shell act(() => { @@ -1161,7 +1178,7 @@ describe('useShellCommandProcessor', () => { // It should NOT change visibility because manual toggle cleared the auto-restore flag // After delay it should stay true (as it was manually toggled to true) await waitFor(() => - expect(result.current.isBackgroundShellVisible).toBe(true), + expect(result.current.isBackgroundTaskVisible).toBe(true), ); }); }); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts similarity index 75% rename from packages/cli/src/ui/hooks/shellCommandProcessor.ts rename to packages/cli/src/ui/hooks/useExecutionLifecycle.ts index 3e67ad84b7..e0b5c3ffaa 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts @@ -9,10 +9,16 @@ import type { IndividualToolCallDisplay, } from '../types.js'; import { useCallback, useReducer, useRef, useEffect } from 'react'; -import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core'; +import type { + AnsiOutput, + Config, + GeminiClient, + CompletionBehavior, +} from '@google/gemini-cli-core'; import { isBinary, ShellExecutionService, + ExecutionLifecycleService, CoreToolCallStatus, } from '@google/gemini-cli-core'; import { type PartListUnion } from '@google/genai'; @@ -27,9 +33,9 @@ import { themeManager } from '../../ui/themes/theme-manager.js'; import { shellReducer, initialState, - type BackgroundShell, + type BackgroundTask, } from './shellReducer.js'; -export { type BackgroundShell }; +export { type BackgroundTask }; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; const RESTORE_VISIBILITY_DELAY_MS = 300; @@ -66,7 +72,7 @@ function addShellCommandToGeminiHistory( * Hook to process shell commands. * Orchestrates command execution and updates history and agent context. */ -export const useShellCommandProcessor = ( +export const useExecutionLifecycle = ( addItemToHistory: UseHistoryManagerReturn['addItem'], setPendingHistoryItem: React.Dispatch< React.SetStateAction @@ -113,7 +119,7 @@ export const useShellCommandProcessor = ( m.restoreTimeout = null; } - if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) { + if (state.isBackgroundTaskVisible && !m.wasVisibleBeforeForeground) { m.wasVisibleBeforeForeground = true; dispatch({ type: 'SET_VISIBILITY', visible: false }); } @@ -135,14 +141,14 @@ export const useShellCommandProcessor = ( }, [ activePtyId, isWaitingForConfirmation, - state.isBackgroundShellVisible, + state.isBackgroundTaskVisible, m, dispatch, ]); useEffect( () => () => { - // Unsubscribe from all background shell events on unmount + // Unsubscribe from all background task events on unmount for (const unsubscribe of m.subscriptions.values()) { unsubscribe(); } @@ -151,9 +157,9 @@ export const useShellCommandProcessor = ( [m], ); - const toggleBackgroundShell = useCallback(() => { - if (state.backgroundShells.size > 0) { - const willBeVisible = !state.isBackgroundShellVisible; + const toggleBackgroundTasks = useCallback(() => { + if (state.backgroundTasks.size > 0) { + const willBeVisible = !state.isBackgroundTaskVisible; dispatch({ type: 'TOGGLE_VISIBILITY' }); const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation; @@ -167,34 +173,44 @@ export const useShellCommandProcessor = ( } if (willBeVisible) { - dispatch({ type: 'SYNC_BACKGROUND_SHELLS' }); + dispatch({ type: 'SYNC_BACKGROUND_TASKS' }); } } else { dispatch({ type: 'SET_VISIBILITY', visible: false }); addItemToHistory( { type: 'info', - text: 'No background shells are currently active.', + text: 'No background tasks are currently active.', }, Date.now(), ); } }, [ addItemToHistory, - state.backgroundShells.size, - state.isBackgroundShellVisible, + state.backgroundTasks.size, + state.isBackgroundTaskVisible, activePtyId, isWaitingForConfirmation, m, dispatch, ]); - const backgroundCurrentShell = useCallback(() => { + const backgroundCurrentExecution = useCallback(() => { const pidToBackground = state.activeShellPtyId ?? activeBackgroundExecutionId; if (pidToBackground) { - ShellExecutionService.background(pidToBackground); + // TRACK THE PID BEFORE TRIGGERING THE BACKGROUND ACTION + // This prevents the onBackground listener from double-registering. m.backgroundedPids.add(pidToBackground); + + // Use ShellExecutionService for shell PTYs (handles log files, etc.), + // fall back to ExecutionLifecycleService for non-shell executions + // (e.g. remote agents, MCP tools, local agents). + if (state.activeShellPtyId) { + ShellExecutionService.background(pidToBackground); + } else { + ExecutionLifecycleService.background(pidToBackground); + } // Ensure backgrounding is silent and doesn't trigger restoration m.wasVisibleBeforeForeground = false; if (m.restoreTimeout) { @@ -204,14 +220,16 @@ export const useShellCommandProcessor = ( } }, [state.activeShellPtyId, activeBackgroundExecutionId, m]); - const dismissBackgroundShell = useCallback( + const dismissBackgroundTask = useCallback( async (pid: number) => { - const shell = state.backgroundShells.get(pid); + const shell = state.backgroundTasks.get(pid); if (shell) { if (shell.status === 'running') { - await ShellExecutionService.kill(pid); + // ExecutionLifecycleService.kill handles both shell and non-shell + // executions. For shells, ShellExecutionService.kill delegates to it. + ExecutionLifecycleService.kill(pid); } - dispatch({ type: 'DISMISS_SHELL', pid }); + dispatch({ type: 'DISMISS_TASK', pid }); m.backgroundedPids.delete(pid); // Unsubscribe from updates @@ -222,40 +240,73 @@ export const useShellCommandProcessor = ( } } }, - [state.backgroundShells, dispatch, m], + [state.backgroundTasks, dispatch, m], ); - const registerBackgroundShell = useCallback( - (pid: number, command: string, initialOutput: string | AnsiOutput) => { - dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput }); + const registerBackgroundTask = useCallback( + ( + pid: number, + command: string, + initialOutput: string | AnsiOutput, + completionBehavior?: CompletionBehavior, + ) => { + m.backgroundedPids.add(pid); + dispatch({ + type: 'REGISTER_TASK', + pid, + command, + initialOutput, + completionBehavior, + }); - // Subscribe to process exit directly - const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => { + // Subscribe to exit via ExecutionLifecycleService (works for all execution types) + const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => { dispatch({ - type: 'UPDATE_SHELL', + type: 'UPDATE_TASK', pid, update: { status: 'exited', exitCode: code }, }); + // Auto-dismiss for inject/notify (output was delivered to conversation). + // Silent tasks stay in the UI until manually dismissed. + if (completionBehavior !== 'silent') { + dispatch({ type: 'DISMISS_TASK', pid }); + } + const unsub = m.subscriptions.get(pid); + if (unsub) { + unsub(); + m.subscriptions.delete(pid); + } m.backgroundedPids.delete(pid); }); - // Subscribe to future updates (data only) - const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => { - if (event.type === 'data') { - dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk }); - } else if (event.type === 'binary_detected') { - dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } }); - } else if (event.type === 'binary_progress') { - dispatch({ - type: 'UPDATE_SHELL', - pid, - update: { - isBinary: true, - binaryBytesReceived: event.bytesReceived, - }, - }); - } - }); + // Subscribe to output via ExecutionLifecycleService (works for all execution types) + const dataUnsubscribe = ExecutionLifecycleService.subscribe( + pid, + (event) => { + if (event.type === 'data') { + dispatch({ + type: 'APPEND_TASK_OUTPUT', + pid, + chunk: event.chunk, + }); + } else if (event.type === 'binary_detected') { + dispatch({ + type: 'UPDATE_TASK', + pid, + update: { isBinary: true }, + }); + } else if (event.type === 'binary_progress') { + dispatch({ + type: 'UPDATE_TASK', + pid, + update: { + isBinary: true, + binaryBytesReceived: event.bytesReceived, + }, + }); + } + }, + ); m.subscriptions.set(pid, () => { exitUnsubscribe(); @@ -265,6 +316,34 @@ export const useShellCommandProcessor = ( [dispatch, m], ); + // Auto-register any execution that gets backgrounded, regardless of type. + // This is the agnostic hook: any tool that calls + // ExecutionLifecycleService.createExecution() or attachExecution() + // automatically gets Ctrl+B support โ€” no UI changes needed per tool. + useEffect(() => { + const listener = (info: { + executionId: number; + label: string; + output: string; + completionBehavior: CompletionBehavior; + }) => { + // Skip if already registered (e.g. shells register via their own flow) + if (m.backgroundedPids.has(info.executionId)) { + return; + } + registerBackgroundTask( + info.executionId, + info.label, + info.output, + info.completionBehavior, + ); + }; + ExecutionLifecycleService.onBackground(listener); + return () => { + ExecutionLifecycleService.offBackground(listener); + }; + }, [registerBackgroundTask, m]); + const handleShellCommand = useCallback( (rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => { if (typeof rawQuery !== 'string' || rawQuery.trim() === '') { @@ -377,7 +456,7 @@ export const useShellCommandProcessor = ( if (executionPid && m.backgroundedPids.has(executionPid)) { // If already backgrounded, let the background shell subscription handle it. dispatch({ - type: 'APPEND_SHELL_OUTPUT', + type: 'APPEND_TASK_OUTPUT', pid: executionPid, chunk: event.type === 'data' ? event.chunk : cumulativeStdout, @@ -437,7 +516,12 @@ export const useShellCommandProcessor = ( setPendingHistoryItem(null); if (result.backgrounded && result.pid) { - registerBackgroundShell(result.pid, rawQuery, cumulativeStdout); + registerBackgroundTask( + result.pid, + rawQuery, + cumulativeStdout, + 'notify', + ); dispatch({ type: 'SET_ACTIVE_PTY', pid: null }); } @@ -529,26 +613,26 @@ export const useShellCommandProcessor = ( setShellInputFocused, terminalHeight, terminalWidth, - registerBackgroundShell, + registerBackgroundTask, m, dispatch, ], ); - const backgroundShellCount = Array.from( - state.backgroundShells.values(), - ).filter((s: BackgroundShell) => s.status === 'running').length; + const backgroundTaskCount = Array.from(state.backgroundTasks.values()).filter( + (s: BackgroundTask) => s.status === 'running', + ).length; return { handleShellCommand, activeShellPtyId: state.activeShellPtyId, lastShellOutputTime: state.lastShellOutputTime, - backgroundShellCount, - isBackgroundShellVisible: state.isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - registerBackgroundShell, - dismissBackgroundShell, - backgroundShells: state.backgroundShells, + backgroundTaskCount, + isBackgroundTaskVisible: state.isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + registerBackgroundTask, + dismissBackgroundTask, + backgroundTasks: state.backgroundTasks, }; }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 7858ad6ede..e7d9949124 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -179,11 +179,18 @@ vi.mock('./useKeypress.js', () => ({ useKeypress: vi.fn(), })); -vi.mock('./shellCommandProcessor.js', () => ({ - useShellCommandProcessor: vi.fn().mockReturnValue({ +vi.mock('./useExecutionLifecycle.js', () => ({ + useExecutionLifecycle: vi.fn().mockReturnValue({ handleShellCommand: vi.fn(), activeShellPtyId: null, lastShellOutputTime: 0, + backgroundTaskCount: 0, + isBackgroundTaskVisible: false, + toggleBackgroundTasks: vi.fn(), + backgroundCurrentExecution: vi.fn(), + backgroundTasks: new Map(), + dismissBackgroundTask: vi.fn(), + registerBackgroundTask: vi.fn(), }), })); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 757c24f2c3..5f5c1ab187 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -40,6 +40,8 @@ import { Kind, ACTIVATE_SKILL_TOOL_NAME, shouldHideToolCall, + UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, } from '@google/gemini-cli-core'; import type { Config, @@ -73,7 +75,7 @@ import { ToolCallStatus, } from '../types.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; -import { useShellCommandProcessor } from './shellCommandProcessor.js'; +import { useExecutionLifecycle } from './useExecutionLifecycle.js'; import { handleAtCommand } from './atCommandProcessor.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; @@ -108,6 +110,9 @@ interface BackgroundedToolInfo { initialOutput: string; } +const isTopicTool = (name: string): boolean => + name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME; + enum StreamProcessingStatus { Completed, UserCancelled, @@ -364,14 +369,14 @@ export const useGeminiStream = ( handleShellCommand, activeShellPtyId, lastShellOutputTime, - backgroundShellCount, - isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - registerBackgroundShell, - dismissBackgroundShell, - backgroundShells, - } = useShellCommandProcessor( + backgroundTaskCount, + isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + registerBackgroundTask, + dismissBackgroundTask, + backgroundTasks, + } = useExecutionLifecycle( addItem, setPendingHistoryItem, onExec, @@ -483,13 +488,23 @@ export const useGeminiStream = ( activeShellPtyId, !!isShellFocused, [], - backgroundShells, + backgroundTasks, ), }); addItem(historyItem); setPushedToolCallIds(newPushed); - setIsFirstToolInGroup(false); + + // If this batch ONLY contains topics, and we were the first in the group, + // the NEXT batch is still effectively the first VISIBLE bordered tool in the group. + if ( + isFirstToolInGroupRef.current && + toolsToPush.every((tc) => isTopicTool(tc.request.name)) + ) { + // Keep it true! + } else { + setIsFirstToolInGroup(false); + } } }, [ toolCalls, @@ -500,9 +515,8 @@ export const useGeminiStream = ( addItem, activeShellPtyId, isShellFocused, - backgroundShells, + backgroundTasks, ]); - const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { const remainingTools = toolCalls.filter( (tc) => !pushedToolCallIds.has(tc.request.callId), @@ -515,19 +529,30 @@ export const useGeminiStream = ( activeShellPtyId, !!isShellFocused, [], - backgroundShells, + backgroundTasks, ); if (remainingTools.length > 0) { + // Should we draw a top border? Yes if NO previous tools were drawn, + // OR if ALL previously drawn tools were topics (which don't draw top borders). + let needsTopBorder = pushedToolCallIds.size === 0; + if (!needsTopBorder) { + const allPushedWereTopics = toolCalls + .filter((tc) => pushedToolCallIds.has(tc.request.callId)) + .every((tc) => isTopicTool(tc.request.name)); + if (allPushedWereTopics) { + needsTopBorder = true; + } + } + items.push( mapTrackedToolCallsToDisplay(remainingTools, { - borderTop: pushedToolCallIds.size === 0, + borderTop: needsTopBorder, borderBottom: false, // Stay open to connect with the slice below ...appearance, }), ); } - // Always show a bottom border slice if we have ANY tools in the batch // and we haven't finished pushing the whole batch to history yet. // Once all tools are terminal and pushed, the last history item handles the closing border. @@ -604,7 +629,7 @@ export const useGeminiStream = ( pushedToolCallIds, activeShellPtyId, isShellFocused, - backgroundShells, + backgroundTasks, ]); const lastQueryRef = useRef(null); @@ -1794,7 +1819,7 @@ export const useGeminiStream = ( for (const toolCall of completedAndReadyToSubmitTools) { const backgroundedTool = getBackgroundedToolInfo(toolCall); if (backgroundedTool) { - registerBackgroundShell( + registerBackgroundTask( backgroundedTool.pid, backgroundedTool.command, backgroundedTool.initialOutput, @@ -1928,7 +1953,7 @@ export const useGeminiStream = ( performMemoryRefresh, modelSwitchedFromQuotaError, addItem, - registerBackgroundShell, + registerBackgroundTask, consumeUserHint, isLowErrorVerbosity, maybeAddSuppressedToolErrorNote, @@ -2023,12 +2048,12 @@ export const useGeminiStream = ( activePtyId, loopDetectionConfirmationRequest, lastOutputTime, - backgroundShellCount, - isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - backgroundShells, - dismissBackgroundShell, + backgroundTaskCount, + isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + backgroundTasks, + dismissBackgroundTask, retryStatus, }; }; diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index c84f189664..ae5350e394 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -74,6 +74,7 @@ export enum Command { // Text Input SUBMIT = 'input.submit', + QUEUE_MESSAGE = 'input.queueMessage', NEWLINE = 'input.newline', OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor', PASTE_CLIPBOARD = 'input.paste', @@ -354,6 +355,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([ // Text Input // Must also exclude shift to allow shift+enter for newline [Command.SUBMIT, [new KeyBinding('enter')]], + [Command.QUEUE_MESSAGE, [new KeyBinding('tab')]], [ Command.NEWLINE, [ @@ -488,6 +490,7 @@ export const commandCategories: readonly CommandCategory[] = [ title: 'Text Input', commands: [ Command.SUBMIT, + Command.QUEUE_MESSAGE, Command.NEWLINE, Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD, @@ -593,6 +596,8 @@ export const commandDescriptions: Readonly> = { // Text Input [Command.SUBMIT]: 'Submit the current prompt.', + [Command.QUEUE_MESSAGE]: + 'Queue the current prompt to be processed after the current task finishes.', [Command.NEWLINE]: 'Insert a newline without submitting.', [Command.OPEN_EXTERNAL_EDITOR]: 'Open the current prompt or the plan in an external editor.', diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx index 7bf51b7d84..402ff501ad 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -10,7 +10,7 @@ import { DefaultAppLayout } from './DefaultAppLayout.js'; import { StreamingState } from '../types.js'; import { Text } from 'ink'; import type { UIState } from '../contexts/UIStateContext.js'; -import type { BackgroundShell } from '../hooks/shellCommandProcessor.js'; +import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js'; // Mock dependencies const mockUIState = { @@ -18,13 +18,13 @@ const mockUIState = { terminalHeight: 24, terminalWidth: 80, mainAreaWidth: 80, - backgroundShells: new Map(), - activeBackgroundShellPid: null as number | null, - backgroundShellHeight: 10, + backgroundTasks: new Map(), + activeBackgroundTaskPid: null as number | null, + backgroundTaskHeight: 10, embeddedShellFocused: false, dialogsVisible: false, streamingState: StreamingState.Idle, - isBackgroundShellListOpen: false, + isBackgroundTaskListOpen: false, mainControlsRef: vi.fn(), customDialog: null, historyManager: { addItem: vi.fn() }, @@ -34,7 +34,7 @@ const mockUIState = { constrainHeight: false, availableTerminalHeight: 20, activePtyId: null, - isBackgroundShellVisible: true, + isBackgroundTaskVisible: true, } as unknown as UIState; vi.mock('../contexts/UIStateContext.js', () => ({ @@ -79,11 +79,11 @@ vi.mock('../components/ExitWarning.js', () => ({ vi.mock('../components/CopyModeWarning.js', () => ({ CopyModeWarning: () => CopyModeWarning, })); -vi.mock('../components/BackgroundShellDisplay.js', () => ({ - BackgroundShellDisplay: () => BackgroundShellDisplay, +vi.mock('../components/BackgroundTaskDisplay.js', () => ({ + BackgroundTaskDisplay: () => BackgroundTaskDisplay, })); -const createMockShell = (pid: number): BackgroundShell => ({ +const createMockShell = (pid: number): BackgroundTask => ({ pid, command: 'test command', output: 'test output', @@ -96,25 +96,25 @@ describe('', () => { beforeEach(() => { vi.clearAllMocks(); // Reset mock state defaults - mockUIState.backgroundShells = new Map(); - mockUIState.activeBackgroundShellPid = null; + mockUIState.backgroundTasks = new Map(); + mockUIState.activeBackgroundTaskPid = null; mockUIState.streamingState = StreamingState.Idle; }); - it('renders BackgroundShellDisplay when shells exist and active', async () => { - mockUIState.backgroundShells.set(123, createMockShell(123)); - mockUIState.activeBackgroundShellPid = 123; - mockUIState.backgroundShellHeight = 5; + it('renders BackgroundTaskDisplay when shells exist and active', async () => { + mockUIState.backgroundTasks.set(123, createMockShell(123)); + mockUIState.activeBackgroundTaskPid = 123; + mockUIState.backgroundTaskHeight = 5; const { lastFrame, unmount } = await render(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); - it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', async () => { - mockUIState.backgroundShells.set(123, createMockShell(123)); - mockUIState.activeBackgroundShellPid = 123; - mockUIState.backgroundShellHeight = 5; + it('hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation', async () => { + mockUIState.backgroundTasks.set(123, createMockShell(123)); + mockUIState.activeBackgroundTaskPid = 123; + mockUIState.backgroundTaskHeight = 5; mockUIState.streamingState = StreamingState.WaitingForConfirmation; const { lastFrame, unmount } = await render(); @@ -122,10 +122,10 @@ describe('', () => { unmount(); }); - it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', async () => { - mockUIState.backgroundShells.set(123, createMockShell(123)); - mockUIState.activeBackgroundShellPid = 123; - mockUIState.backgroundShellHeight = 5; + it('shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation', async () => { + mockUIState.backgroundTasks.set(123, createMockShell(123)); + mockUIState.activeBackgroundTaskPid = 123; + mockUIState.backgroundTaskHeight = 5; mockUIState.streamingState = StreamingState.Responding; const { lastFrame, unmount } = await render(); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 8370b78085..aaa9e04632 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -15,7 +15,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useFlickerDetector } from '../hooks/useFlickerDetector.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { CopyModeWarning } from '../components/CopyModeWarning.js'; -import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js'; +import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js'; import { StreamingState } from '../types.js'; export const DefaultAppLayout: React.FC = () => { @@ -39,21 +39,21 @@ export const DefaultAppLayout: React.FC = () => { > - {uiState.isBackgroundShellVisible && - uiState.backgroundShells.size > 0 && - uiState.activeBackgroundShellPid && - uiState.backgroundShellHeight > 0 && + {uiState.isBackgroundTaskVisible && + uiState.backgroundTasks.size > 0 && + uiState.activeBackgroundTaskPid && + uiState.backgroundTaskHeight > 0 && uiState.streamingState !== StreamingState.WaitingForConfirmation && ( - - + )} diff --git a/packages/cli/src/ui/layouts/__snapshots__/DefaultAppLayout.test.tsx.snap b/packages/cli/src/ui/layouts/__snapshots__/DefaultAppLayout.test.tsx.snap index 30515981af..48cb662534 100644 --- a/packages/cli/src/ui/layouts/__snapshots__/DefaultAppLayout.test.tsx.snap +++ b/packages/cli/src/ui/layouts/__snapshots__/DefaultAppLayout.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = ` +exports[` > hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation 1`] = ` "MainContent Notifications CopyModeWarning @@ -9,9 +9,9 @@ ExitWarning " `; -exports[` > renders BackgroundShellDisplay when shells exist and active 1`] = ` +exports[` > renders BackgroundTaskDisplay when shells exist and active 1`] = ` "MainContent -BackgroundShellDisplay +BackgroundTaskDisplay @@ -23,9 +23,9 @@ ExitWarning " `; -exports[` > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = ` +exports[` > shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation 1`] = ` "MainContent -BackgroundShellDisplay +BackgroundTaskDisplay diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 00efd3f7fc..3aff41d2de 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -41,7 +41,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] { addConfirmUpdateExtensionRequest: (_request) => {}, setConfirmationRequest: (_request) => {}, removeComponent: () => {}, - toggleBackgroundShell: () => {}, + toggleBackgroundTasks: () => {}, toggleShortcutsHelp: () => {}, }; } diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 1710765723..06718afed4 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -118,6 +118,7 @@ export interface IndividualToolCallDisplay { callId: string; parentCallId?: string; name: string; + args?: Record; description: string; resultDisplay: ToolResultDisplay | undefined; status: CoreToolCallStatus; diff --git a/packages/cli/src/ui/utils/borderStyles.ts b/packages/cli/src/ui/utils/borderStyles.ts index 7b7b767734..7b7dba5fc5 100644 --- a/packages/cli/src/ui/utils/borderStyles.ts +++ b/packages/cli/src/ui/utils/borderStyles.ts @@ -13,7 +13,7 @@ import type { HistoryItemToolGroup, IndividualToolCallDisplay, } from '../types.js'; -import type { BackgroundShell } from '../hooks/shellReducer.js'; +import type { BackgroundTask } from '../hooks/shellReducer.js'; import type { TrackedToolCall } from '../hooks/useToolScheduler.js'; function isTrackedToolCall( @@ -33,7 +33,7 @@ export function getToolGroupBorderAppearance( activeShellPtyId: number | null | undefined, embeddedShellFocused: boolean | undefined, allPendingItems: HistoryItemWithoutId[] = [], - backgroundShells: Map = new Map(), + backgroundTasks: Map = new Map(), ): { borderColor: string; borderDimColor: boolean } { if (item.type !== 'tool_group') { return { borderColor: '', borderDimColor: false }; @@ -100,7 +100,7 @@ export function getToolGroupBorderAppearance( // If we have an active PTY that isn't a background shell, then the current // pending batch is definitely a shell batch. const isCurrentlyInShellTurn = - !!activeShellPtyId && !backgroundShells.has(activeShellPtyId); + !!activeShellPtyId && !backgroundTasks.has(activeShellPtyId); const isShell = isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn); diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts index 14cef88a54..8118ccdde9 100644 --- a/packages/cli/src/utils/activityLogger.ts +++ b/packages/cli/src/utils/activityLogger.ts @@ -803,7 +803,26 @@ function setupNetworkLogging( // Flush buffered logs flushBuffer(); break; - + case 'trigger-debugger': { + import('node:inspector') + .then((inspector) => { + inspector.open(); + debugLogger.log( + 'Node debugger attached. Open chrome://inspect in Chrome to start debugging.', + ); + return import('./events.js'); + }) + .then(({ appEvents, AppEvent, TransientMessageType }) => { + appEvents.emit(AppEvent.TransientMessage, { + message: 'Debugger attached from DevTools.', + type: TransientMessageType.Hint, + }); + }) + .catch((err) => + debugLogger.debug('Failed to trigger debugger:', err), + ); + break; + } case 'ping': sendMessage({ type: 'pong', timestamp: Date.now() }); break; diff --git a/packages/cli/src/utils/commands.test.ts b/packages/cli/src/utils/commands.test.ts index 85af0c624b..fa2623f1e8 100644 --- a/packages/cli/src/utils/commands.test.ts +++ b/packages/cli/src/utils/commands.test.ts @@ -137,4 +137,105 @@ describe('parseSlashCommand', () => { expect(result.args).toBe(''); expect(result.canonicalPath).toEqual([]); }); + + describe('backtracking', () => { + const backtrackingCommands: readonly SlashCommand[] = [ + { + name: 'parent', + description: 'Parent command', + kind: CommandKind.BUILT_IN, + action: async () => {}, + subCommands: [ + { + name: 'notakes', + description: 'Subcommand that does not take arguments', + kind: CommandKind.BUILT_IN, + takesArgs: false, + action: async () => {}, + }, + { + name: 'takes', + description: 'Subcommand that takes arguments', + kind: CommandKind.BUILT_IN, + takesArgs: true, + action: async () => {}, + }, + ], + }, + ]; + + it('should backtrack to parent if subcommand has takesArgs: false and args are provided', () => { + const result = parseSlashCommand( + '/parent notakes some prompt', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('parent'); + expect(result.args).toBe('notakes some prompt'); + expect(result.canonicalPath).toEqual(['parent']); + }); + + it('should NOT backtrack if subcommand has takesArgs: false but NO args are provided', () => { + const result = parseSlashCommand('/parent notakes', backtrackingCommands); + expect(result.commandToExecute?.name).toBe('notakes'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['parent', 'notakes']); + }); + + it('should NOT backtrack if subcommand has takesArgs: true and args are provided', () => { + const result = parseSlashCommand( + '/parent takes some args', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('takes'); + expect(result.args).toBe('some args'); + expect(result.canonicalPath).toEqual(['parent', 'takes']); + }); + + it('should NOT backtrack if parent has NO action', () => { + const noActionCommands: readonly SlashCommand[] = [ + { + name: 'parent', + description: 'Parent without action', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'notakes', + description: 'Subcommand without args', + kind: CommandKind.BUILT_IN, + takesArgs: false, + action: async () => {}, + }, + ], + }, + ]; + const result = parseSlashCommand( + '/parent notakes some args', + noActionCommands, + ); + // It stays with the subcommand because parent can't handle it + expect(result.commandToExecute?.name).toBe('notakes'); + expect(result.args).toBe('some args'); + expect(result.canonicalPath).toEqual(['parent', 'notakes']); + }); + + it('should NOT backtrack if subcommand is NOT marked with takesArgs: false', () => { + const result = parseSlashCommand( + '/parent takes some args', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('takes'); + expect(result.args).toBe('some args'); + expect(result.canonicalPath).toEqual(['parent', 'takes']); + }); + + it('should backtrack if subcommand has takesArgs: false and args are provided (like /plan copy foo)', () => { + const result = parseSlashCommand( + '/parent notakes some prompt', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('parent'); + expect(result.args).toBe('notakes some prompt'); + expect(result.canonicalPath).toEqual(['parent']); + }); + }); }); diff --git a/packages/cli/src/utils/commands.ts b/packages/cli/src/utils/commands.ts index c96c8c6ef7..a96537aadf 100644 --- a/packages/cli/src/utils/commands.ts +++ b/packages/cli/src/utils/commands.ts @@ -33,6 +33,7 @@ export const parseSlashCommand = ( let commandToExecute: SlashCommand | undefined; let pathIndex = 0; const canonicalPath: string[] = []; + let parentCommand: SlashCommand | undefined; for (const part of commandPath) { // TODO: For better performance and architectural clarity, this two-pass @@ -52,6 +53,7 @@ export const parseSlashCommand = ( } if (foundCommand) { + parentCommand = commandToExecute; commandToExecute = foundCommand; canonicalPath.push(foundCommand.name); pathIndex++; @@ -67,5 +69,21 @@ export const parseSlashCommand = ( const args = parts.slice(pathIndex).join(' '); + // Backtrack if the matched (sub)command doesn't take arguments but some were provided, + // AND the parent command is capable of handling them. + if ( + commandToExecute && + commandToExecute.takesArgs === false && + args.length > 0 && + parentCommand && + parentCommand.action + ) { + return { + commandToExecute: parentCommand, + args: parts.slice(pathIndex - 1).join(' '), + canonicalPath: canonicalPath.slice(0, -1), + }; + } + return { commandToExecute, args, canonicalPath }; }; diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts index f2e1bd4586..1f95bfbfeb 100644 --- a/packages/cli/test-setup.ts +++ b/packages/cli/test-setup.ts @@ -8,6 +8,10 @@ import { vi, beforeEach, afterEach } from 'vitest'; import { format } from 'node:util'; import { coreEvents } from '@google/gemini-cli-core'; import { themeManager } from './src/ui/themes/theme-manager.js'; +import { mockInkSpinner } from './src/test-utils/mockSpinner.js'; + +// Globally mock ink-spinner to prevent non-deterministic snapshot/act flakes. +mockInkSpinner(); // Unset CI environment variable so that ink renders dynamically as it does in a real terminal if (process.env.CI !== undefined) { diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 0ae523dc94..cb4b645ab3 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -119,7 +119,8 @@ async function initOauthClient( credentials && typeof credentials === 'object' && 'type' in credentials && - credentials.type === 'external_account_authorized_user' + (credentials.type === 'external_account_authorized_user' || + credentials.type === 'service_account') ) { const auth = new GoogleAuth({ scopes: OAUTH_SCOPE, @@ -130,7 +131,7 @@ async function initOauthClient( }); const token = await byoidClient.getAccessToken(); if (token) { - debugLogger.debug('Created BYOID auth client.'); + debugLogger.debug(`Created ${credentials.type} auth client.`); return byoidClient; } } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 99688eead5..14ac3b7cf1 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -92,6 +92,7 @@ vi.mock('../tools/tool-registry', () => { ToolRegistryMock.prototype.sortTools = vi.fn(); ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed ToolRegistryMock.prototype.getTool = vi.fn(); + ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []); ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []); return { ToolRegistry: ToolRegistryMock }; }); @@ -1563,6 +1564,17 @@ describe('Server Config (config.ts)', () => { expect(config.getSandboxNetworkAccess()).toBe(false); }); }); + + it('should have independent TopicState across instances', () => { + const config1 = new Config(baseParams); + const config2 = new Config(baseParams); + + config1.topicState.setTopic('Topic 1'); + config2.topicState.setTopic('Topic 2'); + + expect(config1.topicState.getTopic()).toBe('Topic 1'); + expect(config2.topicState.getTopic()).toBe('Topic 2'); + }); }); describe('GemmaModelRouterSettings', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7701cf245c..1ae7ee7ddd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -36,6 +36,8 @@ import { WebFetchTool } from '../tools/web-fetch.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { WebSearchTool } from '../tools/web-search.js'; import { AskUserTool } from '../tools/ask-user.js'; +import { UpdateTopicTool } from '../tools/topicTool.js'; +import { TopicState } from './topicState.js'; import { ExitPlanModeTool } from '../tools/exit-plan-mode.js'; import { EnterPlanModeTool } from '../tools/enter-plan-mode.js'; import { GeminiClient } from '../core/client.js'; @@ -641,6 +643,7 @@ export interface ConfigParameters { useAlternateBuffer?: boolean; useRipgrep?: boolean; enableInteractiveShell?: boolean; + shellBackgroundCompletionBehavior?: string; skipNextSpeakerCheck?: boolean; shellExecutionConfig?: ShellExecutionConfig; extensionManagement?: boolean; @@ -682,6 +685,11 @@ export interface ConfigParameters { adminSkillsEnabled?: boolean; experimentalJitContext?: boolean; experimentalMemoryManager?: boolean; + experimentalAgentHistoryTruncation?: boolean; + experimentalAgentHistoryTruncationThreshold?: number; + experimentalAgentHistoryRetainedMessages?: number; + experimentalAgentHistorySummarization?: boolean; + memoryBoundaryMarkers?: string[]; topicUpdateNarration?: boolean; toolOutputMasking?: Partial; disableLLMCorrection?: boolean; @@ -725,6 +733,7 @@ export class Config implements McpContext, AgentLoopContext { private clientVersion: string; private fileSystemService: FileSystemService; private trackerService?: TrackerService; + readonly topicState = new TopicState(); private contentGeneratorConfig!: ContentGeneratorConfig; private contentGenerator!: ContentGenerator; readonly modelConfigService: ModelConfigService; @@ -840,6 +849,10 @@ export class Config implements McpContext, AgentLoopContext { private readonly directWebFetch: boolean; private readonly useRipgrep: boolean; private readonly enableInteractiveShell: boolean; + private readonly shellBackgroundCompletionBehavior: + | 'inject' + | 'notify' + | 'silent'; private readonly skipNextSpeakerCheck: boolean; private readonly useBackgroundColor: boolean; private readonly useAlternateBuffer: boolean; @@ -912,6 +925,11 @@ export class Config implements McpContext, AgentLoopContext { private readonly channels: string[]; private readonly experimentalJitContext: boolean; private readonly experimentalMemoryManager: boolean; + private readonly experimentalAgentHistoryTruncation: boolean; + private readonly experimentalAgentHistoryTruncationThreshold: number; + private readonly experimentalAgentHistoryRetainedMessages: number; + private readonly experimentalAgentHistorySummarization: boolean; + private readonly memoryBoundaryMarkers: readonly string[]; private readonly topicUpdateNarration: boolean; private readonly disableLLMCorrection: boolean; private readonly planEnabled: boolean; @@ -1121,6 +1139,15 @@ export class Config implements McpContext, AgentLoopContext { this.experimentalJitContext = params.experimentalJitContext ?? true; this.experimentalMemoryManager = params.experimentalMemoryManager ?? false; + this.experimentalAgentHistoryTruncation = + params.experimentalAgentHistoryTruncation ?? false; + this.experimentalAgentHistoryTruncationThreshold = + params.experimentalAgentHistoryTruncationThreshold ?? 30; + this.experimentalAgentHistoryRetainedMessages = + params.experimentalAgentHistoryRetainedMessages ?? 15; + this.experimentalAgentHistorySummarization = + params.experimentalAgentHistorySummarization ?? false; + this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git']; this.topicUpdateNarration = params.topicUpdateNarration ?? false; this.modelSteering = params.modelSteering ?? false; this.injectionService = new InjectionService(() => @@ -1165,6 +1192,14 @@ export class Config implements McpContext, AgentLoopContext { this.useBackgroundColor = params.useBackgroundColor ?? true; this.useAlternateBuffer = params.useAlternateBuffer ?? false; this.enableInteractiveShell = params.enableInteractiveShell ?? false; + + const requestedBehavior = params.shellBackgroundCompletionBehavior; + if (requestedBehavior === 'inject' || requestedBehavior === 'notify') { + this.shellBackgroundCompletionBehavior = requestedBehavior; + } else { + this.shellBackgroundCompletionBehavior = 'silent'; + } + this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.shellExecutionConfig = { terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80, @@ -1174,6 +1209,7 @@ export class Config implements McpContext, AgentLoopContext { sanitizationConfig: this.sanitizationConfig, sandboxManager: this._sandboxManager, sandboxConfig: this.sandbox, + backgroundCompletionBehavior: this.shellBackgroundCompletionBehavior, }; this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? @@ -2322,10 +2358,30 @@ export class Config implements McpContext, AgentLoopContext { return this.experimentalJitContext; } + getMemoryBoundaryMarkers(): readonly string[] { + return this.memoryBoundaryMarkers; + } + isMemoryManagerEnabled(): boolean { return this.experimentalMemoryManager; } + isExperimentalAgentHistoryTruncationEnabled(): boolean { + return this.experimentalAgentHistoryTruncation; + } + + getExperimentalAgentHistoryTruncationThreshold(): number { + return this.experimentalAgentHistoryTruncationThreshold; + } + + getExperimentalAgentHistoryRetainedMessages(): number { + return this.experimentalAgentHistoryRetainedMessages; + } + + isExperimentalAgentHistorySummarizationEnabled(): boolean { + return this.experimentalAgentHistorySummarization; + } + isTopicUpdateNarrationEnabled(): boolean { return this.topicUpdateNarration; } @@ -3153,6 +3209,10 @@ export class Config implements McpContext, AgentLoopContext { return this.enableInteractiveShell; } + getShellBackgroundCompletionBehavior(): 'inject' | 'notify' | 'silent' { + return this.shellBackgroundCompletionBehavior; + } + getSkipNextSpeakerCheck(): boolean { return this.skipNextSpeakerCheck; } @@ -3343,6 +3403,10 @@ export class Config implements McpContext, AgentLoopContext { } }; + maybeRegister(UpdateTopicTool, () => + registry.registerTool(new UpdateTopicTool(this, this.messageBus)), + ); + maybeRegister(LSTool, () => registry.registerTool(new LSTool(this, this.messageBus)), ); diff --git a/packages/core/src/config/defaultModelConfigs.ts b/packages/core/src/config/defaultModelConfigs.ts index 62357aa733..84c2478a5f 100644 --- a/packages/core/src/config/defaultModelConfigs.ts +++ b/packages/core/src/config/defaultModelConfigs.ts @@ -243,6 +243,11 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { model: 'gemini-3-pro-preview', }, }, + 'agent-history-provider-summarizer': { + modelConfig: { + model: 'gemini-3-flash-preview', + }, + }, }, overrides: [ { diff --git a/packages/core/src/config/topicState.ts b/packages/core/src/config/topicState.ts new file mode 100644 index 0000000000..ee9a50af4f --- /dev/null +++ b/packages/core/src/config/topicState.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Manages the current active topic title and tactical intent for a session. + * Hosted within the Config instance for session-scoping. + */ +export class TopicState { + private activeTopicTitle?: string; + private activeIntent?: string; + + /** + * Sanitizes and sets the topic title and/or intent. + * @returns true if the input was valid and set, false otherwise. + */ + setTopic(title?: string, intent?: string): boolean { + const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' '); + const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' '); + + if (!sanitizedTitle && !sanitizedIntent) return false; + + if (sanitizedTitle) { + this.activeTopicTitle = sanitizedTitle; + } + + if (sanitizedIntent) { + this.activeIntent = sanitizedIntent; + } + + return true; + } + + getTopic(): string | undefined { + return this.activeTopicTitle; + } + + getIntent(): string | undefined { + return this.activeIntent; + } + + reset(): void { + this.activeTopicTitle = undefined; + this.activeIntent = undefined; + } +} diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e93eedf055..e741092ce9 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -279,6 +279,16 @@ describe('Gemini Client (client.ts)', () => { getActiveModel: vi.fn().mockReturnValue('test-model'), setActiveModel: vi.fn(), resetTurn: vi.fn(), + isExperimentalAgentHistoryTruncationEnabled: vi + .fn() + .mockReturnValue(false), + getExperimentalAgentHistoryTruncationThreshold: vi + .fn() + .mockReturnValue(30), + getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(15), + isExperimentalAgentHistorySummarizationEnabled: vi + .fn() + .mockReturnValue(false), getModelAvailabilityService: vi .fn() .mockReturnValue(createAvailabilityServiceMock()), @@ -704,6 +714,43 @@ describe('Gemini Client (client.ts)', () => { }); describe('sendMessageStream', () => { + it('calls AgentHistoryProvider.manageHistory when history truncation is enabled', async () => { + // Arrange + mockConfig.isExperimentalAgentHistoryTruncationEnabled = vi + .fn() + .mockReturnValue(true); + const manageHistorySpy = vi + .spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).agentHistoryProvider, + 'manageHistory', + ) + .mockResolvedValue([ + { role: 'user', parts: [{ text: 'preserved message' }] }, + ]); + + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'Hello' }; + })(), + ); + + // Act + const stream = client.sendMessageStream( + [{ text: 'Hi' }], + new AbortController().signal, + 'prompt-id-1', + ); + + await fromAsync(stream); + + // Assert + expect(manageHistorySpy).toHaveBeenCalledWith( + expect.any(Array), + expect.any(AbortSignal), + ); + }); + it('emits a compression event when the context was automatically compressed', async () => { // Arrange mockTurnRunFn.mockReturnValue( diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 8922c977f2..42adab3a05 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -44,6 +44,7 @@ import type { import type { ContentGenerator } from './contentGenerator.js'; import { LoopDetectionService } from '../services/loopDetectionService.js'; import { ChatCompressionService } from '../services/chatCompressionService.js'; +import { AgentHistoryProvider } from '../services/agentHistoryProvider.js'; import { ideContextStore } from '../ide/ideContext.js'; import { logContentRetryFailure, @@ -98,6 +99,7 @@ export class GeminiClient { private readonly loopDetector: LoopDetectionService; private readonly compressionService: ChatCompressionService; + private readonly agentHistoryProvider: AgentHistoryProvider; private readonly toolOutputMaskingService: ToolOutputMaskingService; private lastPromptId: string; private currentSequenceModel: string | null = null; @@ -113,6 +115,12 @@ export class GeminiClient { constructor(private readonly context: AgentLoopContext) { this.loopDetector = new LoopDetectionService(this.config); this.compressionService = new ChatCompressionService(); + this.agentHistoryProvider = new AgentHistoryProvider(this.config, { + truncationThreshold: + this.config.getExperimentalAgentHistoryTruncationThreshold(), + retainedMessages: + this.config.getExperimentalAgentHistoryRetainedMessages(), + }); this.toolOutputMaskingService = new ToolOutputMaskingService(); this.lastPromptId = this.config.getSessionId(); @@ -613,10 +621,20 @@ export class GeminiClient { // Check for context window overflow const modelForLimitCheck = this._getActiveModelForCurrentTurn(); - const compressed = await this.tryCompressChat(prompt_id, false, signal); + if (this.config.isExperimentalAgentHistoryTruncationEnabled()) { + const newHistory = await this.agentHistoryProvider.manageHistory( + this.getHistory(), + signal, + ); + if (newHistory.length !== this.getHistory().length) { + this.getChat().setHistory(newHistory); + } + } else { + const compressed = await this.tryCompressChat(prompt_id, false, signal); - if (compressed.compressionStatus === CompressionStatus.COMPRESSED) { - yield { type: GeminiEventType.ChatCompressed, value: compressed }; + if (compressed.compressionStatus === CompressionStatus.COMPRESSED) { + yield { type: GeminiEventType.ChatCompressed, value: compressed }; + } } const remainingTokenCount = diff --git a/packages/core/src/core/prompts-substitution.test.ts b/packages/core/src/core/prompts-substitution.test.ts index 9bad6a066d..64eb8d939f 100644 --- a/packages/core/src/core/prompts-substitution.test.ts +++ b/packages/core/src/core/prompts-substitution.test.ts @@ -59,6 +59,11 @@ describe('Core System Prompt Substitution', () => { getSkills: vi.fn().mockReturnValue([]), }), getApprovedPlanPath: vi.fn().mockReturnValue(undefined), + isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false), + isTrackerEnabled: vi.fn().mockReturnValue(false), + isModelSteeringEnabled: vi.fn().mockReturnValue(false), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), + getGemini31LaunchedSync: vi.fn().mockReturnValue(true), } as unknown as Config; }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b8e00a9140..4728eecb5d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -165,12 +165,6 @@ export * from './services/executionLifecycleService.js'; // Export Injection Service export * from './config/injectionService.js'; -// Export Execution Lifecycle Service -export * from './services/executionLifecycleService.js'; - -// Export Injection Service -export * from './config/injectionService.js'; - // Export base tool definitions export * from './tools/tools.js'; export * from './tools/tool-error.js'; diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index b144f3c679..91b3db666a 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -113,6 +113,13 @@ decision = "allow" priority = 70 modes = ["plan"] +# Topic grouping tool is innocuous and used for UI organization. +[[rule]] +toolName = "update_topic" +decision = "allow" +priority = 70 +modes = ["plan"] + [[rule]] toolName = ["ask_user", "save_memory"] decision = "ask_user" diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index 8435e49d0b..66aa4c33ce 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -55,4 +55,10 @@ priority = 50 [[rule]] toolName = ["codebase_investigator", "cli_help", "get_internal_docs"] decision = "allow" +priority = 50 + +# Topic grouping tool is innocuous and used for UI organization. +[[rule]] +toolName = "update_topic" +decision = "allow" priority = 50 \ No newline at end of file diff --git a/packages/core/src/policy/policies/sandbox-default.toml b/packages/core/src/policy/policies/sandbox-default.toml index 0d8467d596..933d85cf9e 100644 --- a/packages/core/src/policy/policies/sandbox-default.toml +++ b/packages/core/src/policy/policies/sandbox-default.toml @@ -7,13 +7,14 @@ allowOverrides = false [modes.default] network = false readonly = true -approvedTools = [] +approvedTools = ['cat', 'ls', 'grep', 'head', 'tail', 'less', 'Get-Content', 'dir', 'type', 'findstr', 'Get-ChildItem', 'echo'] allowOverrides = true [modes.accepting_edits] network = false readonly = false -approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo'] +approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo', 'Add-Content', 'Set-Content'] allowOverrides = true [commands] + diff --git a/packages/core/src/policy/topic-policy.test.ts b/packages/core/src/policy/topic-policy.test.ts new file mode 100644 index 0000000000..91450af056 --- /dev/null +++ b/packages/core/src/policy/topic-policy.test.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import * as path from 'node:path'; +import { loadPoliciesFromToml } from './toml-loader.js'; +import { PolicyEngine } from './policy-engine.js'; +import { ApprovalMode, PolicyDecision } from './types.js'; +import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js'; + +describe('Topic Tool Policy', () => { + async function loadDefaultPolicies() { + // Path relative to packages/core root + const policiesDir = path.resolve(process.cwd(), 'src/policy/policies'); + const getPolicyTier = () => 1; // Default tier + const result = await loadPoliciesFromToml([policiesDir], getPolicyTier); + return result.rules; + } + + it('should allow update_topic in DEFAULT mode', async () => { + const rules = await loadDefaultPolicies(); + const engine = new PolicyEngine({ + rules, + approvalMode: ApprovalMode.DEFAULT, + }); + + const result = await engine.check( + { name: UPDATE_TOPIC_TOOL_NAME }, + undefined, + ); + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should allow update_topic in PLAN mode', async () => { + const rules = await loadDefaultPolicies(); + const engine = new PolicyEngine({ + rules, + approvalMode: ApprovalMode.PLAN, + }); + + const result = await engine.check( + { name: UPDATE_TOPIC_TOOL_NAME }, + undefined, + ); + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should allow update_topic in YOLO mode', async () => { + const rules = await loadDefaultPolicies(); + const engine = new PolicyEngine({ + rules, + approvalMode: ApprovalMode.YOLO, + }); + + const result = await engine.check( + { name: UPDATE_TOPIC_TOOL_NAME }, + undefined, + ); + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); +}); diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index d749a41058..74cc83ae3a 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -15,6 +15,8 @@ import { PREVIEW_GEMINI_MODEL } from '../config/models.js'; import { ApprovalMode } from '../policy/types.js'; import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { MockTool } from '../test-utils/mock-tool.js'; +import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js'; +import { TopicState } from '../config/topicState.js'; import type { CallableTool } from '@google/genai'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; @@ -53,6 +55,7 @@ describe('PromptProvider', () => { ).getToolRegistry?.() as unknown as ToolRegistry; }, getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + topicState: new TopicState(), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getSandboxEnabled: vi.fn().mockReturnValue(false), storage: { @@ -73,6 +76,8 @@ describe('PromptProvider', () => { getApprovedPlanPath: vi.fn().mockReturnValue(undefined), getApprovalMode: vi.fn(), isTrackerEnabled: vi.fn().mockReturnValue(false), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), + getGemini31LaunchedSync: vi.fn().mockReturnValue(true), } as unknown as Config; }); @@ -234,4 +239,67 @@ describe('PromptProvider', () => { expect(prompt).not.toContain('### APPROVED PLAN PRESERVATION'); }); }); + + describe('Topic & Update Narration', () => { + beforeEach(() => { + mockConfig.topicState.reset(); + vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true); + (mockConfig.getToolRegistry as ReturnType).mockReturnValue({ + getAllToolNames: vi.fn().mockReturnValue([UPDATE_TOPIC_TOOL_NAME]), + getAllTools: vi.fn().mockReturnValue([ + new MockTool({ + name: UPDATE_TOPIC_TOOL_NAME, + displayName: 'Topic', + }), + ]), + }); + vi.mocked(mockConfig.getHasAccessToPreviewModel).mockReturnValue(true); + vi.mocked(mockConfig.getGemini31LaunchedSync).mockReturnValue(true); + }); + + it('should include active topic context when narration is enabled', () => { + mockConfig.topicState.setTopic('Active Chapter'); + const provider = new PromptProvider(); + const prompt = provider.getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain('[Active Topic: Active Chapter]'); + }); + + it('should NOT include active topic context when narration is disabled', () => { + vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue( + false, + ); + mockConfig.topicState.setTopic('Active Chapter'); + const provider = new PromptProvider(); + const prompt = provider.getCoreSystemPrompt(mockConfig); + + expect(prompt).not.toContain('[Active Topic: Active Chapter]'); + }); + + it('should filter out update_topic tool when narration is disabled', () => { + vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN); + vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue( + false, + ); + // Simulate registry behavior where it filters out update_topic + vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue( + [], + ); + vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue([]); + + const provider = new PromptProvider(); + + const prompt = provider.getCoreSystemPrompt(mockConfig); + expect(prompt).not.toContain(UPDATE_TOPIC_TOOL_NAME); + }); + + it('should NOT filter out update_topic tool when narration is enabled', () => { + vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN); + vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true); + const provider = new PromptProvider(); + const prompt = provider.getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain(`\`${UPDATE_TOPIC_TOOL_NAME}\``); + }); + }); }); diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index d97e636993..3425809583 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -57,6 +57,7 @@ export class PromptProvider { const skills = context.config.getSkillManager().getSkills(); const toolNames = context.toolRegistry.getAllToolNames(); const enabledToolNames = new Set(toolNames); + const approvedPlanPath = context.config.getApprovedPlanPath(); const desiredModel = resolveModel( @@ -71,7 +72,6 @@ export class PromptProvider { const activeSnippets = isModernModel ? snippets : legacySnippets; const contextFilenames = getAllGeminiMdFilenames(); - // --- Context Gathering --- let planModeToolsList = ''; if (isPlanMode) { const allTools = context.toolRegistry.getAllTools(); @@ -232,7 +232,18 @@ export class PromptProvider { ); // Sanitize erratic newlines from composition - const sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n'); + let sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n'); + + // Context Reinjection (Active Topic) + if (context.config.isTopicUpdateNarrationEnabled()) { + const activeTopic = context.config.topicState.getTopic(); + if (activeTopic) { + const sanitizedTopic = activeTopic + .replace(/\n/g, ' ') + .replace(/\]/g, ''); + sanitizedPrompt += `\n\n[Active Topic: ${sanitizedTopic}]`; + } + } // Write back to file if requested this.maybeWriteSystemMd( diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 27c1fa60a1..a16ef59461 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -10,6 +10,9 @@ import { EDIT_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, + UPDATE_TOPIC_TOOL_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, GLOB_TOOL_NAME, GREP_TOOL_NAME, MEMORY_TOOL_NAME, @@ -239,7 +242,9 @@ Use the following guidelines to optimize your search and read patterns. ? mandateTopicUpdateModel() : mandateExplainBeforeActing() } -- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}${mandateContinueWork(options.interactive)} +- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance( + options.hasSkills, + )}${mandateContinueWork(options.interactive)} `.trim(); } @@ -361,7 +366,7 @@ export function renderOperationalGuidelines( - **Role:** A senior software engineer and collaborative peer programmer. - **High-Signal Output:** Focus exclusively on **intent** and **technical rationale**. Avoid conversational filler, apologies, and ${ options.topicUpdateNarration - ? 'per-tool explanations.' + ? 'unnecessary per-tool explanations.' : 'mechanical tool-use narration (e.g., "I will now call...").' } - **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment. @@ -614,46 +619,19 @@ function mandateConfirm(interactive: boolean): string { function mandateTopicUpdateModel(): string { return ` -- **Protocol: Topic Model** - You are an agentic system. You must maintain a visible state log that tracks broad logical phases using a specific header format. +## Topic Updates +As you work, the user follows along by reading topic updates that you publish with ${UPDATE_TOPIC_TOOL_NAME}. Keep them informed by doing the following: -- **1. Topic Initialization & Persistence:** - - **The Trigger:** You MUST issue a \`Topic: : \` header ONLY when beginning a task or when the broad logical nature of the task changes (e.g., transitioning from research to implementation). - - **The Format:** Use exactly \`Topic: : \` (e.g., \`Topic: : Researching Agent Skills in the repo\`). - - **Persistence:** Once a Topic is declared, do NOT repeat it for subsequent tool calls or in subsequent messages within that same phase. - - **Start of Task:** Your very first tool execution must be preceded by a Topic header. +- Always call ${UPDATE_TOPIC_TOOL_NAME} in your first and last turn. The final turn should always recap what was done. +- Each topic update should give a concise description of what you are doing for the next few turns in the \`${TOPIC_PARAM_SUMMARY}\` parameter. +- Provide topic updates whenever you change "topics". A topic is typically a discrete subgoal and will be every 3 to 10 turns. Do not use ${UPDATE_TOPIC_TOOL_NAME} on every turn. +- The typical user message should call ${UPDATE_TOPIC_TOOL_NAME} 3 or more times. Each corresponds to a distinct phase of the task, such as "Researching X", "Researching Y", "Implementing Z with X", and "Testing Z". +- Remember to call ${UPDATE_TOPIC_TOOL_NAME} when you experience an unexpected event (e.g., a test failure, compilation error, environment issue, or unexpected learning) that requires a strategic detour. +- **Examples:** + - \`update_topic(${TOPIC_PARAM_TITLE}="Researching Parser", ${TOPIC_PARAM_SUMMARY}="I am starting an investigation into the parser timeout bug. My goal is to first understand the current test coverage and then attempt to reproduce the failure. This phase will focus on identifying the bottleneck in the main loop before we move to implementation.")\` + - \`update_topic(${TOPIC_PARAM_TITLE}="Implementing Buffer Fix", ${TOPIC_PARAM_SUMMARY}="I have completed the research phase and identified a race condition in the tokenizer's buffer management. I am now transitioning to implementation. This new chapter will focus on refactoring the buffer logic to handle async chunks safely, followed by unit testing the fix.")\` -- **2. Tool Execution Protocol (Zero-Noise):** - - **No Per-Tool Headers:** It is a violation of protocol to print "Topic:" before every tool call. - - **Silent Mode:** No conversational filler, no "I will now...", and no summaries between tools. - - Only the Topic header at the start of a broad phase is permitted to break the silence. Everything in between must be silent. - -- **3. Thinking Protocol:** - - Use internal thought blocks to keep track of what tools you have called, plan your next steps, and reason about the task. - - Without reasoning and tracking in thought blocks, you may lose context. - - Always use the required syntax for thought blocks to ensure they remain hidden from the user interface. - -- **4. Completion:** - - Only when the entire task is finalized do you provide a **Final Summary**. - -**IMPORTANT: Topic Headers vs. Thoughts** -The \`Topic: : \` header must **NOT** be placed inside a thought block. It must be standard text output so that it is properly rendered and displayed in the UI. - -**Correct State Log Example:** -\`\`\` -Topic: : Researching Agent Skills in the repo - - - - -Topic: : Implementing the skill-creator logic - - - -The task is complete. [Final Summary] -\`\`\` - -- **Constraint Enforcement:** If you repeat a "Topic:" line without a fundamental shift in work, or if you provide a Topic for every tool call, you have failed the system integrity protocol.`; +`; } function mandateExplainBeforeActing(): string { diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index 5543a9024b..2167e28740 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -187,7 +187,7 @@ export class LinuxSandboxManager implements SandboxManager { : false; const workspaceWrite = !isReadonlyMode || isApproved; const networkAccess = - this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; + this.options.modeConfig?.network || req.policy?.networkAccess || false; const persistentPermissions = allowOverrides ? this.options.policyManager?.getCommandPermissions(commandName) diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index 0c147ea03b..212fafed83 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -78,7 +78,7 @@ export class MacOsSandboxManager implements SandboxManager { const workspaceWrite = !isReadonlyMode || isApproved; const defaultNetwork = - this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; + this.options.modeConfig?.network || req.policy?.networkAccess || false; // Fetch persistent approvals for this command const commandName = await getCommandName(req.command, req.args); diff --git a/packages/core/src/sandbox/windows/GeminiSandbox.cs b/packages/core/src/sandbox/windows/GeminiSandbox.cs index eff5ec703a..acc7701e43 100644 --- a/packages/core/src/sandbox/windows/GeminiSandbox.cs +++ b/packages/core/src/sandbox/windows/GeminiSandbox.cs @@ -58,6 +58,13 @@ public class GeminiSandbox { public ulong OtherTransferCount; } + [StructLayout(LayoutKind.Sequential)] + struct JOBOBJECT_NET_RATE_CONTROL_INFORMATION { + public ulong MaxBandwidth; + public uint ControlFlags; + public byte DscpTag; + } + [DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); @@ -70,6 +77,9 @@ public class GeminiSandbox { [DllImport("advapi32.dll", SetLastError = true)] static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle); + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool DuplicateTokenEx(IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes, uint ImpersonationLevel, uint TokenType, out IntPtr phNewToken); + [DllImport("advapi32.dll", SetLastError = true)] static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle); @@ -143,6 +153,8 @@ public class GeminiSandbox { private const int TokenIntegrityLevel = 25; private const uint SE_GROUP_INTEGRITY = 0x00000020; + private const uint TOKEN_ALL_ACCESS = 0xF01FF; + private const uint DISABLE_MAX_PRIVILEGE = 0x1; static int Main(string[] args) { if (args.Length < 3) { @@ -182,14 +194,14 @@ public class GeminiSandbox { IntPtr lowIntegritySid = IntPtr.Zero; try { - // 1. Create Restricted Token - if (!OpenProcessToken(GetCurrentProcess(), 0x0002 /* TOKEN_DUPLICATE */ | 0x0008 /* TOKEN_QUERY */ | 0x0080 /* TOKEN_ADJUST_DEFAULT */, out hToken)) { + // 1. Duplicate Primary Token + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, out hToken)) { Console.WriteLine("Error: OpenProcessToken failed (" + Marshal.GetLastWin32Error() + ")"); return 1; } - // Flags: 0x1 (DISABLE_MAX_PRIVILEGE) - if (!CreateRestrictedToken(hToken, 1, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) { + // Create a restricted token to strip administrative privileges + if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) { Console.WriteLine("Error: CreateRestrictedToken failed (" + Marshal.GetLastWin32Error() + ")"); return 1; } @@ -223,6 +235,18 @@ public class GeminiSandbox { SetInformationJobObject(hJob, 9 /* JobObjectExtendedLimitInformation */, lpJobLimits, (uint)Marshal.SizeOf(jobLimits)); Marshal.FreeHGlobal(lpJobLimits); + if (!networkAccess) { + JOBOBJECT_NET_RATE_CONTROL_INFORMATION netLimits = new JOBOBJECT_NET_RATE_CONTROL_INFORMATION(); + netLimits.MaxBandwidth = 1; + netLimits.ControlFlags = 0x1 | 0x2; // ENABLE | MAX_BANDWIDTH + netLimits.DscpTag = 0; + + IntPtr lpNetLimits = Marshal.AllocHGlobal(Marshal.SizeOf(netLimits)); + Marshal.StructureToPtr(netLimits, lpNetLimits, false); + SetInformationJobObject(hJob, 32 /* JobObjectNetRateControlInformation */, lpNetLimits, (uint)Marshal.SizeOf(netLimits)); + Marshal.FreeHGlobal(lpNetLimits); + } + // 4. Handle Internal Commands or External Process if (command == "__read") { if (argIndex + 1 >= args.Length) { diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 9fb1522000..79e9f50ebf 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -158,7 +158,7 @@ describe('WindowsSandboxManager', () => { expect(icaclsArgs).toContainEqual([ persistentPath, '/setintegritylevel', - 'Low', + '(OI)(CI)Low', ]); }); @@ -227,13 +227,13 @@ describe('WindowsSandboxManager', () => { expect(icaclsArgs).toContainEqual([ path.resolve(testCwd), '/setintegritylevel', - 'Low', + '(OI)(CI)Low', ]); expect(icaclsArgs).toContainEqual([ path.resolve(allowedPath), '/setintegritylevel', - 'Low', + '(OI)(CI)Low', ]); } finally { fs.rmSync(allowedPath, { recursive: true, force: true }); @@ -273,7 +273,7 @@ describe('WindowsSandboxManager', () => { expect(icaclsArgs).toContainEqual([ path.resolve(extraWritePath), '/setintegritylevel', - 'Low', + '(OI)(CI)Low', ]); } finally { fs.rmSync(extraWritePath, { recursive: true, force: true }); @@ -308,7 +308,7 @@ describe('WindowsSandboxManager', () => { expect(icaclsArgs).not.toContainEqual([ uncPath, '/setintegritylevel', - 'Low', + '(OI)(CI)Low', ]); }, ); @@ -343,12 +343,12 @@ describe('WindowsSandboxManager', () => { expect(icaclsArgs).toContainEqual([ longPath, '/setintegritylevel', - 'Low', + '(OI)(CI)Low', ]); expect(icaclsArgs).toContainEqual([ devicePath, '/setintegritylevel', - 'Low', + '(OI)(CI)Low', ]); }, ); diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index fcc9b7543b..16d952ea1b 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -34,6 +34,7 @@ import { isStrictlyApproved, } from './commandSafety.js'; import { verifySandboxOverrides } from '../utils/commandUtils.js'; +import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -66,8 +67,8 @@ export class WindowsSandboxManager implements SandboxManager { return isDangerousCommand(args); } - parseDenials(_result: ShellExecutionResult): ParsedSandboxDenial | undefined { - return undefined; // TODO: Implement Windows-specific denial parsing + parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { + return parseWindowsSandboxDenials(result); } /** @@ -235,6 +236,10 @@ export class WindowsSandboxManager implements SandboxManager { false, }; + const defaultNetwork = + this.options.modeConfig?.network || req.policy?.networkAccess || false; + const networkAccess = defaultNetwork || mergedAdditional.network; + // 1. Handle filesystem permissions for Low Integrity // Grant "Low Mandatory Level" write access to the workspace. // If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes @@ -250,7 +255,7 @@ export class WindowsSandboxManager implements SandboxManager { await this.grantLowIntegrityAccess(this.options.workspace); } - // Grant "Low Mandatory Level" read access to allowedPaths. + // Grant "Low Mandatory Level" read/write access to allowedPaths. const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || []; for (const allowedPath of allowedPaths) { await this.grantLowIntegrityAccess(allowedPath); @@ -341,10 +346,6 @@ export class WindowsSandboxManager implements SandboxManager { // GeminiSandbox.exe --forbidden-manifest [args...] const program = this.helperPath; - const defaultNetwork = - this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; - const networkAccess = defaultNetwork || mergedAdditional.network; - const args = [ networkAccess ? '1' : '0', req.cwd, @@ -394,7 +395,11 @@ export class WindowsSandboxManager implements SandboxManager { } try { - await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']); + await spawnAsync('icacls', [ + resolvedPath, + '/setintegritylevel', + '(OI)(CI)Low', + ]); this.allowedCache.add(resolvedPath); } catch (e) { debugLogger.log( diff --git a/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.test.ts b/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.test.ts new file mode 100644 index 0000000000..93e479a11d --- /dev/null +++ b/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; + +describe('parseWindowsSandboxDenials', () => { + it('should detect CMD "Access is denied" and extract paths', () => { + const parsed = parseWindowsSandboxDenials({ + output: 'Access is denied.\r\n', + error: new Error('Command failed: dir C:\\Windows\\System32\\config'), + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('C:\\Windows\\System32\\config'); + }); + + it('should detect PowerShell "Access to the path is denied"', () => { + const parsed = parseWindowsSandboxDenials({ + output: + "Set-Content : Access to the path 'C:\\test.txt' is denied.\r\nAt line:1 char:1\r\n", + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('C:\\test.txt'); + }); + + it('should detect Node.js EPERM on Windows', () => { + const parsed = parseWindowsSandboxDenials({ + error: { + message: + "Error: EPERM: operation not permitted, open 'D:\\project\\file.ts'", + }, + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('D:\\project\\file.ts'); + }); + + it('should detect network denial (EACCES)', () => { + const parsed = parseWindowsSandboxDenials({ + output: 'Error: listen EACCES: permission denied 0.0.0.0:3000', + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.network).toBe(true); + }); + + it('should detect native Windows error code 0x80070005', () => { + const parsed = parseWindowsSandboxDenials({ + output: 'HRESULT: 0x80070005', + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + // No path in output, but recognized as denial + }); + + it('should handle extended-length paths', () => { + const parsed = parseWindowsSandboxDenials({ + output: 'Access is denied to \\\\?\\C:\\Very\\Long\\Path\\file.txt', + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain( + '\\\\?\\C:\\Very\\Long\\Path\\file.txt', + ); + }); + + it('should detect Windows paths with forward slashes', () => { + const parsed = parseWindowsSandboxDenials({ + output: + "Error: EPERM: operation not permitted, open 'C:/project/file.ts'", + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('C:/project/file.ts'); + }); + + it('should return undefined if no denial detected', () => { + const parsed = parseWindowsSandboxDenials({ + output: + 'Directory of C:\\Users\r\n03/26/2026 11:40 AM .', + } as unknown as ShellExecutionResult); + + expect(parsed).toBeUndefined(); + }); +}); diff --git a/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.ts b/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.ts new file mode 100644 index 0000000000..a2b12b0336 --- /dev/null +++ b/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type ParsedSandboxDenial } from '../../services/sandboxManager.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; + +/** + * Windows-specific sandbox denial detection. + * Extracts paths from "Access is denied" and related errors. + */ +export function parseWindowsSandboxDenials( + result: ShellExecutionResult, +): ParsedSandboxDenial | undefined { + const output = result.output || ''; + const errorOutput = result.error?.message; + const combined = (output + ' ' + (errorOutput || '')).toLowerCase(); + + const isFileDenial = [ + 'access is denied', + 'access to the path', + 'unauthorizedaccessexception', + '0x80070005', + 'eperm: operation not permitted', + ].some((keyword) => combined.includes(keyword)); + + const isNetworkDenial = [ + 'eacces: permission denied', + 'an attempt was made to access a socket in a way forbidden by its access permissions', + // 10013 is WSAEACCES + '10013', + ].some((keyword) => combined.includes(keyword)); + + if (!isFileDenial && !isNetworkDenial) { + return undefined; + } + + const filePaths = new Set(); + + // Regex for Windows absolute paths (e.g., C:\Path or \\?\C:\Path) + // Handles drive letters and potentially quoted paths. + // We use two passes: one for quoted paths (which can contain spaces) + // and one for unquoted paths (which end at common separators). + + // 1. Quoted paths: 'C:\Foo Bar' or "C:\Foo Bar" + const quotedRegex = /['"]((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^'"]+)['"]/g; + for (const match of output.matchAll(quotedRegex)) { + filePaths.add(match[1]); + } + if (errorOutput) { + for (const match of errorOutput.matchAll(quotedRegex)) { + filePaths.add(match[1]); + } + } + + // 2. Unquoted paths or paths in PowerShell error format: PermissionDenied: (C:\path:String) + const generalRegex = + /(?:^|[\s(])((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^"'\s()<>|?*]+)/g; + for (const match of output.matchAll(generalRegex)) { + // Clean up trailing colon which might be part of the error message rather than the path + let p = match[1]; + if (p.endsWith(':')) p = p.slice(0, -1); + filePaths.add(p); + } + if (errorOutput) { + for (const match of errorOutput.matchAll(generalRegex)) { + let p = match[1]; + if (p.endsWith(':')) p = p.slice(0, -1); + filePaths.add(p); + } + } + + return { + network: isNetworkDenial || undefined, + filePaths: filePaths.size > 0 ? Array.from(filePaths) : undefined, + }; +} diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index 25b7f3f01a..54562933a8 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -74,6 +74,7 @@ import { type AnyDeclarativeTool, type AnyToolInvocation, } from '../tools/tools.js'; +import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js'; import { CoreToolCallStatus, ROOT_SCHEDULER_ID, @@ -441,6 +442,44 @@ describe('Scheduler (Orchestrator)', () => { ]), ); }); + + it('should sort UPDATE_TOPIC_TOOL_NAME to the front of the batch', async () => { + const topicReq: ToolCallRequestInfo = { + callId: 'call-topic', + name: UPDATE_TOPIC_TOOL_NAME, + args: { title: 'New Chapter' }, + prompt_id: 'p1', + isClientInitiated: false, + }; + const otherReq: ToolCallRequestInfo = { + callId: 'call-other', + name: 'test-tool', + args: {}, + prompt_id: 'p1', + isClientInitiated: false, + }; + + // Mock tool registry to return a tool for update_topic + vi.mocked(mockToolRegistry.getTool).mockImplementation((name) => { + if (name === UPDATE_TOPIC_TOOL_NAME) { + return { + name: UPDATE_TOPIC_TOOL_NAME, + build: vi.fn().mockReturnValue({}), + } as unknown as AnyDeclarativeTool; + } + return mockTool; + }); + + // Schedule in reverse order (other first, topic second) + await scheduler.schedule([otherReq, topicReq], signal); + + // Verify they were enqueued in the correct sorted order (topic first) + const enqueueCalls = vi.mocked(mockStateManager.enqueue).mock.calls; + const lastCall = enqueueCalls[enqueueCalls.length - 1][0]; + + expect(lastCall[0].request.callId).toBe('call-topic'); + expect(lastCall[1].request.callId).toBe('call-other'); + }); }); describe('Phase 2: Queue Management', () => { diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index ea308a26f6..45bc2f82a7 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -26,6 +26,7 @@ import { type ScheduledToolCall, } from './types.js'; import { ToolErrorType } from '../tools/tool-error.js'; +import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js'; import { PolicyDecision, type ApprovalMode } from '../policy/types.js'; import { ToolConfirmationOutcome, @@ -302,9 +303,16 @@ export class Scheduler { this.state.clearBatch(); const currentApprovalMode = this.config.getApprovalMode(); + // Sort requests to ensure Topic changes happen before actions in the same batch. + const sortedRequests = [...requests].sort((a, b) => { + if (a.name === UPDATE_TOPIC_TOOL_NAME) return -1; + if (b.name === UPDATE_TOPIC_TOOL_NAME) return 1; + return 0; + }); + try { const toolRegistry = this.context.toolRegistry; - const newCalls: ToolCall[] = requests.map((request) => { + const newCalls: ToolCall[] = sortedRequests.map((request) => { const enrichedRequest: ToolCallRequestInfo = { ...request, schedulerId: this.schedulerId, diff --git a/packages/core/src/services/__snapshots__/agentHistoryProvider.test.ts.snap b/packages/core/src/services/__snapshots__/agentHistoryProvider.test.ts.snap new file mode 100644 index 0000000000..af7990ad52 --- /dev/null +++ b/packages/core/src/services/__snapshots__/agentHistoryProvider.test.ts.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AgentHistoryProvider > should handle summarizer failures gracefully 1`] = ` +{ + "parts": [ + { + "text": "[System Note: Prior conversation history was truncated. The most recent user message before truncation was:] + +Message 18", + }, + { + "text": "Message 20", + }, + ], + "role": "user", +} +`; diff --git a/packages/core/src/services/agentHistoryProvider.test.ts b/packages/core/src/services/agentHistoryProvider.test.ts new file mode 100644 index 0000000000..7906398bb9 --- /dev/null +++ b/packages/core/src/services/agentHistoryProvider.test.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AgentHistoryProvider } from './agentHistoryProvider.js'; +import type { Content, GenerateContentResponse } from '@google/genai'; +import type { Config } from '../config/config.js'; +import type { BaseLlmClient } from '../core/baseLlmClient.js'; + +describe('AgentHistoryProvider', () => { + let config: Config; + let provider: AgentHistoryProvider; + let generateContentMock: ReturnType; + + beforeEach(() => { + config = { + isExperimentalAgentHistoryTruncationEnabled: vi + .fn() + .mockReturnValue(false), + isExperimentalAgentHistorySummarizationEnabled: vi + .fn() + .mockReturnValue(false), + getBaseLlmClient: vi.fn(), + } as unknown as Config; + + generateContentMock = vi.fn().mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'Mock intent summary' }] } }], + } as unknown as GenerateContentResponse); + + config.getBaseLlmClient = vi.fn().mockReturnValue({ + generateContent: generateContentMock, + } as unknown as BaseLlmClient); + + provider = new AgentHistoryProvider(config, { + truncationThreshold: 30, + retainedMessages: 15, + }); + }); + + const createMockHistory = (count: number): Content[] => + Array.from({ length: count }).map((_, i) => ({ + role: i % 2 === 0 ? 'user' : 'model', + parts: [{ text: `Message ${i}` }], + })); + + it('should return history unchanged if truncation is disabled', async () => { + vi.spyOn( + config, + 'isExperimentalAgentHistoryTruncationEnabled', + ).mockReturnValue(false); + + const history = createMockHistory(40); + const result = await provider.manageHistory(history); + + expect(result).toBe(history); + expect(result.length).toBe(40); + }); + + it('should return history unchanged if length is under threshold', async () => { + vi.spyOn( + config, + 'isExperimentalAgentHistoryTruncationEnabled', + ).mockReturnValue(true); + + const history = createMockHistory(20); // Threshold is 30 + const result = await provider.manageHistory(history); + + expect(result).toBe(history); + expect(result.length).toBe(20); + }); + + it('should truncate mechanically to RETAINED_MESSAGES without summarization when sum flag is off', async () => { + vi.spyOn( + config, + 'isExperimentalAgentHistoryTruncationEnabled', + ).mockReturnValue(true); + vi.spyOn( + config, + 'isExperimentalAgentHistorySummarizationEnabled', + ).mockReturnValue(false); + + const history = createMockHistory(35); // Above 30 threshold, should truncate to 15 + const result = await provider.manageHistory(history); + + expect(result.length).toBe(15); + expect(generateContentMock).not.toHaveBeenCalled(); + + // Check fallback message logic + // Messages 20 to 34 are retained. Message 20 is 'user'. + expect(result[0].role).toBe('user'); + expect(result[0].parts![0].text).toContain( + 'System Note: Prior conversation history was truncated', + ); + }); + + it('should call summarizer and prepend summary when summarization is enabled', async () => { + vi.spyOn( + config, + 'isExperimentalAgentHistoryTruncationEnabled', + ).mockReturnValue(true); + vi.spyOn( + config, + 'isExperimentalAgentHistorySummarizationEnabled', + ).mockReturnValue(true); + + const history = createMockHistory(35); + const result = await provider.manageHistory(history); + + expect(generateContentMock).toHaveBeenCalled(); + expect(result.length).toBe(15); // retained messages + expect(result[0].role).toBe('user'); + expect(result[0].parts![0].text).toContain(''); + expect(result[0].parts![0].text).toContain('Mock intent summary'); + }); + + it('should handle summarizer failures gracefully', async () => { + vi.spyOn( + config, + 'isExperimentalAgentHistoryTruncationEnabled', + ).mockReturnValue(true); + vi.spyOn( + config, + 'isExperimentalAgentHistorySummarizationEnabled', + ).mockReturnValue(true); + + generateContentMock.mockRejectedValue(new Error('API Error')); + + const history = createMockHistory(35); + const result = await provider.manageHistory(history); + + expect(generateContentMock).toHaveBeenCalled(); + expect(result.length).toBe(15); + expect(result[0]).toMatchSnapshot(); + }); +}); diff --git a/packages/core/src/services/agentHistoryProvider.ts b/packages/core/src/services/agentHistoryProvider.ts new file mode 100644 index 0000000000..fa9f23d437 --- /dev/null +++ b/packages/core/src/services/agentHistoryProvider.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { getResponseText } from '../utils/partUtils.js'; +import { LlmRole } from '../telemetry/llmRole.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +export interface AgentHistoryProviderConfig { + truncationThreshold: number; + retainedMessages: number; +} + +export class AgentHistoryProvider { + constructor( + private readonly config: Config, + private readonly providerConfig: AgentHistoryProviderConfig, + ) {} + + /** + * Evaluates the chat history and performs truncation and summarization if necessary. + * Returns a new array of Content if truncation occurred, otherwise returns the original array. + */ + async manageHistory( + history: readonly Content[], + abortSignal?: AbortSignal, + ): Promise { + if (!this.shouldTruncate(history)) { + return history; + } + + const { messagesToKeep, messagesToTruncate } = + this.splitHistoryForTruncation(history); + + debugLogger.log( + `AgentHistoryProvider: Truncating ${messagesToTruncate.length} messages, retaining ${messagesToKeep.length} messages.`, + ); + + const summaryText = await this.getSummaryText( + messagesToTruncate, + abortSignal, + ); + + return this.mergeSummaryWithHistory(summaryText, messagesToKeep); + } + + private shouldTruncate(history: readonly Content[]): boolean { + if (!this.config.isExperimentalAgentHistoryTruncationEnabled()) { + return false; + } + return history.length > this.providerConfig.truncationThreshold; + } + + private splitHistoryForTruncation(history: readonly Content[]): { + messagesToKeep: readonly Content[]; + messagesToTruncate: readonly Content[]; + } { + return { + messagesToKeep: history.slice(-this.providerConfig.retainedMessages), + messagesToTruncate: history.slice( + 0, + history.length - this.providerConfig.retainedMessages, + ), + }; + } + + private getFallbackSummaryText( + messagesToTruncate: readonly Content[], + ): string { + const defaultNote = + 'System Note: Prior conversation history was truncated to maintain performance and focus. Important context should have been saved to memory.'; + + let lastUserText = ''; + for (let i = messagesToTruncate.length - 1; i >= 0; i--) { + const msg = messagesToTruncate[i]; + if (msg.role === 'user') { + lastUserText = + msg.parts + ?.map((p) => p.text || '') + .join('') + .trim() || ''; + if (lastUserText) { + break; + } + } + } + + if (lastUserText) { + return `[System Note: Prior conversation history was truncated. The most recent user message before truncation was:]\n\n${lastUserText}`; + } + + return defaultNote; + } + + private async getSummaryText( + messagesToTruncate: readonly Content[], + abortSignal?: AbortSignal, + ): Promise { + if (!this.config.isExperimentalAgentHistorySummarizationEnabled()) { + debugLogger.log( + 'AgentHistoryProvider: Summarization disabled, using fallback note.', + ); + return this.getFallbackSummaryText(messagesToTruncate); + } + + try { + const summary = await this.generateIntentSummary( + messagesToTruncate, + abortSignal, + ); + debugLogger.log('AgentHistoryProvider: Summarization successful.'); + return summary; + } catch (error) { + debugLogger.log('AgentHistoryProvider: Summarization failed.', error); + return this.getFallbackSummaryText(messagesToTruncate); + } + } + + private mergeSummaryWithHistory( + summaryText: string, + messagesToKeep: readonly Content[], + ): readonly Content[] { + if (messagesToKeep.length === 0) { + return [{ role: 'user', parts: [{ text: summaryText }] }]; + } + + // To ensure strict user/model alternating roles required by the Gemini API, + // we merge the summary into the first retained message if it's from the 'user'. + const firstRetainedMessage = messagesToKeep[0]; + if (firstRetainedMessage.role === 'user') { + const mergedParts = [ + { text: summaryText }, + ...(firstRetainedMessage.parts || []), + ]; + const mergedMessage: Content = { + role: 'user', + parts: mergedParts, + }; + return [mergedMessage, ...messagesToKeep.slice(1)]; + } else { + const summaryMessage: Content = { + role: 'user', + parts: [{ text: summaryText }], + }; + return [summaryMessage, ...messagesToKeep]; + } + } + + private async generateIntentSummary( + messagesToTruncate: readonly Content[], + abortSignal?: AbortSignal, + ): Promise { + const prompt = `Create a succinct, agent-continuity focused intent summary of the truncated conversation history. +Distill the essence of the ongoing work by capturing: +- The Original Mandate: What the user (or calling agent) originally requested and why. +- The Agent's Strategy: How you (the agent) are approaching the task and where the work is taking place (e.g., specific files, directories, or architectural layers). +- Evolving Context: Any significant shifts in the user's intent or the agent's technical approach over the course of the truncated history. + +Write this summary to orient the active agent. Do NOT predict next steps or summarize the current task state, as those are covered by the active history. Focus purely on foundational context and strategic continuity.`; + + const summaryResponse = await this.config + .getBaseLlmClient() + .generateContent({ + modelConfigKey: { model: 'agent-history-provider-summarizer' }, + contents: [ + ...messagesToTruncate, + { + role: 'user', + parts: [{ text: prompt }], + }, + ], + promptId: 'agent-history-provider', + abortSignal: abortSignal ?? new AbortController().signal, + role: LlmRole.UTILITY_COMPRESSOR, + }); + + let summary = getResponseText(summaryResponse) ?? ''; + summary = summary.replace(/<\/?intent_summary>/g, '').trim(); + return `\n${summary}\n`; + } +} diff --git a/packages/core/src/services/contextManager.test.ts b/packages/core/src/services/contextManager.test.ts index 1d078fd8fb..a6a3c8cd0f 100644 --- a/packages/core/src/services/contextManager.test.ts +++ b/packages/core/src/services/contextManager.test.ts @@ -46,6 +46,7 @@ describe('ContextManager', () => { getMcpInstructions: vi.fn().mockReturnValue('MCP Instructions'), }), isTrustedFolder: vi.fn().mockReturnValue(true), + getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']), } as unknown as Config; contextManager = new ContextManager(mockConfig); @@ -81,12 +82,14 @@ describe('ContextManager', () => { await contextManager.refresh(); expect(memoryDiscovery.getGlobalMemoryPaths).toHaveBeenCalled(); - expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith([ - '/app', - ]); + expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith( + ['/app'], + ['.git'], + ); expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith( expect.arrayContaining([...globalPaths, ...envPaths]), 'tree', + ['.git'], ); expect(contextManager.getGlobalMemory()).toContain('Global Content'); @@ -172,6 +175,7 @@ describe('ContextManager', () => { expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith( ['/home/user/.gemini/GEMINI.md', '/app/gemini.md'], 'tree', + ['.git'], ); expect(contextManager.getEnvironmentMemory()).toContain( 'Project Content', @@ -197,6 +201,7 @@ describe('ContextManager', () => { ['/app'], expect.any(Set), expect.any(Set), + ['.git'], ); expect(result).toMatch(/--- Context from: \/app\/src\/GEMINI\.md ---/); expect(result).toContain('Src Content'); @@ -226,5 +231,25 @@ describe('ContextManager', () => { expect(memoryDiscovery.loadJitSubdirectoryMemory).not.toHaveBeenCalled(); expect(result).toBe(''); }); + + it('should pass custom boundary markers from config', async () => { + const customMarkers = ['.monorepo-root', 'package.json']; + vi.mocked(mockConfig.getMemoryBoundaryMarkers).mockReturnValue( + customMarkers, + ); + vi.mocked(memoryDiscovery.loadJitSubdirectoryMemory).mockResolvedValue({ + files: [], + }); + + await contextManager.discoverContext('/app/src/file.ts', ['/app']); + + expect(memoryDiscovery.loadJitSubdirectoryMemory).toHaveBeenCalledWith( + '/app/src/file.ts', + ['/app'], + expect.any(Set), + expect.any(Set), + customMarkers, + ); + }); }); }); diff --git a/packages/core/src/services/contextManager.ts b/packages/core/src/services/contextManager.ts index b9da286e9c..3d7400c747 100644 --- a/packages/core/src/services/contextManager.ts +++ b/packages/core/src/services/contextManager.ts @@ -51,9 +51,10 @@ export class ContextManager { getExtensionMemoryPaths(this.config.getExtensionLoader()), ), this.config.isTrustedFolder() - ? getEnvironmentMemoryPaths([ - ...this.config.getWorkspaceContext().getDirectories(), - ]) + ? getEnvironmentMemoryPaths( + [...this.config.getWorkspaceContext().getDirectories()], + this.config.getMemoryBoundaryMarkers(), + ) : Promise.resolve([]), ]); @@ -76,6 +77,7 @@ export class ContextManager { const allContents = await readGeminiMdFiles( allPaths, this.config.getImportFormat(), + this.config.getMemoryBoundaryMarkers(), ); const loadedFilePaths = allContents @@ -133,6 +135,7 @@ export class ContextManager { trustedRoots, this.loadedPaths, this.loadedFileIdentities, + this.config.getMemoryBoundaryMarkers(), ); if (result.files.length === 0) { diff --git a/packages/core/src/services/executionLifecycleService.test.ts b/packages/core/src/services/executionLifecycleService.test.ts index 0d800c6e55..ed8dd58a3c 100644 --- a/packages/core/src/services/executionLifecycleService.test.ts +++ b/packages/core/src/services/executionLifecycleService.test.ts @@ -10,6 +10,7 @@ import { type ExecutionHandle, type ExecutionResult, } from './executionLifecycleService.js'; +import { InjectionService } from '../config/injectionService.js'; function createResult( overrides: Partial = {}, @@ -296,6 +297,81 @@ describe('ExecutionLifecycleService', () => { }).toThrow('Execution 4324 is already attached.'); }); + describe('Background Start Listeners', () => { + it('fires onBackground when an execution is backgrounded', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackground(listener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + undefined, + 'My Remote Agent', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.appendOutput(executionId, 'some output'); + ExecutionLifecycleService.background(executionId); + await handle.result; + + expect(listener).toHaveBeenCalledTimes(1); + const info = listener.mock.calls[0][0]; + expect(info.executionId).toBe(executionId); + expect(info.executionMethod).toBe('remote_agent'); + expect(info.label).toBe('My Remote Agent'); + expect(info.output).toBe('some output'); + + ExecutionLifecycleService.offBackground(listener); + }); + + it('uses fallback label when none is provided', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackground(listener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'none', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.background(executionId); + await handle.result; + + const info = listener.mock.calls[0][0]; + expect(info.label).toContain('none'); + expect(info.label).toContain(String(executionId)); + + ExecutionLifecycleService.offBackground(listener); + }); + + it('does not fire onBackground for non-backgrounded completions', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackground(listener); + + const handle = ExecutionLifecycleService.createExecution(); + ExecutionLifecycleService.completeExecution(handle.pid!); + await handle.result; + + expect(listener).not.toHaveBeenCalled(); + + ExecutionLifecycleService.offBackground(listener); + }); + + it('offBackground removes the listener', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackground(listener); + ExecutionLifecycleService.offBackground(listener); + + const handle = ExecutionLifecycleService.createExecution(); + ExecutionLifecycleService.background(handle.pid!); + await handle.result; + + expect(listener).not.toHaveBeenCalled(); + }); + }); + describe('Background Completion Listeners', () => { it('fires onBackgroundComplete with formatInjection text when backgrounded execution settles', async () => { const listener = vi.fn(); @@ -326,7 +402,10 @@ describe('ExecutionLifecycleService', () => { expect(info.executionMethod).toBe('remote_agent'); expect(info.output).toBe('agent output'); expect(info.error).toBeNull(); - expect(info.injectionText).toBe('[Agent completed]\nagent output'); + expect(info.injectionText).toBe( + '\n[Agent completed]\nagent output\n', + ); + expect(info.completionBehavior).toBe('inject'); ExecutionLifecycleService.offBackgroundComplete(listener); }); @@ -353,12 +432,14 @@ describe('ExecutionLifecycleService', () => { expect(listener).toHaveBeenCalledTimes(1); const info = listener.mock.calls[0][0]; expect(info.error?.message).toBe('something broke'); - expect(info.injectionText).toBe('Error: something broke'); + expect(info.injectionText).toBe( + '\nError: something broke\n', + ); ExecutionLifecycleService.offBackgroundComplete(listener); }); - it('sets injectionText to null when no formatInjection callback is provided', async () => { + it('sets injectionText to null and completionBehavior to silent when no formatInjection is provided', async () => { const listener = vi.fn(); ExecutionLifecycleService.onBackgroundComplete(listener); @@ -377,6 +458,7 @@ describe('ExecutionLifecycleService', () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener.mock.calls[0][0].injectionText).toBeNull(); + expect(listener.mock.calls[0][0].completionBehavior).toBe('silent'); ExecutionLifecycleService.offBackgroundComplete(listener); }); @@ -443,5 +525,214 @@ describe('ExecutionLifecycleService', () => { expect(listener).not.toHaveBeenCalled(); }); + + it('explicit notify behavior includes injectionText and auto-dismiss signal', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackgroundComplete(listener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'child_process', + () => '[Command completed. Output saved to /tmp/bg.log]', + undefined, + 'notify', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.background(executionId); + await handle.result; + + ExecutionLifecycleService.completeExecution(executionId); + + expect(listener).toHaveBeenCalledTimes(1); + const info = listener.mock.calls[0][0]; + expect(info.completionBehavior).toBe('notify'); + expect(info.injectionText).toBe( + '\n[Command completed. Output saved to /tmp/bg.log]\n', + ); + + ExecutionLifecycleService.offBackgroundComplete(listener); + }); + + it('explicit silent behavior skips injection even when formatInjection is provided', async () => { + const formatFn = vi.fn().mockReturnValue('should not appear'); + const listener = vi.fn(); + ExecutionLifecycleService.onBackgroundComplete(listener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'none', + formatFn, + undefined, + 'silent', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.background(executionId); + await handle.result; + + ExecutionLifecycleService.completeExecution(executionId); + + expect(listener).toHaveBeenCalledTimes(1); + const info = listener.mock.calls[0][0]; + expect(info.completionBehavior).toBe('silent'); + expect(info.injectionText).toBeNull(); + expect(formatFn).not.toHaveBeenCalled(); + + ExecutionLifecycleService.offBackgroundComplete(listener); + }); + + it('includes completionBehavior in BackgroundStartInfo', async () => { + const bgStartListener = vi.fn(); + ExecutionLifecycleService.onBackground(bgStartListener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + () => 'text', + 'test-label', + 'inject', + ); + + ExecutionLifecycleService.background(handle.pid!); + await handle.result; + + expect(bgStartListener).toHaveBeenCalledTimes(1); + expect(bgStartListener.mock.calls[0][0].completionBehavior).toBe( + 'inject', + ); + + ExecutionLifecycleService.offBackground(bgStartListener); + }); + + it('completionBehavior flows through attachExecution', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackgroundComplete(listener); + + const handle = ExecutionLifecycleService.attachExecution(9999, { + executionMethod: 'child_process', + formatInjection: () => '[notify message]', + completionBehavior: 'notify', + }); + + ExecutionLifecycleService.background(9999); + await handle.result; + + ExecutionLifecycleService.completeWithResult( + 9999, + createResult({ pid: 9999, executionMethod: 'child_process' }), + ); + + expect(listener).toHaveBeenCalledTimes(1); + const info = listener.mock.calls[0][0]; + expect(info.completionBehavior).toBe('notify'); + expect(info.injectionText).toBe('\n[notify message]\n'); + + ExecutionLifecycleService.offBackgroundComplete(listener); + }); + + it('injects directly into InjectionService when wired via setInjectionService', async () => { + const injectionService = new InjectionService(() => true); + ExecutionLifecycleService.setInjectionService(injectionService); + + const injectionListener = vi.fn(); + injectionService.onInjection(injectionListener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + (output) => `[Completed] ${output}`, + undefined, + 'inject', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.appendOutput(executionId, 'agent output'); + ExecutionLifecycleService.background(executionId); + await handle.result; + + ExecutionLifecycleService.completeExecution(executionId); + + expect(injectionListener).toHaveBeenCalledWith( + '\n[Completed] agent output\n', + 'background_completion', + ); + }); + + it('sanitizes injectionText for inject behavior but NOT for notify behavior', async () => { + const injectionService = new InjectionService(() => true); + ExecutionLifecycleService.setInjectionService(injectionService); + + const injectionListener = vi.fn(); + injectionService.onInjection(injectionListener); + + // 1. Test 'inject' sanitization + const handleInject = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + (output) => `Dangerous ${output}`, + undefined, + 'inject', + ); + ExecutionLifecycleService.appendOutput(handleInject.pid!, 'more'); + ExecutionLifecycleService.background(handleInject.pid!); + await handleInject.result; + ExecutionLifecycleService.completeExecution(handleInject.pid!); + + expect(injectionListener).toHaveBeenCalledWith( + '\nDangerous </output> more\n', + 'background_completion', + ); + + // 2. Test 'notify' (should also be wrapped in tag) + injectionListener.mockClear(); + const handleNotify = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + (output) => `Pointer to ${output}`, + undefined, + 'notify', + ); + ExecutionLifecycleService.appendOutput(handleNotify.pid!, 'logs'); + ExecutionLifecycleService.background(handleNotify.pid!); + await handleNotify.result; + ExecutionLifecycleService.completeExecution(handleNotify.pid!); + + expect(injectionListener).toHaveBeenCalledWith( + '\nPointer to logs\n', + 'background_completion', + ); + }); + + it('does not inject into InjectionService for silent behavior', async () => { + const injectionService = new InjectionService(() => true); + ExecutionLifecycleService.setInjectionService(injectionService); + + const injectionListener = vi.fn(); + injectionService.onInjection(injectionListener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'none', + () => 'should not inject', + undefined, + 'silent', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.background(executionId); + await handle.result; + + ExecutionLifecycleService.completeExecution(executionId); + + expect(injectionListener).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/core/src/services/executionLifecycleService.ts b/packages/core/src/services/executionLifecycleService.ts index 5efe26c375..a559fea82c 100644 --- a/packages/core/src/services/executionLifecycleService.ts +++ b/packages/core/src/services/executionLifecycleService.ts @@ -7,6 +7,7 @@ import type { InjectionService } from '../config/injectionService.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { sanitizeOutput } from '../utils/textUtils.js'; export type ExecutionMethod = | 'lydell-node-pty' @@ -59,12 +60,16 @@ export interface ExecutionCompletionOptions { export interface ExternalExecutionRegistration { executionMethod: ExecutionMethod; + /** Human-readable label for the background task UI (e.g. the command string). */ + label?: string; initialOutput?: string; getBackgroundOutput?: () => string; getSubscriptionSnapshot?: () => string | AnsiOutput | undefined; writeInput?: (input: string) => void; kill?: () => void; isActive?: () => boolean; + formatInjection?: FormatInjectionFn; + completionBehavior?: CompletionBehavior; } /** @@ -77,15 +82,41 @@ export type FormatInjectionFn = ( error: Error | null, ) => string | null; +/** + * Controls what happens when a backgrounded execution completes: + * - `'inject'` โ€” full formatted output is injected into the conversation; task auto-dismisses from UI. + * - `'notify'` โ€” a short pointer (e.g. "output saved to /tmp/...") is injected; task auto-dismisses from UI. + * - `'silent'` โ€” nothing is injected; task stays in the UI until manually dismissed. + * + * The distinction between `inject` and `notify` is semantic for now (both inject + dismiss), + * but enables the system to treat them differently in the future (e.g. LLM-decided injection). + */ +export type CompletionBehavior = 'inject' | 'notify' | 'silent'; + interface ManagedExecutionBase { executionMethod: ExecutionMethod; + label?: string; output: string; backgrounded?: boolean; formatInjection?: FormatInjectionFn; + completionBehavior?: CompletionBehavior; getBackgroundOutput?: () => string; getSubscriptionSnapshot?: () => string | AnsiOutput | undefined; } +/** + * Payload emitted when an execution is moved to the background. + */ +export interface BackgroundStartInfo { + executionId: number; + executionMethod: ExecutionMethod; + label: string; + output: string; + completionBehavior: CompletionBehavior; +} + +export type BackgroundStartListener = (info: BackgroundStartInfo) => void; + /** * Payload emitted when a previously-backgrounded execution settles. */ @@ -96,6 +127,7 @@ export interface BackgroundCompletionInfo { error: Error | null; /** Pre-formatted injection text from the execution creator, or `null` if skipped. */ injectionText: string | null; + completionBehavior: CompletionBehavior; } export type BackgroundCompletionListener = ( @@ -124,6 +156,16 @@ const NON_PROCESS_EXECUTION_ID_START = 2_000_000_000; export class ExecutionLifecycleService { private static readonly EXIT_INFO_TTL_MS = 5 * 60 * 1000; private static nextExecutionId = NON_PROCESS_EXECUTION_ID_START; + private static injectionService: InjectionService | null = null; + + /** + * Connects the lifecycle service to the injection service so that + * backgrounded executions are reinjected into the model conversation + * directly from the backend โ€” no UI hop needed. + */ + static setInjectionService(service: InjectionService): void { + this.injectionService = service; + } private static activeExecutions = new Map(); private static activeResolvers = new Map< @@ -140,14 +182,22 @@ export class ExecutionLifecycleService { >(); private static backgroundCompletionListeners = new Set(); - private static injectionService: InjectionService | null = null; + + private static backgroundStartListeners = new Set(); /** - * Wires a singleton InjectionService so that backgrounded executions - * can inject their output directly without routing through the UI layer. + * Registers a listener that fires when any execution is moved to the background. + * This is the hook for the UI to automatically discover backgrounded executions. */ - static setInjectionService(service: InjectionService): void { - this.injectionService = service; + static onBackground(listener: BackgroundStartListener): void { + this.backgroundStartListeners.add(listener); + } + + /** + * Unregisters a background start listener. + */ + static offBackground(listener: BackgroundStartListener): void { + this.backgroundStartListeners.delete(listener); } /** @@ -222,6 +272,7 @@ export class ExecutionLifecycleService { this.exitedExecutionInfo.clear(); this.backgroundCompletionListeners.clear(); this.injectionService = null; + this.backgroundStartListeners.clear(); this.nextExecutionId = NON_PROCESS_EXECUTION_ID_START; } @@ -239,6 +290,7 @@ export class ExecutionLifecycleService { this.activeExecutions.set(executionId, { executionMethod: registration.executionMethod, + label: registration.label, output: registration.initialOutput ?? '', kind: 'external', getBackgroundOutput: registration.getBackgroundOutput, @@ -246,6 +298,8 @@ export class ExecutionLifecycleService { writeInput: registration.writeInput, kill: registration.kill, isActive: registration.isActive, + formatInjection: registration.formatInjection, + completionBehavior: registration.completionBehavior, }); return { @@ -259,15 +313,19 @@ export class ExecutionLifecycleService { onKill?: () => void, executionMethod: ExecutionMethod = 'none', formatInjection?: FormatInjectionFn, + label?: string, + completionBehavior?: CompletionBehavior, ): ExecutionHandle { const executionId = this.allocateExecutionId(); this.activeExecutions.set(executionId, { executionMethod, + label, output: initialOutput, kind: 'virtual', onKill, formatInjection, + completionBehavior, getBackgroundOutput: () => { const state = this.activeExecutions.get(executionId); return state?.output ?? initialOutput; @@ -325,19 +383,17 @@ export class ExecutionLifecycleService { // Fire background completion listeners if this was a backgrounded execution. if (execution.backgrounded && !result.aborted) { - const injectionText = execution.formatInjection - ? execution.formatInjection(result.output, result.error) - : null; - const info: BackgroundCompletionInfo = { - executionId, - executionMethod: execution.executionMethod, - output: result.output, - error: result.error, - injectionText, - }; + const behavior = + execution.completionBehavior ?? + (execution.formatInjection ? 'inject' : 'silent'); + const rawInjection = + behavior !== 'silent' && execution.formatInjection + ? execution.formatInjection(result.output, result.error) + : null; - // Inject directly into the model conversation if injection text is - // available and the injection service has been wired up. + const injectionText = rawInjection ? sanitizeOutput(rawInjection) : null; + + // Inject directly into the model conversation from the backend. if (injectionText && this.injectionService) { this.injectionService.addInjection( injectionText, @@ -345,6 +401,15 @@ export class ExecutionLifecycleService { ); } + const info: BackgroundCompletionInfo = { + executionId, + executionMethod: execution.executionMethod, + output: result.output, + error: result.error, + injectionText, + completionBehavior: behavior, + }; + for (const listener of this.backgroundCompletionListeners) { try { listener(info); @@ -434,6 +499,21 @@ export class ExecutionLifecycleService { this.activeResolvers.delete(executionId); execution.backgrounded = true; + + // Notify listeners that an execution was moved to the background. + const info: BackgroundStartInfo = { + executionId, + executionMethod: execution.executionMethod, + label: + execution.label ?? `${execution.executionMethod} (ID: ${executionId})`, + output, + completionBehavior: + execution.completionBehavior ?? + (execution.formatInjection ? 'inject' : 'silent'), + }; + for (const listener of this.backgroundStartListeners) { + listener(info); + } } static subscribe( diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 9d82a3d87f..0454581aac 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -10,7 +10,6 @@ import fsPromises from 'node:fs/promises'; import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; import { NoopSandboxManager, - LocalSandboxManager, sanitizePaths, findSecretFiles, isSecretFile, @@ -374,6 +373,7 @@ describe('SandboxManager', () => { it.each([ { platform: 'linux', expected: LinuxSandboxManager }, { platform: 'darwin', expected: MacOsSandboxManager }, + { platform: 'win32', expected: WindowsSandboxManager }, ] as const)( 'should return $expected.name if sandboxing is enabled and platform is $platform', ({ platform, expected }) => { @@ -386,22 +386,13 @@ describe('SandboxManager', () => { }, ); - it("should return WindowsSandboxManager if sandboxing is enabled with 'windows-native' command on win32", () => { + it('should return WindowsSandboxManager if sandboxing is enabled on win32', () => { vi.spyOn(os, 'platform').mockReturnValue('win32'); const manager = createSandboxManager( - { enabled: true, command: 'windows-native' }, + { enabled: true }, { workspace: '/workspace' }, ); expect(manager).toBeInstanceOf(WindowsSandboxManager); }); - - it('should return LocalSandboxManager on win32 if command is not windows-native', () => { - vi.spyOn(os, 'platform').mockReturnValue('win32'); - const manager = createSandboxManager( - { enabled: true, command: 'docker' as unknown as 'windows-native' }, - { workspace: '/workspace' }, - ); - expect(manager).toBeInstanceOf(LocalSandboxManager); - }); }); }); diff --git a/packages/core/src/services/sandboxManagerFactory.ts b/packages/core/src/services/sandboxManagerFactory.ts index 29c89cc722..cb70f796d1 100644 --- a/packages/core/src/services/sandboxManagerFactory.ts +++ b/packages/core/src/services/sandboxManagerFactory.ts @@ -33,7 +33,7 @@ export function createSandboxManager( } if (sandbox?.enabled) { - if (os.platform() === 'win32' && sandbox?.command === 'windows-native') { + if (os.platform() === 'win32') { return new WindowsSandboxManager(options); } else if (os.platform() === 'linux') { return new LinuxSandboxManager(options); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 6184354a2a..b757bdd793 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -19,7 +19,7 @@ import { resolveExecutable, type ShellType, } from '../utils/shell-utils.js'; -import { isBinary } from '../utils/textUtils.js'; +import { isBinary, truncateString } from '../utils/textUtils.js'; import pkg from '@xterm/headless'; import { debugLogger } from '../utils/debugLogger.js'; import { Storage } from '../config/storage.js'; @@ -102,6 +102,7 @@ export interface ShellExecutionConfig { scrollback?: number; maxSerializedLines?: number; sandboxConfig?: SandboxConfig; + backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent'; } /** @@ -239,6 +240,23 @@ export class ShellExecutionService { return path.join(Storage.getGlobalTempDir(), 'background-processes'); } + private static formatShellBackgroundCompletion( + pid: number, + behavior: string, + output: string, + error?: Error, + ): string { + const logPath = ShellExecutionService.getLogFilePath(pid); + const status = error ? `with error: ${error.message}` : 'successfully'; + + if (behavior === 'inject') { + const truncated = truncateString(output, 5000); + return `[Background command completed ${status}. Output saved to ${logPath}]\n\n${truncated}`; + } + + return `[Background command completed ${status}. Output saved to ${logPath}]`; + } + static getLogFilePath(pid: number): string { return path.join(this.getLogDir(), `background-${pid}.log`); } @@ -532,6 +550,15 @@ export class ShellExecutionService { return false; } }, + formatInjection: (output, error) => + ShellExecutionService.formatShellBackgroundCompletion( + child.pid!, + shellExecutionConfig.backgroundCompletionBehavior || 'silent', + output, + error ?? undefined, + ), + completionBehavior: + shellExecutionConfig.backgroundCompletionBehavior || 'silent', }) : undefined; @@ -862,6 +889,15 @@ export class ShellExecutionService { ); return bufferData.length > 0 ? bufferData : undefined; }, + formatInjection: (output, error) => + ShellExecutionService.formatShellBackgroundCompletion( + ptyPid, + shellExecutionConfig.backgroundCompletionBehavior || 'silent', + output, + error ?? undefined, + ), + completionBehavior: + shellExecutionConfig.backgroundCompletionBehavior || 'silent', }).result; let processingChain = Promise.resolve(); diff --git a/packages/core/src/services/test-data/resolved-aliases-retry.golden.json b/packages/core/src/services/test-data/resolved-aliases-retry.golden.json index 52e2eb7722..33e9ce684b 100644 --- a/packages/core/src/services/test-data/resolved-aliases-retry.golden.json +++ b/packages/core/src/services/test-data/resolved-aliases-retry.golden.json @@ -256,5 +256,9 @@ "chat-compression-default": { "model": "gemini-3-pro-preview", "generateContentConfig": {} + }, + "agent-history-provider-summarizer": { + "model": "gemini-3-flash-preview", + "generateContentConfig": {} } } diff --git a/packages/core/src/services/test-data/resolved-aliases.golden.json b/packages/core/src/services/test-data/resolved-aliases.golden.json index 52e2eb7722..33e9ce684b 100644 --- a/packages/core/src/services/test-data/resolved-aliases.golden.json +++ b/packages/core/src/services/test-data/resolved-aliases.golden.json @@ -256,5 +256,9 @@ "chat-compression-default": { "model": "gemini-3-pro-preview", "generateContentConfig": {} + }, + "agent-history-provider-summarizer": { + "model": "gemini-3-flash-preview", + "generateContentConfig": {} } } diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 48b7792168..b21fc606e2 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -1151,8 +1151,11 @@ describe('loggers', () => { getQuestion: () => 'test-question', getToolRegistry: () => new ToolRegistry(cfg1, {} as unknown as MessageBus), - getUserMemory: () => 'user-memory', + isExperimentalAgentHistoryTruncationEnabled: () => false, + getExperimentalAgentHistoryTruncationThreshold: () => 30, + getExperimentalAgentHistoryRetainedMessages: () => 15, + isExperimentalAgentHistorySummarizationEnabled: () => false, } as unknown as Config; (cfg2 as unknown as { config: Config; promptId: string }).config = cfg2; diff --git a/packages/core/src/tools/definitions/base-declarations.ts b/packages/core/src/tools/definitions/base-declarations.ts index c7c4223546..08b14ce6cb 100644 --- a/packages/core/src/tools/definitions/base-declarations.ts +++ b/packages/core/src/tools/definitions/base-declarations.ts @@ -125,3 +125,10 @@ export const PLAN_MODE_PARAM_REASON = 'reason'; // -- sandbox -- export const PARAM_ADDITIONAL_PERMISSIONS = 'additional_permissions'; + +// -- update_topic -- +export const UPDATE_TOPIC_TOOL_NAME = 'update_topic'; +export const UPDATE_TOPIC_DISPLAY_NAME = 'Update Topic Context'; +export const TOPIC_PARAM_TITLE = 'title'; +export const TOPIC_PARAM_SUMMARY = 'summary'; +export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent'; diff --git a/packages/core/src/tools/definitions/coreTools.ts b/packages/core/src/tools/definitions/coreTools.ts index 85fc9906e6..f642d2709f 100644 --- a/packages/core/src/tools/definitions/coreTools.ts +++ b/packages/core/src/tools/definitions/coreTools.ts @@ -17,6 +17,7 @@ import { getShellDeclaration, getExitPlanModeDeclaration, getActivateSkillDeclaration, + getUpdateTopicDeclaration, } from './dynamic-declaration-helpers.js'; // Re-export names for compatibility @@ -38,6 +39,8 @@ export { ASK_USER_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, + UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, // Shared parameter names PARAM_FILE_PATH, PARAM_DIR_PATH, @@ -91,6 +94,9 @@ export { PLAN_MODE_PARAM_REASON, EXIT_PLAN_PARAM_PLAN_FILENAME, SKILL_PARAM_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, + TOPIC_PARAM_STRATEGIC_INTENT, } from './base-declarations.js'; // Re-export sets for compatibility @@ -221,6 +227,13 @@ export const ENTER_PLAN_MODE_DEFINITION: ToolDefinition = { overrides: (modelId) => getToolSet(modelId).enter_plan_mode, }; +export const UPDATE_TOPIC_DEFINITION: ToolDefinition = { + get base() { + return getUpdateTopicDeclaration(); + }, + overrides: (modelId) => getToolSet(modelId).update_topic, +}; + // ============================================================================ // DYNAMIC TOOL DEFINITIONS (LEGACY EXPORTS) // ============================================================================ diff --git a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts index 530f908977..59b1bf7479 100644 --- a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts +++ b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts @@ -24,6 +24,10 @@ import { EXIT_PLAN_PARAM_PLAN_FILENAME, SKILL_PARAM_NAME, PARAM_ADDITIONAL_PERMISSIONS, + UPDATE_TOPIC_TOOL_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, + TOPIC_PARAM_STRATEGIC_INTENT, } from './base-declarations.js'; /** @@ -204,3 +208,34 @@ export function getActivateSkillDeclaration( parametersJsonSchema: zodToJsonSchema(schema), }; } + +/** + * Returns the FunctionDeclaration for updating the topic context. + */ +export function getUpdateTopicDeclaration(): FunctionDeclaration { + return { + name: UPDATE_TOPIC_TOOL_NAME, + description: + 'Manages your narrative flow. Include `title` and `summary` only when starting a new Chapter (logical phase) or shifting strategic intent.', + parametersJsonSchema: { + type: 'object', + properties: { + [TOPIC_PARAM_TITLE]: { + type: 'string', + description: 'The title of the new topic or chapter.', + }, + [TOPIC_PARAM_SUMMARY]: { + type: 'string', + description: + '(OPTIONAL) A detailed summary (5-10 sentences) covering both the work completed in the previous topic and the strategic intent of the new topic. This is required when transitioning between topics to maintain continuity.', + }, + [TOPIC_PARAM_STRATEGIC_INTENT]: { + type: 'string', + description: + 'A mandatory one-sentence statement of your immediate intent.', + }, + }, + required: [TOPIC_PARAM_STRATEGIC_INTENT], + }, + }; +} diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index 7543adc2ae..b19c157f22 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -78,6 +78,7 @@ import { getShellDeclaration, getExitPlanModeDeclaration, getActivateSkillDeclaration, + getUpdateTopicDeclaration, } from '../dynamic-declaration-helpers.js'; import { DEFAULT_MAX_LINES_TEXT_FILE, @@ -724,4 +725,5 @@ The agent did not use the todo list because this task could be completed by a ti exit_plan_mode: () => getExitPlanModeDeclaration(), activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames), + update_topic: getUpdateTopicDeclaration(), }; diff --git a/packages/core/src/tools/definitions/types.ts b/packages/core/src/tools/definitions/types.ts index 30cffe5474..42c0cc7028 100644 --- a/packages/core/src/tools/definitions/types.ts +++ b/packages/core/src/tools/definitions/types.ts @@ -50,4 +50,5 @@ export interface CoreToolSet { enter_plan_mode: FunctionDeclaration; exit_plan_mode: () => FunctionDeclaration; activate_skill: (skillNames: string[]) => FunctionDeclaration; + update_topic?: FunctionDeclaration; } diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index 1f0a8ee98f..7bfc59435f 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -53,6 +53,13 @@ describe('GrepTool', () => { getFileExclusions: () => ({ getGlobExcludes: () => [], }), + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + maxFileCount: 1000, + searchTimeout: 30000, + customIgnoreFilePaths: [], + }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, @@ -337,6 +344,13 @@ describe('GrepTool', () => { getFileExclusions: () => ({ getGlobExcludes: () => [], }), + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + maxFileCount: 1000, + searchTimeout: 30000, + customIgnoreFilePaths: [], + }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, @@ -414,6 +428,13 @@ describe('GrepTool', () => { getFileExclusions: () => ({ getGlobExcludes: () => [], }), + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + maxFileCount: 1000, + searchTimeout: 30000, + customIgnoreFilePaths: [], + }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, @@ -618,6 +639,13 @@ describe('GrepTool', () => { getFileExclusions: () => ({ getGlobExcludes: () => [], }), + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + maxFileCount: 1000, + searchTimeout: 30000, + customIgnoreFilePaths: [], + }), } as unknown as Config; const multiDirGrepTool = new GrepTool( diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index ea202c57de..e913c4b184 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -215,9 +215,17 @@ class GrepToolInvocation extends BaseToolInvocation< // Create a timeout controller to prevent indefinitely hanging searches const timeoutController = new AbortController(); + const configTimeout = this.config.getFileFilteringOptions().searchTimeout; + // If configTimeout is less than standard default, it might be too short for grep. + // We check if it's greater or if we should use DEFAULT_SEARCH_TIMEOUT_MS as a fallback. + // Let's assume the user can set it higher if they want. Using it directly if it exists, otherwise fallback. + const timeoutMs = + configTimeout && configTimeout > DEFAULT_SEARCH_TIMEOUT_MS + ? configTimeout + : DEFAULT_SEARCH_TIMEOUT_MS; const timeoutId = setTimeout(() => { timeoutController.abort(); - }, DEFAULT_SEARCH_TIMEOUT_MS); + }, timeoutMs); // Link the passed signal to our timeout controller const onAbort = () => timeoutController.abort(); @@ -252,6 +260,13 @@ class GrepToolInvocation extends BaseToolInvocation< allMatches = allMatches.concat(matches); } + } catch (error) { + if (timeoutController.signal.aborted) { + throw new Error( + `Operation timed out after ${timeoutMs}ms. In large repositories, consider narrowing your search scope by specifying a 'dir_path' or an 'include_pattern'.`, + ); + } + throw error; } finally { clearTimeout(timeoutId); signal.removeEventListener('abort', onAbort); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 69f269143b..415b8c780d 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -250,9 +250,17 @@ class GrepToolInvocation extends BaseToolInvocation< // Create a timeout controller to prevent indefinitely hanging searches const timeoutController = new AbortController(); + const configTimeout = this.config.getFileFilteringOptions().searchTimeout; + // If configTimeout is less than standard default, it might be too short for grep. + // We check if it's greater or if we should use DEFAULT_SEARCH_TIMEOUT_MS as a fallback. + // Let's assume the user can set it higher if they want. Using it directly if it exists, otherwise fallback. + const timeoutMs = + configTimeout && configTimeout > DEFAULT_SEARCH_TIMEOUT_MS + ? configTimeout + : DEFAULT_SEARCH_TIMEOUT_MS; const timeoutId = setTimeout(() => { timeoutController.abort(); - }, DEFAULT_SEARCH_TIMEOUT_MS); + }, timeoutMs); // Link the passed signal to our timeout controller const onAbort = () => timeoutController.abort(); @@ -279,6 +287,13 @@ class GrepToolInvocation extends BaseToolInvocation< max_matches_per_file: this.params.max_matches_per_file, signal: timeoutController.signal, }); + } catch (error) { + if (timeoutController.signal.aborted) { + throw new Error( + `Operation timed out after ${timeoutMs}ms. In large repositories, consider narrowing your search scope by specifying a 'dir_path' or an 'include_pattern'.`, + ); + } + throw error; } finally { clearTimeout(timeoutId); signal.removeEventListener('abort', onAbort); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index d1dfc415b7..a19520f0e1 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -136,6 +136,7 @@ describe('ShellTool', () => { getGeminiClient: vi.fn().mockReturnValue({}), getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000), getEnableInteractiveShell: vi.fn().mockReturnValue(false), + getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getSandboxEnabled: vi.fn().mockReturnValue(false), sanitizationConfig: {}, @@ -277,7 +278,7 @@ describe('ShellTool', () => { const result = await promise; - const wrappedCommand = `{ my-command & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + const wrappedCommand = `(\n${'my-command &'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, tempRootDir, @@ -295,6 +296,42 @@ describe('ShellTool', () => { expect(fs.existsSync(tmpFile)).toBe(false); }); + it('should add a space when command ends with a backslash to prevent escaping newline', async () => { + const invocation = shellTool.build({ command: 'ls\\' }); + const promise = invocation.execute(mockAbortSignal); + resolveShellExecution(); + await promise; + + const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); + const wrappedCommand = `(\nls\\ \n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + expect(mockShellExecutionService).toHaveBeenCalledWith( + wrappedCommand, + tempRootDir, + expect.any(Function), + expect.any(AbortSignal), + false, + expect.any(Object), + ); + }); + + it('should handle trailing comments correctly by placing them on their own line', async () => { + const invocation = shellTool.build({ command: 'ls # comment' }); + const promise = invocation.execute(mockAbortSignal); + resolveShellExecution(); + await promise; + + const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); + const wrappedCommand = `(\nls # comment\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + expect(mockShellExecutionService).toHaveBeenCalledWith( + wrappedCommand, + tempRootDir, + expect.any(Function), + expect.any(AbortSignal), + false, + expect.any(Object), + ); + }); + it('should use the provided absolute directory as cwd', async () => { const subdir = path.join(tempRootDir, 'subdir'); const invocation = shellTool.build({ @@ -306,7 +343,7 @@ describe('ShellTool', () => { await promise; const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); - const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + const wrappedCommand = `(\n${'ls'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, subdir, @@ -331,7 +368,7 @@ describe('ShellTool', () => { await promise; const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); - const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + const wrappedCommand = `(\n${'ls'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, path.join(tempRootDir, 'subdir'), diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 0b4760ccc7..63a9b1dc83 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -76,6 +76,33 @@ export class ShellToolInvocation extends BaseToolInvocation< super(params, messageBus, _toolName, _toolDisplayName); } + /** + * Wraps a command in a subshell `()` to capture background process IDs (PIDs) using pgrep. + * Uses newlines to prevent breaking heredocs or trailing comments. + * + * @param command The raw command string to execute. + * @param tempFilePath Path to the temporary file where PIDs will be written. + * @param isWindows Whether the current platform is Windows (if true, the command is returned as-is). + * @returns The wrapped command string. + */ + private wrapCommandForPgrep( + command: string, + tempFilePath: string, + isWindows: boolean, + ): string { + if (isWindows) { + return command; + } + let trimmed = command.trim(); + if (!trimmed) { + return ''; + } + if (trimmed.endsWith('\\')) { + trimmed += ' '; + } + return `(\n${trimmed}\n); __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; + } + private getContextualDetails(): string { let details = ''; // append optional [in directory] @@ -232,14 +259,11 @@ export class ShellToolInvocation extends BaseToolInvocation< try { // pgrep is not available on Windows, so we can't get background PIDs - const commandToExecute = isWindows - ? strippedCommand - : (() => { - // wrap command to append subprocess pids (via pgrep) to temporary file - let command = strippedCommand.trim(); - if (!command.endsWith('&')) command += ';'; - return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; - })(); + const commandToExecute = this.wrapCommandForPgrep( + strippedCommand, + tempFilePath, + isWindows, + ); const cwd = this.params.dir_path ? path.resolve(this.context.config.getTargetDir(), this.params.dir_path) @@ -333,6 +357,8 @@ export class ShellToolInvocation extends BaseToolInvocation< this.context.config.sanitizationConfig, sandboxManager: this.context.config.sandboxManager, additionalPermissions: this.params[PARAM_ADDITIONAL_PERMISSIONS], + backgroundCompletionBehavior: + this.context.config.getShellBackgroundCompletionBehavior(), }, ); diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 1bd97aca9c..935c1834e7 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -75,6 +75,11 @@ import { PLAN_MODE_PARAM_REASON, EXIT_PLAN_PARAM_PLAN_FILENAME, SKILL_PARAM_NAME, + UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, + TOPIC_PARAM_STRATEGIC_INTENT, } from './definitions/coreTools.js'; export { @@ -95,6 +100,8 @@ export { ASK_USER_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, + UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, // Shared parameter names PARAM_FILE_PATH, PARAM_DIR_PATH, @@ -148,6 +155,9 @@ export { PLAN_MODE_PARAM_REASON, EXIT_PLAN_PARAM_PLAN_FILENAME, SKILL_PARAM_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, + TOPIC_PARAM_STRATEGIC_INTENT, }; export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]); @@ -253,6 +263,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [ GET_INTERNAL_DOCS_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, + UPDATE_TOPIC_TOOL_NAME, ] as const; /** @@ -269,6 +280,7 @@ export const PLAN_MODE_TOOLS = [ ASK_USER_TOOL_NAME, ACTIVATE_SKILL_TOOL_NAME, GET_INTERNAL_DOCS_TOOL_NAME, + UPDATE_TOPIC_TOOL_NAME, 'codebase_investigator', 'cli_help', ] as const; diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 291f43d908..3d27171ad1 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -19,7 +19,10 @@ import { Config, type ConfigParameters } from '../config/config.js'; import { ApprovalMode } from '../policy/types.js'; import { ToolRegistry, DiscoveredTool } from './tool-registry.js'; -import { DISCOVERED_TOOL_PREFIX } from './tool-names.js'; +import { + DISCOVERED_TOOL_PREFIX, + UPDATE_TOPIC_TOOL_NAME, +} from './tool-names.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; import { mcpToTool, @@ -800,6 +803,36 @@ describe('ToolRegistry', () => { const toolNames = allTools.map((t) => t.name); expect(toolNames).not.toContain('mcp_test-server_write-mcp-tool'); }); + + it('should exclude topic tool when narration is disabled in config', () => { + const topicTool = new MockTool({ + name: UPDATE_TOPIC_TOOL_NAME, + displayName: 'Topic Tool', + }); + toolRegistry.registerTool(topicTool); + + vi.spyOn(config, 'isTopicUpdateNarrationEnabled').mockReturnValue(false); + mockConfigGetExcludedTools.mockReturnValue(new Set()); + + expect(toolRegistry.getAllToolNames()).not.toContain( + UPDATE_TOPIC_TOOL_NAME, + ); + expect(toolRegistry.getTool(UPDATE_TOPIC_TOOL_NAME)).toBeUndefined(); + }); + + it('should NOT exclude topic tool when narration is enabled in config', () => { + const topicTool = new MockTool({ + name: UPDATE_TOPIC_TOOL_NAME, + displayName: 'Topic Tool', + }); + toolRegistry.registerTool(topicTool); + + vi.spyOn(config, 'isTopicUpdateNarrationEnabled').mockReturnValue(true); + mockConfigGetExcludedTools.mockReturnValue(new Set()); + + expect(toolRegistry.getAllToolNames()).toContain(UPDATE_TOPIC_TOOL_NAME); + expect(toolRegistry.getTool(UPDATE_TOPIC_TOOL_NAME)).toBe(topicTool); + }); }); describe('DiscoveredToolInvocation', () => { diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index c91e4ca7e3..a059c964d0 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -30,6 +30,7 @@ import { getToolAliases, WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, + UPDATE_TOPIC_TOOL_NAME, } from './tool-names.js'; type ToolParams = Record; @@ -576,6 +577,12 @@ export class ToolRegistry { ), ) ?? new Set([]); + if (tool.name === UPDATE_TOPIC_TOOL_NAME) { + if (!this.config.isTopicUpdateNarrationEnabled()) { + return false; + } + } + const normalizedClassName = tool.constructor.name.replace(/^_+/, ''); const possibleNames = [tool.name, normalizedClassName]; if (tool instanceof DiscoveredMCPTool) { diff --git a/packages/core/src/tools/topicTool.test.ts b/packages/core/src/tools/topicTool.test.ts new file mode 100644 index 0000000000..25d2730e8c --- /dev/null +++ b/packages/core/src/tools/topicTool.test.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { UpdateTopicTool } from './topicTool.js'; +import { TopicState } from '../config/topicState.js'; +import { MessageBus } from '../confirmation-bus/message-bus.js'; +import type { PolicyEngine } from '../policy/policy-engine.js'; +import { + UPDATE_TOPIC_TOOL_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, + TOPIC_PARAM_STRATEGIC_INTENT, +} from './definitions/base-declarations.js'; +import type { Config } from '../config/config.js'; + +describe('TopicState', () => { + let state: TopicState; + + beforeEach(() => { + state = new TopicState(); + }); + + it('should store and retrieve topic title and intent', () => { + expect(state.getTopic()).toBeUndefined(); + expect(state.getIntent()).toBeUndefined(); + const success = state.setTopic('Test Topic', 'Test Intent'); + expect(success).toBe(true); + expect(state.getTopic()).toBe('Test Topic'); + expect(state.getIntent()).toBe('Test Intent'); + }); + + it('should sanitize newlines and carriage returns', () => { + state.setTopic('Topic\nWith\r\nLines', 'Intent\nWith\r\nLines'); + expect(state.getTopic()).toBe('Topic With Lines'); + expect(state.getIntent()).toBe('Intent With Lines'); + }); + + it('should trim whitespace', () => { + state.setTopic(' Spaced Topic ', ' Spaced Intent '); + expect(state.getTopic()).toBe('Spaced Topic'); + expect(state.getIntent()).toBe('Spaced Intent'); + }); + + it('should reject empty or whitespace-only inputs', () => { + expect(state.setTopic('', '')).toBe(false); + }); + + it('should reset topic and intent', () => { + state.setTopic('Test Topic', 'Test Intent'); + state.reset(); + expect(state.getTopic()).toBeUndefined(); + expect(state.getIntent()).toBeUndefined(); + }); +}); + +describe('UpdateTopicTool', () => { + let tool: UpdateTopicTool; + let mockMessageBus: MessageBus; + let mockConfig: Config; + + beforeEach(() => { + mockMessageBus = new MessageBus(vi.mocked({} as PolicyEngine)); + // Mock enough of Config to satisfy the tool + mockConfig = { + topicState: new TopicState(), + } as unknown as Config; + tool = new UpdateTopicTool(mockConfig, mockMessageBus); + }); + + it('should have correct name and display name', () => { + expect(tool.name).toBe(UPDATE_TOPIC_TOOL_NAME); + expect(tool.displayName).toBe('Update Topic Context'); + }); + + it('should update TopicState and include strategic intent on execute', async () => { + const invocation = tool.build({ + [TOPIC_PARAM_TITLE]: 'New Chapter', + [TOPIC_PARAM_SUMMARY]: 'The goal is to implement X. Previously we did Y.', + [TOPIC_PARAM_STRATEGIC_INTENT]: 'Initial Move', + }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Current topic: "New Chapter"'); + expect(result.llmContent).toContain( + 'Topic summary: The goal is to implement X. Previously we did Y.', + ); + expect(result.llmContent).toContain('Strategic Intent: Initial Move'); + expect(mockConfig.topicState.getTopic()).toBe('New Chapter'); + expect(mockConfig.topicState.getIntent()).toBe('Initial Move'); + expect(result.returnDisplay).toContain('## ๐Ÿ“‚ Topic: **New Chapter**'); + expect(result.returnDisplay).toContain('**Summary:**'); + expect(result.returnDisplay).toContain( + '> [!STRATEGY]\n> **Intent:** Initial Move', + ); + }); + + it('should render only intent for tactical updates (same topic)', async () => { + mockConfig.topicState.setTopic('New Chapter'); + + const invocation = tool.build({ + [TOPIC_PARAM_TITLE]: 'New Chapter', + [TOPIC_PARAM_STRATEGIC_INTENT]: 'Subsequent Move', + }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.returnDisplay).not.toContain('## ๐Ÿ“‚ Topic:'); + expect(result.returnDisplay).toBe( + '> [!STRATEGY]\n> **Intent:** Subsequent Move', + ); + expect(result.llmContent).toBe('Strategic Intent: Subsequent Move'); + }); + + it('should return error if strategic_intent is missing', async () => { + try { + tool.build({ + [TOPIC_PARAM_TITLE]: 'Title', + }); + expect.fail('Should have thrown validation error'); + } catch (e: unknown) { + if (e instanceof Error) { + expect(e.message).toContain( + "must have required property 'strategic_intent'", + ); + } else { + expect.fail('Expected Error instance'); + } + } + expect(mockConfig.topicState.getTopic()).toBeUndefined(); + }); +}); diff --git a/packages/core/src/tools/topicTool.ts b/packages/core/src/tools/topicTool.ts new file mode 100644 index 0000000000..91d1b5abc5 --- /dev/null +++ b/packages/core/src/tools/topicTool.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, + TOPIC_PARAM_STRATEGIC_INTENT, +} from './definitions/coreTools.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolResult, +} from './tools.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { getUpdateTopicDeclaration } from './definitions/dynamic-declaration-helpers.js'; +import type { Config } from '../config/config.js'; + +interface UpdateTopicParams { + [TOPIC_PARAM_TITLE]?: string; + [TOPIC_PARAM_SUMMARY]?: string; + [TOPIC_PARAM_STRATEGIC_INTENT]?: string; +} + +class UpdateTopicInvocation extends BaseToolInvocation< + UpdateTopicParams, + ToolResult +> { + constructor( + params: UpdateTopicParams, + messageBus: MessageBus, + toolName: string, + private readonly config: Config, + ) { + super(params, messageBus, toolName); + } + + getDescription(): string { + const title = this.params[TOPIC_PARAM_TITLE]; + const intent = this.params[TOPIC_PARAM_STRATEGIC_INTENT]; + if (title) { + return `Update topic to: "${title}"`; + } + return `Update tactical intent: "${intent || '...'}"`; + } + + async execute(): Promise { + const title = this.params[TOPIC_PARAM_TITLE]; + const summary = this.params[TOPIC_PARAM_SUMMARY]; + const strategicIntent = this.params[TOPIC_PARAM_STRATEGIC_INTENT]; + + const activeTopic = this.config.topicState.getTopic(); + const isNewTopic = !!( + title && + title.trim() !== '' && + title.trim() !== activeTopic + ); + + this.config.topicState.setTopic(title, strategicIntent); + + const currentTopic = this.config.topicState.getTopic() || '...'; + const currentIntent = + strategicIntent || this.config.topicState.getIntent() || '...'; + + debugLogger.log( + `[TopicTool] Update: Topic="${currentTopic}", Intent="${currentIntent}", isNew=${isNewTopic}`, + ); + + let llmContent = ''; + let returnDisplay = ''; + + if (isNewTopic) { + // Handle New Topic Header & Summary + llmContent = `Current topic: "${currentTopic}"\nTopic summary: ${summary || '...'}`; + returnDisplay = `## ๐Ÿ“‚ Topic: **${currentTopic}**\n\n**Summary:**\n${summary || '...'}`; + + if (strategicIntent && strategicIntent.trim()) { + llmContent += `\n\nStrategic Intent: ${strategicIntent.trim()}`; + returnDisplay += `\n\n> [!STRATEGY]\n> **Intent:** ${strategicIntent.trim()}`; + } + } else { + // Tactical update only + llmContent = `Strategic Intent: ${currentIntent}`; + returnDisplay = `> [!STRATEGY]\n> **Intent:** ${currentIntent}`; + } + + return { + llmContent, + returnDisplay, + }; + } +} + +/** + * Tool to update semantic topic context and tactical intent for UI grouping and model focus. + */ +export class UpdateTopicTool extends BaseDeclarativeTool< + UpdateTopicParams, + ToolResult +> { + constructor( + private readonly config: Config, + messageBus: MessageBus, + ) { + const declaration = getUpdateTopicDeclaration(); + super( + UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, + declaration.description ?? '', + Kind.Think, + declaration.parametersJsonSchema, + messageBus, + ); + } + + protected createInvocation( + params: UpdateTopicParams, + messageBus: MessageBus, + ): UpdateTopicInvocation { + return new UpdateTopicInvocation( + params, + messageBus, + this.name, + this.config, + ); + } +} diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 8ec6909b41..9e18a41f66 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -1269,6 +1269,96 @@ included directory memory expect(result.files[0].path).toBe(subDirMemory); expect(result.files[0].content).toBe('Content without git'); }); + + it('should stop at a custom boundary marker instead of .git', async () => { + const rootDir = await createEmptyDir( + path.join(testRootDir, 'custom_marker'), + ); + // Use a custom marker file instead of .git + await createTestFile(path.join(rootDir, '.monorepo-root'), ''); + const subDir = await createEmptyDir(path.join(rootDir, 'packages/app')); + const targetFile = path.join(subDir, 'file.ts'); + + const rootMemory = await createTestFile( + path.join(rootDir, DEFAULT_CONTEXT_FILENAME), + 'Root rules', + ); + const subDirMemory = await createTestFile( + path.join(subDir, DEFAULT_CONTEXT_FILENAME), + 'App rules', + ); + + const result = await loadJitSubdirectoryMemory( + targetFile, + [rootDir], + new Set(), + undefined, + ['.monorepo-root'], + ); + + expect(result.files).toHaveLength(2); + expect(result.files.find((f) => f.path === rootMemory)).toBeDefined(); + expect(result.files.find((f) => f.path === subDirMemory)).toBeDefined(); + }); + + it('should support multiple boundary markers', async () => { + const rootDir = await createEmptyDir( + path.join(testRootDir, 'multi_marker'), + ); + // Use a non-.git marker + await createTestFile(path.join(rootDir, 'package.json'), '{}'); + const subDir = await createEmptyDir(path.join(rootDir, 'src')); + const targetFile = path.join(subDir, 'index.ts'); + + const rootMemory = await createTestFile( + path.join(rootDir, DEFAULT_CONTEXT_FILENAME), + 'Root content', + ); + + const result = await loadJitSubdirectoryMemory( + targetFile, + [rootDir], + new Set(), + undefined, + ['.git', 'package.json'], + ); + + // Should find the root because package.json is a marker + expect(result.files).toHaveLength(1); + expect(result.files[0].path).toBe(rootMemory); + }); + + it('should disable parent traversal when boundary markers array is empty', async () => { + const rootDir = await createEmptyDir( + path.join(testRootDir, 'empty_markers'), + ); + await createEmptyDir(path.join(rootDir, '.git')); + const subDir = await createEmptyDir(path.join(rootDir, 'subdir')); + const targetFile = path.join(subDir, 'target.txt'); + + await createTestFile( + path.join(rootDir, DEFAULT_CONTEXT_FILENAME), + 'Root content', + ); + const subDirMemory = await createTestFile( + path.join(subDir, DEFAULT_CONTEXT_FILENAME), + 'Subdir content', + ); + + const result = await loadJitSubdirectoryMemory( + targetFile, + [rootDir], + new Set(), + undefined, + [], + ); + + // With empty markers, no project root is found so the trusted root + // is used as the ceiling. Traversal still finds files between the + // target path and the trusted root. + expect(result.files).toHaveLength(2); + expect(result.files.find((f) => f.path === subDirMemory)).toBeDefined(); + }); }); it('refreshServerHierarchicalMemory should refresh memory and update config', async () => { @@ -1341,6 +1431,7 @@ included directory memory getImportFormat: vi.fn().mockReturnValue('tree'), getFileFilteringOptions: vi.fn().mockReturnValue(undefined), getDiscoveryMaxDirs: vi.fn().mockReturnValue(200), + getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']), setUserMemory: vi.fn(), setGeminiMdFileCount: vi.fn(), setGeminiMdFilePaths: vi.fn(), diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 21b87330a1..01b9f9fb5a 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -146,41 +146,54 @@ export async function deduplicatePathsByFileIdentity( }; } -async function findProjectRoot(startDir: string): Promise { +async function findProjectRoot( + startDir: string, + boundaryMarkers: readonly string[] = ['.git'], +): Promise { + if (boundaryMarkers.length === 0) { + return null; + } + let currentDir = normalizePath(startDir); while (true) { - const gitPath = path.join(currentDir, '.git'); - try { - // Check for existence only โ€” .git can be a directory (normal repos) - // or a file (submodules / worktrees). - await fs.access(gitPath); - return currentDir; - } catch (error: unknown) { - // Don't log ENOENT errors as they're expected when .git doesn't exist - // Also don't log errors in test environments, which often have mocked fs - const isENOENT = - typeof error === 'object' && - error !== null && - 'code' in error && - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (error as { code: string }).code === 'ENOENT'; - - // Only log unexpected errors in non-test environments - // process.env['NODE_ENV'] === 'test' or VITEST are common test indicators - const isTestEnv = - process.env['NODE_ENV'] === 'test' || process.env['VITEST']; - - if (!isENOENT && !isTestEnv) { - if (typeof error === 'object' && error !== null && 'code' in error) { + for (const marker of boundaryMarkers) { + // Sanitize: skip markers with path traversal or absolute paths + if (path.isAbsolute(marker) || marker.includes('..')) { + continue; + } + const markerPath = path.join(currentDir, marker); + try { + // Check for existence only โ€” marker can be a directory (normal repos) + // or a file (submodules / worktrees). + await fs.access(markerPath); + return currentDir; + } catch (error: unknown) { + // Don't log ENOENT errors as they're expected when marker doesn't exist + // Also don't log errors in test environments, which often have mocked fs + const isENOENT = + typeof error === 'object' && + error !== null && + 'code' in error && // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const fsError = error as { code: string; message: string }; - logger.warn( - `Error checking for .git at ${gitPath}: ${fsError.message}`, - ); - } else { - logger.warn( - `Non-standard error checking for .git at ${gitPath}: ${String(error)}`, - ); + (error as { code: string }).code === 'ENOENT'; + + // Only log unexpected errors in non-test environments + // process.env['NODE_ENV'] === 'test' or VITEST are common test indicators + const isTestEnv = + process.env['NODE_ENV'] === 'test' || process.env['VITEST']; + + if (!isENOENT && !isTestEnv) { + if (typeof error === 'object' && error !== null && 'code' in error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const fsError = error as { code: string; message: string }; + logger.warn( + `Error checking for ${marker} at ${markerPath}: ${fsError.message}`, + ); + } else { + logger.warn( + `Non-standard error checking for ${marker} at ${markerPath}: ${String(error)}`, + ); + } } } } @@ -200,6 +213,7 @@ async function getGeminiMdFilePathsInternal( folderTrust: boolean, fileFilteringOptions: FileFilteringOptions, maxDirs: number, + boundaryMarkers: readonly string[] = ['.git'], ): Promise<{ global: string[]; project: string[] }> { const dirs = new Set([ ...includeDirectoriesToReadGemini, @@ -222,6 +236,7 @@ async function getGeminiMdFilePathsInternal( folderTrust, fileFilteringOptions, maxDirs, + boundaryMarkers, ), ); @@ -253,6 +268,7 @@ async function getGeminiMdFilePathsInternalForEachDir( folderTrust: boolean, fileFilteringOptions: FileFilteringOptions, maxDirs: number, + boundaryMarkers: readonly string[] = ['.git'], ): Promise<{ global: string[]; project: string[] }> { const globalPaths = new Set(); const projectPaths = new Set(); @@ -289,7 +305,7 @@ async function getGeminiMdFilePathsInternalForEachDir( resolvedCwd, ); - const projectRoot = await findProjectRoot(resolvedCwd); + const projectRoot = await findProjectRoot(resolvedCwd, boundaryMarkers); debugLogger.debug( '[DEBUG] [MemoryDiscovery] Determined project root:', projectRoot ?? 'None', @@ -356,6 +372,7 @@ async function getGeminiMdFilePathsInternalForEachDir( export async function readGeminiMdFiles( filePaths: string[], importFormat: 'flat' | 'tree' = 'tree', + boundaryMarkers: readonly string[] = ['.git'], ): Promise { // Process files in parallel with concurrency limit to prevent EMFILE errors const CONCURRENT_LIMIT = 20; // Higher limit for file reads as they're typically faster @@ -376,6 +393,7 @@ export async function readGeminiMdFiles( undefined, undefined, importFormat, + boundaryMarkers, ); debugLogger.debug( '[DEBUG] [MemoryDiscovery] Successfully read and processed imports:', @@ -481,13 +499,14 @@ export function getExtensionMemoryPaths( export async function getEnvironmentMemoryPaths( trustedRoots: string[], + boundaryMarkers: readonly string[] = ['.git'], ): Promise { const allPaths = new Set(); // Trusted Roots Upward Traversal (Parallelized) const traversalPromises = trustedRoots.map(async (root) => { const resolvedRoot = normalizePath(root); - const gitRoot = await findProjectRoot(resolvedRoot); + const gitRoot = await findProjectRoot(resolvedRoot, boundaryMarkers); const ceiling = gitRoot ? normalizePath(gitRoot) : resolvedRoot; debugLogger.debug( '[DEBUG] [MemoryDiscovery] Loading environment memory for trusted root:', @@ -597,6 +616,7 @@ export async function loadServerHierarchicalMemory( importFormat: 'flat' | 'tree' = 'tree', fileFilteringOptions?: FileFilteringOptions, maxDirs: number = 200, + boundaryMarkers: readonly string[] = ['.git'], ): Promise { // FIX: Use real, canonical paths for a reliable comparison to handle symlinks. const realCwd = normalizePath( @@ -629,6 +649,7 @@ export async function loadServerHierarchicalMemory( folderTrust, fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, maxDirs, + boundaryMarkers, ), Promise.resolve(getExtensionMemoryPaths(extensionLoader)), ]); @@ -669,7 +690,11 @@ export async function loadServerHierarchicalMemory( } // 2. GATHER: Read all files in parallel - const allContents = await readGeminiMdFiles(allFilePaths, importFormat); + const allContents = await readGeminiMdFiles( + allFilePaths, + importFormat, + boundaryMarkers, + ); const contentsMap = new Map(allContents.map((c) => [c.filePath, c])); // 3. CATEGORIZE: Back into Global, Project, Extension @@ -707,6 +732,7 @@ export async function refreshServerHierarchicalMemory(config: Config) { config.getImportFormat(), config.getFileFilteringOptions(), config.getDiscoveryMaxDirs(), + config.getMemoryBoundaryMarkers(), ); const mcpInstructions = config.getMcpClientManager()?.getMcpInstructions() || ''; @@ -728,6 +754,7 @@ export async function loadJitSubdirectoryMemory( trustedRoots: string[], alreadyLoadedPaths: Set, alreadyLoadedIdentities?: Set, + boundaryMarkers: readonly string[] = ['.git'], ): Promise { const resolvedTarget = normalizePath(targetPath); let bestRoot: string | null = null; @@ -760,7 +787,7 @@ export async function loadJitSubdirectoryMemory( // Find the git root to use as the traversal ceiling. // If no git root exists, fall back to the trusted root as the ceiling. - const gitRoot = await findProjectRoot(bestRoot); + const gitRoot = await findProjectRoot(bestRoot, boundaryMarkers); const resolvedCeiling = gitRoot ? normalizePath(gitRoot) : bestRoot; debugLogger.debug( @@ -850,7 +877,7 @@ export async function loadJitSubdirectoryMemory( JSON.stringify(newPaths), ); - const contents = await readGeminiMdFiles(newPaths, 'tree'); + const contents = await readGeminiMdFiles(newPaths, 'tree', boundaryMarkers); return { files: contents diff --git a/packages/core/src/utils/memoryImportProcessor.ts b/packages/core/src/utils/memoryImportProcessor.ts index 10bf1ad592..dc4b0b8537 100644 --- a/packages/core/src/utils/memoryImportProcessor.ts +++ b/packages/core/src/utils/memoryImportProcessor.ts @@ -48,18 +48,31 @@ export interface ProcessImportsResult { importTree: MemoryFile; } -// Helper to find the project root (looks for .git directory or file for worktrees) -async function findProjectRoot(startDir: string): Promise { +// Helper to find the project root (looks for boundary marker directories/files) +async function findProjectRoot( + startDir: string, + boundaryMarkers: readonly string[] = ['.git'], +): Promise { + if (boundaryMarkers.length === 0) { + return path.resolve(startDir); + } + let currentDir = path.resolve(startDir); while (true) { - const gitPath = path.join(currentDir, '.git'); - try { - // Check for existence only โ€” .git can be a directory (normal repos) - // or a file (submodules / worktrees). - await fs.access(gitPath); - return currentDir; - } catch { - // .git not found, continue to parent + for (const marker of boundaryMarkers) { + // Sanitize: skip markers with path traversal or absolute paths + if (path.isAbsolute(marker) || marker.includes('..')) { + continue; + } + const markerPath = path.join(currentDir, marker); + try { + // Check for existence only โ€” marker can be a directory (normal repos) + // or a file (submodules / worktrees). + await fs.access(markerPath); + return currentDir; + } catch { + // marker not found, continue + } } const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { @@ -68,7 +81,7 @@ async function findProjectRoot(startDir: string): Promise { } currentDir = parentDir; } - // Fallback to startDir if .git not found + // Fallback to startDir if no marker found return path.resolve(startDir); } @@ -185,9 +198,10 @@ export async function processImports( }, projectRoot?: string, importFormat: 'flat' | 'tree' = 'tree', + boundaryMarkers: readonly string[] = ['.git'], ): Promise { if (!projectRoot) { - projectRoot = await findProjectRoot(basePath); + projectRoot = await findProjectRoot(basePath, boundaryMarkers); } if (importState.currentDepth >= importState.maxDepth) { @@ -346,6 +360,7 @@ export async function processImports( newImportState, projectRoot, importFormat, + boundaryMarkers, ); result += `\n${imported.content}\n`; imports.push(imported.importTree); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 11e17ca358..e2a240a0b0 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -408,7 +408,9 @@ function hasPromptCommandTransform(root: Node): boolean { return false; } -function parseBashCommandDetails(command: string): CommandParseResult | null { +export function parseBashCommandDetails( + command: string, +): CommandParseResult | null { if (treeSitterInitializationError) { debugLogger.debug( 'Bash parser not initialized:', @@ -557,7 +559,19 @@ export function parseCommandDetails( const configuration = getShellConfiguration(); if (configuration.shell === 'powershell') { - return parsePowerShellCommandDetails(command, configuration.executable); + const result = parsePowerShellCommandDetails( + command, + configuration.executable, + ); + if (!result || result.hasError) { + // Fallback to bash parser which is usually good enough for simple commands + // and doesn't rely on the host PowerShell environment restrictions (e.g., ConstrainedLanguage) + const bashResult = parseBashCommandDetails(command); + if (bashResult && !bashResult.hasError) { + return bashResult; + } + } + return result; } if (configuration.shell === 'bash') { diff --git a/packages/core/src/utils/textUtils.ts b/packages/core/src/utils/textUtils.ts index 2069eedca9..4f26151e70 100644 --- a/packages/core/src/utils/textUtils.ts +++ b/packages/core/src/utils/textUtils.ts @@ -154,3 +154,21 @@ export function safeTemplateReplace( : match, ); } + +/** + * Sanitizes output for injection into the model conversation. + * Wraps output in a secure tag and handles potential injection vectors + * (like closing tags or template patterns) within the data. + * @param output The raw output to sanitize. + * @returns The sanitized string ready for injection. + */ +export function sanitizeOutput(output: string): string { + const trimmed = output.trim(); + if (trimmed.length === 0) { + return ''; + } + + // Prevent direct closing tag injection. + const escaped = trimmed.replaceAll('', '</output>'); + return `\n${escaped}\n`; +} diff --git a/packages/devtools/GEMINI.md b/packages/devtools/GEMINI.md index 9da1828a25..7397cedf84 100644 --- a/packages/devtools/GEMINI.md +++ b/packages/devtools/GEMINI.md @@ -51,10 +51,11 @@ gemini.tsx / nonInteractiveCli.ts ## API Endpoints -| Endpoint | Method | Description | -| --------- | --------- | --------------------------------------------------------------------------- | -| `/ws` | WebSocket | Log ingestion from CLI sessions (register, network, console) | -| `/events` | SSE | Pushes snapshot on connect, then incremental network/console/session events | +| Endpoint | Method | Description | +| ----------------------- | --------- | --------------------------------------------------------------------------- | +| `/ws` | WebSocket | Log ingestion from CLI sessions (register, network, console) | +| `/events` | SSE | Pushes snapshot on connect, then incremental network/console/session events | +| `/api/trigger-debugger` | POST | Triggers the Node.js debugger for a specific CLI session via WebSocket | ## Development diff --git a/packages/devtools/client/src/App.tsx b/packages/devtools/client/src/App.tsx index 9c531435b4..7869b93c3c 100644 --- a/packages/devtools/client/src/App.tsx +++ b/packages/devtools/client/src/App.tsx @@ -39,6 +39,21 @@ export default function App() { null, ); + // --- Toast Logic --- + const [toastMessage, setToastMessage] = useState(null); + const toastTimeoutRef = useRef | null>(null); + + const showToast = (msg: string) => { + setToastMessage(msg); + if (toastTimeoutRef.current) { + clearTimeout(toastTimeoutRef.current); + } + toastTimeoutRef.current = setTimeout(() => { + setToastMessage(null); + toastTimeoutRef.current = null; + }, 5000); + }; + // --- Theme Logic --- const [themeMode, setThemeMode] = useState(() => { const saved = localStorage.getItem('devtools-theme'); @@ -306,21 +321,52 @@ export default function App() { > {selectedSessionId && connectedSessions.includes(selectedSessionId) && ( - + <> + + + )}