mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 809de1fbbf | |||
| 4523560278 | |||
| f08b4af654 | |||
| 8e99c26dd8 | |||
| 0567b25a26 | |||
| f40498db64 | |||
| 4196596f7f | |||
| dceb2ea306 | |||
| e4315b36eb | |||
| d2cd12a7cb | |||
| ae87e208ac | |||
| cfcecebe80 | |||
| 5110bdf56c | |||
| 665228e983 | |||
| 013914071c | |||
| 211e7d1aec | |||
| b77beba13a | |||
| c82e2b5976 | |||
| bd53951dc8 | |||
| 5cac7c10fa | |||
| 41c9260cae | |||
| 8b56d27901 | |||
| 85563dabe8 | |||
| 4a5d5cfc9f | |||
| 630ecc21b9 | |||
| 3cc7e5b096 | |||
| a00d03efc6 | |||
| 980a2574df | |||
| 5188601de0 | |||
| e6f92d66f6 | |||
| d1fa323cfb | |||
| ba04e99bea | |||
| 6afb109953 |
@@ -0,0 +1,107 @@
|
||||
name: 'PR Size Labeler (Batch)'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
process_all:
|
||||
description: 'Process all PRs (open and closed) or open only'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: 'choice'
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
limit:
|
||||
description: 'Max number of PRs to fetch and check'
|
||||
required: true
|
||||
default: '100'
|
||||
type: 'string'
|
||||
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
batch-label:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Batch label PRs'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GH_REPO: '${{ github.repository }}'
|
||||
run: |
|
||||
# Determine the state filter
|
||||
STATE="open"
|
||||
if [ "${{ github.event.inputs.process_all }}" = "true" ]; then
|
||||
STATE="all"
|
||||
fi
|
||||
|
||||
LIMIT="${{ github.event.inputs.limit }}"
|
||||
echo "Batch labeling up to $LIMIT $STATE PRs..."
|
||||
|
||||
# 1. Ensure standard premium size labels exist in the repository (self-healing)
|
||||
gh label create "size/XS" --color "7ee081" --description "XS: <10 lines changed" 2>/dev/null || true
|
||||
gh label create "size/S" --color "a6d49f" --description "S: 10-49 lines changed" 2>/dev/null || true
|
||||
gh label create "size/M" --color "f7d070" --description "M: 50-249 lines changed" 2>/dev/null || true
|
||||
gh label create "size/L" --color "f48c06" --description "L: 250-999 lines changed" 2>/dev/null || true
|
||||
gh label create "size/XL" --color "dc2f02" --description "XL: >=1000 lines changed" 2>/dev/null || true
|
||||
|
||||
# 2. Query PR list with all required fields in ONE call to prevent N+1 queries
|
||||
PR_LIST=$(gh pr list --state "$STATE" --limit "$LIMIT" --json number,additions,deletions,labels)
|
||||
if [ -z "$PR_LIST" ] || [ "$PR_LIST" = "[]" ]; then
|
||||
echo "ℹ️ No PRs found matching the criteria."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse and iterate over PRs
|
||||
UPDATED_COUNT=0
|
||||
SKIPPED_COUNT=0
|
||||
|
||||
echo "$PR_LIST" | jq -c '.[]' | while read -r PR_JSON; do
|
||||
PR_NUMBER=$(echo "$PR_JSON" | jq '.number')
|
||||
ADDITIONS=$(echo "$PR_JSON" | jq '.additions')
|
||||
DELETIONS=$(echo "$PR_JSON" | jq '.deletions')
|
||||
TOTAL=$((ADDITIONS + DELETIONS))
|
||||
|
||||
# Calculate target size
|
||||
if [ $TOTAL -lt 10 ]; then
|
||||
SIZE="size/XS"
|
||||
elif [ $TOTAL -lt 50 ]; then
|
||||
SIZE="size/S"
|
||||
elif [ $TOTAL -lt 250 ]; then
|
||||
SIZE="size/M"
|
||||
elif [ $TOTAL -lt 1000 ]; then
|
||||
SIZE="size/L"
|
||||
else
|
||||
SIZE="size/XL"
|
||||
fi
|
||||
|
||||
# Inspect existing labels to detect discrepancies
|
||||
EXISTING_LABELS=$(echo "$PR_JSON" | jq -r '.labels[].name' 2>/dev/null || echo "")
|
||||
|
||||
LABELS_TO_REMOVE=()
|
||||
for L in size/XS size/S size/M size/L size/XL; do
|
||||
if echo "$EXISTING_LABELS" | grep -Fq "$L" && [ "$L" != "$SIZE" ]; then
|
||||
LABELS_TO_REMOVE+=("--remove-label" "$L")
|
||||
fi
|
||||
done
|
||||
|
||||
LABEL_TO_ADD=()
|
||||
if ! echo "$EXISTING_LABELS" | grep -Fq "$SIZE"; then
|
||||
LABEL_TO_ADD+=("--add-label" "$SIZE")
|
||||
fi
|
||||
|
||||
# Update labels if there's a difference
|
||||
if [ ${#LABELS_TO_REMOVE[@]} -gt 0 ] || [ ${#LABEL_TO_ADD[@]} -gt 0 ]; then
|
||||
echo "🔄 PR #$PR_NUMBER (+$ADDITIONS/-$DELETIONS = $TOTAL lines): updating size to $SIZE"
|
||||
gh pr edit "$PR_NUMBER" "${LABELS_TO_REMOVE[@]}" "${LABEL_TO_ADD[@]}" 2>/dev/null || true
|
||||
UPDATED_COUNT=$((UPDATED_COUNT + 1))
|
||||
else
|
||||
echo "✅ PR #$PR_NUMBER (+$ADDITIONS/-$DELETIONS = $TOTAL lines): already has correct label ($SIZE). Skipping."
|
||||
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "============================================"
|
||||
echo "🎉 Batch run completed!"
|
||||
echo "Skipped (already correct): $SKIPPED_COUNT"
|
||||
echo "Updated: $UPDATED_COUNT"
|
||||
@@ -0,0 +1,120 @@
|
||||
name: 'PR Size Labeler'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: ['opened', 'synchronize', 'reopened']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to label manually (for workflow_dispatch)'
|
||||
required: false
|
||||
type: 'string'
|
||||
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
issues: 'write'
|
||||
|
||||
jobs:
|
||||
size-label:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Run size labeler'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GH_REPO: '${{ github.repository }}'
|
||||
run: |
|
||||
# Determine the target PR number
|
||||
if [ -n "${{ github.event.pull_request.number }}" ]; then
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
elif [ -n "${{ github.event.inputs.pr_number }}" ]; then
|
||||
PR_NUMBER="${{ github.event.inputs.pr_number }}"
|
||||
else
|
||||
echo "❌ Error: No PR number provided."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking PR #$PR_NUMBER..."
|
||||
|
||||
# 1. Ensure standard premium size labels exist in the repository (self-healing)
|
||||
# size/XS: Light green (#7ee081)
|
||||
# size/S: Yellow-green (#a6d49f)
|
||||
# size/M: Amber/Yellow (#f7d070)
|
||||
# size/L: Orange (#f48c06)
|
||||
# size/XL: Red (#dc2f02)
|
||||
gh label create "size/XS" --color "7ee081" --description "XS: <10 lines changed" 2>/dev/null || true
|
||||
gh label create "size/S" --color "a6d49f" --description "S: 10-49 lines changed" 2>/dev/null || true
|
||||
gh label create "size/M" --color "f7d070" --description "M: 50-249 lines changed" 2>/dev/null || true
|
||||
gh label create "size/L" --color "f48c06" --description "L: 250-999 lines changed" 2>/dev/null || true
|
||||
gh label create "size/XL" --color "dc2f02" --description "XL: >=1000 lines changed" 2>/dev/null || true
|
||||
|
||||
# 2. Fetch PR details in a single efficient API call
|
||||
PR_DATA=$(gh pr view "$PR_NUMBER" --json additions,deletions,changedFiles,labels)
|
||||
if [ -z "$PR_DATA" ]; then
|
||||
echo "❌ Error: Could not fetch PR details."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ADDITIONS=$(echo "$PR_DATA" | jq '.additions')
|
||||
DELETIONS=$(echo "$PR_DATA" | jq '.deletions')
|
||||
CHANGED_FILES=$(echo "$PR_DATA" | jq '.changedFiles')
|
||||
TOTAL=$((ADDITIONS + DELETIONS))
|
||||
|
||||
echo "PR additions: $ADDITIONS, deletions: $DELETIONS, total changes: $TOTAL, files: $CHANGED_FILES"
|
||||
|
||||
# 3. Calculate new size label
|
||||
if [ $TOTAL -lt 10 ]; then
|
||||
SIZE="size/XS"
|
||||
elif [ $TOTAL -lt 50 ]; then
|
||||
SIZE="size/S"
|
||||
elif [ $TOTAL -lt 250 ]; then
|
||||
SIZE="size/M"
|
||||
elif [ $TOTAL -lt 1000 ]; then
|
||||
SIZE="size/L"
|
||||
else
|
||||
SIZE="size/XL"
|
||||
fi
|
||||
|
||||
# 4. Check existing labels and update only if necessary
|
||||
EXISTING_LABELS=$(echo "$PR_DATA" | jq -r '.labels[].name' 2>/dev/null || echo "")
|
||||
|
||||
LABELS_TO_REMOVE=()
|
||||
for L in size/XS size/S size/M size/L size/XL; do
|
||||
if echo "$EXISTING_LABELS" | grep -Fq "$L" && [ "$L" != "$SIZE" ]; then
|
||||
LABELS_TO_REMOVE+=("--remove-label" "$L")
|
||||
fi
|
||||
done
|
||||
|
||||
LABEL_TO_ADD=()
|
||||
if ! echo "$EXISTING_LABELS" | grep -Fq "$SIZE"; then
|
||||
LABEL_TO_ADD+=("--add-label" "$SIZE")
|
||||
fi
|
||||
|
||||
# Perform a single, highly atomic edit call if changes are needed
|
||||
if [ ${#LABELS_TO_REMOVE[@]} -gt 0 ] || [ ${#LABEL_TO_ADD[@]} -gt 0 ]; then
|
||||
echo "Updating labels: removing ${LABELS_TO_REMOVE[*]}, adding $SIZE"
|
||||
gh pr edit "$PR_NUMBER" "${LABELS_TO_REMOVE[@]}" "${LABEL_TO_ADD[@]}"
|
||||
else
|
||||
echo "✅ PR #$PR_NUMBER already has the correct size label ($SIZE)."
|
||||
fi
|
||||
|
||||
# 5. Premium, anti-spam comment logic (updates previous comment to keep thread clean)
|
||||
COMMENT="📊 PR Size: **$SIZE**
|
||||
- Lines changed: **$TOTAL**
|
||||
- Additions: +$ADDITIONS
|
||||
- Deletions: -$DELETIONS
|
||||
- Files changed: $CHANGED_FILES"
|
||||
|
||||
# Find any existing size labeler comment by the github-actions bot
|
||||
echo "Searching for existing size comment..."
|
||||
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments" \
|
||||
--jq '.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("📊 PR Size:"))) | .id' | head -n 1)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
echo "Updating existing comment (ID: $COMMENT_ID)..."
|
||||
gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -X PATCH -f body="$COMMENT" > /dev/null
|
||||
else
|
||||
echo "Creating new comment..."
|
||||
gh pr comment "$PR_NUMBER" --body "$COMMENT" > /dev/null
|
||||
fi
|
||||
|
||||
echo "🎉 PR size labeling completed successfully."
|
||||
@@ -18,6 +18,53 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.45.0 - 2026-06-03
|
||||
|
||||
- **Context Simplification:** Completed major architectural work to simplify the
|
||||
`ContextManager`, improving system robustness and performance
|
||||
([#27345](https://github.com/google-gemini/gemini-cli/pull/27345) by
|
||||
@joshualitt).
|
||||
- **A2A Usage Metadata:** Exposed critical usage metadata in the Agent-to-Agent
|
||||
(A2A) protocol for better resource tracking
|
||||
([#27288](https://github.com/google-gemini/gemini-cli/pull/27288) by
|
||||
@jvargassanchez-dot).
|
||||
- **Reliability Fixes:** Addressed Termux relaunch loops, PTY resize errors, and
|
||||
forced sequential execution for topic updates
|
||||
([#27110](https://github.com/google-gemini/gemini-cli/pull/27110) by @saymanq,
|
||||
[#27357](https://github.com/google-gemini/gemini-cli/pull/27357) by
|
||||
@jvargassanchez-dot,
|
||||
[#27461](https://github.com/google-gemini/gemini-cli/pull/27461) by
|
||||
@scidomino).
|
||||
|
||||
## Announcements: v0.44.0 - 2026-05-27
|
||||
|
||||
- **Unified Auto Mode:** Streamlined the automation experience by merging
|
||||
specialized Auto modes into a single, unified mode
|
||||
([#26714](https://github.com/google-gemini/gemini-cli/pull/26714) by
|
||||
@DavidAPierce).
|
||||
- **New Editor Integrations:** Added native support for Sublime Text and Emacs
|
||||
Client ([#21090](https://github.com/google-gemini/gemini-cli/pull/21090) by
|
||||
@alberti42).
|
||||
- **Enhanced TUI Testing:** Introduced `agent-tui` and `tui-tester` skills for
|
||||
programmatic testing and automation of terminal UI applications
|
||||
([#27121](https://github.com/google-gemini/gemini-cli/pull/27121) by
|
||||
@adamfweidman).
|
||||
|
||||
## Announcements: v0.43.0 - 2026-05-22
|
||||
|
||||
- **Surgical Code Edits:** Steered Gemini models to prefer the `edit` tool for
|
||||
surgical modifications, improving speed and precision
|
||||
([#26480](https://github.com/google-gemini/gemini-cli/pull/26480) by
|
||||
@aishaneeshah).
|
||||
- **Session Export and Import:** Added the ability to export sessions to files
|
||||
and import them via a new flag, facilitating session portability
|
||||
([#26514](https://github.com/google-gemini/gemini-cli/pull/26514) by
|
||||
@cocosheng-g).
|
||||
- **Adaptive Token Estimation:** Introduced an adaptive token calculator for
|
||||
more accurate content size estimation, enhancing context management efficiency
|
||||
([#26888](https://github.com/google-gemini/gemini-cli/pull/26888) by
|
||||
@joshualitt).
|
||||
|
||||
## Announcements: v0.42.0 - 2026-05-12
|
||||
|
||||
- **Auto Memory Inbox:** Introduced a new inbox flow for Auto Memory with a
|
||||
|
||||
+50
-264
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.42.0
|
||||
# Latest stable release: v0.45.3
|
||||
|
||||
Released: May 12, 2026
|
||||
Released: June 09, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,272 +11,58 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Auto Memory Inbox:** Introduced a new inbox flow for Auto Memory using a
|
||||
canonical-patch contract, enabling more robust and manageable skill
|
||||
extraction.
|
||||
- **Gemma 4 Default:** Gemma 4 models are now enabled by default via the Gemini
|
||||
API, providing improved performance and capabilities out of the box.
|
||||
- **Voice Mode Polish:** Added wave animations for visual feedback and
|
||||
privacy/compliance UX warnings specifically for the Gemini Live backend.
|
||||
- **Session Management:** Added a `--delete` flag to the `/exit` command for
|
||||
instant session deletion and introduced `/bug-memory` for easier heap
|
||||
diagnostics.
|
||||
- **Improved Reliability:** Reduced default API timeouts to 60s and implemented
|
||||
retries for undici and premature stream closure errors.
|
||||
- **Context Manager Simplification:** Completed a significant refactoring of the
|
||||
context management system to improve reliability and architectural clarity.
|
||||
- **A2A Usage Metadata:** Enhanced the Agent-to-Agent protocol to expose usage
|
||||
metadata, enabling more transparent resource monitoring.
|
||||
- **Terminal & PTY Robustness:** Resolved several critical issues related to
|
||||
terminal interactions, including Termux relaunch loops and PTY resize errors.
|
||||
- **Routing Optimizations:** Updated default auto-routing and bypassed
|
||||
classifiers for specific tool responses to prevent orphaned function errors.
|
||||
- **Tool Execution Control:** Forced the `update_topic` tool to execute
|
||||
sequentially, ensuring consistent narrative flow in agent interactions.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(cli): prevent automatic updates from switching to less stable channels by
|
||||
@Adib234 in [#26132](https://github.com/google-gemini/gemini-cli/pull/26132)
|
||||
- chore(release): bump version to 0.42.0-nightly.20260428.g59b2dea0e by
|
||||
- fix(patch): cherry-pick f08b4af to release/v0.45.2-pr-27749 to patch version
|
||||
v0.45.2 and create version 0.45.3 by @gemini-cli-robot in
|
||||
[#27769](https://github.com/google-gemini/gemini-cli/pull/27769)
|
||||
- chore(release): bump version to 0.45.0-nightly.20260521.g854f811be by
|
||||
@gemini-cli-robot in
|
||||
[#26142](https://github.com/google-gemini/gemini-cli/pull/26142)
|
||||
- fix(cli): pass node arguments via NODE_OPTIONS during relaunch to support SEA
|
||||
by @cocosheng-g in
|
||||
[#26130](https://github.com/google-gemini/gemini-cli/pull/26130)
|
||||
- fix(cli): handle DECKPAM keypad Enter sequences in terminal by @Gitanaskhan26
|
||||
in [#26092](https://github.com/google-gemini/gemini-cli/pull/26092)
|
||||
- docs(cli): point plan-mode session retention to actual /settings labels by
|
||||
@ifitisit in [#25978](https://github.com/google-gemini/gemini-cli/pull/25978)
|
||||
- fix(core): add missing oauth fields support in subagent parsing by
|
||||
@abhipatel12 in
|
||||
[#26141](https://github.com/google-gemini/gemini-cli/pull/26141)
|
||||
- fix(core): disconnect extension-backed MCP clients in stopExtension by
|
||||
@cocosheng-g in
|
||||
[#26136](https://github.com/google-gemini/gemini-cli/pull/26136)
|
||||
- Update documentation workflows with workspace trust by @g-samroberts in
|
||||
[#26150](https://github.com/google-gemini/gemini-cli/pull/26150)
|
||||
- refactor(acp): modularize monolithic acpClient into specialized files by
|
||||
@sripasg in [#26143](https://github.com/google-gemini/gemini-cli/pull/26143)
|
||||
- test: fix failures due to antigravity environment leakage by @adamfweidman in
|
||||
[#26162](https://github.com/google-gemini/gemini-cli/pull/26162)
|
||||
- fix(core): add explicit empty log guard in A2A pushMessage by @adamfweidman in
|
||||
[#26198](https://github.com/google-gemini/gemini-cli/pull/26198)
|
||||
- feat(cli): add --delete flag to /exit command for session deletion by
|
||||
@AbdulTawabJuly in
|
||||
[#19332](https://github.com/google-gemini/gemini-cli/pull/19332)
|
||||
- test(core): add regression test for issue for ToolConfirmationResponse by
|
||||
@Adib234 in [#26194](https://github.com/google-gemini/gemini-cli/pull/26194)
|
||||
- Add the ability to @ mention the gemini robot. by @gundermanc in
|
||||
[#26207](https://github.com/google-gemini/gemini-cli/pull/26207)
|
||||
- test(evals): add EvalMetadata JSDoc annotations to older tests by @akh64bit in
|
||||
[#26147](https://github.com/google-gemini/gemini-cli/pull/26147)
|
||||
- fix(core): reduce default API timeout to 60s and enable retries for undici
|
||||
timeouts by @Adib234 in
|
||||
[#26191](https://github.com/google-gemini/gemini-cli/pull/26191)
|
||||
- fix(core): distinguish fallback chains and fix maxAttempts for auto vs
|
||||
explicit model selection by @adamfweidman in
|
||||
[#26163](https://github.com/google-gemini/gemini-cli/pull/26163)
|
||||
- fix(cli): handle InvalidStream event gracefully without throwing by
|
||||
@adamfweidman in
|
||||
[#26218](https://github.com/google-gemini/gemini-cli/pull/26218)
|
||||
- ci(github-actions): switch to github app token and fix bot self-trigger by
|
||||
@gundermanc in
|
||||
[#26223](https://github.com/google-gemini/gemini-cli/pull/26223)
|
||||
- Respect logPrompts flag for logging sensitive fields by @lp-peg in
|
||||
[#26153](https://github.com/google-gemini/gemini-cli/pull/26153)
|
||||
- fix: correct API key validation logic in handleApiKeySubmit by
|
||||
@martin-hsu-test in
|
||||
[#25453](https://github.com/google-gemini/gemini-cli/pull/25453)
|
||||
- fix(agent): prevent exit_plan_mode from being called via shell by
|
||||
@Abhijit-2592 in
|
||||
[#26230](https://github.com/google-gemini/gemini-cli/pull/26230)
|
||||
- # Fix: Inconsistent Case-Sensitivity in GrepTool by @.github/workflows/gemini-cli-bot-pulse.yml[bot] in [#26235](https://github.com/google-gemini/gemini-cli/pull/26235)
|
||||
- docs(core): add automated gemma setup guide by @Samee24 in
|
||||
[#26233](https://github.com/google-gemini/gemini-cli/pull/26233)
|
||||
- Allow non-https proxy urls to support container environments by @stevemk14ebr
|
||||
in [#26234](https://github.com/google-gemini/gemini-cli/pull/26234)
|
||||
- fix(bot): productivity and backlog optimizations by @gundermanc in
|
||||
[#26236](https://github.com/google-gemini/gemini-cli/pull/26236)
|
||||
- refactor(acp): delegate prompt turn processing logic to GeminiClient by
|
||||
@sripasg in [#26222](https://github.com/google-gemini/gemini-cli/pull/26222)
|
||||
- fix(cli): refine platform-specific undo/redo and smart bubbling for WSL by
|
||||
@cocosheng-g in
|
||||
[#26202](https://github.com/google-gemini/gemini-cli/pull/26202)
|
||||
- fix: suppress duplicate extension warnings during startup by @cocosheng-g in
|
||||
[#26208](https://github.com/google-gemini/gemini-cli/pull/26208)
|
||||
- fix(cli): use byte length instead of string length for readStdin size limits
|
||||
by @Adib234 in
|
||||
[#26224](https://github.com/google-gemini/gemini-cli/pull/26224)
|
||||
- fix(ui): made shell tool header wrap on Ctrl+O by @devr0306 in
|
||||
[#26229](https://github.com/google-gemini/gemini-cli/pull/26229)
|
||||
- Changelog for v0.41.0-preview.0 by @gemini-cli-robot in
|
||||
[#26244](https://github.com/google-gemini/gemini-cli/pull/26244)
|
||||
- Skip binary CLI relaunch by @ruomengz in
|
||||
[#26261](https://github.com/google-gemini/gemini-cli/pull/26261)
|
||||
- fix(cli): do not override GOOGLE_CLOUD_PROJECT in Cloud Shell when using
|
||||
Vertex AI by @jackwotherspoon in
|
||||
[#24455](https://github.com/google-gemini/gemini-cli/pull/24455)
|
||||
- docs(cli): add skill discovery troubleshooting checklist to tutorial by
|
||||
@pmenic in [#26018](https://github.com/google-gemini/gemini-cli/pull/26018)
|
||||
- docs(policy-engine): link to tools reference for tool names and args by
|
||||
@Aaxhirrr in [#22081](https://github.com/google-gemini/gemini-cli/pull/22081)
|
||||
- Fix posting invalid response to a comment by @gundermanc in
|
||||
[#26266](https://github.com/google-gemini/gemini-cli/pull/26266)
|
||||
- fix(cli): prevent informational logs from polluting json output by
|
||||
@cocosheng-g in
|
||||
[#26264](https://github.com/google-gemini/gemini-cli/pull/26264)
|
||||
- feat(ui): added microphone and updated placeholder for voice mode by @devr0306
|
||||
in [#26270](https://github.com/google-gemini/gemini-cli/pull/26270)
|
||||
- feat(cli): Add 'list' subcommand to '/commands' by @Jwhyee in
|
||||
[#22324](https://github.com/google-gemini/gemini-cli/pull/22324)
|
||||
- fix(core): ensure tool output cleanup on session deletion for legacy files by
|
||||
@cocosheng-g in
|
||||
[#26263](https://github.com/google-gemini/gemini-cli/pull/26263)
|
||||
- Docs: Update Agent Skills documentation by @jkcinouye in
|
||||
[#22388](https://github.com/google-gemini/gemini-cli/pull/22388)
|
||||
- test(acp): add missing coverage for extensions command error paths by
|
||||
@sahilkirad in
|
||||
[#25313](https://github.com/google-gemini/gemini-cli/pull/25313)
|
||||
- Changelog for v0.40.0 by @gemini-cli-robot in
|
||||
[#26245](https://github.com/google-gemini/gemini-cli/pull/26245)
|
||||
- fix: report AgentExecutionBlocked in non-interactive programmatic modes by
|
||||
@cocosheng-g in
|
||||
[#26262](https://github.com/google-gemini/gemini-cli/pull/26262)
|
||||
- feat(extensions): add 'delete' as an alias for /extensions uninstall by
|
||||
@martin-hsu-test in
|
||||
[#25660](https://github.com/google-gemini/gemini-cli/pull/25660)
|
||||
- fix(core): silently skip GEMINI.md paths that are directories (EISDIR) by
|
||||
@martin-hsu-test in
|
||||
[#25662](https://github.com/google-gemini/gemini-cli/pull/25662)
|
||||
- fix(ci): checkout PR branch instead of main in bot workflow by @gundermanc in
|
||||
[#26289](https://github.com/google-gemini/gemini-cli/pull/26289)
|
||||
- fix(cli): use resolved sandbox state for auto-update check by @Adib234 in
|
||||
[#26285](https://github.com/google-gemini/gemini-cli/pull/26285)
|
||||
- # Metrics Integrity & Standardized Reporting (BT-01) by @.github/workflows/gemini-cli-bot-pulse.yml[bot] in [#26240](https://github.com/google-gemini/gemini-cli/pull/26240)
|
||||
- Add Star History section to README by @bdmorgan in
|
||||
[#26290](https://github.com/google-gemini/gemini-cli/pull/26290)
|
||||
- Add Star History section to README by @bdmorgan in
|
||||
[#26308](https://github.com/google-gemini/gemini-cli/pull/26308)
|
||||
- Remove Star History section from README by @bdmorgan in
|
||||
[#26309](https://github.com/google-gemini/gemini-cli/pull/26309)
|
||||
- test(evals): add behavioral eval for file creation and write_file tool
|
||||
selection by @akh64bit in
|
||||
[#26292](https://github.com/google-gemini/gemini-cli/pull/26292)
|
||||
- feat(config): enable Gemma 4 models by default via Gemini API by @Abhijit-2592
|
||||
in [#26307](https://github.com/google-gemini/gemini-cli/pull/26307)
|
||||
- fix(cli): insert voice transcription at cursor position instead of ap… by
|
||||
@Zheyuan-Lin in
|
||||
[#26287](https://github.com/google-gemini/gemini-cli/pull/26287)
|
||||
- fix(ui): fix issue with box edges by @gundermanc in
|
||||
[#26148](https://github.com/google-gemini/gemini-cli/pull/26148)
|
||||
- fix(cli): respect .env override for GOOGLE_CLOUD_PROJECT by @DavidAPierce in
|
||||
[#26288](https://github.com/google-gemini/gemini-cli/pull/26288)
|
||||
- fix(ci): robust version checking in release verification by @scidomino in
|
||||
[#26337](https://github.com/google-gemini/gemini-cli/pull/26337)
|
||||
- fix(cli): enable daemon relaunch in binary and bundle keytar by @ruomengz in
|
||||
[#26333](https://github.com/google-gemini/gemini-cli/pull/26333)
|
||||
- fix(core): discourage unprompted git add . in prompt snippets by @akh64bit in
|
||||
[#26220](https://github.com/google-gemini/gemini-cli/pull/26220)
|
||||
- feat(ui): added wave animation for voice mode by @devr0306 in
|
||||
[#26284](https://github.com/google-gemini/gemini-cli/pull/26284)
|
||||
- fix(cli): prevent Escape from clearing input buffer (#17083) by @cocosheng-g
|
||||
in [#26339](https://github.com/google-gemini/gemini-cli/pull/26339)
|
||||
- fix(cli): undeprecate --prompt and correct positional query docs by @Adib234
|
||||
in [#26329](https://github.com/google-gemini/gemini-cli/pull/26329)
|
||||
- Metrics updates by @.github/workflows/gemini-cli-bot-pulse.yml[bot] in
|
||||
[#26348](https://github.com/google-gemini/gemini-cli/pull/26348)
|
||||
- fix(core): remove "System: Please continue." injection on InvalidStream events
|
||||
by @SandyTao520 in
|
||||
[#26340](https://github.com/google-gemini/gemini-cli/pull/26340)
|
||||
- docs(policy-engine): add tool argument keys reference and shell policy
|
||||
cross-links by @harshpujari in
|
||||
[#25292](https://github.com/google-gemini/gemini-cli/pull/25292)
|
||||
- fix(cli): resolve Ghostty/raw-mode False Cancellation in oauth flow by
|
||||
@Aarchi-07 in [#25026](https://github.com/google-gemini/gemini-cli/pull/25026)
|
||||
- fix(core): reset session-scoped state on resumption by @cocosheng-g in
|
||||
[#26342](https://github.com/google-gemini/gemini-cli/pull/26342)
|
||||
- Fix bulk of remaining issues with generalist profile by @joshualitt in
|
||||
[#26073](https://github.com/google-gemini/gemini-cli/pull/26073)
|
||||
- fix(core): make subagents aware of active approval modes by @akh64bit in
|
||||
[#23608](https://github.com/google-gemini/gemini-cli/pull/23608)
|
||||
- fix(acp): resolve agent mode disconnect and improve mode awareness by @sripasg
|
||||
in [#26332](https://github.com/google-gemini/gemini-cli/pull/26332)
|
||||
- docs(sdk): add JSDoc to exported interfaces in packages/sdk/src/types.ts by
|
||||
@cocosheng-g in
|
||||
[#26441](https://github.com/google-gemini/gemini-cli/pull/26441)
|
||||
- perf: skip redundant GEMINI.md loading in partialConfig by @cocosheng-g in
|
||||
[#26443](https://github.com/google-gemini/gemini-cli/pull/26443)
|
||||
- Enhance React guidelines by @psinha40898 in
|
||||
[#22667](https://github.com/google-gemini/gemini-cli/pull/22667)
|
||||
- feat(core): reinforce Inquiry constraints to prevent unauthorized changes by
|
||||
@akh64bit in [#26310](https://github.com/google-gemini/gemini-cli/pull/26310)
|
||||
- revert: fix(ci): robust version checking in release verification (#26337) by
|
||||
@scidomino in [#26450](https://github.com/google-gemini/gemini-cli/pull/26450)
|
||||
- refactor(UI): created constants file for ThemeDialog by @devr0306 in
|
||||
[#26446](https://github.com/google-gemini/gemini-cli/pull/26446)
|
||||
- docs: fix GitHub capitalization in releases guide by @haosenwang1018 in
|
||||
[#26379](https://github.com/google-gemini/gemini-cli/pull/26379)
|
||||
- fix(cli): ensure branch indicator updates in sub-directories and worktrees by
|
||||
@Adib234 in [#26330](https://github.com/google-gemini/gemini-cli/pull/26330)
|
||||
- feat: add minimal V8 heap snapshot utility for memory diagnostics by
|
||||
@cocosheng-g in
|
||||
[#26440](https://github.com/google-gemini/gemini-cli/pull/26440)
|
||||
- fix(hooks): preserve non-text parts in fromHookLLMRequest by @SandyTao520 in
|
||||
[#26275](https://github.com/google-gemini/gemini-cli/pull/26275)
|
||||
- fix(cli): allow early stdout when config is undefined by @cocosheng-g in
|
||||
[#26453](https://github.com/google-gemini/gemini-cli/pull/26453)
|
||||
- fix(cli)#21297: clear skills consent dialog before reload by @manavmax in
|
||||
[#26431](https://github.com/google-gemini/gemini-cli/pull/26431)
|
||||
- fix(cli): render LaTeX-style output as Unicode in the TUI by @dimssu in
|
||||
[#25802](https://github.com/google-gemini/gemini-cli/pull/25802)
|
||||
- fix(core): use close event instead of exit in child_process fallback by
|
||||
@tusaryan in [#25695](https://github.com/google-gemini/gemini-cli/pull/25695)
|
||||
- feat(voice): add privacy and compliance UX warning for Gemini Live backend by
|
||||
@cocosheng-g in
|
||||
[#26454](https://github.com/google-gemini/gemini-cli/pull/26454)
|
||||
- feat(memory): add Auto Memory inbox flow with canonical-patch contract by
|
||||
@SandyTao520 in
|
||||
[#26338](https://github.com/google-gemini/gemini-cli/pull/26338)
|
||||
- test(cleanup): fix temporary directory leaks in test suites by @Adib234 in
|
||||
[#26217](https://github.com/google-gemini/gemini-cli/pull/26217)
|
||||
- feat: add ignoreLocalEnv setting and --ignore-env flag (#2493) by @cocosheng-g
|
||||
in [#26445](https://github.com/google-gemini/gemini-cli/pull/26445)
|
||||
- docs(sdk): add JSDoc to all exported interfaces and types by @fauzan171 in
|
||||
[#26277](https://github.com/google-gemini/gemini-cli/pull/26277)
|
||||
- feat(cli): improve /agents refresh logging by @cocosheng-g in
|
||||
[#26442](https://github.com/google-gemini/gemini-cli/pull/26442)
|
||||
- Fix: make Dockerfile self-contained with multi-stage build by @Famous077 in
|
||||
[#24277](https://github.com/google-gemini/gemini-cli/pull/24277)
|
||||
- fix(core): filter unsupported multimodal types from tool responses by
|
||||
@aishaneeshah in
|
||||
[#26352](https://github.com/google-gemini/gemini-cli/pull/26352)
|
||||
- fix(core): properly format markdown in AskUser tool by unescaping newlines by
|
||||
@Adib234 in [#26349](https://github.com/google-gemini/gemini-cli/pull/26349)
|
||||
- feat(bot): add actions spend metric script by @gundermanc in
|
||||
[#26463](https://github.com/google-gemini/gemini-cli/pull/26463)
|
||||
- feat(cli): add /bug-memory command and auto-capture heap snapshot in /bug by
|
||||
@Anjaligarhwal in
|
||||
[#25639](https://github.com/google-gemini/gemini-cli/pull/25639)
|
||||
- fix(cli): make SkillInboxDialog fit and scroll in alternate buffer by
|
||||
@SandyTao520 in
|
||||
[#26455](https://github.com/google-gemini/gemini-cli/pull/26455)
|
||||
- Robust Scale-Safe Lifecycle Consolidation by @gemini-cli-robot in
|
||||
[#26355](https://github.com/google-gemini/gemini-cli/pull/26355)
|
||||
- fix(ci): respect exempt labels when closing stale items by @gundermanc in
|
||||
[#26475](https://github.com/google-gemini/gemini-cli/pull/26475)
|
||||
- fix(cli): use os.homedir() for home directory warning check by @TirthNaik-99
|
||||
in [#25890](https://github.com/google-gemini/gemini-cli/pull/25890)
|
||||
- fix(a2a-server): resolve tool approval race condition and improve status
|
||||
reporting by @kschaab in
|
||||
[#26479](https://github.com/google-gemini/gemini-cli/pull/26479)
|
||||
- fix(cli): prevent settings dialog border clipping using maxHeight by
|
||||
@jackwotherspoon in
|
||||
[#26507](https://github.com/google-gemini/gemini-cli/pull/26507)
|
||||
- feat: allow queuing messages during compression (#24071) by @cocosheng-g in
|
||||
[#26506](https://github.com/google-gemini/gemini-cli/pull/26506)
|
||||
- fix(core): retry on ERR_STREAM_PREMATURE_CLOSE errors by @cocosheng-g in
|
||||
[#26519](https://github.com/google-gemini/gemini-cli/pull/26519)
|
||||
- fix(core): Minor fixes for generalist profile. by @joshualitt in
|
||||
[#26357](https://github.com/google-gemini/gemini-cli/pull/26357)
|
||||
- fix(patch): cherry-pick 3627f47 to release/v0.42.0-preview.0-pr-26542 to patch
|
||||
version v0.42.0-preview.0 and create version 0.42.0-preview.1 by
|
||||
[#27362](https://github.com/google-gemini/gemini-cli/pull/27362)
|
||||
- fix(cli): prevent Termux relaunch and resize remount loops by @saymanq in
|
||||
[#27110](https://github.com/google-gemini/gemini-cli/pull/27110)
|
||||
- Feat/a2a expose usage metadata by @jvargassanchez-dot in
|
||||
[#27288](https://github.com/google-gemini/gemini-cli/pull/27288)
|
||||
- feat(context): Complete simplification work. by @joshualitt in
|
||||
[#27345](https://github.com/google-gemini/gemini-cli/pull/27345)
|
||||
- fix(core): force update_topic tool to execute sequentially by
|
||||
@jvargassanchez-dot in
|
||||
[#27357](https://github.com/google-gemini/gemini-cli/pull/27357)
|
||||
- Changelog for v0.44.0-preview.0 by @gemini-cli-robot in
|
||||
[#27360](https://github.com/google-gemini/gemini-cli/pull/27360)
|
||||
- Changelog for v0.43.0 by @gemini-cli-robot in
|
||||
[#27361](https://github.com/google-gemini/gemini-cli/pull/27361)
|
||||
- Revert "fix(core): prevent SIGHUP kills in PTY environments" by @bbiggs in
|
||||
[#27401](https://github.com/google-gemini/gemini-cli/pull/27401)
|
||||
- fix(cli): filter internal session context from history during resumption by
|
||||
@rmedranollamas in
|
||||
[#27391](https://github.com/google-gemini/gemini-cli/pull/27391)
|
||||
- Update default auto routing by @DavidAPierce in
|
||||
[#27071](https://github.com/google-gemini/gemini-cli/pull/27071)
|
||||
- fix(core): bypass routing classifiers to prevent orphaned function response
|
||||
errors by @danielweis in
|
||||
[#27389](https://github.com/google-gemini/gemini-cli/pull/27389)
|
||||
- fix(core): suppress PTY resize EBADF errors by @scidomino in
|
||||
[#27461](https://github.com/google-gemini/gemini-cli/pull/27461)
|
||||
- fix(core): prevent blacklist bypass in mcp list by @ompatel-aiml in
|
||||
[#27377](https://github.com/google-gemini/gemini-cli/pull/27377)
|
||||
- fix(cli): ignore unmapped vim normal keys by @MukundaKatta in
|
||||
[#27102](https://github.com/google-gemini/gemini-cli/pull/27102)
|
||||
- fix(patch): cherry-pick bd53951 to release/v0.45.0-preview.0-pr-27496 to patch
|
||||
version v0.45.0-preview.0 and create version 0.45.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#26544](https://github.com/google-gemini/gemini-cli/pull/26544)
|
||||
- fix(patch): cherry-pick 02995ba to release/v0.42.0-preview.1-pr-26568 to patch
|
||||
version v0.42.0-preview.1 and create version 0.42.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#26590](https://github.com/google-gemini/gemini-cli/pull/26590)
|
||||
[#27535](https://github.com/google-gemini/gemini-cli/pull/27535)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.41.2...v0.42.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.44.1...v0.45.3
|
||||
|
||||
+28
-181
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.43.0-preview.1
|
||||
# Preview release: v0.46.0-preview.0
|
||||
|
||||
Released: May 19, 2026
|
||||
Released: June 3, 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).
|
||||
@@ -13,187 +13,34 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Surgical Code Edits:** Steer models to use the `edit` tool for precise code
|
||||
modifications, improving accuracy and reducing context usage.
|
||||
- **Session Portability:** Added ability to export chat sessions to files and
|
||||
import them via a new CLI flag, enabling session persistence and sharing.
|
||||
- **Enhanced Security:** Introduced comprehensive shell command safety
|
||||
evaluations and strengthened model steering to prevent unauthorized changes.
|
||||
- **Context Management:** Implemented a new adaptive token calculator for more
|
||||
accurate content size estimations and optimized context pipelines.
|
||||
- **UX Improvements:** Enhanced tool call visibility with prefixed IDs and
|
||||
improved the UI for session resumption and MCP list management.
|
||||
- **Model Update:** Added support for transitioning to the Flash GA model when
|
||||
the experimental flag is enabled, providing access to the latest model
|
||||
improvements.
|
||||
- **Improved Stability:** Hardened PTY resize logic to prevent native crashes,
|
||||
ensuring a more robust terminal experience.
|
||||
- **Bug Fix:** Resolved an issue where an invalid `preferredEditor`
|
||||
configuration could lead to a notification spam loop.
|
||||
- **CI Enhancements:** Optimized Pull Request labeling and introduced batch
|
||||
workflows to improve development efficiency.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 85566a7 to release/v0.43.0-preview.0-pr-27073
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#27256](https://github.com/google-gemini/gemini-cli/pull/27256)
|
||||
- feat(core): steer model to use edit tool for surgical edits, fix a typo in
|
||||
[#26480](https://github.com/google-gemini/gemini-cli/pull/26480)
|
||||
- docs: clarify Auto Memory proposes memory updates and skills in
|
||||
[#26527](https://github.com/google-gemini/gemini-cli/pull/26527)
|
||||
- fix(core): reject numeric project IDs in GOOGLE_CLOUD_PROJECT (#24695) in
|
||||
[#26532](https://github.com/google-gemini/gemini-cli/pull/26532)
|
||||
- fix(core): remove unsafe type assertion suppressions in error utils in
|
||||
[#19881](https://github.com/google-gemini/gemini-cli/pull/19881)
|
||||
- fix(core): allow redirection in YOLO and AUTO_EDIT modes without sandboxing in
|
||||
[#26542](https://github.com/google-gemini/gemini-cli/pull/26542)
|
||||
- ci(release): build and attach unsigned macOS binaries to releases in
|
||||
[#26462](https://github.com/google-gemini/gemini-cli/pull/26462)
|
||||
- fix(core): Fix chat corruption bug in context manager. in
|
||||
[#26534](https://github.com/google-gemini/gemini-cli/pull/26534)
|
||||
- fix(cli): provide JSON output for AgentExecutionStopped in non-interactive
|
||||
mode in [#26504](https://github.com/google-gemini/gemini-cli/pull/26504)
|
||||
- feat(evals): add shell command safety evals in
|
||||
[#26528](https://github.com/google-gemini/gemini-cli/pull/26528)
|
||||
- fix(core): handle invalid custom plans directory gracefully in
|
||||
[#26560](https://github.com/google-gemini/gemini-cli/pull/26560)
|
||||
- fix(acp): move tool explanation from thought stream to tool call content in
|
||||
[#26554](https://github.com/google-gemini/gemini-cli/pull/26554)
|
||||
- fix(a2a-server): Resolve race condition in tool completion waiting in
|
||||
[#26568](https://github.com/google-gemini/gemini-cli/pull/26568)
|
||||
- fix(cli): randomize sandbox container names in
|
||||
[#26014](https://github.com/google-gemini/gemini-cli/pull/26014)
|
||||
- fix(core): Fix hysteresis in async context management pipelines. in
|
||||
[#26452](https://github.com/google-gemini/gemini-cli/pull/26452)
|
||||
- Tighten private Auto Memory patch allowlist in
|
||||
[#26535](https://github.com/google-gemini/gemini-cli/pull/26535)
|
||||
- fix(cli): hide read-only settings scopes in
|
||||
[#26249](https://github.com/google-gemini/gemini-cli/pull/26249)
|
||||
- fix(ci): preserve executable bit for mac binaries in
|
||||
[#26600](https://github.com/google-gemini/gemini-cli/pull/26600)
|
||||
- fix(cli): improve mcp list UX in untrusted folders in
|
||||
[#26457](https://github.com/google-gemini/gemini-cli/pull/26457)
|
||||
- fix(core): prevent silent hang during OAuth auth on headless Linux in
|
||||
[#26571](https://github.com/google-gemini/gemini-cli/pull/26571)
|
||||
- Changelog for v0.42.0-preview.0 in
|
||||
[#26537](https://github.com/google-gemini/gemini-cli/pull/26537)
|
||||
- ci: fix Argument list too long in triage workflows in
|
||||
[#26603](https://github.com/google-gemini/gemini-cli/pull/26603)
|
||||
- refactor(cli): migrate core tools to native ToolDisplay property and fix UI
|
||||
rendering in [#25186](https://github.com/google-gemini/gemini-cli/pull/25186)
|
||||
- don't wrap args unnecessarily in
|
||||
[#26599](https://github.com/google-gemini/gemini-cli/pull/26599)
|
||||
- fix(core): preserve system PATH in Git environment to fix ENOENT (#25034) in
|
||||
[#26587](https://github.com/google-gemini/gemini-cli/pull/26587)
|
||||
- fix(routing): fix resolveClassifierModel argument mismatch in
|
||||
ApprovalModeStrategy in
|
||||
[#26658](https://github.com/google-gemini/gemini-cli/pull/26658)
|
||||
- docs: add vi mode shortcuts and clarify MCP/custom sandbox setup in
|
||||
[#23853](https://github.com/google-gemini/gemini-cli/pull/23853)
|
||||
- fix(ux): fixed issue with transcribed text not showing after releasing space
|
||||
in [#26609](https://github.com/google-gemini/gemini-cli/pull/26609)
|
||||
- ci: fix json parsing in scheduled triage workflow in
|
||||
[#26656](https://github.com/google-gemini/gemini-cli/pull/26656)
|
||||
- fix(cli): hide /memory add subcommand when memoryV2 is enabled in
|
||||
[#26605](https://github.com/google-gemini/gemini-cli/pull/26605)
|
||||
- fix: prevent false command conflicts when launching from home directory in
|
||||
[#23069](https://github.com/google-gemini/gemini-cli/pull/23069)
|
||||
- fix(core): cache model routing decision in LocalAgentExecutor in
|
||||
[#26548](https://github.com/google-gemini/gemini-cli/pull/26548)
|
||||
- Changelog for v0.42.0-preview.2 in
|
||||
[#26597](https://github.com/google-gemini/gemini-cli/pull/26597)
|
||||
- skip broken test in
|
||||
[#26705](https://github.com/google-gemini/gemini-cli/pull/26705)
|
||||
- feat: export session to file and import via flag in
|
||||
[#26514](https://github.com/google-gemini/gemini-cli/pull/26514)
|
||||
- Feat: Add Machine Hostname to CLI interface in
|
||||
[#25637](https://github.com/google-gemini/gemini-cli/pull/25637)
|
||||
- docs(extensions): refactor releasing guide and add update mechanisms in
|
||||
[#26595](https://github.com/google-gemini/gemini-cli/pull/26595)
|
||||
- fix(ci): fix maintainer identification in lifecycle manager in
|
||||
[#26706](https://github.com/google-gemini/gemini-cli/pull/26706)
|
||||
- fix(ui): added quotes around session id in resume tip in
|
||||
[#26669](https://github.com/google-gemini/gemini-cli/pull/26669)
|
||||
- Changelog for v0.41.0 in
|
||||
[#26670](https://github.com/google-gemini/gemini-cli/pull/26670)
|
||||
- refactor(core): agent session protocol changes in
|
||||
[#26661](https://github.com/google-gemini/gemini-cli/pull/26661)
|
||||
- fix(context): implement loose boundary policy for gc backstop. in
|
||||
[#26594](https://github.com/google-gemini/gemini-cli/pull/26594)
|
||||
- fix(core): throw explicit error on dropped tool responses in
|
||||
[#26668](https://github.com/google-gemini/gemini-cli/pull/26668)
|
||||
- fix: resolve "function response turn must come immediately after function
|
||||
call" error in
|
||||
[#26691](https://github.com/google-gemini/gemini-cli/pull/26691)
|
||||
- fix(core): resolve parallel tool call streaming ID collision in
|
||||
[#26646](https://github.com/google-gemini/gemini-cli/pull/26646)
|
||||
- feat(core): add LocalSubagentProtocol behind AgentProtocol in
|
||||
[#25302](https://github.com/google-gemini/gemini-cli/pull/25302)
|
||||
- fix(cli): remove noisy theme registration logs from terminal in
|
||||
[#25858](https://github.com/google-gemini/gemini-cli/pull/25858)
|
||||
- ci: implement codebase-aware effort level triage in
|
||||
[#26666](https://github.com/google-gemini/gemini-cli/pull/26666)
|
||||
- feat(acp/core): prefix tool call IDs with tool names to support tool rendering
|
||||
in ACP compliant IDEs. in
|
||||
[#26676](https://github.com/google-gemini/gemini-cli/pull/26676)
|
||||
- fix(mcp): treat GET 404 as 405 in StreamableHTTPClientTransport in
|
||||
[#24847](https://github.com/google-gemini/gemini-cli/pull/24847)
|
||||
- feat(core): add RemoteSubagentProtocol behind AgentProtocol in
|
||||
[#25303](https://github.com/google-gemini/gemini-cli/pull/25303)
|
||||
- feat(context): Improvements to the snapshotter. in
|
||||
[#26655](https://github.com/google-gemini/gemini-cli/pull/26655)
|
||||
- fix(context): Change snapshotter model config. in
|
||||
[#26745](https://github.com/google-gemini/gemini-cli/pull/26745)
|
||||
- fix(cli): allow installing extensions from ssh repo in
|
||||
[#26274](https://github.com/google-gemini/gemini-cli/pull/26274)
|
||||
- fix(cli): prevent duplicate SessionStart systemMessage render in
|
||||
[#25827](https://github.com/google-gemini/gemini-cli/pull/25827)
|
||||
- fix(cli/acp): prevent infinite thought loop in ACP mode by disablig
|
||||
nextSpeakerCheck in
|
||||
[#26874](https://github.com/google-gemini/gemini-cli/pull/26874)
|
||||
- fix(cli): use static tool name in confirmation prompt to avoid parsing errors
|
||||
in [#26866](https://github.com/google-gemini/gemini-cli/pull/26866)
|
||||
- fix(routing): Refactor tool turn handling for the conversation history in
|
||||
NumericalClassifierStrategy to prevent 400 Bad Request in
|
||||
[#26761](https://github.com/google-gemini/gemini-cli/pull/26761)
|
||||
- fix(core): handle malformed projects.json in ProjectRegistry in
|
||||
[#26885](https://github.com/google-gemini/gemini-cli/pull/26885)
|
||||
- fix(ui): added a gutter width to the input prompt width calculation in
|
||||
[#26882](https://github.com/google-gemini/gemini-cli/pull/26882)
|
||||
- fix: prevent EISDIR crash when customIgnoreFilePaths contains directories
|
||||
(#19868) in [#19898](https://github.com/google-gemini/gemini-cli/pull/19898)
|
||||
- revert 6b9b778d821728427eea07b1b97ba07378137d0b in
|
||||
[#26893](https://github.com/google-gemini/gemini-cli/pull/26893)
|
||||
- Fix/vscode run current file ts in
|
||||
[#22894](https://github.com/google-gemini/gemini-cli/pull/22894)
|
||||
- Allow Enter to select session while in search mode in /resume in
|
||||
[#21523](https://github.com/google-gemini/gemini-cli/pull/21523)
|
||||
- fix(core): ignore .pak and .rpa game archive formats by default in
|
||||
[#26884](https://github.com/google-gemini/gemini-cli/pull/26884)
|
||||
- fix(cli): enable adk non-interactive session in
|
||||
[#26895](https://github.com/google-gemini/gemini-cli/pull/26895)
|
||||
- fix(cli): restore resume for legacy sessions in
|
||||
[#26577](https://github.com/google-gemini/gemini-cli/pull/26577)
|
||||
- fix: respect explicit model selection after Flash quota exhaustion (#26759) in
|
||||
[#26872](https://github.com/google-gemini/gemini-cli/pull/26872)
|
||||
- feat(context): Introduce adaptive token calculator to more accurately
|
||||
calculate content sizes. in
|
||||
[#26888](https://github.com/google-gemini/gemini-cli/pull/26888)
|
||||
- chore: update checkout action configuration in workflows in
|
||||
[#26897](https://github.com/google-gemini/gemini-cli/pull/26897)
|
||||
- fix (telemetry): inject quota_project_id to prevent fallback to default oauth
|
||||
client in [#26698](https://github.com/google-gemini/gemini-cli/pull/26698)
|
||||
- Exclude extension context from skill extraction agent in
|
||||
[#26879](https://github.com/google-gemini/gemini-cli/pull/26879)
|
||||
- Enable NumericalRouter when using dynamic model configs in
|
||||
[#26929](https://github.com/google-gemini/gemini-cli/pull/26929)
|
||||
- ci: actively triage missing priority labels and intelligently clean up
|
||||
conflicting labels in
|
||||
[#26865](https://github.com/google-gemini/gemini-cli/pull/26865)
|
||||
- refactor(core): introduce SubagentState enum for progress in
|
||||
[#26934](https://github.com/google-gemini/gemini-cli/pull/26934)
|
||||
- fix(ci): replace brittle --no-tag with explicit staging-tmp tag in
|
||||
[#26940](https://github.com/google-gemini/gemini-cli/pull/26940)
|
||||
- Incremental refactor repo agent towards skills-based composition in
|
||||
[#26717](https://github.com/google-gemini/gemini-cli/pull/26717)
|
||||
- fix(ui): fixed line wrap padding for selection lists in
|
||||
[#26944](https://github.com/google-gemini/gemini-cli/pull/26944)
|
||||
- fix(core): update read_file schema for v1 compatibility (#22183) in
|
||||
[#26922](https://github.com/google-gemini/gemini-cli/pull/26922)
|
||||
- fix(ci): configure git remote with token for authentication in
|
||||
[#26949](https://github.com/google-gemini/gemini-cli/pull/26949)
|
||||
- fix(core): harden PTY resize against native crashes by @scidomino in
|
||||
[#27496](https://github.com/google-gemini/gemini-cli/pull/27496)
|
||||
- Changelog for v0.45.0-preview.0 by @gemini-cli-robot in
|
||||
[#27495](https://github.com/google-gemini/gemini-cli/pull/27495)
|
||||
- Changelog for v0.44.0 by @gemini-cli-robot in
|
||||
[#27569](https://github.com/google-gemini/gemini-cli/pull/27569)
|
||||
- fix(cli): prevent spam loop when preferredEditor is invalid by @Niralisj in
|
||||
[#25324](https://github.com/google-gemini/gemini-cli/pull/25324)
|
||||
- Adding quote by @scidomino in
|
||||
[#27571](https://github.com/google-gemini/gemini-cli/pull/27571)
|
||||
- Transition to flash GA model when experiment flag is present. by @DavidAPierce
|
||||
in [#27570](https://github.com/google-gemini/gemini-cli/pull/27570)
|
||||
- chore(ci): add optimized PR size labeler and batch workflows by @sripasg in
|
||||
[#27616](https://github.com/google-gemini/gemini-cli/pull/27616)
|
||||
- fix(ci): use pull_request_target trigger to grant write access on fork PRs by
|
||||
@sripasg in [#27637](https://github.com/google-gemini/gemini-cli/pull/27637)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.42.0-preview.2...v0.43.0-preview.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.45.0-preview.1...v0.46.0-preview.0
|
||||
|
||||
@@ -105,7 +105,7 @@ Gemini CLI comes with the following built-in subagents:
|
||||
slow. You can invoke it explicitly using `@generalist`.
|
||||
- **Configuration:** Enabled by default.
|
||||
|
||||
### Browser Agent (experimental)
|
||||
### Browser Agent
|
||||
|
||||
- **Name:** `browser_agent`
|
||||
- **Purpose:** Automate web browser tasks — navigating websites, filling forms,
|
||||
@@ -115,10 +115,6 @@ Gemini CLI comes with the following built-in subagents:
|
||||
the pricing table from this page," "Click the login button and enter my
|
||||
credentials."
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> This is a preview feature currently under active development.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
The browser agent requires:
|
||||
|
||||
@@ -154,7 +154,35 @@ and will never be auto-unassigned.
|
||||
- **Unassign yourself** if you can no longer work on the issue by commenting
|
||||
`/unassign`, so other contributors can pick it up right away.
|
||||
|
||||
### 6. Release automation
|
||||
### 6. Automatically label PRs by size: `PR Size Labeler`
|
||||
|
||||
To help maintainers estimate review effort and keep the PR history clean, this
|
||||
workflow automatically tags every pull request with a size label representing
|
||||
the total volume of line changes.
|
||||
|
||||
- **Workflow File**: `.github/workflows/pr-size-labeler.yml`
|
||||
- **When it runs**: Immediately after a pull request is created, synchronized
|
||||
(new commits pushed), or reopened. It can also be triggered manually via
|
||||
`workflow_dispatch` with a PR number.
|
||||
- **What it does**:
|
||||
- **Calculates total changes**: Summarizes additions and deletions across all
|
||||
changed files in a single consolidated API request.
|
||||
- **Applies standard size labels**:
|
||||
- `size/XS`: < 10 lines changed
|
||||
- `size/S`: 10-49 lines changed
|
||||
- `size/M`: 50-249 lines changed
|
||||
- `size/L`: 250-999 lines changed
|
||||
- `size/XL`: >= 1000 lines changed
|
||||
- **Updates size tag atomically**: Adds the new correct size label and removes
|
||||
any obsolete size labels in one atomic step.
|
||||
- **Updates/Posts PR size info comment**: Instead of spamming a new comment on
|
||||
every commit push, it updates the existing size labeler status comment
|
||||
inline to keep the PR conversation timeline perfectly neat and clean.
|
||||
- **What you should do**:
|
||||
- You do not need to take any actions. The workflow runs automatically and
|
||||
updates the label and comment seamlessly as you push new updates.
|
||||
|
||||
### 7. Release automation
|
||||
|
||||
This workflow handles the process of packaging and publishing new versions of
|
||||
Gemini CLI.
|
||||
|
||||
@@ -596,6 +596,18 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-2.5-flash-lite"
|
||||
}
|
||||
},
|
||||
"gemini-3.1-flash-lite": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.1-flash-lite"
|
||||
}
|
||||
},
|
||||
"gemini-3.5-flash": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.5-flash"
|
||||
}
|
||||
},
|
||||
"gemma-4-31b-it": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
@@ -620,10 +632,16 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"gemini-3.5-flash-base": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.5-flash"
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"model": "flash-lite",
|
||||
"generateContentConfig": {
|
||||
"maxOutputTokens": 1024,
|
||||
"thinkingConfig": {
|
||||
@@ -635,7 +653,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"prompt-completion": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"model": "flash-lite",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0.3,
|
||||
"maxOutputTokens": 16000,
|
||||
@@ -648,7 +666,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"fast-ack-helper": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"model": "flash-lite",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0.2,
|
||||
"maxOutputTokens": 120,
|
||||
@@ -661,7 +679,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"edit-corrector": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"model": "flash-lite",
|
||||
"generateContentConfig": {
|
||||
"thinkingConfig": {
|
||||
"thinkingBudget": 0
|
||||
@@ -672,7 +690,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"summarizer-default": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"model": "flash-lite",
|
||||
"generateContentConfig": {
|
||||
"maxOutputTokens": 2000
|
||||
}
|
||||
@@ -681,7 +699,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"summarizer-shell": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"model": "flash-lite",
|
||||
"generateContentConfig": {
|
||||
"maxOutputTokens": 2000
|
||||
}
|
||||
@@ -758,7 +776,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"chat-compression-3.1-flash-lite": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.1-flash-lite-preview"
|
||||
"model": "gemini-3.1-flash-lite"
|
||||
}
|
||||
},
|
||||
"chat-compression-2.5-pro": {
|
||||
@@ -812,10 +830,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
```json
|
||||
{
|
||||
"gemini-3.1-flash-lite-preview": {
|
||||
"gemini-3.1-flash-lite": {
|
||||
"tier": "flash-lite",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"isPreview": false,
|
||||
"isVisible": true,
|
||||
"features": {
|
||||
"thinking": false,
|
||||
@@ -862,6 +880,16 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3.5-flash": {
|
||||
"tier": "flash",
|
||||
"family": "gemini-3",
|
||||
"isPreview": false,
|
||||
"isVisible": true,
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-2.5",
|
||||
@@ -1014,9 +1042,46 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"contexts": [
|
||||
{
|
||||
"condition": {
|
||||
"hasAccessToPreview": false,
|
||||
"useGemini3_5Flash": true
|
||||
},
|
||||
"target": "gemini-3.5-flash"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"hasAccessToPreview": false,
|
||||
"useGemini3_5Flash": false
|
||||
},
|
||||
"target": "gemini-2.5-flash"
|
||||
}
|
||||
]
|
||||
},
|
||||
"gemini-3.5-flash": {
|
||||
"default": "gemini-3.5-flash",
|
||||
"contexts": [
|
||||
{
|
||||
"condition": {
|
||||
"useGemini3_5Flash": false,
|
||||
"hasAccessToPreview": false
|
||||
},
|
||||
"target": "gemini-2.5-flash"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"useGemini3_5Flash": false
|
||||
},
|
||||
"target": "gemini-3-flash-preview"
|
||||
}
|
||||
]
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
"default": "gemini-2.5-flash",
|
||||
"contexts": [
|
||||
{
|
||||
"condition": {
|
||||
"useGemini3_5Flash": true
|
||||
},
|
||||
"target": "gemini-3.5-flash"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1092,20 +1157,18 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
]
|
||||
},
|
||||
"gemini-3.1-flash-lite-preview": {
|
||||
"default": "gemini-3.1-flash-lite-preview",
|
||||
"contexts": [
|
||||
{
|
||||
"condition": {
|
||||
"useGemini3_1FlashLite": false
|
||||
},
|
||||
"target": "gemini-2.5-flash-lite"
|
||||
}
|
||||
]
|
||||
"gemini-3.1-flash-lite": {
|
||||
"default": "gemini-3.1-flash-lite"
|
||||
},
|
||||
"flash": {
|
||||
"default": "gemini-3-flash-preview",
|
||||
"contexts": [
|
||||
{
|
||||
"condition": {
|
||||
"useGemini3_5Flash": true
|
||||
},
|
||||
"target": "gemini-3.5-flash"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"hasAccessToPreview": false
|
||||
@@ -1115,15 +1178,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
]
|
||||
},
|
||||
"flash-lite": {
|
||||
"default": "gemini-2.5-flash-lite",
|
||||
"contexts": [
|
||||
{
|
||||
"condition": {
|
||||
"useGemini3_1FlashLite": true
|
||||
},
|
||||
"target": "gemini-3.1-flash-lite-preview"
|
||||
}
|
||||
]
|
||||
"default": "gemini-3.1-flash-lite"
|
||||
},
|
||||
"auto-gemini-3": {
|
||||
"default": "gemini-3-pro-preview",
|
||||
@@ -1167,6 +1222,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"flash": {
|
||||
"default": "gemini-3-flash-preview",
|
||||
"contexts": [
|
||||
{
|
||||
"condition": {
|
||||
"useGemini3_5Flash": true
|
||||
},
|
||||
"target": "gemini-3.5-flash"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"hasAccessToPreview": false
|
||||
@@ -1363,7 +1424,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
],
|
||||
"lite": [
|
||||
{
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"model": "flash-lite",
|
||||
"actions": {
|
||||
"terminal": "silent",
|
||||
"transient": "silent",
|
||||
@@ -1974,6 +2035,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.powerUserProfile`** (boolean):
|
||||
- **Description:** Less cache friendly version of the generalist profile.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.contextManagement`** (boolean):
|
||||
- **Description:** Enable logic for context management.
|
||||
- **Default:** `false`
|
||||
|
||||
+24
-4
@@ -76,10 +76,30 @@ export class LLMJudge {
|
||||
|
||||
for (const res of rawResults) {
|
||||
// Remove any punctuation the model might have appended
|
||||
const cleanRes = res.replace(/[^A-Z]/g, '');
|
||||
if (cleanRes.startsWith('YES')) yes++;
|
||||
else if (cleanRes.startsWith('NO')) no++;
|
||||
else other++;
|
||||
const cleanRes = res.replace(/[^A-Z ]/g, '');
|
||||
if (
|
||||
cleanRes.includes('THE ANSWER IS YES') ||
|
||||
cleanRes.includes('ANSWER IS YES') ||
|
||||
cleanRes.endsWith('YES')
|
||||
) {
|
||||
yes++;
|
||||
} else if (
|
||||
cleanRes.includes('THE ANSWER IS NO') ||
|
||||
cleanRes.includes('ANSWER IS NO') ||
|
||||
cleanRes.endsWith('NO')
|
||||
) {
|
||||
no++;
|
||||
} else if (cleanRes.trim() === 'YES') {
|
||||
yes++;
|
||||
} else if (cleanRes.trim() === 'NO') {
|
||||
no++;
|
||||
} else {
|
||||
// Fallback: look for YES or NO as standalone words or at the end
|
||||
const words = cleanRes.split(/\s+/);
|
||||
if (words.includes('YES')) yes++;
|
||||
else if (words.includes('NO')) no++;
|
||||
else other++;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass if YES > NO and YES > OTHER (plurality)
|
||||
|
||||
@@ -32,227 +32,256 @@ describe('Context Management Fidelity E2E', () => {
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should reproduce the exact context working buffer on resume', async () => {
|
||||
// Mock responses to trigger GC (summarization)
|
||||
const snapshotResponse: FakeResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: JSON.stringify({
|
||||
new_facts: ['GC Triggered.'],
|
||||
new_constraints: [],
|
||||
new_tasks: [],
|
||||
resolved_task_ids: [],
|
||||
obsolete_fact_indices: [],
|
||||
obsolete_constraint_indices: [],
|
||||
chronological_summary: 'Snapshot created.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: FinishReason.STOP,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse,
|
||||
};
|
||||
|
||||
const countTokensResponse: FakeResponse = {
|
||||
method: 'countTokens',
|
||||
response: { totalTokens: 1000 },
|
||||
};
|
||||
|
||||
const streamResponse = (text: string): FakeResponse => ({
|
||||
method: 'generateContentStream',
|
||||
response: [
|
||||
{
|
||||
it(
|
||||
'should reproduce the exact context working buffer on resume',
|
||||
{ timeout: 300000 },
|
||||
async () => {
|
||||
// Mock responses to trigger GC (summarization)
|
||||
const snapshotResponse: FakeResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text }], role: 'model' },
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: JSON.stringify({
|
||||
new_facts: ['GC Triggered.'],
|
||||
new_constraints: [],
|
||||
new_tasks: [],
|
||||
resolved_task_ids: [],
|
||||
obsolete_fact_indices: [],
|
||||
obsolete_constraint_indices: [],
|
||||
chronological_summary: 'Snapshot created.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: FinishReason.STOP,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[],
|
||||
});
|
||||
} as unknown as GenerateContentResponse,
|
||||
};
|
||||
|
||||
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
|
||||
const filePath = path.join(rig.testDir!, fileName);
|
||||
const countTokensResponse: FakeResponse = {
|
||||
method: 'countTokens',
|
||||
response: { totalTokens: 1000 },
|
||||
};
|
||||
|
||||
const streamResponse = (text: string): FakeResponse => ({
|
||||
method: 'generateContentStream',
|
||||
response: [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text }], role: 'model' },
|
||||
finishReason: FinishReason.STOP,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[],
|
||||
});
|
||||
|
||||
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
|
||||
const filePath = path.join(rig.testDir!, fileName);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
mocks.map((m) => JSON.stringify(m)).join('\n'),
|
||||
);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
await rig.setup('context-fidelity', {
|
||||
settings: {
|
||||
experimental: {
|
||||
stressTestProfile: true, // Lowers thresholds to trigger GC easily
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const traceDir = path.join(rig.testDir!, 'traces');
|
||||
fs.mkdirSync(traceDir, { recursive: true });
|
||||
const traceLog = path.join(traceDir, 'trace.log');
|
||||
|
||||
// Ignore trace and response files to keep environment context clean and stable
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
mocks.map((m) => JSON.stringify(m)).join('\n'),
|
||||
path.join(rig.testDir!, '.geminiignore'),
|
||||
'traces/\nresp*.json\ndebug.log\n',
|
||||
);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
await rig.setup('context-fidelity', {
|
||||
settings: {
|
||||
experimental: {
|
||||
stressTestProfile: true, // Lowers thresholds to trigger GC easily
|
||||
},
|
||||
},
|
||||
});
|
||||
const commonEnv = {
|
||||
GEMINI_API_KEY: 'mock-key',
|
||||
GEMINI_CONTEXT_TRACE_DIR: traceDir,
|
||||
GEMINI_CONTEXT_TRACE_ENABLED: 'true',
|
||||
GEMINI_DEBUG_LOG_FILE: path.join(rig.testDir!, 'debug.log'),
|
||||
};
|
||||
|
||||
const traceDir = path.join(rig.testDir!, 'traces');
|
||||
fs.mkdirSync(traceDir, { recursive: true });
|
||||
const traceLog = path.join(traceDir, 'trace.log');
|
||||
const runMocks: FakeResponse[] = [
|
||||
streamResponse('Ack 1'),
|
||||
streamResponse('Ack 2'),
|
||||
streamResponse('Ack 3'),
|
||||
streamResponse('Ack 4'),
|
||||
streamResponse('Ack 5'),
|
||||
streamResponse('Ack 6'),
|
||||
streamResponse('Ack 7'),
|
||||
streamResponse('Ack 8'),
|
||||
streamResponse('Ack 9'),
|
||||
streamResponse('Ack 10'),
|
||||
streamResponse('Ack 11'),
|
||||
streamResponse('Ack 12'),
|
||||
];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
runMocks.push(snapshotResponse);
|
||||
runMocks.push(countTokensResponse);
|
||||
}
|
||||
|
||||
const commonEnv = {
|
||||
GEMINI_API_KEY: 'mock-key',
|
||||
GEMINI_CONTEXT_TRACE_DIR: traceDir,
|
||||
GEMINI_CONTEXT_TRACE_ENABLED: 'true',
|
||||
GEMINI_DEBUG_LOG_FILE: path.join(rig.testDir!, 'debug.log'),
|
||||
};
|
||||
// Turns 1-10: Build up history
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
i === 1 ? '' : '--resume',
|
||||
i === 1 ? '' : 'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses(`resp_init_${i}.json`, runMocks),
|
||||
].filter(Boolean),
|
||||
stdin: `Turn ${i}: ` + generateRandomString(900),
|
||||
env: commonEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const runMocks: FakeResponse[] = [
|
||||
streamResponse('Ack 1'),
|
||||
streamResponse('Ack 2'),
|
||||
streamResponse('Ack 3'),
|
||||
streamResponse('Ack 4'),
|
||||
streamResponse('Ack 5'),
|
||||
streamResponse('Ack 6'),
|
||||
streamResponse('Ack 7'),
|
||||
streamResponse('Ack 8'),
|
||||
streamResponse('Ack 9'),
|
||||
streamResponse('Ack 10'),
|
||||
streamResponse('Ack 11'),
|
||||
streamResponse('Ack 12'),
|
||||
];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
runMocks.push(snapshotResponse);
|
||||
runMocks.push(countTokensResponse);
|
||||
}
|
||||
|
||||
// Turns 1-10: Build up history
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
// Turn 11: Penultimate turn
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
i === 1 ? '' : '--resume',
|
||||
i === 1 ? '' : 'latest',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses(`resp_init_${i}.json`, runMocks),
|
||||
].filter(Boolean),
|
||||
stdin: `Turn ${i}: ` + generateRandomString(900),
|
||||
setupResponses('resp2.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 11: ' + generateRandomString(900),
|
||||
env: commonEnv,
|
||||
});
|
||||
}
|
||||
|
||||
// Turn 11: Penultimate turn
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp2.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 11: ' + generateRandomString(900),
|
||||
env: commonEnv,
|
||||
});
|
||||
// Turn 12: Breach threshold and force GC
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp3.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 12: ' + generateRandomString(900),
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
// Turn 12: Breach threshold and force GC
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp3.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 12: ' + generateRandomString(900),
|
||||
env: commonEnv,
|
||||
});
|
||||
// Extract the rendered context asset from the log
|
||||
const getRenderedContext = (logContent: string): HistoryTurn[] | null => {
|
||||
const lines = logContent.split('\n');
|
||||
const renderLines = lines.filter(
|
||||
(l) =>
|
||||
l.includes('[Render] Render Sanitized Context for LLM') ||
|
||||
l.includes('[Render] Render Context for LLM'),
|
||||
);
|
||||
if (renderLines.length === 0) return null;
|
||||
|
||||
// Extract the rendered context asset from the log
|
||||
const getRenderedContext = (logContent: string): HistoryTurn[] | null => {
|
||||
const lines = logContent.split('\n');
|
||||
const renderLines = lines.filter(
|
||||
(l) =>
|
||||
l.includes('[Render] Render Sanitized Context for LLM') ||
|
||||
l.includes('[Render] Render Context for LLM'),
|
||||
const lastRender = renderLines[renderLines.length - 1];
|
||||
const detailsMatch = lastRender.match(/\| Details: (.*)$/);
|
||||
if (!detailsMatch) return null;
|
||||
|
||||
const details = JSON.parse(detailsMatch[1]);
|
||||
const assetInfo =
|
||||
details.renderedContextSanitized || details.renderedContext;
|
||||
if (assetInfo && assetInfo.$asset) {
|
||||
const assetPath = path.join(traceDir, 'assets', assetInfo.$asset);
|
||||
return JSON.parse(fs.readFileSync(assetPath, 'utf-8'));
|
||||
}
|
||||
return assetInfo;
|
||||
};
|
||||
|
||||
const log1 = fs.readFileSync(traceLog, 'utf-8');
|
||||
const contextBeforeExit = getRenderedContext(log1);
|
||||
expect(contextBeforeExit).toBeDefined();
|
||||
console.log(
|
||||
'Context Before Exit (First 2 turns):',
|
||||
JSON.stringify(contextBeforeExit!.slice(0, 2), null, 2),
|
||||
);
|
||||
if (renderLines.length === 0) return null;
|
||||
|
||||
const lastRender = renderLines[renderLines.length - 1];
|
||||
const detailsMatch = lastRender.match(/\| Details: (.*)$/);
|
||||
if (!detailsMatch) return null;
|
||||
// Turn 4: Resume and run a small command
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp4.json', runMocks),
|
||||
'continue',
|
||||
],
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
const details = JSON.parse(detailsMatch[1]);
|
||||
const assetInfo =
|
||||
details.renderedContextSanitized || details.renderedContext;
|
||||
if (assetInfo && assetInfo.$asset) {
|
||||
const assetPath = path.join(traceDir, 'assets', assetInfo.$asset);
|
||||
return JSON.parse(fs.readFileSync(assetPath, 'utf-8'));
|
||||
const log2 = fs.readFileSync(traceLog, 'utf-8');
|
||||
const contextAfterResume = getRenderedContext(log2);
|
||||
expect(contextAfterResume).toBeDefined();
|
||||
console.log(
|
||||
'Context After Resume (First 2 turns):',
|
||||
JSON.stringify(contextAfterResume!.slice(0, 2), null, 2),
|
||||
);
|
||||
|
||||
expect(contextAfterResume!.length).toBeGreaterThanOrEqual(
|
||||
contextBeforeExit!.length,
|
||||
);
|
||||
|
||||
// The environment context is intentionally refreshed on resume to reflect
|
||||
// the current state of the workspace (e.g. new files, current date).
|
||||
// We allow its content to differ but ensure it's still an environment context.
|
||||
const isEnvContext = (turn: HistoryTurn) =>
|
||||
turn.content.parts?.some((p) => p.text?.includes('<session_context>'));
|
||||
|
||||
for (let i = 0; i < contextBeforeExit!.length; i++) {
|
||||
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
|
||||
|
||||
const turnBefore = contextBeforeExit![i];
|
||||
const turnAfter = contextAfterResume![i];
|
||||
|
||||
if (isEnvContext(turnBefore)) {
|
||||
expect(isEnvContext(turnAfter)).toBe(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
expect(turnAfter.content).toEqual(turnBefore.content);
|
||||
}
|
||||
return assetInfo;
|
||||
};
|
||||
|
||||
const log1 = fs.readFileSync(traceLog, 'utf-8');
|
||||
const contextBeforeExit = getRenderedContext(log1);
|
||||
expect(contextBeforeExit).toBeDefined();
|
||||
console.log(
|
||||
'Context Before Exit (First 2 turns):',
|
||||
JSON.stringify(contextBeforeExit!.slice(0, 2), null, 2),
|
||||
);
|
||||
|
||||
// Turn 4: Resume and run a small command
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp4.json', runMocks),
|
||||
'continue',
|
||||
],
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
const log2 = fs.readFileSync(traceLog, 'utf-8');
|
||||
const contextAfterResume = getRenderedContext(log2);
|
||||
expect(contextAfterResume).toBeDefined();
|
||||
console.log(
|
||||
'Context After Resume (First 2 turns):',
|
||||
JSON.stringify(contextAfterResume!.slice(0, 2), null, 2),
|
||||
);
|
||||
|
||||
expect(contextAfterResume!.length).toBeGreaterThanOrEqual(
|
||||
contextBeforeExit!.length,
|
||||
);
|
||||
|
||||
for (let i = 0; i < contextBeforeExit!.length; i++) {
|
||||
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
|
||||
expect(contextAfterResume![i].content).toEqual(
|
||||
contextBeforeExit![i].content,
|
||||
// Most importantly, synthetic IDs (like summaries) must be stable.
|
||||
const syntheticTurns = contextBeforeExit!.filter(
|
||||
(t: HistoryTurn) =>
|
||||
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
|
||||
(t.id && t.id.length === 32),
|
||||
);
|
||||
}
|
||||
expect(syntheticTurns.length).toBeGreaterThan(0);
|
||||
|
||||
// Most importantly, synthetic IDs (like summaries) must be stable.
|
||||
const syntheticTurns = contextBeforeExit!.filter(
|
||||
(t: HistoryTurn) =>
|
||||
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
|
||||
(t.id && t.id.length === 32),
|
||||
);
|
||||
expect(syntheticTurns.length).toBeGreaterThan(0);
|
||||
const syntheticTurnsAfter = contextAfterResume!.filter(
|
||||
(t: HistoryTurn) =>
|
||||
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
|
||||
(t.id && t.id.length === 32),
|
||||
);
|
||||
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
|
||||
syntheticTurns.length,
|
||||
);
|
||||
|
||||
const syntheticTurnsAfter = contextAfterResume!.filter(
|
||||
(t: HistoryTurn) =>
|
||||
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
|
||||
(t.id && t.id.length === 32),
|
||||
);
|
||||
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
|
||||
syntheticTurns.length,
|
||||
);
|
||||
|
||||
// Check if the first synthetic turn is identical
|
||||
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
|
||||
expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content);
|
||||
});
|
||||
// Check if the first synthetic turn is identical (with relaxation for environment context)
|
||||
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
|
||||
if (isEnvContext(syntheticTurns[0])) {
|
||||
expect(isEnvContext(syntheticTurnsAfter[0])).toBe(true);
|
||||
} else {
|
||||
expect(syntheticTurnsAfter[0].content).toEqual(
|
||||
syntheticTurns[0].content,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -18117,7 +18117,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
@@ -18246,7 +18246,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -18394,7 +18394,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18674,7 +18674,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18689,7 +18689,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18720,7 +18720,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18752,7 +18752,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.44.0-nightly.20260521.g57c42a5c4"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.47.0-nightly.20260602.gcfcecebe8"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -294,6 +294,66 @@ describe('Task', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should capture usageMetadata on Finished event and include it in final status update', async () => {
|
||||
const mockConfig = createMockConfig();
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
finished: vi.fn(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Calling private constructor for test purposes.
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
const finishedEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: 'STOP',
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 150,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await task.acceptAgentMessage(finishedEvent);
|
||||
expect(task.usageMetadata).toEqual({
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 150,
|
||||
});
|
||||
|
||||
task.setTaskStateAndPublishUpdate(
|
||||
'input-required',
|
||||
{ kind: CoderAgentEvent.StateChangeEvent },
|
||||
undefined,
|
||||
undefined,
|
||||
true, // final
|
||||
);
|
||||
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
final: true,
|
||||
metadata: expect.objectContaining({
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 150,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update modelInfo and reflect it in metadata and status updates', async () => {
|
||||
const mockConfig = createMockConfig();
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
|
||||
@@ -89,6 +89,12 @@ export class Task {
|
||||
currentAgentMessageId = uuidv4();
|
||||
promptCount = 0;
|
||||
autoExecute: boolean;
|
||||
usageMetadata?: {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
cachedContentTokenCount?: number;
|
||||
};
|
||||
private get isYoloMatch(): boolean {
|
||||
return (
|
||||
this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO
|
||||
@@ -274,6 +280,7 @@ export class Task {
|
||||
userTier?: UserTierId;
|
||||
error?: string;
|
||||
traceId?: string;
|
||||
usageMetadata?: Task['usageMetadata'];
|
||||
} = {
|
||||
coderAgent: coderAgentMessage,
|
||||
model: this.modelInfo || this.config.getModel(),
|
||||
@@ -288,6 +295,10 @@ export class Task {
|
||||
metadata.traceId = traceId;
|
||||
}
|
||||
|
||||
if (final && this.usageMetadata) {
|
||||
metadata.usageMetadata = this.usageMetadata;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'status-update',
|
||||
taskId: this.id,
|
||||
@@ -857,8 +868,18 @@ export class Task {
|
||||
break;
|
||||
case GeminiEventType.Finished:
|
||||
logger.info(`[Task ${this.id}] Agent finished its turn.`);
|
||||
// Capture the usage metadata when the stream finishes
|
||||
if (
|
||||
event.value &&
|
||||
typeof event.value === 'object' &&
|
||||
'usageMetadata' in event.value
|
||||
) {
|
||||
this.usageMetadata = event.value
|
||||
.usageMetadata as typeof this.usageMetadata;
|
||||
}
|
||||
break;
|
||||
case GeminiEventType.ModelInfo:
|
||||
this.usageMetadata = undefined;
|
||||
this.modelInfo = event.value;
|
||||
break;
|
||||
case GeminiEventType.Retry:
|
||||
|
||||
+17
-10
@@ -17,17 +17,24 @@ import {
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
// Suppress known race condition error in node-pty on Windows
|
||||
// Suppress known race condition error in node-pty on Windows and Linux
|
||||
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
error instanceof Error &&
|
||||
error.message === 'Cannot resize a pty that has already exited'
|
||||
) {
|
||||
// This error happens on Windows with node-pty when resizing a pty that has just exited.
|
||||
// It is a race condition in node-pty that we cannot prevent, so we silence it.
|
||||
return;
|
||||
if (error instanceof Error) {
|
||||
const message = error.message || '';
|
||||
const isPtyResizeError =
|
||||
message === 'Cannot resize a pty that has already exited';
|
||||
const isEbadfError =
|
||||
message.includes('EBADF') ||
|
||||
(error as { code?: string }).code === 'EBADF';
|
||||
const isFromNodePty =
|
||||
error.stack?.includes('node-pty') || error.stack?.includes('PtyResize');
|
||||
|
||||
if ((isPtyResizeError || isEbadfError) && isFromNodePty) {
|
||||
// This error happens with node-pty when resizing a pty that has just exited.
|
||||
// It is a race condition in node-pty that we cannot prevent, so we silence it.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For other errors, we rely on the default behavior, but since we attached a listener,
|
||||
@@ -126,7 +133,7 @@ async function run() {
|
||||
while (true) {
|
||||
try {
|
||||
const exitCode = await runner();
|
||||
if (exitCode !== RELAUNCH_EXIT_CODE) {
|
||||
if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.44.0-nightly.20260521.g57c42a5c4"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.47.0-nightly.20260602.gcfcecebe8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -217,13 +217,12 @@ describe('AcpSessionManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => {
|
||||
it('should NOT include retired preview models (none) in available models', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
@@ -233,14 +232,9 @@ describe('AcpSessionManager', () => {
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.models?.availableModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'gemini-3.1-flash-lite-preview',
|
||||
name: 'gemini-3.1-flash-lite-preview',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const modelIds =
|
||||
response.models?.availableModels?.map((m) => m.modelId) ?? [];
|
||||
expect(modelIds).not.toContain('none');
|
||||
});
|
||||
|
||||
it('should return modes with plan mode when plan is enabled', async () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
ToolConfirmationOutcome,
|
||||
@@ -265,8 +265,7 @@ export function buildAvailableModels(
|
||||
const preferredModel = config.getModel() || GEMINI_MODEL_ALIAS_AUTO;
|
||||
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
@@ -278,7 +277,7 @@ export function buildAvailableModels(
|
||||
) {
|
||||
const options = config.getModelConfigService().getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useGemini3_5Flash,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
});
|
||||
@@ -297,6 +296,7 @@ export function buildAvailableModels(
|
||||
description: getAutoModelDescription(
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
useGemini3_5Flash,
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -336,10 +336,10 @@ export function buildAvailableModels(
|
||||
},
|
||||
];
|
||||
|
||||
if (useGemini31FlashLite) {
|
||||
if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
value: PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -475,4 +475,258 @@ describe('mcp list command', () => {
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block servers excluded by user settings even if workspace settings override/clear the excluded list', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
excluded: ['blocked-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
excluded: ['blocked-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
path: '/workspace/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
excluded: [],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
excluded: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcp: {
|
||||
excluded: [], // workspace has overridden user settings!
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'blocked-server: /test/server (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block servers case-insensitively when excluded', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
excluded: ['BLOCKED-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
excluded: ['BLOCKED-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'blocked-server: /test/server (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restrict allowed servers to the intersection of all defined allowlists', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'allowed-server-2'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'allowed-server-2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
path: '/workspace/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'malicious-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'malicious-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'allowed-server-1': { command: '/allowed/1' },
|
||||
'allowed-server-2': { command: '/allowed/2' },
|
||||
'malicious-server': { command: '/malicious' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'malicious-server'], // workspace overrode user settings!
|
||||
},
|
||||
mcpServers: {
|
||||
'allowed-server-1': { command: '/allowed/1' },
|
||||
'allowed-server-2': { command: '/allowed/2' },
|
||||
'malicious-server': { command: '/malicious' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
// allowed-server-1 is in the intersection, so it should connect
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'allowed-server-1: /allowed/1 (stdio) - Connected',
|
||||
),
|
||||
);
|
||||
// allowed-server-2 and malicious-server are not in the intersection, so they should be Blocked
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'allowed-server-2: /allowed/2 (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'malicious-server: /malicious (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
|
||||
expect(mockedCreateTransport).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCreateTransport).toHaveBeenCalledWith(
|
||||
'allowed-server-1',
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should block all servers if the intersection of user and workspace allowlists is empty (disjoint allowlists)', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['user-allowed-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['user-allowed-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
path: '/workspace/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['workspace-allowed-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['workspace-allowed-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'user-allowed-server': { command: '/allowed/user' },
|
||||
'workspace-allowed-server': { command: '/allowed/workspace' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcp: {
|
||||
allowed: ['workspace-allowed-server'], // workspace override
|
||||
},
|
||||
mcpServers: {
|
||||
'user-allowed-server': { command: '/allowed/user' },
|
||||
'workspace-allowed-server': { command: '/allowed/workspace' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
// Since the intersection is empty ([]), both servers should be Blocked!
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'user-allowed-server: /allowed/user (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'workspace-allowed-server: /allowed/workspace (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block all servers if allowlist is configured as empty array []', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
mcp: {
|
||||
allowed: [], // empty allowlist configured!
|
||||
},
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-server: /test/server (stdio) - Blocked'),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,12 +159,16 @@ async function getServerStatus(
|
||||
server: MCPServerConfig,
|
||||
isTrusted: boolean,
|
||||
activeSettings: MergedSettings,
|
||||
consolidatedExcluded: string[],
|
||||
consolidatedAllowed: string[] | undefined,
|
||||
): Promise<MCPServerStatus> {
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
|
||||
const loadResult = await canLoadServer(serverName, {
|
||||
adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true,
|
||||
allowedList: activeSettings.mcp?.allowed,
|
||||
excludedList: activeSettings.mcp?.excluded,
|
||||
allowedList: consolidatedAllowed,
|
||||
excludedList:
|
||||
consolidatedExcluded.length > 0 ? consolidatedExcluded : undefined,
|
||||
enablement: mcpEnablementManager.getEnablementCallbacks(),
|
||||
});
|
||||
|
||||
@@ -227,6 +231,10 @@ export async function listMcpServers(
|
||||
);
|
||||
}
|
||||
|
||||
const consolidatedExcluded =
|
||||
loadedSettings.getConsolidatedExcludedMcpServers();
|
||||
const consolidatedAllowed = loadedSettings.getConsolidatedAllowedMcpServers();
|
||||
|
||||
debugLogger.log('Configured MCP servers:\n');
|
||||
|
||||
for (const serverName of serverNames) {
|
||||
@@ -237,6 +245,8 @@ export async function listMcpServers(
|
||||
server,
|
||||
loadedSettings.isTrusted,
|
||||
activeSettings,
|
||||
consolidatedExcluded,
|
||||
consolidatedAllowed,
|
||||
);
|
||||
|
||||
let statusIndicator = '';
|
||||
|
||||
@@ -576,6 +576,7 @@ export interface LoadCliConfigOptions {
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
skipExtensions?: boolean;
|
||||
loadedSettings?: LoadedSettings;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -584,7 +585,12 @@ export async function loadCliConfig(
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
projectHooks,
|
||||
skipExtensions = false,
|
||||
loadedSettings,
|
||||
} = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const worktreeSettings =
|
||||
@@ -936,6 +942,8 @@ export async function loadCliConfig(
|
||||
let profileSelector: string | undefined = undefined;
|
||||
if (settings.experimental?.stressTestProfile) {
|
||||
profileSelector = 'stressTestProfile';
|
||||
} else if (settings.experimental?.powerUserProfile) {
|
||||
profileSelector = 'powerUserProfile';
|
||||
} else if (
|
||||
settings.experimental?.generalistProfile ||
|
||||
settings.experimental?.contextManagement
|
||||
@@ -983,12 +991,17 @@ export async function loadCliConfig(
|
||||
agents: settings.agents,
|
||||
adminSkillsEnabled,
|
||||
allowedMcpServers: mcpEnabled
|
||||
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
|
||||
? (argv.allowedMcpServerNames ??
|
||||
(loadedSettings
|
||||
? loadedSettings.getConsolidatedAllowedMcpServers()
|
||||
: settings.mcp?.allowed))
|
||||
: undefined,
|
||||
blockedMcpServers: mcpEnabled
|
||||
? argv.allowedMcpServerNames
|
||||
? undefined
|
||||
: settings.mcp?.excluded
|
||||
: loadedSettings
|
||||
? loadedSettings.getConsolidatedExcludedMcpServers()
|
||||
: settings.mcp?.excluded
|
||||
: undefined,
|
||||
blockedEnvironmentVariables:
|
||||
settings.security?.environmentVariableRedaction?.blocked,
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function canLoadServer(
|
||||
}
|
||||
|
||||
// 2. Allowlist check
|
||||
if (config.allowedList && config.allowedList.length > 0) {
|
||||
if (config.allowedList !== undefined) {
|
||||
const { found, deprecationWarning } = isInSettingsList(
|
||||
normalizedId,
|
||||
config.allowedList,
|
||||
|
||||
@@ -1109,6 +1109,77 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoadedSettings MCP consolidation', () => {
|
||||
it('should consolidate mcp excluded list across all scopes', () => {
|
||||
const loaded = new LoadedSettings(
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { excluded: ['system-excluded'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { excluded: ['defaults-excluded'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { excluded: ['user-excluded'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { excluded: ['workspace-excluded'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(loaded.getConsolidatedExcludedMcpServers()).toEqual([
|
||||
'system-excluded',
|
||||
'defaults-excluded',
|
||||
'user-excluded',
|
||||
'workspace-excluded',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should consolidate allowed mcp list via case-insensitive intersection', () => {
|
||||
const loaded = new LoadedSettings(
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { allowed: ['Server-A', 'Server-B'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { allowed: ['server-a', 'Server-C'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
{ path: '', settings: {}, originalSettings: {} }, // no allowlist in user
|
||||
{
|
||||
path: '',
|
||||
settings: { mcp: { allowed: ['SERVER-A', 'Server-D'] } },
|
||||
originalSettings: {},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(loaded.getConsolidatedAllowedMcpServers()).toEqual(['Server-A']);
|
||||
});
|
||||
|
||||
it('should return undefined allowed list if no scopes define one', () => {
|
||||
const loaded = new LoadedSettings(
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
true,
|
||||
);
|
||||
|
||||
expect(loaded.getConsolidatedAllowedMcpServers()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compressionThreshold settings', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -509,6 +509,51 @@ export class LoadedSettings {
|
||||
this._remoteAdminSettings = { admin };
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a consolidated list of excluded MCP servers across all settings files.
|
||||
*/
|
||||
getConsolidatedExcludedMcpServers(): string[] {
|
||||
const scopes = [
|
||||
this.system,
|
||||
this.systemDefaults,
|
||||
this.user,
|
||||
this.workspace,
|
||||
];
|
||||
return scopes.flatMap((scope) => {
|
||||
const excluded = scope?.settings?.mcp?.excluded;
|
||||
return Array.isArray(excluded) ? excluded : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a consolidated list of allowed MCP servers (via intersection of all defined lists).
|
||||
*/
|
||||
getConsolidatedAllowedMcpServers(): string[] | undefined {
|
||||
const scopes = [
|
||||
this.system,
|
||||
this.systemDefaults,
|
||||
this.user,
|
||||
this.workspace,
|
||||
];
|
||||
const definedAllowlists = scopes.flatMap((scope) => {
|
||||
const allowed = scope?.settings?.mcp?.allowed;
|
||||
return Array.isArray(allowed) ? [allowed] : [];
|
||||
});
|
||||
|
||||
if (definedAllowlists.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return definedAllowlists.reduce((acc, current) => {
|
||||
const normalizedCurrent = new Set(
|
||||
current.map((item) => item.toLowerCase().trim()),
|
||||
);
|
||||
return acc.filter((item) =>
|
||||
normalizedCurrent.has(item.toLowerCase().trim()),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(
|
||||
|
||||
@@ -2449,6 +2449,15 @@ const SETTINGS_SCHEMA = {
|
||||
'Suitable for general coding and software development tasks.',
|
||||
showInDialog: true,
|
||||
},
|
||||
powerUserProfile: {
|
||||
type: 'boolean',
|
||||
label: 'Use the power user profile to manage agent contexts.',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Less cache friendly version of the generalist profile.',
|
||||
showInDialog: false,
|
||||
},
|
||||
contextManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Context Management',
|
||||
|
||||
@@ -499,6 +499,7 @@ export async function main() {
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
skipExtensions: true,
|
||||
loadedSettings: settings,
|
||||
});
|
||||
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
@@ -627,6 +628,7 @@ export async function main() {
|
||||
config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
worktreeSettings: worktreeInfo,
|
||||
loadedSettings: settings,
|
||||
});
|
||||
loadConfigHandle?.end();
|
||||
|
||||
|
||||
@@ -254,7 +254,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}, [mouseMode, setOptions]);
|
||||
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
const [quittingMessages, setQuittingMessages] = useState<
|
||||
HistoryItem[] | null
|
||||
@@ -1687,8 +1686,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
needsRestart: ideNeedsRestart,
|
||||
restartReason: ideTrustRestartReason,
|
||||
} = useIdeTrustListener();
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
|
||||
|
||||
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -1741,8 +1738,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const { handleSuspend } = useSuspend({
|
||||
handleWarning,
|
||||
setRawMode,
|
||||
refreshStatic,
|
||||
setForceRerenderKey,
|
||||
shouldUseAlternateScreen,
|
||||
});
|
||||
|
||||
@@ -1753,21 +1748,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}, [ideNeedsRestart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = setTimeout(() => {
|
||||
refreshStatic();
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [terminalWidth, refreshStatic]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = ideContextStore.subscribe(setIdeContextState);
|
||||
setIdeContextState(ideContextStore.get());
|
||||
@@ -2872,7 +2852,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<MouseProvider mouseEventsEnabled={mouseMode}>
|
||||
<ScrollProvider>
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
<App />
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
|
||||
@@ -12,7 +12,12 @@ import { MessageType } from '../types.js';
|
||||
|
||||
describe('helpCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalEnv = { ...process.env };
|
||||
const originalPlatform = process.platform;
|
||||
const action = helpCommand.action;
|
||||
|
||||
if (!action) {
|
||||
throw new Error('Help command has no action');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
@@ -23,16 +28,13 @@ describe('helpCommand', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should add a help message to the UI history', async () => {
|
||||
if (!helpCommand.action) {
|
||||
throw new Error('Help command has no action');
|
||||
}
|
||||
|
||||
await helpCommand.action(mockContext, '');
|
||||
it('should add a help message to the UI history by default', async () => {
|
||||
await action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -47,4 +49,85 @@ describe('helpCommand', () => {
|
||||
expect(helpCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
expect(helpCommand.description).toBe('For help on gemini-cli');
|
||||
});
|
||||
|
||||
describe('Antigravity installer commands help', () => {
|
||||
it('should output macOS installation command on darwin platform', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
await action(mockContext, 'install antigravity cli');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: `To install the Antigravity CLI on macOS, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should output Linux installation command on linux platform', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
|
||||
await action(mockContext, 'how do I install antigravity CLI');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: `To install the Antigravity CLI on Linux, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should output Windows PowerShell installation command on win32 when PSModulePath is set', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
vi.stubEnv('PSModulePath', 'C:\\some\\path');
|
||||
|
||||
await action(mockContext, 'how do I migrate to antigravity CLI');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: `To install the Antigravity CLI on Windows (PowerShell), run the following command:\n\n'irm https://antigravity.google/cli/install.ps1 | iex'`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should output Windows CMD installation command on win32 when PSModulePath is not set', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
vi.stubEnv('PSModulePath', '');
|
||||
|
||||
await action(mockContext, 'install antigravity cli');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: `To install the Antigravity CLI on Windows (Command Prompt), run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd'`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should learn more message on unsupported platform', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'freebsd' });
|
||||
|
||||
await action(mockContext, 'install antigravity cli');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to default help if query does not contain install or migrate', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
await action(mockContext, 'antigravity cli');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HELP,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,36 @@
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { MessageType, type HistoryItemHelp } from '../types.js';
|
||||
import { getAntigravityInstallInfo } from '../utils/antigravityUtils.js';
|
||||
|
||||
export const helpCommand: SlashCommand = {
|
||||
name: 'help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'For help on gemini-cli',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
action: async (context, args) => {
|
||||
const lowerArgs = args?.toLowerCase() || '';
|
||||
const hasAntigravity = lowerArgs.includes('antigravity');
|
||||
const hasInstallOrMigrate =
|
||||
lowerArgs.includes('install') || lowerArgs.includes('migrate');
|
||||
|
||||
if (hasAntigravity && hasInstallOrMigrate) {
|
||||
const info = getAntigravityInstallInfo();
|
||||
|
||||
if (info) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `To install the Antigravity CLI on ${info.platformName}, run the following command:\n\n'${info.installCmd}'`,
|
||||
});
|
||||
} else {
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const helpItem: Omit<HistoryItemHelp, 'id'> = {
|
||||
type: MessageType.HELP,
|
||||
timestamp: new Date(),
|
||||
|
||||
@@ -175,4 +175,45 @@ describe('EditorSettingsDialog', () => {
|
||||
}
|
||||
expect(frame).toContain('(Also modified');
|
||||
});
|
||||
|
||||
it('emits error feedback only once when preferredEditor is invalid', async () => {
|
||||
const mockEmitFeedback = vi.fn();
|
||||
vi.spyOn(
|
||||
await import('@google/gemini-cli-core').then((m) => m.coreEvents),
|
||||
'emitFeedback',
|
||||
).mockImplementation(mockEmitFeedback);
|
||||
|
||||
const invalidSettings = {
|
||||
forScope: (_scope: string) => ({
|
||||
settings: {
|
||||
general: {
|
||||
preferredEditor: 'invalideditor',
|
||||
},
|
||||
},
|
||||
}),
|
||||
merged: {
|
||||
general: {
|
||||
preferredEditor: 'invalideditor',
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { unmount } = await renderWithProvider(
|
||||
<EditorSettingsDialog
|
||||
onSelect={vi.fn()}
|
||||
settings={invalidSettings}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEmitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Editor is not supported: invalideditor',
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockEmitFeedback).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type EditorType,
|
||||
isEditorAvailable,
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
@@ -70,10 +71,20 @@ export function EditorSettingsDialog({
|
||||
(item: EditorDisplay) => item.type === currentPreference,
|
||||
)
|
||||
: 0;
|
||||
if (editorIndex === -1) {
|
||||
const isUnsupportedEditor = editorIndex === -1;
|
||||
if (isUnsupportedEditor) {
|
||||
editorIndex = 0;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isUnsupportedEditor && currentPreference) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Editor is not supported: ${currentPreference}`,
|
||||
);
|
||||
}
|
||||
}, [isUnsupportedEditor, currentPreference]);
|
||||
|
||||
const scopeItems: Array<{
|
||||
label: string;
|
||||
value: LoadableSettingScope;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
|
||||
@@ -47,7 +47,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
mockModelSlashCommandEvent(model);
|
||||
}
|
||||
},
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: 'gemini-3.1-flash-lite-preview',
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL: 'none',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,7 +67,6 @@ describe('<ModelDialog />', () => {
|
||||
getHasAccessToPreviewModel: () => boolean;
|
||||
getIdeMode: () => boolean;
|
||||
getGemini31LaunchedSync: () => boolean;
|
||||
getGemini31FlashLiteLaunchedSync: () => boolean;
|
||||
getProModelNoAccess: () => Promise<boolean>;
|
||||
getProModelNoAccessSync: () => boolean;
|
||||
getExperimentalGemma: () => boolean;
|
||||
@@ -88,7 +87,6 @@ describe('<ModelDialog />', () => {
|
||||
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
|
||||
getIdeMode: () => false,
|
||||
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
|
||||
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
|
||||
getProModelNoAccess: mockGetProModelNoAccess,
|
||||
getProModelNoAccessSync: mockGetProModelNoAccessSync,
|
||||
getExperimentalGemma: () => false,
|
||||
@@ -101,7 +99,6 @@ describe('<ModelDialog />', () => {
|
||||
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_AUTO);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(false);
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
|
||||
@@ -157,17 +154,13 @@ describe('<ModelDialog />', () => {
|
||||
expect(output).not.toContain(DEFAULT_GEMINI_MODEL);
|
||||
expect(output).not.toContain(PREVIEW_GEMINI_MODEL);
|
||||
|
||||
// Verify order: Flash Preview -> Flash Lite Preview -> Flash -> Flash Lite
|
||||
// Verify order: Flash Preview -> Flash Lite (Preview/Default) -> Flash
|
||||
const flashPreviewIdx = output.indexOf(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
const flashLitePreviewIdx = output.indexOf(
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
);
|
||||
const flashIdx = output.indexOf(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
const flashLiteIdx = output.indexOf(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
const flashIdx = output.indexOf(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
|
||||
expect(flashPreviewIdx).toBeLessThan(flashLitePreviewIdx);
|
||||
expect(flashLitePreviewIdx).toBeLessThan(flashIdx);
|
||||
expect(flashIdx).toBeLessThan(flashLiteIdx);
|
||||
expect(flashPreviewIdx).toBeLessThan(flashLiteIdx);
|
||||
expect(flashLiteIdx).toBeLessThan(flashIdx);
|
||||
|
||||
expect(output).not.toContain('Auto');
|
||||
unmount();
|
||||
@@ -453,7 +446,7 @@ describe('<ModelDialog />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Flash Lite Preview model regardless of tier when flag is enabled', async () => {
|
||||
it('does not show Flash Lite Preview model when it is retired (none) even if flag is enabled', async () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
@@ -472,7 +465,8 @@ describe('<ModelDialog />', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
|
||||
expect(output).not.toContain(PREVIEW_GEMINI_FLASH_LITE_MODEL);
|
||||
expect(output).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
@@ -67,8 +67,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel() ?? false;
|
||||
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const useGemini3_5Flash = config?.hasGemini35FlashGAAccess?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
@@ -94,9 +93,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
];
|
||||
].filter((m) => m !== 'none');
|
||||
if (manualModels.includes(preferredModel)) {
|
||||
return preferredModel;
|
||||
}
|
||||
@@ -131,7 +130,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
.getModelConfigService()
|
||||
.getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useGemini3_5Flash,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
@@ -165,6 +164,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
description: getAutoModelDescription(
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
useGemini3_5Flash,
|
||||
),
|
||||
key: GEMINI_MODEL_ALIAS_AUTO,
|
||||
},
|
||||
@@ -184,7 +184,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
shouldShowPreviewModels,
|
||||
manualModelSelected,
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useGemini3_5Flash,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
]);
|
||||
@@ -199,7 +199,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
.getModelConfigService()
|
||||
.getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useGemini3_5Flash,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
@@ -223,16 +223,16 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
key: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
|
||||
key: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
|
||||
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
|
||||
key: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
];
|
||||
|
||||
if (showGemmaModels) {
|
||||
@@ -272,11 +272,11 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
if (useGemini31FlashLite) {
|
||||
if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
key: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
value: PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL),
|
||||
key: PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,13 +292,23 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
}, [
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useGemini3_5Flash,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
config,
|
||||
]);
|
||||
|
||||
const options = view === 'main' ? mainOptions : manualOptions;
|
||||
const options = useMemo(() => {
|
||||
const rawOptions = view === 'main' ? mainOptions : manualOptions;
|
||||
const seen = new Set<string>();
|
||||
return rawOptions.filter((option) => {
|
||||
if (seen.has(option.value)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(option.value);
|
||||
return true;
|
||||
});
|
||||
}, [view, mainOptions, manualOptions]);
|
||||
|
||||
// Calculate the initial index based on the preferred model.
|
||||
const initialIndex = useMemo(() => {
|
||||
|
||||
@@ -353,6 +353,49 @@ describe('<ModelStatsDisplay />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should resolve gemini-3-flash to gemini-3.5-flash via getDisplayString', async () => {
|
||||
const { lastFrame, unmount } = await renderWithMockedStats({
|
||||
models: {
|
||||
'gemini-3-flash': {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: {
|
||||
input: 5,
|
||||
prompt: 10,
|
||||
candidates: 20,
|
||||
total: 30,
|
||||
cached: 5,
|
||||
thoughts: 2,
|
||||
tool: 1,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: {
|
||||
accept: 0,
|
||||
reject: 0,
|
||||
modify: 0,
|
||||
[ToolCallDecision.AUTO_ACCEPT]: 0,
|
||||
},
|
||||
byName: {},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('gemini-3.5-flash');
|
||||
expect(output).not.toContain('gemini-3-flash');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle models with long names (gemini-3-*-preview) without layout breaking', async () => {
|
||||
const { lastFrame, unmount } = await renderWithMockedStats(
|
||||
{
|
||||
|
||||
@@ -299,7 +299,7 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
},
|
||||
...modelNames.map((name) => ({
|
||||
key: name,
|
||||
header: name,
|
||||
header: getDisplayString(name),
|
||||
flexGrow: 1,
|
||||
renderCell: (row: StatRowData) => {
|
||||
// Don't render anything for section headers in model columns
|
||||
|
||||
@@ -131,6 +131,33 @@ describe('<StatsDisplay />', () => {
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('resolves gemini-3-flash to gemini-3.5-flash in the model usage table', async () => {
|
||||
const metrics = createTestMetrics({
|
||||
models: {
|
||||
'gemini-3-flash': {
|
||||
api: { totalRequests: 5, totalErrors: 0, totalLatencyMs: 3000 },
|
||||
tokens: {
|
||||
input: 1000,
|
||||
prompt: 2000,
|
||||
candidates: 3000,
|
||||
total: 5000,
|
||||
cached: 500,
|
||||
thoughts: 100,
|
||||
tool: 50,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderWithMockedStats(metrics);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('gemini-3.5-flash');
|
||||
expect(output).not.toContain('gemini-3-flash\u0020'); // Avoid matching parts of substrings if not intended
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders role breakdown correctly under models', async () => {
|
||||
const metrics = createTestMetrics({
|
||||
models: {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import type { QuotaStats } from '../types.js';
|
||||
import { LlmRole } from '@google/gemini-cli-core';
|
||||
import { LlmRole, getDisplayString } from '@google/gemini-cli-core';
|
||||
|
||||
// A more flexible and powerful StatRow component
|
||||
interface StatRowProps {
|
||||
@@ -101,7 +101,7 @@ const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
|
||||
Object.entries(models).forEach(([name, metrics]) => {
|
||||
rows.push({
|
||||
name,
|
||||
displayName: name,
|
||||
displayName: getDisplayString(name),
|
||||
requests: metrics.api.totalRequests,
|
||||
cachedTokens: metrics.tokens.cached.toLocaleString(),
|
||||
inputTokens: metrics.tokens.prompt.toLocaleString(),
|
||||
|
||||
@@ -60,12 +60,6 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 2`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello 👍 world
|
||||
|
||||
@@ -215,3 +215,26 @@ exports[`<ModelStatsDisplay /> > should render "no API calls" message when there
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should resolve gemini-3-flash to gemini-3.5-flash via getDisplayString 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-3.5-flash │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 1 │
|
||||
│ Errors 0 (0.0%) │
|
||||
│ Avg Latency 100ms │
|
||||
│ Tokens │
|
||||
│ Total 30 │
|
||||
│ ↳ Input 5 │
|
||||
│ ↳ Cache Reads 5 (50.0%) │
|
||||
│ ↳ Thoughts 2 │
|
||||
│ ↳ Tool 1 │
|
||||
│ ↳ Output 20 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -292,3 +292,30 @@ exports[`<StatsDisplay /> > renders role breakdown correctly under models 1`] =
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > resolves gemini-3-flash to gemini-3.5-flash in the model usage table 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 3.0s │
|
||||
│ » API Time: 3.0s (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage │
|
||||
│ Use /model to view model quota information │
|
||||
│ │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-3.5-flash 5 2,000 500 3,000 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -134,4 +134,5 @@ export const WITTY_LOADING_PHRASES = [
|
||||
'Constructing additional pylons',
|
||||
'New line? That’s Ctrl+J.',
|
||||
'Releasing the HypnoDrones',
|
||||
'Pushing the button, Frank.',
|
||||
];
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockedFunction,
|
||||
} from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useBanner, _clearSessionBannersForTest } from './useBanner.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import crypto from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
|
||||
vi.mock('../../utils/persistentState.js', () => ({
|
||||
persistentState: {
|
||||
@@ -77,10 +79,26 @@ describe('useBanner', () => {
|
||||
.update(defaultBannerData.defaultText)
|
||||
.digest('hex')]: 5,
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = await renderHook(() => useBanner(defaultBannerData));
|
||||
it('should not hide banner if show count exceeds max limit (Legacy format) if it contains an Antigravity announcement', async () => {
|
||||
const antigravityBannerData = {
|
||||
defaultText: 'Antigravity is coming to town!',
|
||||
warningText: '',
|
||||
};
|
||||
|
||||
expect(result.current.bannerText).toBe('');
|
||||
mockedPersistentStateGet.mockReturnValue({
|
||||
[crypto
|
||||
.createHash('sha256')
|
||||
.update(antigravityBannerData.defaultText)
|
||||
.digest('hex')]: 5,
|
||||
});
|
||||
|
||||
const { result } = await renderHook(() => useBanner(antigravityBannerData));
|
||||
|
||||
expect(result.current.bannerText).toContain(
|
||||
'Antigravity is coming to town!',
|
||||
);
|
||||
});
|
||||
|
||||
it('should increment the persistent count when banner is shown', async () => {
|
||||
@@ -123,4 +141,77 @@ describe('useBanner', () => {
|
||||
|
||||
expect(result.current.bannerText).toBe('Line1\nLine2');
|
||||
});
|
||||
|
||||
describe('Antigravity installation commands', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should append macOS & Linux install command when on darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
|
||||
|
||||
const { result } = await renderHook(() => useBanner(data));
|
||||
|
||||
expect(result.current.bannerText).toBe(
|
||||
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('curl -fsSL https://antigravity.google/cli/install.sh | bash')}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should append macOS & Linux install command when on linux', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
|
||||
|
||||
const { result } = await renderHook(() => useBanner(data));
|
||||
|
||||
expect(result.current.bannerText).toBe(
|
||||
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('curl -fsSL https://antigravity.google/cli/install.sh | bash')}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should append Windows PowerShell install command when on win32 and PSModulePath is set', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
vi.stubEnv('PSModulePath', 'C:\\some\\path');
|
||||
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
|
||||
|
||||
const { result } = await renderHook(() => useBanner(data));
|
||||
|
||||
expect(result.current.bannerText).toBe(
|
||||
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('irm https://antigravity.google/cli/install.ps1 | iex')}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should append Windows CMD install command when on win32 and PSModulePath is not set', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
vi.stubEnv('PSModulePath', '');
|
||||
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
|
||||
|
||||
const { result } = await renderHook(() => useBanner(data));
|
||||
|
||||
expect(result.current.bannerText).toBe(
|
||||
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd')}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not append install command if banner text does not contain Antigravity', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
const data = { defaultText: 'Regular Banner', warningText: '' };
|
||||
|
||||
const { result } = await renderHook(() => useBanner(data));
|
||||
|
||||
expect(result.current.bannerText).toBe('Regular Banner');
|
||||
});
|
||||
|
||||
it('should not append install command if process.platform is an unsupported platform', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'freebsd' });
|
||||
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
|
||||
|
||||
const { result } = await renderHook(() => useBanner(data));
|
||||
|
||||
expect(result.current.bannerText).toBe('Welcome to Antigravity!');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import crypto from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
import { getAntigravityInstallInfo } from '../utils/antigravityUtils.js';
|
||||
|
||||
const DEFAULT_MAX_BANNER_SHOWN_COUNT = 5;
|
||||
|
||||
@@ -41,10 +43,19 @@ export function useBanner(bannerData: BannerData) {
|
||||
const currentBannerCount = bannerCounts[hashedText] || 0;
|
||||
|
||||
const showBanner =
|
||||
activeText !== '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT;
|
||||
activeText !== '' &&
|
||||
(currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT ||
|
||||
activeText.includes('Antigravity'));
|
||||
|
||||
const rawBannerText = showBanner ? activeText : '';
|
||||
const bannerText = rawBannerText.replace(/\\n/g, '\n');
|
||||
let bannerText = rawBannerText.replace(/\\n/g, '\n');
|
||||
|
||||
if (showBanner && activeText.includes('Antigravity')) {
|
||||
const info = getAntigravityInstallInfo();
|
||||
if (info) {
|
||||
bannerText += `\n \nTo install run "${chalk.bold(info.installCmd)}"`;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showBanner && activeText) {
|
||||
|
||||
@@ -83,8 +83,6 @@ describe('useSuspend', () => {
|
||||
it('cleans terminal state on suspend and restores/repaints on resume in alternate screen mode', async () => {
|
||||
const handleWarning = vi.fn();
|
||||
const setRawMode = vi.fn();
|
||||
const refreshStatic = vi.fn();
|
||||
const setForceRerenderKey = vi.fn();
|
||||
const enableSupportedModes =
|
||||
terminalCapabilityManager.enableSupportedModes as unknown as Mock;
|
||||
|
||||
@@ -92,8 +90,6 @@ describe('useSuspend', () => {
|
||||
useSuspend({
|
||||
handleWarning,
|
||||
setRawMode,
|
||||
refreshStatic,
|
||||
setForceRerenderKey,
|
||||
shouldUseAlternateScreen: true,
|
||||
}),
|
||||
);
|
||||
@@ -131,8 +127,6 @@ describe('useSuspend', () => {
|
||||
expect(enableSupportedModes).toHaveBeenCalledTimes(1);
|
||||
expect(enableMouseEvents).toHaveBeenCalledTimes(1);
|
||||
expect(setRawMode).toHaveBeenCalledWith(true);
|
||||
expect(refreshStatic).toHaveBeenCalledTimes(1);
|
||||
expect(setForceRerenderKey).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -140,15 +134,11 @@ describe('useSuspend', () => {
|
||||
it('does not toggle alternate screen or mouse restore when alternate screen mode is disabled', async () => {
|
||||
const handleWarning = vi.fn();
|
||||
const setRawMode = vi.fn();
|
||||
const refreshStatic = vi.fn();
|
||||
const setForceRerenderKey = vi.fn();
|
||||
|
||||
const { result, unmount } = await renderHook(() =>
|
||||
useSuspend({
|
||||
handleWarning,
|
||||
setRawMode,
|
||||
refreshStatic,
|
||||
setForceRerenderKey,
|
||||
shouldUseAlternateScreen: false,
|
||||
}),
|
||||
);
|
||||
@@ -174,15 +164,11 @@ describe('useSuspend', () => {
|
||||
|
||||
const handleWarning = vi.fn();
|
||||
const setRawMode = vi.fn();
|
||||
const refreshStatic = vi.fn();
|
||||
const setForceRerenderKey = vi.fn();
|
||||
|
||||
const { result, unmount } = await renderHook(() =>
|
||||
useSuspend({
|
||||
handleWarning,
|
||||
setRawMode,
|
||||
refreshStatic,
|
||||
setForceRerenderKey,
|
||||
shouldUseAlternateScreen: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -26,16 +26,12 @@ import { Command } from '../key/keyBindings.js';
|
||||
interface UseSuspendProps {
|
||||
handleWarning: (message: string) => void;
|
||||
setRawMode: (mode: boolean) => void;
|
||||
refreshStatic: () => void;
|
||||
setForceRerenderKey: (updater: (prev: number) => number) => void;
|
||||
shouldUseAlternateScreen: boolean;
|
||||
}
|
||||
|
||||
export function useSuspend({
|
||||
handleWarning,
|
||||
setRawMode,
|
||||
refreshStatic,
|
||||
setForceRerenderKey,
|
||||
shouldUseAlternateScreen,
|
||||
}: UseSuspendProps) {
|
||||
const [ctrlZPressCount, setCtrlZPressCount] = useState(0);
|
||||
@@ -108,16 +104,8 @@ export function useSuspend({
|
||||
enableMouseEvents();
|
||||
}
|
||||
|
||||
// Force Ink to do a complete repaint by:
|
||||
// 1. Emitting a resize event (tricks Ink into full redraw)
|
||||
// 2. Remounting components via state changes
|
||||
// Force Ink to do a complete repaint without remounting the app.
|
||||
process.stdout.emit('resize');
|
||||
|
||||
// Give a tick for resize to process, then trigger remount
|
||||
setImmediate(() => {
|
||||
refreshStatic();
|
||||
setForceRerenderKey((prev) => prev + 1);
|
||||
});
|
||||
} finally {
|
||||
if (onResumeHandlerRef.current === onResume) {
|
||||
onResumeHandlerRef.current = null;
|
||||
@@ -142,14 +130,7 @@ export function useSuspend({
|
||||
ctrlZTimerRef.current = null;
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}
|
||||
}, [
|
||||
ctrlZPressCount,
|
||||
handleWarning,
|
||||
setRawMode,
|
||||
refreshStatic,
|
||||
setForceRerenderKey,
|
||||
shouldUseAlternateScreen,
|
||||
]);
|
||||
}, [ctrlZPressCount, handleWarning, setRawMode, shouldUseAlternateScreen]);
|
||||
|
||||
const handleSuspend = useCallback(() => {
|
||||
setCtrlZPressCount((prev) => prev + 1);
|
||||
|
||||
@@ -81,4 +81,24 @@ describe('useVim passthrough', () => {
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['H', 'M', 'Q', 'm'])(
|
||||
'should ignore unmapped printable key %s in NORMAL mode',
|
||||
async (sequence) => {
|
||||
mockVimContext.vimMode = 'NORMAL';
|
||||
const { result } = await renderHook(() =>
|
||||
useVim(mockBuffer as TextBuffer),
|
||||
);
|
||||
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = result.current.handleInput(
|
||||
createKey({ name: sequence, sequence, insertable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1486,8 +1486,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
// Unknown command, clear count and pending states
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
|
||||
// Ignore any Insertable key in Normal Mode
|
||||
if (normalizedKey.insertable) {
|
||||
// Ignore unmapped Insertable keys in Normal Mode, but let
|
||||
// modifier-key chords (ctrl/alt/cmd) fall through to other handlers.
|
||||
if (
|
||||
normalizedKey.insertable &&
|
||||
!normalizedKey.ctrl &&
|
||||
!normalizedKey.alt &&
|
||||
!normalizedKey.cmd
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { getAntigravityInstallInfo } from './antigravityUtils.js';
|
||||
|
||||
describe('antigravityUtils', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should return macOS installation info on darwin platform', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
const info = getAntigravityInstallInfo();
|
||||
|
||||
expect(info).toEqual({
|
||||
platformName: 'macOS',
|
||||
installCmd: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return Linux installation info on linux platform', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
|
||||
const info = getAntigravityInstallInfo();
|
||||
|
||||
expect(info).toEqual({
|
||||
platformName: 'Linux',
|
||||
installCmd: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return Windows PowerShell installation info on win32 when PSModulePath is set', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
vi.stubEnv('PSModulePath', 'C:\\some\\path');
|
||||
|
||||
const info = getAntigravityInstallInfo();
|
||||
|
||||
expect(info).toEqual({
|
||||
platformName: 'Windows (PowerShell)',
|
||||
installCmd: 'irm https://antigravity.google/cli/install.ps1 | iex',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return Windows CMD installation info on win32 when PSModulePath is not set', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
vi.stubEnv('PSModulePath', '');
|
||||
|
||||
const info = getAntigravityInstallInfo();
|
||||
|
||||
expect(info).toEqual({
|
||||
platformName: 'Windows (Command Prompt)',
|
||||
installCmd:
|
||||
'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null on unsupported platform', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'freebsd' });
|
||||
|
||||
const info = getAntigravityInstallInfo();
|
||||
|
||||
expect(info).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
const ANTIGRAVITY_SH_INSTALL =
|
||||
'curl -fsSL https://antigravity.google/cli/install.sh | bash';
|
||||
|
||||
export interface AntigravityInstallInfo {
|
||||
platformName: string;
|
||||
installCmd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the platform-specific installation details for the Antigravity CLI.
|
||||
* Returns null if the current platform is unsupported.
|
||||
*/
|
||||
export function getAntigravityInstallInfo(): AntigravityInstallInfo | null {
|
||||
if (process.platform === 'win32') {
|
||||
if (process.env['PSModulePath']) {
|
||||
return {
|
||||
platformName: 'Windows (PowerShell)',
|
||||
installCmd: 'irm https://antigravity.google/cli/install.ps1 | iex',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
platformName: 'Windows (Command Prompt)',
|
||||
installCmd:
|
||||
'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd',
|
||||
};
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
return {
|
||||
platformName: 'macOS',
|
||||
installCmd: ANTIGRAVITY_SH_INSTALL,
|
||||
};
|
||||
} else if (process.platform === 'linux') {
|
||||
return {
|
||||
platformName: 'Linux',
|
||||
installCmd: ANTIGRAVITY_SH_INSTALL,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -46,6 +46,14 @@ import { relaunchAppInChildProcess, relaunchOnExitCode } from './relaunch.js';
|
||||
describe('relaunchOnExitCode', () => {
|
||||
let processExitSpy: MockInstance;
|
||||
let stdinResumeSpy: MockInstance;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
const setPlatform = (platform: NodeJS.Platform) => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
@@ -60,6 +68,7 @@ describe('relaunchOnExitCode', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
setPlatform(originalPlatform);
|
||||
processExitSpy.mockRestore();
|
||||
stdinResumeSpy.mockRestore();
|
||||
});
|
||||
@@ -92,6 +101,18 @@ describe('relaunchOnExitCode', () => {
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('should not relaunch on Android when RELAUNCH_EXIT_CODE is returned', async () => {
|
||||
setPlatform('android');
|
||||
const runner = vi.fn().mockResolvedValue(RELAUNCH_EXIT_CODE);
|
||||
|
||||
await expect(relaunchOnExitCode(runner)).rejects.toThrow(
|
||||
'PROCESS_EXIT_CALLED',
|
||||
);
|
||||
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
});
|
||||
|
||||
it('should handle runner errors', async () => {
|
||||
const error = new Error('Runner failed');
|
||||
const runner = vi.fn().mockRejectedValue(error);
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function relaunchOnExitCode(runner: () => Promise<number>) {
|
||||
try {
|
||||
const exitCode = await runner();
|
||||
|
||||
if (exitCode !== RELAUNCH_EXIT_CODE) {
|
||||
if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1111,6 +1111,28 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out <session_context> from UI history', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'user',
|
||||
content:
|
||||
'<session_context>\nThis is the Gemini CLI\n</session_context>',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'user',
|
||||
content: 'Real message',
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
expect(result.uiHistory).toHaveLength(1);
|
||||
expect(result.uiHistory[0].text).toBe('Real message');
|
||||
});
|
||||
|
||||
it('should handle missing tool descriptions and displayNames', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
|
||||
@@ -606,7 +606,16 @@ export function convertSessionToHistoryFormats(
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
const uiText = displayContentString || contentString;
|
||||
|
||||
if (uiText.trim()) {
|
||||
// Skip internal context messages in the UI history
|
||||
const trimmedText = uiText.trim();
|
||||
if (
|
||||
trimmedText.startsWith('<session_context>') ||
|
||||
trimmedText.startsWith('<hook_context>')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedText) {
|
||||
let messageType: MessageType;
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.44.0-nightly.20260521.g57c42a5c4",
|
||||
"version": "0.47.0-nightly.20260602.gcfcecebe8",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -28,7 +28,6 @@ describe('policyCatalog', () => {
|
||||
const chain = getModelPolicyChain({
|
||||
previewEnabled: true,
|
||||
useGemini31: true,
|
||||
useGemini31FlashLite: false,
|
||||
});
|
||||
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_MODEL);
|
||||
expect(chain).toHaveLength(2);
|
||||
@@ -39,7 +38,6 @@ describe('policyCatalog', () => {
|
||||
const chain = getModelPolicyChain({
|
||||
previewEnabled: true,
|
||||
useGemini31: true,
|
||||
useGemini31FlashLite: false,
|
||||
useCustomToolModel: true,
|
||||
});
|
||||
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ModelPolicyOptions {
|
||||
useGemini31?: boolean;
|
||||
useGemini31FlashLite?: boolean;
|
||||
useCustomToolModel?: boolean;
|
||||
useGemini3_5Flash?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIONS: ModelPolicyActionMap = {
|
||||
@@ -93,8 +94,10 @@ export function getModelPolicyChain(
|
||||
const proModel = resolveModel(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
options.useGemini31,
|
||||
options.useGemini31FlashLite,
|
||||
options.useCustomToolModel,
|
||||
true,
|
||||
undefined,
|
||||
options.useGemini3_5Flash,
|
||||
);
|
||||
return [
|
||||
definePolicy({
|
||||
|
||||
@@ -30,7 +30,6 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config => {
|
||||
getUserTier: () => undefined,
|
||||
getModel: () => 'gemini-2.5-pro',
|
||||
getGemini31LaunchedSync: () => false,
|
||||
getGemini31FlashLiteLaunchedSync: () => false,
|
||||
getUseCustomToolModelSync: () => {
|
||||
const useGemini31 = config.getGemini31LaunchedSync();
|
||||
const authType = config.getContentGeneratorConfig().authType;
|
||||
@@ -114,7 +113,7 @@ describe('policyHelpers', () => {
|
||||
});
|
||||
const chain = resolvePolicyChain(config, DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
expect(chain).toHaveLength(3);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash-lite');
|
||||
expect(chain[0]?.model).toBe('gemini-3.1-flash-lite');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
expect(chain[2]?.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
@@ -125,7 +124,7 @@ describe('policyHelpers', () => {
|
||||
});
|
||||
const chain = resolvePolicyChain(config);
|
||||
expect(chain).toHaveLength(3);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash-lite');
|
||||
expect(chain[0]?.model).toBe('gemini-3.1-flash-lite');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
expect(chain[2]?.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
@@ -227,7 +226,6 @@ describe('policyHelpers', () => {
|
||||
getExperimentalDynamicModelConfiguration: () => dynamic,
|
||||
getModel: () => model,
|
||||
getGemini31LaunchedSync: () => useGemini31 ?? false,
|
||||
getGemini31FlashLiteLaunchedSync: () => false,
|
||||
getHasAccessToPreviewModel: () => hasAccess ?? true,
|
||||
getContentGeneratorConfig: () => ({ authType }),
|
||||
getReleaseChannel: () => 'preview',
|
||||
|
||||
@@ -52,10 +52,9 @@ export function resolvePolicyChain(
|
||||
|
||||
let chain: ModelPolicyChain | undefined;
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const useCustomToolModel = config.getUseCustomToolModelSync?.() ?? false;
|
||||
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? false;
|
||||
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
|
||||
|
||||
// Capture the original family intent before any normalization or early downgrade.
|
||||
const isOriginallyGemini3 = isGemini3Model(modelFromConfig, config);
|
||||
@@ -64,10 +63,10 @@ export function resolvePolicyChain(
|
||||
resolveModel(
|
||||
modelFromConfig,
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
config,
|
||||
useGemini3_5Flash,
|
||||
),
|
||||
);
|
||||
const isAutoPreferred = normalizedPreferredModel
|
||||
@@ -84,8 +83,8 @@ export function resolvePolicyChain(
|
||||
if (config.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const context = {
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
useGemini3_5Flash,
|
||||
};
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
@@ -139,8 +138,8 @@ export function resolvePolicyChain(
|
||||
isAutoSelection,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
useGemini3_5Flash,
|
||||
});
|
||||
} else {
|
||||
// User requested Gemini 3 but has no access. Proactively downgrade
|
||||
@@ -150,8 +149,8 @@ export function resolvePolicyChain(
|
||||
isAutoSelection,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
useGemini3_5Flash,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './codeAssist.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { LoggingContentGenerator } from '../core/loggingContentGenerator.js';
|
||||
import { ModelMappingContentGenerator } from '../core/modelMappingContentGenerator.js';
|
||||
import { UserTierId } from './types.js';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -22,11 +23,15 @@ vi.mock('./oauth2.js');
|
||||
vi.mock('./setup.js');
|
||||
vi.mock('./server.js');
|
||||
vi.mock('../core/loggingContentGenerator.js');
|
||||
vi.mock('../core/modelMappingContentGenerator.js');
|
||||
|
||||
const mockedGetOauthClient = vi.mocked(getOauthClient);
|
||||
const mockedSetupUser = vi.mocked(setupUser);
|
||||
const MockedCodeAssistServer = vi.mocked(CodeAssistServer);
|
||||
const MockedLoggingContentGenerator = vi.mocked(LoggingContentGenerator);
|
||||
const MockedModelMappingContentGenerator = vi.mocked(
|
||||
ModelMappingContentGenerator,
|
||||
);
|
||||
|
||||
describe('codeAssist', () => {
|
||||
beforeEach(() => {
|
||||
@@ -178,5 +183,47 @@ describe('codeAssist', () => {
|
||||
const server = getCodeAssistServer(mockConfig);
|
||||
expect(server).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should unwrap and return the server if it is wrapped in a ModelMappingContentGenerator', () => {
|
||||
const mockServer = new MockedCodeAssistServer({} as never, '', {});
|
||||
const mockMapper = new MockedModelMappingContentGenerator(
|
||||
{} as never,
|
||||
{},
|
||||
);
|
||||
vi.spyOn(mockMapper, 'getWrapped').mockReturnValue(mockServer);
|
||||
|
||||
const mockConfig = {
|
||||
getContentGenerator: () => mockMapper,
|
||||
} as unknown as Config;
|
||||
|
||||
const server = getCodeAssistServer(mockConfig);
|
||||
expect(server).toBe(mockServer);
|
||||
expect(mockMapper.getWrapped).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should recursively unwrap multiple layers of LoggingContentGenerator and ModelMappingContentGenerator', () => {
|
||||
const mockServer = new MockedCodeAssistServer({} as never, '', {});
|
||||
const mockLogger = new MockedLoggingContentGenerator(
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
const mockMapper = new MockedModelMappingContentGenerator(
|
||||
{} as never,
|
||||
{},
|
||||
);
|
||||
|
||||
// Mapper wraps Logger wraps Server
|
||||
vi.spyOn(mockMapper, 'getWrapped').mockReturnValue(mockLogger);
|
||||
vi.spyOn(mockLogger, 'getWrapped').mockReturnValue(mockServer);
|
||||
|
||||
const mockConfig = {
|
||||
getContentGenerator: () => mockMapper,
|
||||
} as unknown as Config;
|
||||
|
||||
const server = getCodeAssistServer(mockConfig);
|
||||
expect(server).toBe(mockServer);
|
||||
expect(mockMapper.getWrapped).toHaveBeenCalled();
|
||||
expect(mockLogger.getWrapped).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { setupUser } from './setup.js';
|
||||
import { CodeAssistServer, type HttpOptions } from './server.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { LoggingContentGenerator } from '../core/loggingContentGenerator.js';
|
||||
import { ModelMappingContentGenerator } from '../core/modelMappingContentGenerator.js';
|
||||
|
||||
export async function createCodeAssistContentGenerator(
|
||||
httpOptions: HttpOptions,
|
||||
@@ -43,9 +44,15 @@ export function getCodeAssistServer(
|
||||
): CodeAssistServer | undefined {
|
||||
let server = config.getContentGenerator();
|
||||
|
||||
// Unwrap LoggingContentGenerator if present
|
||||
if (server instanceof LoggingContentGenerator) {
|
||||
server = server.getWrapped();
|
||||
// Recursively unwrap LoggingContentGenerator and ModelMappingContentGenerator
|
||||
while (true) {
|
||||
if (server instanceof LoggingContentGenerator) {
|
||||
server = server.getWrapped();
|
||||
} else if (server instanceof ModelMappingContentGenerator) {
|
||||
server = server.getWrapped();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(server instanceof CodeAssistServer)) {
|
||||
|
||||
@@ -18,8 +18,8 @@ export const ExperimentFlags = {
|
||||
MASKING_PROTECT_LATEST_TURN: 45758819,
|
||||
GEMINI_3_1_PRO_LAUNCHED: 45760185,
|
||||
PRO_MODEL_NO_ACCESS: 45768879,
|
||||
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
|
||||
DEFAULT_REQUEST_TIMEOUT: 45773134,
|
||||
GEMINI_3_5_FLASH_GA_LAUNCHED: 45780819,
|
||||
} as const;
|
||||
|
||||
export type ExperimentFlagName =
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
} from './models.js';
|
||||
import { Storage } from './storage.js';
|
||||
import type { AgentLoopContext } from './agent-loop-context.js';
|
||||
@@ -716,20 +717,6 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGemini31FlashLiteLaunchedSync', () => {
|
||||
it.each([AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, AuthType.GATEWAY])(
|
||||
'should return true for %s',
|
||||
async (authType) => {
|
||||
const config = new Config(baseParams);
|
||||
vi.mocked(createContentGeneratorConfig).mockResolvedValue({
|
||||
authType,
|
||||
});
|
||||
await config.refreshAuth(authType);
|
||||
expect(config.getGemini31FlashLiteLaunchedSync()).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('getProModelNoAccessSync', () => {
|
||||
it('should return experiment value for AuthType.LOGIN_WITH_GOOGLE', async () => {
|
||||
vi.mocked(getExperiments).mockResolvedValue({
|
||||
@@ -4360,3 +4347,57 @@ describe('ADKSettings', () => {
|
||||
expect(config.getAgentSessionNoninteractiveEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasGemini35FlashGAAccess model setting', () => {
|
||||
const baseParams: ConfigParameters = {
|
||||
sessionId: 'test',
|
||||
targetDir: '.',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '.',
|
||||
};
|
||||
|
||||
it('should set DEFAULT_GEMINI_FLASH_MODEL to gemini-3.5-flash and PREVIEW_GEMINI_FLASH_MODEL to gemini-3-flash-preview if hasGemini35FlashGAAccess returns true and authType is USE_GEMINI', () => {
|
||||
const config = new Config(baseParams);
|
||||
config['contentGeneratorConfig'] = { authType: AuthType.USE_GEMINI };
|
||||
|
||||
// Set experiment to return true for GEMINI_3_5_FLASH_GA_LAUNCHED
|
||||
config.setExperiments({
|
||||
experimentIds: [],
|
||||
flags: {
|
||||
[ExperimentFlags.GEMINI_3_5_FLASH_GA_LAUNCHED]: {
|
||||
boolValue: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Call the method
|
||||
const result = config.hasGemini35FlashGAAccess();
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');
|
||||
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('should set DEFAULT_GEMINI_FLASH_MODEL and PREVIEW_GEMINI_FLASH_MODEL to gemini-3.5-flash if hasGemini35FlashGAAccess returns true and authType is not USE_GEMINI', () => {
|
||||
const config = new Config(baseParams);
|
||||
config['contentGeneratorConfig'] = { authType: AuthType.LOGIN_WITH_GOOGLE };
|
||||
|
||||
// Set experiment to return true for GEMINI_3_5_FLASH_GA_LAUNCHED
|
||||
config.setExperiments({
|
||||
experimentIds: [],
|
||||
flags: {
|
||||
[ExperimentFlags.GEMINI_3_5_FLASH_GA_LAUNCHED]: {
|
||||
boolValue: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Call the method
|
||||
const result = config.hasGemini35FlashGAAccess();
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');
|
||||
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
isGemini2Model,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
resolveModel,
|
||||
setFlashModels,
|
||||
} from './models.js';
|
||||
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||
@@ -2051,10 +2052,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
const primaryModel = resolveModel(
|
||||
model,
|
||||
this.getGemini31LaunchedSync(),
|
||||
this.getGemini31FlashLiteLaunchedSync(),
|
||||
this.getUseCustomToolModelSync(),
|
||||
this.getHasAccessToPreviewModel(),
|
||||
this,
|
||||
this.hasGemini35FlashGAAccess(),
|
||||
);
|
||||
|
||||
const isPreview = isPreviewModel(primaryModel, this);
|
||||
@@ -2091,10 +2092,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
const primaryModel = resolveModel(
|
||||
this.getModel(),
|
||||
this.getGemini31LaunchedSync(),
|
||||
this.getGemini31FlashLiteLaunchedSync(),
|
||||
this.getUseCustomToolModelSync(),
|
||||
this.getHasAccessToPreviewModel(),
|
||||
this,
|
||||
this.hasGemini35FlashGAAccess(),
|
||||
);
|
||||
return this.modelQuotas.get(primaryModel)?.remaining;
|
||||
}
|
||||
@@ -2107,10 +2108,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
const primaryModel = resolveModel(
|
||||
this.getModel(),
|
||||
this.getGemini31LaunchedSync(),
|
||||
this.getGemini31FlashLiteLaunchedSync(),
|
||||
this.getUseCustomToolModelSync(),
|
||||
this.getHasAccessToPreviewModel(),
|
||||
this,
|
||||
this.hasGemini35FlashGAAccess(),
|
||||
);
|
||||
return this.modelQuotas.get(primaryModel)?.limit;
|
||||
}
|
||||
@@ -2123,10 +2124,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
const primaryModel = resolveModel(
|
||||
this.getModel(),
|
||||
this.getGemini31LaunchedSync(),
|
||||
this.getGemini31FlashLiteLaunchedSync(),
|
||||
this.getUseCustomToolModelSync(),
|
||||
this.getHasAccessToPreviewModel(),
|
||||
this,
|
||||
this.hasGemini35FlashGAAccess(),
|
||||
);
|
||||
return this.modelQuotas.get(primaryModel)?.resetTime;
|
||||
}
|
||||
@@ -3513,15 +3514,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.getGemini31LaunchedSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether Gemini 3.1 Flash Lite has been launched.
|
||||
* This method is async and ensures that experiments are loaded before returning the result.
|
||||
*/
|
||||
async getGemini31FlashLiteLaunched(): Promise<boolean> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
return this.getGemini31FlashLiteLaunchedSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the custom tool model should be used.
|
||||
*/
|
||||
@@ -3550,6 +3542,38 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether Gemini 3.5 Flash GA has been launched.
|
||||
*
|
||||
* Note: This method should only be called after startup, once experiments have been loaded.
|
||||
*/
|
||||
hasGemini35FlashGAAccess(): boolean {
|
||||
const authType = this.contentGeneratorConfig?.authType;
|
||||
const hasAccess = (() => {
|
||||
if (this.isGemini31LaunchedForAuthType(authType)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
this.experiments?.flags[ExperimentFlags.GEMINI_3_5_FLASH_GA_LAUNCHED]
|
||||
?.boolValue ?? false
|
||||
);
|
||||
})();
|
||||
// Used to set default flash models based on access
|
||||
// TODO: Remove once the experiment for 3_5 flash rollut can be cleaned up.
|
||||
if (hasAccess) {
|
||||
// Gemini API key users should have the ability to manually select the
|
||||
// old preview flash model.
|
||||
if (authType === AuthType.USE_GEMINI) {
|
||||
setFlashModels('gemini-3-flash-preview', 'gemini-3.5-flash');
|
||||
} else {
|
||||
setFlashModels('gemini-3.5-flash', 'gemini-3.5-flash');
|
||||
}
|
||||
} else {
|
||||
setFlashModels('gemini-3-flash-preview', 'gemini-2.5-flash');
|
||||
}
|
||||
return hasAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether Gemini 3.1 has been launched.
|
||||
*
|
||||
@@ -3583,24 +3607,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether Gemini 3.1 Flash Lite has been launched.
|
||||
*
|
||||
* Note: This method should only be called after startup, once experiments have been loaded.
|
||||
* If you need to call this during startup or from an async context, use
|
||||
* getGemini31FlashLiteLaunched instead.
|
||||
*/
|
||||
getGemini31FlashLiteLaunchedSync(): boolean {
|
||||
const authType = this.contentGeneratorConfig?.authType;
|
||||
if (this.isGemini31LaunchedForAuthType(authType)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
this.experiments?.flags[ExperimentFlags.GEMINI_3_1_FLASH_LITE_LAUNCHED]
|
||||
?.boolValue ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the client version.
|
||||
*/
|
||||
|
||||
@@ -107,6 +107,18 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
},
|
||||
},
|
||||
'gemini-3.1-flash-lite': {
|
||||
extends: 'chat-base-3',
|
||||
modelConfig: {
|
||||
model: 'gemini-3.1-flash-lite',
|
||||
},
|
||||
},
|
||||
'gemini-3.5-flash': {
|
||||
extends: 'chat-base-3',
|
||||
modelConfig: {
|
||||
model: 'gemini-3.5-flash',
|
||||
},
|
||||
},
|
||||
'gemma-4-31b-it': {
|
||||
extends: 'chat-base-3',
|
||||
modelConfig: {
|
||||
@@ -133,10 +145,16 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
model: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
'gemini-3.5-flash-base': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-3.5-flash',
|
||||
},
|
||||
},
|
||||
classifier: {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
model: 'flash-lite',
|
||||
generateContentConfig: {
|
||||
maxOutputTokens: 1024,
|
||||
thinkingConfig: {
|
||||
@@ -148,7 +166,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
'prompt-completion': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
model: 'flash-lite',
|
||||
generateContentConfig: {
|
||||
temperature: 0.3,
|
||||
maxOutputTokens: 16000,
|
||||
@@ -161,7 +179,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
'fast-ack-helper': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
model: 'flash-lite',
|
||||
generateContentConfig: {
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 120,
|
||||
@@ -174,7 +192,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
'edit-corrector': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
model: 'flash-lite',
|
||||
generateContentConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
@@ -185,7 +203,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
'summarizer-default': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
model: 'flash-lite',
|
||||
generateContentConfig: {
|
||||
maxOutputTokens: 2000,
|
||||
},
|
||||
@@ -194,7 +212,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
'summarizer-shell': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
model: 'flash-lite',
|
||||
generateContentConfig: {
|
||||
maxOutputTokens: 2000,
|
||||
},
|
||||
@@ -264,7 +282,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
'chat-compression-3.1-flash-lite': {
|
||||
modelConfig: {
|
||||
model: 'gemini-3.1-flash-lite-preview',
|
||||
model: 'gemini-3.1-flash-lite',
|
||||
},
|
||||
},
|
||||
'chat-compression-2.5-pro': {
|
||||
@@ -305,10 +323,10 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
],
|
||||
modelDefinitions: {
|
||||
// Concrete Models
|
||||
'gemini-3.1-flash-lite-preview': {
|
||||
'gemini-3.1-flash-lite': {
|
||||
tier: 'flash-lite',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
isPreview: false,
|
||||
isVisible: true,
|
||||
features: { thinking: false, multimodalToolUse: true },
|
||||
},
|
||||
@@ -340,6 +358,13 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
isVisible: true,
|
||||
features: { thinking: false, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3.5-flash': {
|
||||
tier: 'flash',
|
||||
family: 'gemini-3',
|
||||
isPreview: false,
|
||||
isVisible: true,
|
||||
features: { thinking: false, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-2.5',
|
||||
@@ -445,11 +470,34 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
default: 'gemini-3-flash-preview',
|
||||
contexts: [
|
||||
{
|
||||
condition: { hasAccessToPreview: false },
|
||||
condition: { hasAccessToPreview: false, useGemini3_5Flash: true },
|
||||
target: 'gemini-3.5-flash',
|
||||
},
|
||||
{
|
||||
condition: { hasAccessToPreview: false, useGemini3_5Flash: false },
|
||||
target: 'gemini-2.5-flash',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gemini-3.5-flash': {
|
||||
default: 'gemini-3.5-flash',
|
||||
contexts: [
|
||||
{
|
||||
condition: { useGemini3_5Flash: false, hasAccessToPreview: false },
|
||||
target: 'gemini-2.5-flash',
|
||||
},
|
||||
{
|
||||
condition: { useGemini3_5Flash: false },
|
||||
target: 'gemini-3-flash-preview',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
default: 'gemini-2.5-flash',
|
||||
contexts: [
|
||||
{ condition: { useGemini3_5Flash: true }, target: 'gemini-3.5-flash' },
|
||||
],
|
||||
},
|
||||
'gemini-3-pro-preview': {
|
||||
default: 'gemini-3-pro-preview',
|
||||
contexts: [
|
||||
@@ -492,18 +540,13 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'gemini-3.1-flash-lite-preview': {
|
||||
default: 'gemini-3.1-flash-lite-preview',
|
||||
contexts: [
|
||||
{
|
||||
condition: { useGemini3_1FlashLite: false },
|
||||
target: 'gemini-2.5-flash-lite',
|
||||
},
|
||||
],
|
||||
'gemini-3.1-flash-lite': {
|
||||
default: 'gemini-3.1-flash-lite',
|
||||
},
|
||||
flash: {
|
||||
default: 'gemini-3-flash-preview',
|
||||
contexts: [
|
||||
{ condition: { useGemini3_5Flash: true }, target: 'gemini-3.5-flash' },
|
||||
{
|
||||
condition: { hasAccessToPreview: false },
|
||||
target: 'gemini-2.5-flash',
|
||||
@@ -511,13 +554,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
],
|
||||
},
|
||||
'flash-lite': {
|
||||
default: 'gemini-2.5-flash-lite',
|
||||
contexts: [
|
||||
{
|
||||
condition: { useGemini3_1FlashLite: true },
|
||||
target: 'gemini-3.1-flash-lite-preview',
|
||||
},
|
||||
],
|
||||
default: 'gemini-3.1-flash-lite',
|
||||
},
|
||||
'auto-gemini-3': {
|
||||
default: 'gemini-3-pro-preview',
|
||||
@@ -541,6 +578,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
flash: {
|
||||
default: 'gemini-3-flash-preview',
|
||||
contexts: [
|
||||
{ condition: { useGemini3_5Flash: true }, target: 'gemini-3.5-flash' },
|
||||
{
|
||||
condition: { hasAccessToPreview: false },
|
||||
target: 'gemini-2.5-flash',
|
||||
@@ -714,7 +752,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
],
|
||||
lite: [
|
||||
{
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
model: 'flash-lite',
|
||||
actions: {
|
||||
terminal: 'silent',
|
||||
transient: 'silent',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_3_5_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
supportsMultimodalFunctionResponse,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
isActiveModel,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
isPreviewModel,
|
||||
isProModel,
|
||||
@@ -67,22 +68,14 @@ describe('Dynamic Configuration Parity', () => {
|
||||
const flagCombos = [
|
||||
{
|
||||
useGemini3_1: false,
|
||||
useGemini3_1FlashLite: false,
|
||||
useCustomToolModel: false,
|
||||
},
|
||||
{
|
||||
useGemini3_1: true,
|
||||
useGemini3_1FlashLite: false,
|
||||
useCustomToolModel: false,
|
||||
},
|
||||
{
|
||||
useGemini3_1: true,
|
||||
useGemini3_1FlashLite: true,
|
||||
useCustomToolModel: false,
|
||||
},
|
||||
{
|
||||
useGemini3_1: true,
|
||||
useGemini3_1FlashLite: true,
|
||||
useCustomToolModel: true,
|
||||
},
|
||||
];
|
||||
@@ -105,7 +98,6 @@ describe('Dynamic Configuration Parity', () => {
|
||||
const legacy = resolveModel(
|
||||
model,
|
||||
flags.useGemini3_1,
|
||||
flags.useGemini3_1FlashLite,
|
||||
flags.useCustomToolModel,
|
||||
hasAccess,
|
||||
mockLegacyConfig,
|
||||
@@ -113,7 +105,6 @@ describe('Dynamic Configuration Parity', () => {
|
||||
const dynamic = resolveModel(
|
||||
model,
|
||||
flags.useGemini3_1,
|
||||
flags.useGemini3_1FlashLite,
|
||||
flags.useCustomToolModel,
|
||||
hasAccess,
|
||||
mockDynamicConfig,
|
||||
@@ -152,7 +143,6 @@ describe('Dynamic Configuration Parity', () => {
|
||||
anchor,
|
||||
tier,
|
||||
flags.useGemini3_1,
|
||||
flags.useGemini3_1FlashLite,
|
||||
flags.useCustomToolModel,
|
||||
hasAccess,
|
||||
mockLegacyConfig,
|
||||
@@ -161,7 +151,6 @@ describe('Dynamic Configuration Parity', () => {
|
||||
anchor,
|
||||
tier,
|
||||
flags.useGemini3_1,
|
||||
flags.useGemini3_1FlashLite,
|
||||
flags.useCustomToolModel,
|
||||
hasAccess,
|
||||
mockDynamicConfig,
|
||||
@@ -229,12 +218,29 @@ describe('Dynamic Configuration Parity', () => {
|
||||
});
|
||||
|
||||
describe('isPreviewModel', () => {
|
||||
it('should return true for preview models', () => {
|
||||
expect(isPreviewModel(PREVIEW_GEMINI_MODEL)).toBe(true);
|
||||
expect(isPreviewModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(true);
|
||||
expect(isPreviewModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL)).toBe(true);
|
||||
expect(isPreviewModel(PREVIEW_GEMINI_FLASH_MODEL)).toBe(true);
|
||||
const PREVIEW_MODELS = [
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
];
|
||||
|
||||
it('should return true for active preview models', () => {
|
||||
for (const model of PREVIEW_MODELS) {
|
||||
if (model !== 'none') {
|
||||
expect(isPreviewModel(model)).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(isPreviewModel(PREVIEW_GEMINI_MODEL_AUTO)).toBe(true);
|
||||
expect(isPreviewModel(GEMINI_MODEL_ALIAS_AUTO)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if a preview model is retired (set to none)', () => {
|
||||
const retiredModels = PREVIEW_MODELS.filter((m) => m === 'none');
|
||||
for (const model of retiredModels) {
|
||||
expect(isPreviewModel(model)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-preview models', () => {
|
||||
@@ -358,9 +364,9 @@ describe('getDisplayString', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL for PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL', () => {
|
||||
expect(getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
it('should return PREVIEW_GEMINI_FLASH_LITE_MODEL for PREVIEW_GEMINI_FLASH_LITE_MODEL', () => {
|
||||
expect(getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL)).toBe(
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -405,7 +411,7 @@ describe('resolveModel', () => {
|
||||
});
|
||||
|
||||
it('should return Gemini 3.1 Pro Custom Tools when auto-gemini-3 is requested, useGemini3_1 is true, and useCustomToolModel is true', () => {
|
||||
const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, true);
|
||||
const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, true);
|
||||
expect(model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
|
||||
});
|
||||
|
||||
@@ -419,9 +425,9 @@ describe('resolveModel', () => {
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Preview Flash-Lite model when flash-lite is requested and useGemini3_1FlashLite is true', () => {
|
||||
const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH_LITE, false, true);
|
||||
expect(model).toBe(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
|
||||
it('should return the Flash-Lite model when flash-lite is requested', () => {
|
||||
const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH_LITE, false);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return the requested model as-is for explicit specific models', () => {
|
||||
@@ -452,45 +458,39 @@ describe('resolveModel', () => {
|
||||
|
||||
describe('hasAccessToPreview logic', () => {
|
||||
it('should return default model when access to preview is false and preview model is requested', () => {
|
||||
expect(
|
||||
resolveModel(PREVIEW_GEMINI_MODEL, false, false, false, false),
|
||||
).toBe(DEFAULT_GEMINI_MODEL);
|
||||
expect(resolveModel(PREVIEW_GEMINI_MODEL, false, false, false)).toBe(
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return default flash model when access to preview is false and preview flash model is requested', () => {
|
||||
expect(
|
||||
resolveModel(PREVIEW_GEMINI_FLASH_MODEL, false, false, false, false),
|
||||
resolveModel(PREVIEW_GEMINI_FLASH_MODEL, false, false, false),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return default flash lite model when access to preview is false and preview flash lite model is requested', () => {
|
||||
expect(
|
||||
resolveModel(
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
resolveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, false, false, false),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return default model when access to preview is false and auto-gemini-3 is requested', () => {
|
||||
expect(
|
||||
resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false, false),
|
||||
).toBe(DEFAULT_GEMINI_MODEL);
|
||||
expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false)).toBe(
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return default model when access to preview is false and Gemini 3.1 is requested', () => {
|
||||
expect(
|
||||
resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, false, false),
|
||||
).toBe(DEFAULT_GEMINI_MODEL);
|
||||
expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, false)).toBe(
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should still return default model when access to preview is false and auto-gemini-2.5 is requested', () => {
|
||||
expect(
|
||||
resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false, false, false, false),
|
||||
).toBe(DEFAULT_GEMINI_MODEL);
|
||||
expect(resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false, false, false)).toBe(
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -582,7 +582,6 @@ describe('resolveClassifierModel', () => {
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
|
||||
@@ -599,17 +598,14 @@ describe('isActiveModel', () => {
|
||||
it('should return true for Gemma 4 models when experimentalGemma is not provided (defaults to true)', () => {
|
||||
expect(isActiveModel(GEMMA_4_31B_IT_MODEL)).toBe(true);
|
||||
expect(isActiveModel(GEMMA_4_26B_A4B_IT_MODEL)).toBe(true);
|
||||
expect(isActiveModel(GEMMA_4_31B_IT_MODEL, false, false, false, true)).toBe(
|
||||
expect(isActiveModel(GEMMA_4_31B_IT_MODEL, false, false, true)).toBe(true);
|
||||
expect(isActiveModel(GEMMA_4_26B_A4B_IT_MODEL, false, false, true)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isActiveModel(GEMMA_4_26B_A4B_IT_MODEL, false, false, false, true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for Gemini 3.1 models when Gemini 3.1 is not launched', () => {
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(false);
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for unknown models and aliases', () => {
|
||||
@@ -625,37 +621,48 @@ describe('isActiveModel', () => {
|
||||
expect(isActiveModel(DEFAULT_GEMINI_MODEL, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL only when useGemini3_1FlashLite is true', () => {
|
||||
expect(
|
||||
isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, true),
|
||||
).toBe(true);
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true, true)).toBe(
|
||||
it('should handle PREVIEW_GEMINI_FLASH_LITE_MODEL activity correctly based on retirement status', () => {
|
||||
if (PREVIEW_GEMINI_FLASH_LITE_MODEL === 'none') {
|
||||
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, false, true)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, true, true)).toBe(
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, false, true)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, true, true)).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
expect(isActiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, false, false)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isActiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, true, true)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isActiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, true, false)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true, false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly filter Gemini 3.1 models based on useCustomToolModel when useGemini3_1 is true', () => {
|
||||
// When custom tools are preferred, standard 3.1 should be inactive
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false, true)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, true)).toBe(false);
|
||||
expect(
|
||||
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false, true),
|
||||
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, true),
|
||||
).toBe(true);
|
||||
|
||||
// When custom tools are NOT preferred, custom tools 3.1 should be inactive
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false, false)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false)).toBe(true);
|
||||
expect(
|
||||
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false, false),
|
||||
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for Gemini 3.1 models when useGemini3_1 and useGemini3_1FlashLite are false', () => {
|
||||
it('should return false for Gemini 3.1 preview models when useGemini3_1 is false', () => {
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false, true)).toBe(
|
||||
false,
|
||||
);
|
||||
@@ -668,9 +675,14 @@ describe('isActiveModel', () => {
|
||||
expect(
|
||||
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false, false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, false),
|
||||
).toBe(false);
|
||||
if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
|
||||
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, false, false)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
expect(isActiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, false, false)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -695,14 +707,23 @@ describe('Gemini 3.1 Config Resolution', () => {
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
|
||||
const resolved = modelConfigService.getResolvedConfig({
|
||||
model: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
isChatModel: true,
|
||||
});
|
||||
expect(
|
||||
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
|
||||
).toBeDefined();
|
||||
it('PREVIEW_GEMINI_FLASH_LITE_MODEL should resolve to appropriate config based on retirement status', () => {
|
||||
if (PREVIEW_GEMINI_FLASH_LITE_MODEL === 'none') {
|
||||
// If none, it falls back to chat-base which may not have thinkingLevel
|
||||
const resolved = modelConfigService.getResolvedConfig({
|
||||
model: PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
isChatModel: true,
|
||||
});
|
||||
expect(resolved.model).toBe(PREVIEW_GEMINI_FLASH_LITE_MODEL);
|
||||
} else {
|
||||
const resolved = modelConfigService.getResolvedConfig({
|
||||
model: PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
isChatModel: true,
|
||||
});
|
||||
expect(
|
||||
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
|
||||
).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -715,13 +736,317 @@ describe('getAutoModelDescription', () => {
|
||||
|
||||
it('should return Gemini 3.0 description when hasAccessToPreview is true', () => {
|
||||
const desc = getAutoModelDescription(true, false);
|
||||
expect(desc).toContain('gemini-3-pro');
|
||||
expect(desc).toContain('gemini-3-flash');
|
||||
expect(desc).toContain('gemini-3-pro-preview');
|
||||
expect(desc).toContain('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('should return Gemini 3.1 description when hasAccessToPreview and useGemini3_1 are true', () => {
|
||||
const desc = getAutoModelDescription(true, true);
|
||||
expect(desc).toContain('gemini-3.1-pro');
|
||||
expect(desc).toContain('gemini-3-flash');
|
||||
expect(desc).toContain('gemini-3.1-pro-preview');
|
||||
expect(desc).toContain('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('should return Gemini 3.5 Flash description when hasAccessToPreview and useGemini3_5Flash are true', () => {
|
||||
const desc = getAutoModelDescription(true, true, true);
|
||||
expect(desc).toContain('gemini-3.1-pro-preview');
|
||||
expect(desc).toContain(DEFAULT_GEMINI_3_5_FLASH_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModel Gemini 3.5 Flash GA', () => {
|
||||
it('should resolve all but preview flash models to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true (legacy)', () => {
|
||||
expect(
|
||||
resolveModel(
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(
|
||||
resolveModel(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(
|
||||
resolveModel(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should resolve all but preview flash models to gemini-3.5-flash when useGemini3_5Flash is true (dynamic)', () => {
|
||||
const mockDynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
expect(
|
||||
resolveModel(
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
mockDynamicConfig,
|
||||
true,
|
||||
),
|
||||
).toBe('gemini-3.5-flash');
|
||||
expect(
|
||||
resolveModel(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
mockDynamicConfig,
|
||||
true,
|
||||
),
|
||||
).toBe('gemini-3.5-flash');
|
||||
expect(
|
||||
resolveModel(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
mockDynamicConfig,
|
||||
true,
|
||||
),
|
||||
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should NOT resolve flash models to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is false', () => {
|
||||
expect(
|
||||
resolveModel(
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
false,
|
||||
),
|
||||
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
expect(
|
||||
resolveModel(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
false,
|
||||
),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(
|
||||
resolveModel(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
false,
|
||||
),
|
||||
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should resolve to DEFAULT_GEMINI_FLASH_MODEL when GA is false AND preview access is false (dynamic)', () => {
|
||||
const mockDynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
expect(
|
||||
resolveModel(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
false, // No preview access
|
||||
mockDynamicConfig,
|
||||
false, // GA false
|
||||
),
|
||||
).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('should resolve auto to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true and classifier selects flash', () => {
|
||||
expect(
|
||||
resolveClassifierModel(
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should resolve auto to gemini-3.5-flash when useGemini3_5Flash is true and classifier selects flash (dynamic)', () => {
|
||||
const mockDynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
expect(
|
||||
resolveClassifierModel(
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
mockDynamicConfig,
|
||||
true,
|
||||
),
|
||||
).toBe('gemini-3.5-flash');
|
||||
});
|
||||
|
||||
describe('Flash model promotion and manual override routing logic', () => {
|
||||
it('should resolve flash alias to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true (static)', () => {
|
||||
expect(
|
||||
resolveModel(
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should resolve flash alias to gemini-3.5-flash when useGemini3_5Flash is true (dynamic)', () => {
|
||||
const mockDynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
expect(
|
||||
resolveModel(
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
mockDynamicConfig,
|
||||
true,
|
||||
),
|
||||
).toBe('gemini-3.5-flash');
|
||||
});
|
||||
|
||||
it('should resolve manual selection of gemini-3-flash-preview to gemini-3-flash-preview when useGemini3_5Flash is true and has preview access (static)', () => {
|
||||
expect(
|
||||
resolveModel(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('should resolve manual selection of gemini-3-flash-preview to gemini-3-flash-preview when useGemini3_5Flash is true and has preview access (dynamic)', () => {
|
||||
const mockDynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
expect(
|
||||
resolveModel(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
mockDynamicConfig,
|
||||
true,
|
||||
),
|
||||
).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('should resolve manual selection of gemini-3-flash-preview to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true but lacks preview access (static)', () => {
|
||||
expect(
|
||||
resolveModel(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should resolve manual selection of gemini-3-flash-preview to gemini-3.5-flash when useGemini3_5Flash is true but lacks preview access (dynamic)', () => {
|
||||
const mockDynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
expect(
|
||||
resolveModel(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
mockDynamicConfig,
|
||||
true,
|
||||
),
|
||||
).toBe('gemini-3.5-flash');
|
||||
});
|
||||
|
||||
it('should resolve classifier-selected flash alias to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true (static)', () => {
|
||||
expect(
|
||||
resolveClassifierModel(
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should resolve classifier-selected flash alias to gemini-3.5-flash when useGemini3_5Flash is true (dynamic)', () => {
|
||||
const mockDynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
expect(
|
||||
resolveClassifierModel(
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
mockDynamicConfig,
|
||||
true,
|
||||
),
|
||||
).toBe('gemini-3.5-flash');
|
||||
});
|
||||
|
||||
it('should resolve auto to PREVIEW_GEMINI_MODEL when useGemini3_5Flash is true and has preview access', () => {
|
||||
expect(
|
||||
resolveModel(
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
false,
|
||||
false,
|
||||
true, // hasAccessToPreview
|
||||
undefined,
|
||||
true, // useGemini3_5Flash
|
||||
),
|
||||
).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
export interface ModelResolutionContext {
|
||||
useGemini3_1?: boolean;
|
||||
useGemini3_1FlashLite?: boolean;
|
||||
useGemini3_5Flash?: boolean;
|
||||
useCustomTools?: boolean;
|
||||
hasAccessToPreview?: boolean;
|
||||
requestedModel?: string;
|
||||
@@ -55,12 +55,32 @@ export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
|
||||
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
|
||||
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
|
||||
'gemini-3.1-pro-preview-customtools';
|
||||
export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview';
|
||||
export const PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL =
|
||||
'gemini-3.1-flash-lite-preview';
|
||||
// TODO: set to none and const once the experiment for 3_5 flash rollut can be
|
||||
// cleaned up.
|
||||
export let PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview';
|
||||
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
|
||||
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
|
||||
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
|
||||
// TODO: Set to const and update to 'gemini-3.5-flash' once the experiment for
|
||||
// 3_5 flash rollut can be cleaned up.
|
||||
// This is set to either the same as the DEFAULT_GEMINI_3_5_FLASH_MODEL const
|
||||
// OR the SECONDARY_GEMINI_3_5_FLASH_MODEL depending on which is needed for
|
||||
// the user's backend as determined by hasGemini35FlashGAAccess in
|
||||
// packages/core/src/config/config.ts
|
||||
export let DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
|
||||
export const DEFAULT_GEMINI_3_5_FLASH_MODEL = 'gemini-3.5-flash';
|
||||
// This is resolved to 3.5 flash in backends where it is used,
|
||||
// however those backends do not expect to see the string gemini-3.5-flash
|
||||
// so we need to provide this model as an alternative name in certain instances.
|
||||
export const SECONDARY_GEMINI_3_5_FLASH_MODEL = 'gemini-3-flash';
|
||||
|
||||
// Used to set default flash models based on access
|
||||
// TODO: Cleanup once the experiment for 3_5 flash rollut can be cleaned up.
|
||||
export function setFlashModels(preview: string, defaultFlash: string) {
|
||||
PREVIEW_GEMINI_FLASH_MODEL = preview;
|
||||
DEFAULT_GEMINI_FLASH_MODEL = defaultFlash;
|
||||
}
|
||||
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-3.1-flash-lite';
|
||||
/** @deprecated Gemini 3.1 Flash Lite is now GA. Use DEFAULT_GEMINI_FLASH_LITE_MODEL. */
|
||||
export const PREVIEW_GEMINI_FLASH_LITE_MODEL = 'none';
|
||||
|
||||
export const GEMMA_4_31B_IT_MODEL = 'gemma-4-31b-it';
|
||||
export const GEMMA_4_26B_A4B_IT_MODEL = 'gemma-4-26b-a4b-it';
|
||||
@@ -70,9 +90,11 @@ export const VALID_GEMINI_MODELS = new Set([
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_3_5_FLASH_MODEL,
|
||||
SECONDARY_GEMINI_3_5_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
|
||||
GEMMA_4_31B_IT_MODEL,
|
||||
@@ -98,14 +120,19 @@ export const DEFAULT_THINKING_MODE = 8192;
|
||||
export function getAutoModelDescription(
|
||||
hasAccessToPreview: boolean,
|
||||
useGemini3_1: boolean = false,
|
||||
useGemini3_5Flash: boolean = false,
|
||||
) {
|
||||
const proModel = hasAccessToPreview
|
||||
? useGemini3_1
|
||||
? 'gemini-3.1-pro'
|
||||
: 'gemini-3-pro'
|
||||
: 'gemini-2.5-pro';
|
||||
const flashModel = hasAccessToPreview ? 'gemini-3-flash' : 'gemini-2.5-flash';
|
||||
return `Let Gemini CLI decide the best model for the task: ${proModel}, ${flashModel}`;
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
: PREVIEW_GEMINI_MODEL
|
||||
: DEFAULT_GEMINI_MODEL;
|
||||
const flashModel = hasAccessToPreview
|
||||
? useGemini3_5Flash
|
||||
? DEFAULT_GEMINI_3_5_FLASH_MODEL
|
||||
: PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
return `Let Gemini CLI decide the best model for the task: ${getDisplayString(proModel)}, ${getDisplayString(flashModel)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,16 +141,17 @@ export function getAutoModelDescription(
|
||||
*
|
||||
* @param requestedModel The model alias or concrete model name requested by the user.
|
||||
* @param useGemini3_1 Whether to use Gemini 3.1 Pro Preview for auto/pro aliases.
|
||||
* @param useGemini3_5Flash Whether to use Gemini 3.5 Flash GA.
|
||||
* @param hasAccessToPreview Whether the user has access to preview models.
|
||||
* @returns The resolved concrete model name.
|
||||
*/
|
||||
export function resolveModel(
|
||||
requestedModel: string,
|
||||
useGemini3_1: boolean = false,
|
||||
useGemini3_1FlashLite: boolean = false,
|
||||
useCustomToolModel: boolean = false,
|
||||
hasAccessToPreview: boolean = true,
|
||||
config?: ModelCapabilityContext,
|
||||
useGemini3_5Flash: boolean = false,
|
||||
): string {
|
||||
// Defensive check against non-string inputs at runtime
|
||||
const normalizedModel = Array.isArray(requestedModel)
|
||||
@@ -135,9 +163,9 @@ export function resolveModel(
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const resolved = config.modelConfigService.resolveModelId(normalizedModel, {
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
useGemini3_5Flash,
|
||||
});
|
||||
|
||||
if (!hasAccessToPreview && isPreviewModel(resolved, config)) {
|
||||
@@ -180,13 +208,13 @@ export function resolveModel(
|
||||
break;
|
||||
}
|
||||
case GEMINI_MODEL_ALIAS_FLASH: {
|
||||
resolved = PREVIEW_GEMINI_FLASH_MODEL;
|
||||
resolved = useGemini3_5Flash
|
||||
? DEFAULT_GEMINI_FLASH_MODEL
|
||||
: PREVIEW_GEMINI_FLASH_MODEL;
|
||||
break;
|
||||
}
|
||||
case GEMINI_MODEL_ALIAS_FLASH_LITE: {
|
||||
resolved = useGemini3_1FlashLite
|
||||
? PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_LITE_MODEL;
|
||||
resolved = DEFAULT_GEMINI_FLASH_LITE_MODEL;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -195,13 +223,23 @@ export function resolveModel(
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved === 'none') {
|
||||
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
|
||||
}
|
||||
|
||||
if (
|
||||
useGemini3_5Flash &&
|
||||
isFlashModel(resolved) &&
|
||||
normalizedModel !== PREVIEW_GEMINI_FLASH_MODEL
|
||||
) {
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
|
||||
if (!hasAccessToPreview && isPreviewModel(resolved)) {
|
||||
// Downgrade to stable models if user lacks preview access.
|
||||
switch (resolved) {
|
||||
case PREVIEW_GEMINI_FLASH_MODEL:
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL:
|
||||
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
|
||||
case PREVIEW_GEMINI_MODEL:
|
||||
case PREVIEW_GEMINI_3_1_MODEL:
|
||||
case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL:
|
||||
@@ -221,6 +259,17 @@ export function resolveModel(
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isFlashModel(model: string): boolean {
|
||||
return (
|
||||
model === DEFAULT_GEMINI_FLASH_MODEL ||
|
||||
model === PREVIEW_GEMINI_FLASH_MODEL ||
|
||||
model === DEFAULT_GEMINI_3_5_FLASH_MODEL ||
|
||||
model === SECONDARY_GEMINI_3_5_FLASH_MODEL ||
|
||||
model === 'flash' ||
|
||||
model.endsWith('flash')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the appropriate model based on the classifier's decision.
|
||||
*
|
||||
@@ -235,10 +284,10 @@ export function resolveClassifierModel(
|
||||
requestedModel: string,
|
||||
modelAlias: string,
|
||||
useGemini3_1: boolean = false,
|
||||
useGemini3_1FlashLite: boolean = false,
|
||||
useCustomToolModel: boolean = false,
|
||||
hasAccessToPreview: boolean = true,
|
||||
config?: ModelCapabilityContext,
|
||||
useGemini3_5Flash: boolean = false,
|
||||
): string {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return config.modelConfigService.resolveClassifierModelId(
|
||||
@@ -246,9 +295,9 @@ export function resolveClassifierModel(
|
||||
requestedModel,
|
||||
{
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
useGemini3_5Flash,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -265,6 +314,9 @@ export function resolveClassifierModel(
|
||||
requestedModel === PREVIEW_GEMINI_MODEL ||
|
||||
requestedModel === GEMINI_MODEL_ALIAS_AUTO
|
||||
) {
|
||||
if (useGemini3_5Flash) {
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
return hasAccessToPreview
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
@@ -273,16 +325,18 @@ export function resolveClassifierModel(
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
hasAccessToPreview,
|
||||
config,
|
||||
useGemini3_5Flash,
|
||||
);
|
||||
}
|
||||
return resolveModel(
|
||||
requestedModel,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
config,
|
||||
useGemini3_5Flash,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,6 +352,8 @@ export function getDisplayString(
|
||||
}
|
||||
|
||||
switch (model) {
|
||||
case 'gemini-3-flash':
|
||||
return DEFAULT_GEMINI_3_5_FLASH_MODEL;
|
||||
case GEMINI_MODEL_ALIAS_AUTO:
|
||||
return 'Auto';
|
||||
case PREVIEW_GEMINI_MODEL_AUTO:
|
||||
@@ -314,8 +370,8 @@ export function getDisplayString(
|
||||
return PREVIEW_GEMINI_FLASH_MODEL;
|
||||
case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL:
|
||||
return PREVIEW_GEMINI_3_1_MODEL;
|
||||
case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL:
|
||||
return PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL;
|
||||
case PREVIEW_GEMINI_FLASH_LITE_MODEL:
|
||||
return PREVIEW_GEMINI_FLASH_LITE_MODEL;
|
||||
default:
|
||||
return model;
|
||||
}
|
||||
@@ -332,6 +388,9 @@ export function isPreviewModel(
|
||||
model: string,
|
||||
config?: ModelCapabilityContext,
|
||||
): boolean {
|
||||
if (model === 'none') {
|
||||
return false;
|
||||
}
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(model)?.isPreview === true
|
||||
@@ -345,7 +404,7 @@ export function isPreviewModel(
|
||||
model === PREVIEW_GEMINI_FLASH_MODEL ||
|
||||
model === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
model === GEMINI_MODEL_ALIAS_AUTO ||
|
||||
model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL
|
||||
model === PREVIEW_GEMINI_FLASH_LITE_MODEL
|
||||
);
|
||||
}
|
||||
|
||||
@@ -379,7 +438,7 @@ export function isGemini3Model(
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
// Legacy behavior resolves the model first.
|
||||
const resolved = resolveModel(model, false, false, false, true, config);
|
||||
const resolved = resolveModel(model, false, false, true, config);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.family ===
|
||||
'gemini-3'
|
||||
@@ -414,7 +473,7 @@ export function isCustomModel(
|
||||
config?: ModelCapabilityContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const resolved = resolveModel(model, false, false, false, true, config);
|
||||
const resolved = resolveModel(model, false, false, true, config);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.tier ===
|
||||
'custom' || !resolved.startsWith('gemini-')
|
||||
@@ -487,18 +546,17 @@ export function supportsMultimodalFunctionResponse(
|
||||
export function isActiveModel(
|
||||
model: string,
|
||||
useGemini3_1: boolean = false,
|
||||
useGemini3_1FlashLite: boolean = false,
|
||||
useCustomToolModel: boolean = false,
|
||||
experimentalGemma: boolean = true,
|
||||
): boolean {
|
||||
if (!VALID_GEMINI_MODELS.has(model)) {
|
||||
if (!VALID_GEMINI_MODELS.has(model) || model === 'none') {
|
||||
return false;
|
||||
}
|
||||
if (model === GEMMA_4_31B_IT_MODEL || model === GEMMA_4_26B_A4B_IT_MODEL) {
|
||||
return experimentalGemma;
|
||||
}
|
||||
if (model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL) {
|
||||
return useGemini3_1FlashLite;
|
||||
if (model === PREVIEW_GEMINI_FLASH_LITE_MODEL) {
|
||||
return false;
|
||||
}
|
||||
if (useGemini3_1) {
|
||||
if (model === PREVIEW_GEMINI_MODEL) {
|
||||
@@ -516,3 +574,7 @@ export function isActiveModel(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const CCPA_AI_MODEL_MAPPINGS: Record<string, string> = {
|
||||
[DEFAULT_GEMINI_3_5_FLASH_MODEL]: SECONDARY_GEMINI_3_5_FLASH_MODEL,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { PreCompressTrigger } from '../hooks/types.js';
|
||||
|
||||
@@ -106,14 +106,16 @@ export function modelStringToModelConfigAlias(model: string): string {
|
||||
return 'chat-compression-3-pro';
|
||||
case PREVIEW_GEMINI_FLASH_MODEL:
|
||||
return 'chat-compression-3-flash';
|
||||
case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL:
|
||||
case PREVIEW_GEMINI_FLASH_LITE_MODEL:
|
||||
// fallthrough
|
||||
case DEFAULT_GEMINI_FLASH_LITE_MODEL:
|
||||
return 'chat-compression-3.1-flash-lite';
|
||||
case 'gemini-2.5-flash-lite':
|
||||
return 'chat-compression-2.5-flash-lite';
|
||||
case DEFAULT_GEMINI_MODEL:
|
||||
return 'chat-compression-2.5-pro';
|
||||
case DEFAULT_GEMINI_FLASH_MODEL:
|
||||
return 'chat-compression-2.5-flash';
|
||||
case DEFAULT_GEMINI_FLASH_LITE_MODEL:
|
||||
return 'chat-compression-2.5-flash-lite';
|
||||
default:
|
||||
return 'chat-compression-default';
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ContextManagementConfig } from './types.js';
|
||||
import {
|
||||
generalistProfile,
|
||||
stressTestProfile,
|
||||
powerUserProfile,
|
||||
type ContextProfile,
|
||||
} from './profiles.js';
|
||||
import { SchemaValidator } from '../../utils/schemaValidator.js';
|
||||
@@ -80,6 +81,10 @@ export async function loadContextManagementConfig(
|
||||
return stressTestProfile;
|
||||
}
|
||||
|
||||
if (sidecarPath === 'powerUserProfile') {
|
||||
return powerUserProfile;
|
||||
}
|
||||
|
||||
if (sidecarPath === 'generalistProfile') {
|
||||
return generalistProfile;
|
||||
}
|
||||
|
||||
@@ -206,3 +206,130 @@ export const stressTestProfile: ContextProfile = {
|
||||
buildPipelines: generalistProfile.buildPipelines,
|
||||
buildAsyncPipelines: generalistProfile.buildAsyncPipelines,
|
||||
};
|
||||
|
||||
/**
|
||||
* An experimental profile for power users testing maximum context endurance.
|
||||
* Uses a three-stage pipeline (retained -> normalized -> archived) and incremental GC.
|
||||
*/
|
||||
export const powerUserProfile: ContextProfile = {
|
||||
name: 'Power User (Experimental)',
|
||||
sentinels: generalistProfile.sentinels,
|
||||
config: {
|
||||
budget: {
|
||||
retainedTokens: 65000,
|
||||
normalizedTokens: 100000,
|
||||
maxTokens: 150000,
|
||||
coalescingThresholdTokens: 5000,
|
||||
},
|
||||
gcStrategy: 'incremental',
|
||||
},
|
||||
buildPipelines: (
|
||||
env: ContextEnvironment,
|
||||
config?: ContextManagementConfig,
|
||||
): PipelineDef[] => [
|
||||
{
|
||||
name: 'Immediate Sanitization',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
createToolMaskingProcessor(
|
||||
'ToolMasking',
|
||||
env,
|
||||
resolveProcessorOptions(config, 'ToolMasking', {
|
||||
stringLengthThresholdTokens: 8000,
|
||||
}),
|
||||
),
|
||||
createBlobDegradationProcessor('BlobDegradation', env),
|
||||
createNodeDistillationProcessor(
|
||||
'ImmediateNodeDistillation',
|
||||
env,
|
||||
resolveProcessorOptions(config, 'ImmediateNodeDistillation', {
|
||||
nodeThresholdTokens: 15000,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Normalization',
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
createNodeDistillationProcessor(
|
||||
'NodeDistillation',
|
||||
env,
|
||||
resolveProcessorOptions(config, 'NodeDistillation', {
|
||||
nodeThresholdTokens: 3000,
|
||||
}),
|
||||
),
|
||||
createNodeTruncationProcessor(
|
||||
'NodeTruncation',
|
||||
env,
|
||||
resolveProcessorOptions(config, 'NodeTruncation', {
|
||||
maxTokensPerNode: 4000,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Archiving',
|
||||
triggers: ['normalized_exceeded'],
|
||||
processors: [
|
||||
createNodeDistillationProcessor(
|
||||
'ArchiveNodeDistillation',
|
||||
env,
|
||||
resolveProcessorOptions(config, 'ArchiveNodeDistillation', {
|
||||
nodeThresholdTokens: 1000,
|
||||
}),
|
||||
),
|
||||
createNodeTruncationProcessor(
|
||||
'ArchiveNodeTruncation',
|
||||
env,
|
||||
resolveProcessorOptions(config, 'ArchiveNodeTruncation', {
|
||||
maxTokensPerNode: 1500,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Emergency Backstop',
|
||||
triggers: ['gc_backstop'],
|
||||
processors: [
|
||||
createStateSnapshotProcessor(
|
||||
'StateSnapshotSync',
|
||||
env,
|
||||
resolveProcessorOptions(config, 'StateSnapshotSync', {
|
||||
target: 'max',
|
||||
maxStateTokens: 2000,
|
||||
maxSummaryTurns: 10,
|
||||
}),
|
||||
),
|
||||
// If we STILL exceed max tokens, aggressively truncate
|
||||
createNodeTruncationProcessor(
|
||||
'EmergencyNodeTruncation',
|
||||
env,
|
||||
resolveProcessorOptions(config, 'EmergencyNodeTruncation', {
|
||||
maxTokensPerNode: 500,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
buildAsyncPipelines: (
|
||||
env: ContextEnvironment,
|
||||
config?: ContextManagementConfig,
|
||||
): AsyncPipelineDef[] => [
|
||||
{
|
||||
name: 'Async Background GC',
|
||||
triggers: ['nodes_aged_out'],
|
||||
processors: [
|
||||
createStateSnapshotAsyncProcessor(
|
||||
'StateSnapshotAsync',
|
||||
env,
|
||||
resolveProcessorOptions(config, 'StateSnapshotAsync', {
|
||||
type: 'accumulate',
|
||||
maxStateTokens: 4000,
|
||||
maxSummaryTurns: 5,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ContextProcessor, AsyncContextProcessor } from '../pipeline.js';
|
||||
export type PipelineTrigger =
|
||||
| 'new_message'
|
||||
| 'retained_exceeded'
|
||||
| 'normalized_exceeded'
|
||||
| 'gc_backstop'
|
||||
| 'nodes_added'
|
||||
| 'nodes_aged_out'
|
||||
@@ -28,6 +29,7 @@ export interface AsyncPipelineDef {
|
||||
|
||||
export interface ContextBudget {
|
||||
retainedTokens: number;
|
||||
normalizedTokens?: number;
|
||||
maxTokens: number;
|
||||
/**
|
||||
* Only trigger background consolidation (snapshots) when at least this many
|
||||
@@ -43,6 +45,13 @@ export interface ContextManagementConfig {
|
||||
/** Defines the token ceilings and limits for the pipeline. */
|
||||
budget: ContextBudget;
|
||||
|
||||
/**
|
||||
* Strategy for the GC backstop when maxTokens is exceeded.
|
||||
* 'bulk' (default): Processes all nodes that have aged out of retainedTokens.
|
||||
* 'incremental': Processes only the oldest nodes necessary to get back under maxTokens.
|
||||
*/
|
||||
gcStrategy?: 'bulk' | 'incremental';
|
||||
|
||||
/**
|
||||
* Dynamic hyperparameter overrides for individual ContextProcessors and AsyncProcessors.
|
||||
* Keys are named identifiers (e.g. "gentleTruncation").
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContextManager } from './contextManager.js';
|
||||
import {
|
||||
createMockEnvironment,
|
||||
createDummyNode,
|
||||
} from './testing/contextTestUtils.js';
|
||||
import type { ContextProfile } from './config/profiles.js';
|
||||
import { NodeType, type ConcreteNode } from './graph/types.js';
|
||||
import type { PipelineOrchestrator } from './pipeline/orchestrator.js';
|
||||
import type { AgentChatHistory } from '../core/agentChatHistory.js';
|
||||
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
|
||||
import type { ContextManagementConfig } from './config/types.js';
|
||||
import type { ContextEnvironment } from './pipeline/environment.js';
|
||||
import type { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
|
||||
|
||||
describe('ContextManager - Multi-stage and Incremental GC', () => {
|
||||
let mockEnv: ReturnType<typeof createMockEnvironment>;
|
||||
let mockOrchestrator: PipelineOrchestrator;
|
||||
let mockChatHistory: AgentChatHistory;
|
||||
let mockAdvancedTokenCalculator: AdvancedTokenCalculator;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnv = createMockEnvironment();
|
||||
|
||||
mockOrchestrator = {
|
||||
setNodeProvider: vi.fn(),
|
||||
waitForPipelines: vi.fn().mockResolvedValue(undefined),
|
||||
executeTriggerSync: vi
|
||||
.fn()
|
||||
.mockImplementation(async (trigger, buffer) => buffer),
|
||||
executeIngestionPipeline: vi
|
||||
.fn()
|
||||
.mockImplementation(async (nodes) => nodes),
|
||||
shutdown: vi.fn(),
|
||||
} as unknown as PipelineOrchestrator;
|
||||
|
||||
mockChatHistory = {
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
get: vi.fn().mockReturnValue([]),
|
||||
subscribe: vi.fn(),
|
||||
} as unknown as AgentChatHistory;
|
||||
|
||||
mockAdvancedTokenCalculator = {
|
||||
getRawBaseUnits: vi.fn().mockReturnValue(0),
|
||||
getRawBaseUnitsForContent: vi.fn().mockReturnValue(0),
|
||||
calculateTokensAndBaseUnits: vi.fn(),
|
||||
} as unknown as AdvancedTokenCalculator;
|
||||
});
|
||||
|
||||
const setupManager = (config: ContextManagementConfig) => {
|
||||
const sidecar: ContextProfile = {
|
||||
name: 'test',
|
||||
config,
|
||||
buildPipelines: () => [],
|
||||
buildAsyncPipelines: () => [],
|
||||
};
|
||||
return new ContextManager(
|
||||
sidecar,
|
||||
mockEnv as unknown as ContextEnvironment,
|
||||
mockEnv.tracer,
|
||||
mockOrchestrator,
|
||||
mockChatHistory,
|
||||
mockAdvancedTokenCalculator,
|
||||
);
|
||||
};
|
||||
|
||||
it('should emit NormalizeNeeded when normalizedTokens budget is exceeded', async () => {
|
||||
const manager = setupManager({
|
||||
budget: {
|
||||
retainedTokens: 100,
|
||||
normalizedTokens: 150,
|
||||
maxTokens: 300,
|
||||
},
|
||||
} as unknown as ContextManagementConfig);
|
||||
|
||||
const normalizeSpy = vi.fn();
|
||||
mockEnv.eventBus.onNormalizeNeeded(normalizeSpy);
|
||||
const consolidationSpy = vi.fn();
|
||||
mockEnv.eventBus.onConsolidationNeeded(consolidationSpy);
|
||||
|
||||
// Mock token calculator for evaluateTriggers
|
||||
mockEnv.tokenCalculator.calculateConcreteListTokens = vi
|
||||
.fn()
|
||||
.mockImplementation((nodes: ConcreteNode[]) =>
|
||||
nodes.reduce(
|
||||
(sum: number, n: ConcreteNode) =>
|
||||
// Look for the mock tokens we attached to the dummy node
|
||||
sum + ((n as unknown as { _mockTokens: number })._mockTokens || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
const createNodeWithTokens = (
|
||||
id: string,
|
||||
type: NodeType,
|
||||
tokens: number,
|
||||
) => {
|
||||
const node = createDummyNode(id, type);
|
||||
// @ts-expect-error - attaching mock tokens for test
|
||||
node._mockTokens = tokens;
|
||||
return node;
|
||||
};
|
||||
|
||||
// Create 4 nodes, each 80 tokens. Total = 320 tokens.
|
||||
// Node 1 (oldest): prior=240. 240 > 150 -> Normalization (Archiving trigger)
|
||||
// Node 2: prior=160. 160 > 150 -> Normalization
|
||||
// Node 3: prior=80. 80 <= 100 -> Retained
|
||||
// Node 4 (newest): prior=0. 0 <= 100 -> Retained
|
||||
const nodes = [
|
||||
createNodeWithTokens('ep1', NodeType.USER_PROMPT, 80),
|
||||
createNodeWithTokens('ep2', NodeType.AGENT_THOUGHT, 80),
|
||||
createNodeWithTokens('ep3', NodeType.TOOL_EXECUTION, 80),
|
||||
createNodeWithTokens('ep4', NodeType.TOOL_EXECUTION, 80),
|
||||
];
|
||||
|
||||
// @ts-expect-error - access private method for testing
|
||||
manager.buffer = { nodes } as unknown as ContextWorkingBufferImpl;
|
||||
|
||||
// Trigger evaluation manually with a dummy "new node" to bypass the empty check
|
||||
// @ts-expect-error - access private method for testing
|
||||
await manager.evaluateTriggers(nodes, new Set([nodes[3].id]), new Set());
|
||||
|
||||
// Nodes 3 and 4 are retained.
|
||||
// Node 2 and Node 1 both fall out of normalizedTokens (160 > 150, 240 > 150).
|
||||
// Therefore they should trigger NormalizeNeeded. They should NOT trigger ConsolidationNeeded
|
||||
// because they exceeded normalized budget, so they skip the retained fallback.
|
||||
expect(consolidationSpy).not.toHaveBeenCalled();
|
||||
|
||||
expect(normalizeSpy).toHaveBeenCalledOnce();
|
||||
const normalizeEvent = normalizeSpy.mock.calls[0][0];
|
||||
expect(normalizeEvent.targetNodeIds.has(nodes[0].id)).toBe(true);
|
||||
expect(normalizeEvent.targetNodeIds.has(nodes[1].id)).toBe(true);
|
||||
expect(normalizeEvent.targetNodeIds.has(nodes[2].id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
} from '../core/agentChatHistory.js';
|
||||
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
|
||||
import { createMockEnvironment } from './testing/contextTestUtils.js';
|
||||
import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
|
||||
import { deriveStableId } from '../utils/cryptoUtils.js';
|
||||
|
||||
describe('ContextManager', () => {
|
||||
let mockSidecar: ContextProfile;
|
||||
@@ -43,7 +45,7 @@ describe('ContextManager', () => {
|
||||
waitForPipelines: vi.fn().mockResolvedValue(undefined),
|
||||
executeTriggerSync: vi
|
||||
.fn()
|
||||
.mockImplementation(async (trigger, nodes) => nodes),
|
||||
.mockImplementation(async (trigger, buffer) => buffer),
|
||||
shutdown: vi.fn(),
|
||||
} as unknown as PipelineOrchestrator;
|
||||
|
||||
@@ -108,14 +110,15 @@ describe('ContextManager', () => {
|
||||
|
||||
expect(mockOrchestrator.executeTriggerSync).toHaveBeenCalledExactlyOnceWith(
|
||||
'new_message',
|
||||
expect.any(Array),
|
||||
expect.any(ContextWorkingBufferImpl),
|
||||
expect.any(Set),
|
||||
);
|
||||
|
||||
// Check that the node passed to the orchestrator corresponds to our pendingRequest
|
||||
const call = (mockOrchestrator.executeTriggerSync as unknown as Mock).mock
|
||||
.calls[0];
|
||||
const passedNodes = call[1];
|
||||
const passedBuffer = call[1];
|
||||
const passedNodes = passedBuffer.nodes;
|
||||
const passedNodeIds = call[2];
|
||||
|
||||
expect(passedNodes).toHaveLength(1);
|
||||
@@ -126,6 +129,61 @@ describe('ContextManager', () => {
|
||||
expect(passedNodeIds.has(passedNodes[0].id)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly split historical context and pending prompt for late binding', async () => {
|
||||
const envContextId = deriveStableId(['environment-context']);
|
||||
const historicalTurn: HistoryTurn = {
|
||||
id: `turn_${envContextId}`, // Turn 0
|
||||
content: { role: 'user', parts: [{ text: 'System instruction' }] },
|
||||
};
|
||||
const organicTurn: HistoryTurn = {
|
||||
id: 'turn-1',
|
||||
content: { role: 'model', parts: [{ text: 'Previous model message' }] },
|
||||
};
|
||||
|
||||
// Setup history with Turn 0 and Turn 1
|
||||
(mockChatHistory.get as Mock).mockReturnValue([
|
||||
historicalTurn,
|
||||
organicTurn,
|
||||
]);
|
||||
|
||||
const contextManager = new ContextManager(
|
||||
mockSidecar,
|
||||
mockEnv,
|
||||
mockTracer,
|
||||
mockOrchestrator,
|
||||
mockChatHistory,
|
||||
mockAdvancedTokenCalculator,
|
||||
);
|
||||
|
||||
const pendingRequest: HistoryTurn = {
|
||||
id: 'pending-turn',
|
||||
content: { role: 'user', parts: [{ text: 'Active prompt' }] },
|
||||
};
|
||||
|
||||
const { apiHistory, pendingApiHistory } =
|
||||
await contextManager.renderHistory(pendingRequest);
|
||||
|
||||
// apiHistory should contain Turn 0 and the previous model message.
|
||||
// Note: hardenHistory may inject a sentinel user turn if the history segment
|
||||
// being hardened starts with a model turn.
|
||||
expect(apiHistory.length).toBeGreaterThanOrEqual(2);
|
||||
expect((apiHistory[0].parts![0] as unknown as { text: string }).text).toBe(
|
||||
'System instruction',
|
||||
);
|
||||
|
||||
// pendingApiHistory should contain ONLY the pending request
|
||||
expect(pendingApiHistory).toHaveLength(1);
|
||||
expect(
|
||||
(pendingApiHistory[0].parts![0] as unknown as { text: string }).text,
|
||||
).toBe('Active prompt');
|
||||
|
||||
// The total combined history should be a valid alternating sequence
|
||||
const combined = [...apiHistory, ...pendingApiHistory];
|
||||
for (let i = 1; i < combined.length; i++) {
|
||||
expect(combined[i].role).not.toBe(combined[i - 1].role);
|
||||
}
|
||||
});
|
||||
|
||||
it('renderHistory should exclude pendingRequest from the result (late binding)', async () => {
|
||||
const contextManager = new ContextManager(
|
||||
mockSidecar,
|
||||
|
||||
@@ -36,6 +36,7 @@ export class ContextManager {
|
||||
|
||||
// Hysteresis tracking to prevent utility call churn
|
||||
private lastTriggeredDeficit = 0;
|
||||
private lastTriggeredNormalizeDeficit = 0;
|
||||
|
||||
// Cache for Anomaly 3 (Redundant Renders)
|
||||
private lastRenderCache?: {
|
||||
@@ -64,19 +65,16 @@ export class ContextManager {
|
||||
this.eventBus = env.eventBus;
|
||||
this.orchestrator = orchestrator;
|
||||
|
||||
// Provide the orchestrator with a way to fetch the latest nodes from the live buffer
|
||||
// Direct synchronization: ContextManager is the "Pull Master"
|
||||
// and tells the orchestrator what to do.
|
||||
this.orchestrator.setNodeProvider(() => this.buffer.nodes);
|
||||
|
||||
this.eventBus.onProcessorResult((event) => {
|
||||
// Defensive: Verify all targets are still present in the buffer.
|
||||
const currentIds = new Set(this.buffer.nodes.map((n) => n.id));
|
||||
const allTargetsPresent = event.targets.every((t) =>
|
||||
currentIds.has(t.id),
|
||||
);
|
||||
|
||||
if (!allTargetsPresent) {
|
||||
debugLogger.log(
|
||||
`[ContextManager] Dropping stale processor result from ${event.processorId}. One or more targets were already removed.`,
|
||||
const bufferIds = new Set(this.buffer.nodes.map((n) => n.id));
|
||||
if (!event.targets.every((t) => bufferIds.has(t.id))) {
|
||||
debugLogger.warn(
|
||||
`[ContextManager] Dropping processor result from ${event.processorId}: targets no longer in buffer.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -89,145 +87,8 @@ export class ContextManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when all currently executing async pipelines have finished.
|
||||
*/
|
||||
async waitForPipelines(): Promise<void> {
|
||||
return this.orchestrator.waitForPipelines();
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely stops background async pipelines and clears event listeners.
|
||||
*/
|
||||
shutdown() {
|
||||
this.orchestrator.shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates if the current working buffer exceeds configured budget thresholds,
|
||||
* firing consolidation events if necessary.
|
||||
*/
|
||||
private async evaluateTriggers(newNodes: Set<string>) {
|
||||
if (!this.sidecar.config.budget) return;
|
||||
|
||||
if (newNodes.size > 0) {
|
||||
await this.orchestrator.executeTriggerSync(
|
||||
'new_message',
|
||||
this.buffer.nodes,
|
||||
newNodes,
|
||||
);
|
||||
}
|
||||
|
||||
const currentTokens = this.env.tokenCalculator.calculateConcreteListTokens(
|
||||
this.buffer.nodes,
|
||||
);
|
||||
|
||||
if (currentTokens > this.sidecar.config.budget.retainedTokens) {
|
||||
const agedOutNodes = new Set<string>();
|
||||
let rollingTokens = 0;
|
||||
|
||||
// Identify nodes that must NEVER be truncated
|
||||
const protectedIds = this.getProtectedNodeIds(this.buffer.nodes);
|
||||
|
||||
// Walk backwards finding nodes that fall out of the retained budget
|
||||
for (let i = this.buffer.nodes.length - 1; i >= 0; i--) {
|
||||
const node = this.buffer.nodes[i];
|
||||
const priorTokens = rollingTokens;
|
||||
rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([
|
||||
node,
|
||||
]);
|
||||
|
||||
if (priorTokens > this.sidecar.config.budget.retainedTokens) {
|
||||
if (!protectedIds.has(node.id)) {
|
||||
agedOutNodes.add(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (agedOutNodes.size > 0) {
|
||||
const targetDeficit =
|
||||
currentTokens - this.sidecar.config.budget.retainedTokens;
|
||||
|
||||
if (targetDeficit < this.lastTriggeredDeficit) {
|
||||
this.lastTriggeredDeficit = targetDeficit;
|
||||
}
|
||||
|
||||
const threshold =
|
||||
this.sidecar.config.budget.coalescingThresholdTokens || 0;
|
||||
const growthSinceLast = targetDeficit - this.lastTriggeredDeficit;
|
||||
|
||||
if (
|
||||
targetDeficit >= threshold &&
|
||||
(growthSinceLast >= threshold || this.lastTriggeredDeficit === 0)
|
||||
) {
|
||||
this.lastTriggeredDeficit = targetDeficit;
|
||||
this.env.tokenCalculator.garbageCollectCache(
|
||||
new Set(this.buffer.nodes.map((n) => n.id)),
|
||||
);
|
||||
|
||||
// Trigger synchronous consolidation for budget deficit
|
||||
await this.orchestrator.executeTriggerSync(
|
||||
'nodes_aged_out',
|
||||
this.buffer.nodes,
|
||||
agedOutNodes,
|
||||
new Set(protectedIds.keys()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.lastTriggeredDeficit = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getProtectedNodeIds(
|
||||
nodes: readonly ConcreteNode[],
|
||||
extraProtectedIds: Set<string> = new Set(),
|
||||
): Map<string, string> {
|
||||
const protectionMap = new Map<string, string>();
|
||||
if (nodes.length === 0) return protectionMap;
|
||||
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
const lastTurnId = lastNode.turnId;
|
||||
const envTurnId = `turn_${deriveStableId(['environment-context'])}`;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.turnId === lastTurnId) {
|
||||
protectionMap.set(node.id, 'recent_turn');
|
||||
} else if (node.turnId === envTurnId) {
|
||||
protectionMap.set(node.id, 'environment_context');
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of extraProtectedIds) {
|
||||
protectionMap.set(id, 'external_active_task');
|
||||
}
|
||||
|
||||
return protectionMap;
|
||||
}
|
||||
|
||||
getPristineGraph(): readonly ConcreteNode[] {
|
||||
const pristineSet = new Map<string, ConcreteNode>();
|
||||
for (const node of this.buffer.nodes) {
|
||||
const roots = this.buffer.getPristineNodes(node.id);
|
||||
for (const root of roots) {
|
||||
pristineSet.set(root.id, root);
|
||||
}
|
||||
}
|
||||
return Array.from(pristineSet.values()).sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
getNodes(): readonly ConcreteNode[] {
|
||||
return [...this.buffer.nodes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a virtual view of the pristine graph, substituting in variants
|
||||
* up to the configured token budget.
|
||||
*/
|
||||
async renderHistory(
|
||||
pendingRequest?: { id: string; content: Content },
|
||||
pendingRequest?: HistoryTurn,
|
||||
activeTaskIds: Set<string> = new Set(),
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{
|
||||
@@ -241,7 +102,6 @@ export class ContextManager {
|
||||
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
|
||||
|
||||
// 1. Explicit Sync with the durable history.
|
||||
// This replaces the background HistoryObserver.
|
||||
const currentHistory = this.chatHistory.get();
|
||||
const pristineNodes = this.env.graphMapper.sync(currentHistory);
|
||||
|
||||
@@ -259,19 +119,19 @@ export class ContextManager {
|
||||
// 2. Preview the pending request.
|
||||
let previewNodes: readonly ConcreteNode[] = [];
|
||||
if (pendingRequest) {
|
||||
previewNodes = this.env.graphMapper.sync([pendingRequest]);
|
||||
const syncedNodes = this.env.graphMapper.sync([pendingRequest]);
|
||||
const previewNodeIds = new Set(syncedNodes.map((n) => n.id));
|
||||
|
||||
const previewNodeIds = new Set(previewNodes.map((n) => n.id));
|
||||
const previewBuffer = ContextWorkingBufferImpl.initialize(syncedNodes);
|
||||
|
||||
previewNodes = await this.orchestrator.executeTriggerSync(
|
||||
const processedPreviewBuffer = await this.orchestrator.executeTriggerSync(
|
||||
'new_message',
|
||||
previewNodes,
|
||||
previewBuffer,
|
||||
previewNodeIds,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Trigger evaluation (Sync budget management).
|
||||
await this.evaluateTriggers(newPrimalNodes);
|
||||
previewNodes = processedPreviewBuffer.nodes;
|
||||
}
|
||||
|
||||
// --- Hot Start Calibration ---
|
||||
const hotStartPromise = (async () => {
|
||||
@@ -284,30 +144,37 @@ export class ContextManager {
|
||||
}
|
||||
})();
|
||||
|
||||
// 3. Synchronous Pressure Barrier
|
||||
await Promise.all([this.orchestrator.waitForPipelines(), hotStartPromise]);
|
||||
|
||||
let nodes = this.buffer.nodes;
|
||||
const previewNodeIds = new Set<string>();
|
||||
|
||||
if (previewNodes.length > 0) {
|
||||
for (const n of previewNodes) {
|
||||
previewNodeIds.add(n.id);
|
||||
}
|
||||
nodes = [...nodes, ...previewNodes];
|
||||
for (const node of previewNodes) {
|
||||
previewNodeIds.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Trigger Management (GC/Distillation/Normalization)
|
||||
await this.evaluateTriggers(nodes, newPrimalNodes, activeTaskIds);
|
||||
|
||||
// Re-fetch nodes from buffer (master) and combine with ephemeral previews
|
||||
nodes = [...this.buffer.nodes, ...previewNodes];
|
||||
|
||||
// 5. Final Render
|
||||
const header = this.headerProvider
|
||||
? await this.headerProvider()
|
||||
: undefined;
|
||||
|
||||
const graphHash = nodes.map((n) => n.id).join('|');
|
||||
const headerHash = header ? JSON.stringify(header.parts) : 'no-header';
|
||||
const totalHash = `${graphHash}::${headerHash}`;
|
||||
const nodesHash = deriveStableId([
|
||||
...nodes.map((n) => n.id),
|
||||
header ? JSON.stringify(header.parts) : 'no-header',
|
||||
]);
|
||||
|
||||
if (this.lastRenderCache?.nodesHash === totalHash) {
|
||||
debugLogger.log(
|
||||
'[ContextManager] Render cache hit. Skipping redundant render.',
|
||||
);
|
||||
if (this.lastRenderCache?.nodesHash === nodesHash) {
|
||||
this.tracer.logEvent('ContextManager', 'Render Cache Hit', { nodesHash });
|
||||
return this.lastRenderCache.result;
|
||||
}
|
||||
|
||||
@@ -336,62 +203,269 @@ export class ContextManager {
|
||||
} = renderResult;
|
||||
|
||||
if (didApplyManagement) {
|
||||
// Commit the GC backstop results back to the master buffer.
|
||||
// We must be careful to only apply results to the nodes that belong to the master buffer.
|
||||
const masterIdsInResult = new Set(this.buffer.nodes.map((n) => n.id));
|
||||
const processedMasterNodes = processedNodes.filter(
|
||||
(n) => !previewNodeIds.has(n.id) || masterIdsInResult.has(n.id),
|
||||
);
|
||||
|
||||
this.buffer = this.buffer.applyProcessorResult(
|
||||
'sync_backstop',
|
||||
this.buffer.nodes,
|
||||
processedNodes.filter((n) => !previewNodeIds.has(n.id)),
|
||||
processedMasterNodes,
|
||||
);
|
||||
}
|
||||
|
||||
// Structural validation
|
||||
checkContextInvariants(this.buffer.nodes, 'RenderHistory');
|
||||
|
||||
this.tracer.logEvent('ContextManager', 'Finished rendering');
|
||||
const fullHistoryToHarden = [...renderedHistory, ...pendingHistory];
|
||||
|
||||
const allHistory = [...renderedHistory, ...pendingHistory];
|
||||
const hardenedAllHistory = hardenHistory(allHistory, {
|
||||
const hardenedFullHistory = hardenHistory(fullHistoryToHarden, {
|
||||
sentinels: this.sidecar.sentinels,
|
||||
});
|
||||
|
||||
const firstPendingId = pendingHistory[0]?.id;
|
||||
let splitIndex = renderedHistory.length;
|
||||
if (firstPendingId) {
|
||||
const foundIndex = hardenedAllHistory.findIndex(
|
||||
(h) => h.id === firstPendingId,
|
||||
);
|
||||
if (foundIndex !== -1) {
|
||||
splitIndex = foundIndex;
|
||||
const envContextId = deriveStableId(['environment-context']);
|
||||
const pendingIds = new Set(pendingHistory.map((t) => t.id));
|
||||
const resultHistory: HistoryTurn[] = [];
|
||||
const resultPending: HistoryTurn[] = [];
|
||||
|
||||
let foundPending = false;
|
||||
for (const turn of hardenedFullHistory) {
|
||||
if (
|
||||
!foundPending &&
|
||||
(pendingIds.has(turn.id) ||
|
||||
(turn.id.startsWith('turn_') &&
|
||||
pendingIds.has(turn.id.substring(5)))) &&
|
||||
turn.id !== envContextId &&
|
||||
turn.id !== `turn_${envContextId}`
|
||||
) {
|
||||
foundPending = true;
|
||||
}
|
||||
}
|
||||
|
||||
const apiHistory = hardenedAllHistory
|
||||
.slice(0, splitIndex)
|
||||
.map((h) => h.content);
|
||||
|
||||
const pendingApiHistory = hardenedAllHistory
|
||||
.slice(splitIndex)
|
||||
.map((h) => h.content);
|
||||
|
||||
if (header) {
|
||||
apiHistory.unshift(header);
|
||||
if (foundPending) {
|
||||
resultPending.push(turn);
|
||||
} else {
|
||||
resultHistory.push(turn);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
history: renderedHistory,
|
||||
apiHistory,
|
||||
pendingApiHistory,
|
||||
apiHistory: resultHistory.map((h) => h.content),
|
||||
pendingApiHistory: resultPending.map((h) => h.content),
|
||||
didApplyManagement,
|
||||
baseUnits,
|
||||
processedNodes,
|
||||
};
|
||||
|
||||
this.lastRenderCache = {
|
||||
nodesHash: totalHash,
|
||||
result,
|
||||
};
|
||||
if (header) {
|
||||
result.apiHistory.unshift(header);
|
||||
}
|
||||
|
||||
this.lastRenderCache = { nodesHash, result };
|
||||
|
||||
this.tracer.logEvent('ContextManager', 'Rendering Complete', {
|
||||
historySize: renderedHistory.length,
|
||||
pendingSize: pendingHistory.length,
|
||||
didApplyManagement,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async waitForPipelines(): Promise<void> {
|
||||
await this.orchestrator.waitForPipelines();
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.orchestrator.shutdown();
|
||||
}
|
||||
|
||||
getNodes(): readonly ConcreteNode[] {
|
||||
return this.buffer.nodes;
|
||||
}
|
||||
|
||||
getEnvironment(): ContextEnvironment {
|
||||
return this.env;
|
||||
}
|
||||
|
||||
getPristineGraph(): readonly ConcreteNode[] {
|
||||
const pristineSet = new Map<string, ConcreteNode>();
|
||||
for (const node of this.buffer.nodes) {
|
||||
const roots = this.buffer.getPristineNodes(node.id);
|
||||
for (const root of roots) {
|
||||
pristineSet.set(root.id, root);
|
||||
}
|
||||
}
|
||||
return Array.from(pristineSet.values()).sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
private async evaluateTriggers(
|
||||
nodes: readonly ConcreteNode[],
|
||||
newPrimalNodes: ReadonlySet<string>,
|
||||
activeTaskIds: Set<string>,
|
||||
) {
|
||||
if (newPrimalNodes.size > 0) {
|
||||
this.buffer = await this.orchestrator.executeTriggerSync(
|
||||
'nodes_added',
|
||||
this.buffer,
|
||||
newPrimalNodes,
|
||||
);
|
||||
}
|
||||
|
||||
// Identify ephemeral preview nodes that are NOT in the master buffer.
|
||||
const bufferIds = new Set(this.buffer.nodes.map((n) => n.id));
|
||||
const previewNodes = nodes.filter((n) => !bufferIds.has(n.id));
|
||||
const currentNodes = [...this.buffer.nodes, ...previewNodes];
|
||||
|
||||
const currentTokens =
|
||||
this.env.tokenCalculator.calculateConcreteListTokens(currentNodes);
|
||||
|
||||
if (currentTokens > this.sidecar.config.budget.retainedTokens) {
|
||||
const agedOutRetainedNodes = new Set<string>();
|
||||
const agedOutNormalizedNodes = new Set<string>();
|
||||
|
||||
const protectionMap = this.getProtectedNodeIds(
|
||||
currentNodes,
|
||||
activeTaskIds,
|
||||
);
|
||||
const protectedIds = new Set(protectionMap.keys());
|
||||
|
||||
// Also pin Turn 0 (Environment Context)
|
||||
const envTurnId = `turn_${deriveStableId(['environment-context'])}`;
|
||||
const turn0Nodes = currentNodes.filter((n) => n.turnId === envTurnId);
|
||||
for (const n of turn0Nodes) {
|
||||
protectedIds.add(n.id);
|
||||
}
|
||||
|
||||
let rollingTokens = 0;
|
||||
for (let i = currentNodes.length - 1; i >= 0; i--) {
|
||||
const node = currentNodes[i];
|
||||
const priorTokens = rollingTokens;
|
||||
rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([
|
||||
node,
|
||||
]);
|
||||
|
||||
if (priorTokens > this.sidecar.config.budget.retainedTokens) {
|
||||
if (!protectedIds.has(node.id)) {
|
||||
const hasNormalizedTier =
|
||||
this.sidecar.config.budget.normalizedTokens !== undefined;
|
||||
if (
|
||||
!hasNormalizedTier ||
|
||||
priorTokens <= this.sidecar.config.budget.normalizedTokens!
|
||||
) {
|
||||
agedOutRetainedNodes.add(node.id);
|
||||
}
|
||||
if (
|
||||
hasNormalizedTier &&
|
||||
priorTokens > this.sidecar.config.budget.normalizedTokens!
|
||||
) {
|
||||
agedOutNormalizedNodes.add(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (agedOutRetainedNodes.size > 0) {
|
||||
const targetDeficit =
|
||||
currentTokens - this.sidecar.config.budget.retainedTokens;
|
||||
const threshold =
|
||||
this.sidecar.config.budget.coalescingThresholdTokens || 0;
|
||||
|
||||
if (targetDeficit < this.lastTriggeredDeficit) {
|
||||
this.lastTriggeredDeficit = targetDeficit;
|
||||
}
|
||||
|
||||
if (targetDeficit > this.lastTriggeredDeficit + threshold) {
|
||||
this.lastTriggeredDeficit = targetDeficit;
|
||||
|
||||
this.eventBus.emitConsolidationNeeded({
|
||||
nodes: this.buffer.nodes,
|
||||
targetDeficit,
|
||||
targetNodeIds: agedOutRetainedNodes,
|
||||
});
|
||||
|
||||
this.env.tokenCalculator.garbageCollectCache(
|
||||
new Set(this.buffer.nodes.map((n) => n.id)),
|
||||
);
|
||||
|
||||
this.buffer = await this.orchestrator.executeTriggerSync(
|
||||
'nodes_aged_out',
|
||||
this.buffer,
|
||||
agedOutRetainedNodes,
|
||||
protectedIds,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.lastTriggeredDeficit = 0;
|
||||
}
|
||||
|
||||
if (agedOutNormalizedNodes.size > 0) {
|
||||
const targetDeficit =
|
||||
currentTokens - this.sidecar.config.budget.normalizedTokens!;
|
||||
const threshold =
|
||||
this.sidecar.config.budget.coalescingThresholdTokens || 0;
|
||||
|
||||
if (targetDeficit < this.lastTriggeredNormalizeDeficit) {
|
||||
this.lastTriggeredNormalizeDeficit = targetDeficit;
|
||||
}
|
||||
|
||||
if (targetDeficit > this.lastTriggeredNormalizeDeficit + threshold) {
|
||||
this.lastTriggeredNormalizeDeficit = targetDeficit;
|
||||
|
||||
this.eventBus.emitNormalizeNeeded({
|
||||
nodes: this.buffer.nodes,
|
||||
targetDeficit,
|
||||
targetNodeIds: agedOutNormalizedNodes,
|
||||
});
|
||||
|
||||
this.buffer = await this.orchestrator.executeTriggerSync(
|
||||
'normalized_exceeded',
|
||||
this.buffer,
|
||||
agedOutNormalizedNodes,
|
||||
protectedIds,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.lastTriggeredNormalizeDeficit = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getProtectedNodeIds(
|
||||
nodes: readonly ConcreteNode[],
|
||||
extraProtectedIds: Set<string> = new Set(),
|
||||
): Map<string, string> {
|
||||
const protectionMap = new Map<string, string>();
|
||||
if (nodes.length === 0) return protectionMap;
|
||||
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
const lastTurnId = lastNode.turnId;
|
||||
|
||||
// Identify Environment Context (Turn 0) for pinning
|
||||
const envContextId = deriveStableId(['environment-context']);
|
||||
const envContextTurnId = `turn_${envContextId}`;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.turnId === envContextTurnId || node.turnId === envContextId) {
|
||||
protectionMap.set(node.id, 'environment_context');
|
||||
}
|
||||
if (node.turnId === lastTurnId) {
|
||||
protectionMap.set(node.id, 'recent_turn');
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of extraProtectedIds) {
|
||||
protectionMap.set(id, 'external_active_task');
|
||||
}
|
||||
|
||||
return protectionMap;
|
||||
}
|
||||
|
||||
private async performHotStartCalibration(
|
||||
nodes: readonly ConcreteNode[],
|
||||
abortSignal?: AbortSignal,
|
||||
@@ -416,8 +490,4 @@ export class ContextManager {
|
||||
debugLogger.warn('[ContextManager] Hot start calibration failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
getEnvironment(): ContextEnvironment {
|
||||
return this.env;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface TokenGroundTruthEvent {
|
||||
promptBaseUnits: number;
|
||||
}
|
||||
|
||||
export interface NormalizeNeededEvent {
|
||||
nodes: readonly ConcreteNode[];
|
||||
targetDeficit: number;
|
||||
targetNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export class ContextEventBus extends EventEmitter {
|
||||
emitTokenGroundTruth(event: TokenGroundTruthEvent) {
|
||||
this.emit('TOKEN_GROUND_TRUTH', event);
|
||||
@@ -69,6 +75,14 @@ export class ContextEventBus extends EventEmitter {
|
||||
this.on('BUDGET_RETAINED_CROSSED', listener);
|
||||
}
|
||||
|
||||
emitNormalizeNeeded(event: NormalizeNeededEvent) {
|
||||
this.emit('BUDGET_NORMALIZED_CROSSED', event);
|
||||
}
|
||||
|
||||
onNormalizeNeeded(listener: (event: NormalizeNeededEvent) => void) {
|
||||
this.on('BUDGET_NORMALIZED_CROSSED', listener);
|
||||
}
|
||||
|
||||
emitProcessorResult(event: ProcessorResultEvent) {
|
||||
this.emit('PROCESSOR_RESULT', event);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { ContextProfile } from '../config/profiles.js';
|
||||
import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
import { ContextWorkingBufferImpl } from '../pipeline/contextWorkingBuffer.js';
|
||||
|
||||
describe('render', () => {
|
||||
it('should render all provided nodes', async () => {
|
||||
const mockNodes: ConcreteNode[] = [
|
||||
@@ -116,9 +118,12 @@ describe('render', () => {
|
||||
};
|
||||
|
||||
const orchestrator = {
|
||||
executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) =>
|
||||
nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)),
|
||||
),
|
||||
executeTriggerSync: vi.fn(async (trigger, buffer, agedOutNodes) => {
|
||||
const filteredNodes = buffer.nodes.filter(
|
||||
(n: ConcreteNode) => !agedOutNodes.has(n.id),
|
||||
);
|
||||
return ContextWorkingBufferImpl.initialize(filteredNodes);
|
||||
}),
|
||||
} as unknown as PipelineOrchestrator;
|
||||
|
||||
const sidecar = {
|
||||
@@ -215,9 +220,12 @@ describe('render', () => {
|
||||
};
|
||||
|
||||
const orchestrator = {
|
||||
executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) =>
|
||||
nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)),
|
||||
),
|
||||
executeTriggerSync: vi.fn(async (trigger, buffer, agedOutNodes) => {
|
||||
const filteredNodes = buffer.nodes.filter(
|
||||
(n: ConcreteNode) => !agedOutNodes.has(n.id),
|
||||
);
|
||||
return ContextWorkingBufferImpl.initialize(filteredNodes);
|
||||
}),
|
||||
} as unknown as PipelineOrchestrator;
|
||||
|
||||
const sidecar = {
|
||||
@@ -303,7 +311,7 @@ describe('render', () => {
|
||||
];
|
||||
|
||||
const orchestrator = {
|
||||
executeTriggerSync: vi.fn(async (trigger, nodes) => nodes),
|
||||
executeTriggerSync: vi.fn(async (trigger, buffer) => buffer),
|
||||
} as unknown as PipelineOrchestrator;
|
||||
const sidecar = { config: {} } as ContextProfile; // No budget
|
||||
const mockAdvancedTokenCalculator = {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
import { performCalibration } from '../utils/tokenCalibration.js';
|
||||
import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import type { HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
import { ContextWorkingBufferImpl } from '../pipeline/contextWorkingBuffer.js';
|
||||
|
||||
export interface RenderOptions {
|
||||
protectionReasons?: Map<string, string>;
|
||||
@@ -178,7 +179,20 @@ export async function render(
|
||||
rollingTokens += nodeTokens;
|
||||
|
||||
if (priorTokens > sidecar.config.budget.retainedTokens) {
|
||||
agedOutNodes.add(node.id);
|
||||
if (sidecar.config.gcStrategy === 'incremental') {
|
||||
// Only target enough of the oldest nodes to get back under maxTokens
|
||||
// priorTokens represents tokens newer than this node.
|
||||
// If the newer tokens alone are enough to push us over maxTokens, we MUST compress this node.
|
||||
// If the newer tokens are under maxTokens, we can stop compressing.
|
||||
if (priorTokens > maxTokens) {
|
||||
agedOutNodes.add(node.id);
|
||||
} else if (rollingTokens > maxTokens) {
|
||||
// This is the boundary node that pushes us over maxTokens. Compress it.
|
||||
agedOutNodes.add(node.id);
|
||||
}
|
||||
} else {
|
||||
agedOutNodes.add(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,13 +204,15 @@ export async function render(
|
||||
}
|
||||
}
|
||||
|
||||
const processedNodes = await orchestrator.executeTriggerSync(
|
||||
const processedBuffer = await orchestrator.executeTriggerSync(
|
||||
'gc_backstop',
|
||||
nodes,
|
||||
ContextWorkingBufferImpl.initialize(nodes),
|
||||
agedOutNodes,
|
||||
protectedIds,
|
||||
);
|
||||
|
||||
const processedNodes = processedBuffer.nodes;
|
||||
|
||||
const skipList = new Set<string>();
|
||||
for (const node of processedNodes) {
|
||||
if (node.abstractsIds) {
|
||||
|
||||
@@ -197,7 +197,13 @@ export class ContextGraphBuilder {
|
||||
const msg = turn.content;
|
||||
if (!msg.parts) continue;
|
||||
|
||||
const turnSalt = turn.id;
|
||||
const hasEnvHeader = msg.parts?.some(
|
||||
(p) => isTextPart(p) && p.text.trim().startsWith('<session_context>'),
|
||||
);
|
||||
const turnSalt =
|
||||
hasEnvHeader && turnIdx === 0
|
||||
? deriveStableId(['environment-context'])
|
||||
: turn.id;
|
||||
const turnId = turnSalt.startsWith('turn_')
|
||||
? turnSalt
|
||||
: `turn_${turnSalt}`;
|
||||
@@ -207,12 +213,10 @@ export class ContextGraphBuilder {
|
||||
const part = msg.parts[partIdx];
|
||||
|
||||
// Skip legacy session context headers if they appear later in history (after Turn 0).
|
||||
// We identify Turn 0 by its deterministic ID.
|
||||
const envTurnId = deriveStableId(['environment-context']);
|
||||
if (
|
||||
isTextPart(part) &&
|
||||
part.text.trim().startsWith('<session_context>') &&
|
||||
turnSalt !== envTurnId
|
||||
turnIdx > 0
|
||||
) {
|
||||
debugLogger.log(
|
||||
'[ContextGraphBuilder] Skipping legacy environment header turn from graph.',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { PipelineOrchestrator } from './orchestrator.js';
|
||||
import { ContextWorkingBufferImpl } from './contextWorkingBuffer.js';
|
||||
import {
|
||||
createMockEnvironment,
|
||||
createDummyNode,
|
||||
@@ -115,13 +116,15 @@ describe('PipelineOrchestrator (Component)', () => {
|
||||
payload: { text: 'Original' },
|
||||
});
|
||||
|
||||
const processed = await orchestrator.executeTriggerSync(
|
||||
const processedBuffer = await orchestrator.executeTriggerSync(
|
||||
'new_message',
|
||||
[originalNode],
|
||||
ContextWorkingBufferImpl.initialize([originalNode]),
|
||||
new Set([originalNode.id]),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const processed = processedBuffer.nodes;
|
||||
|
||||
expect(processed.length).toBe(1);
|
||||
const resultingNode = processed[0] as UserPrompt;
|
||||
expect(resultingNode.payload.text).toBe('Original [modified]');
|
||||
@@ -142,13 +145,15 @@ describe('PipelineOrchestrator (Component)', () => {
|
||||
payload: { text: 'Original' },
|
||||
});
|
||||
|
||||
const processed = await orchestrator.executeTriggerSync(
|
||||
const processedBuffer = await orchestrator.executeTriggerSync(
|
||||
'new_message',
|
||||
[originalNode],
|
||||
ContextWorkingBufferImpl.initialize([originalNode]),
|
||||
new Set([originalNode.id]),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const processed = processedBuffer.nodes;
|
||||
|
||||
expect(processed).toEqual([originalNode]); // Untouched
|
||||
});
|
||||
|
||||
@@ -170,13 +175,15 @@ describe('PipelineOrchestrator (Component)', () => {
|
||||
});
|
||||
|
||||
// The throwing processor should be caught and logged, allowing Mod to still run.
|
||||
const processed = await orchestrator.executeTriggerSync(
|
||||
const processedBuffer = await orchestrator.executeTriggerSync(
|
||||
'new_message',
|
||||
[originalNode],
|
||||
ContextWorkingBufferImpl.initialize([originalNode]),
|
||||
new Set([originalNode.id]),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const processed = processedBuffer.nodes;
|
||||
|
||||
expect(processed.length).toBe(1);
|
||||
const resultingNode = processed[0] as UserPrompt;
|
||||
expect(resultingNode.payload.text).toBe('Original [modified]');
|
||||
@@ -207,7 +214,7 @@ describe('PipelineOrchestrator (Component)', () => {
|
||||
|
||||
await orchestrator.executeTriggerSync(
|
||||
'nodes_added',
|
||||
[node1, node2],
|
||||
ContextWorkingBufferImpl.initialize([node1, node2]),
|
||||
new Set([node2.id]),
|
||||
);
|
||||
|
||||
|
||||
@@ -67,18 +67,18 @@ export class PipelineOrchestrator {
|
||||
|
||||
async executeTriggerSync(
|
||||
trigger: PipelineTrigger,
|
||||
nodes: readonly ConcreteNode[],
|
||||
buffer: ContextWorkingBufferImpl,
|
||||
triggerTargets: ReadonlySet<string>,
|
||||
protectedTurnIds: ReadonlySet<string> = new Set(),
|
||||
): Promise<readonly ConcreteNode[]> {
|
||||
): Promise<ContextWorkingBufferImpl> {
|
||||
this.tracer.logEvent('Orchestrator', 'Strategy Intent', {
|
||||
trigger,
|
||||
totalNodes: nodes.length,
|
||||
totalNodes: buffer.nodes.length,
|
||||
targetNodes: triggerTargets.size,
|
||||
});
|
||||
|
||||
// First, run any sync pipelines matching this trigger
|
||||
let currentBuffer = ContextWorkingBufferImpl.initialize(nodes);
|
||||
let currentBuffer = buffer;
|
||||
const triggerPipelines = this.pipelines.filter((p) =>
|
||||
p.triggers.includes(trigger),
|
||||
);
|
||||
@@ -148,7 +148,7 @@ export class PipelineOrchestrator {
|
||||
// Success! Drain consumed messages
|
||||
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
|
||||
|
||||
return currentBuffer.nodes;
|
||||
return currentBuffer;
|
||||
}
|
||||
|
||||
private async executeTriggerAsync(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Power User Lifecycle Tests > should correctly execute the three-tier budget pipeline 1`] = `
|
||||
{
|
||||
"baseUnits": 4688,
|
||||
"finalProjection": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "{"active_tasks":[],"discovered_facts":[],"constraints_and_preferences":[],"recent_arc":[]}",
|
||||
},
|
||||
],
|
||||
"role": "user",
|
||||
},
|
||||
"id": "<UUID>",
|
||||
},
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
|
||||
},
|
||||
],
|
||||
"role": "model",
|
||||
},
|
||||
"id": "<UUID>",
|
||||
},
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "Mock response from: utility_compressor, for: {"text":"C...CCCCCCCC"}",
|
||||
},
|
||||
],
|
||||
"role": "user",
|
||||
},
|
||||
"id": "<UUID>",
|
||||
},
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
|
||||
},
|
||||
],
|
||||
"role": "model",
|
||||
},
|
||||
"id": "<UUID>",
|
||||
},
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE",
|
||||
},
|
||||
],
|
||||
"role": "user",
|
||||
},
|
||||
"id": "<UUID>",
|
||||
},
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
|
||||
},
|
||||
],
|
||||
"role": "model",
|
||||
},
|
||||
"id": "<UUID>",
|
||||
},
|
||||
],
|
||||
"tokenTrajectory": [
|
||||
{
|
||||
"tokensAfterBackground": 33,
|
||||
"tokensBeforeBackground": 9,
|
||||
"turnIndex": 0,
|
||||
},
|
||||
{
|
||||
"tokensAfterBackground": 52,
|
||||
"tokensBeforeBackground": 41,
|
||||
"turnIndex": 1,
|
||||
},
|
||||
{
|
||||
"tokensAfterBackground": 1262,
|
||||
"tokensBeforeBackground": 457,
|
||||
"turnIndex": 2,
|
||||
},
|
||||
{
|
||||
"tokensAfterBackground": 3072,
|
||||
"tokensBeforeBackground": 1867,
|
||||
"turnIndex": 3,
|
||||
},
|
||||
{
|
||||
"tokensAfterBackground": 4688,
|
||||
"tokensBeforeBackground": 4077,
|
||||
"turnIndex": 4,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import { SimulationHarness } from './simulationHarness.js';
|
||||
import { createMockLlmClient } from '../testing/contextTestUtils.js';
|
||||
import type { ContextProfile } from '../config/profiles.js';
|
||||
import { powerUserProfile } from '../config/profiles.js';
|
||||
|
||||
expect.addSnapshotSerializer({
|
||||
test: (val) =>
|
||||
typeof val === 'string' &&
|
||||
(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(
|
||||
val,
|
||||
) ||
|
||||
/\b[0-9a-f]{32}\b/i.test(val) ||
|
||||
/\bsynth_[a-zA-Z0-9_]+_[0-9a-f]{32}\b/.test(val) ||
|
||||
/[\\/]tmp[\\/]sim/.test(val)),
|
||||
print: (val) => {
|
||||
if (typeof val !== 'string') return `"${val}"`;
|
||||
let scrubbed = val
|
||||
.replace(
|
||||
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
|
||||
'<UUID>',
|
||||
)
|
||||
.replace(/\b[0-9a-f]{32}\b/gi, '<UUID>')
|
||||
.replace(/\bsynth_[a-zA-Z0-9_]+_[0-9a-f]{32}\b/g, 'synth_<NAME>_<HASH>')
|
||||
.replace(/[\\/]tmp[\\/]sim[^\s"'\]]*/g, '<MOCKED_DIR>');
|
||||
|
||||
// Also scrub timestamps in filenames like blob_1234567890_...
|
||||
scrubbed = scrubbed.replace(/blob_\d+_/g, 'blob_<TIMESTAMP>_');
|
||||
|
||||
return `"${scrubbed}"`;
|
||||
},
|
||||
});
|
||||
|
||||
describe('Power User Lifecycle Tests', () => {
|
||||
afterAll(async () => {
|
||||
fs.rmSync('/tmp/sim', { recursive: true, force: true });
|
||||
fs.rmSync('mock', { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mockLlmClient = createMockLlmClient();
|
||||
|
||||
it('should correctly execute the three-tier budget pipeline', async () => {
|
||||
// 1. Setup a Power User Stress Profile
|
||||
const powerStressProfile: ContextProfile = {
|
||||
...powerUserProfile,
|
||||
config: {
|
||||
...powerUserProfile.config,
|
||||
budget: {
|
||||
retainedTokens: 1000,
|
||||
normalizedTokens: 2000,
|
||||
maxTokens: 5000,
|
||||
coalescingThresholdTokens: 200,
|
||||
},
|
||||
gcStrategy: 'incremental',
|
||||
},
|
||||
};
|
||||
|
||||
const harness = await SimulationHarness.create(
|
||||
powerStressProfile,
|
||||
mockLlmClient,
|
||||
);
|
||||
|
||||
// Turn 0: System Prompt
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'System Instructions' }] },
|
||||
{ role: 'model', parts: [{ text: 'Ack.' }] },
|
||||
]);
|
||||
|
||||
// Turn 1: Normal conversation
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'Hello!' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi!' }] },
|
||||
]);
|
||||
|
||||
// Turn 2: Large message to cross retainedTokens (1000) but stay under normalizedTokens (2000)
|
||||
// Should trigger 'retained_exceeded' (Normalization pipeline)
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'A'.repeat(800) }] },
|
||||
{ role: 'model', parts: [{ text: 'B'.repeat(400) }] },
|
||||
]);
|
||||
|
||||
// Turn 3: Large message to cross normalizedTokens (2000) but stay under maxTokens (5000)
|
||||
// Should trigger 'normalized_exceeded' (Archiving pipeline)
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'C'.repeat(1200) }] },
|
||||
{ role: 'model', parts: [{ text: 'D'.repeat(600) }] },
|
||||
]);
|
||||
|
||||
// Turn 4: Large message to cross maxTokens (5000)
|
||||
// Should trigger 'gc_backstop' (Emergency pipeline) AND 'nodes_aged_out' (Async BG)
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'E'.repeat(2500) }] },
|
||||
{ role: 'model', parts: [{ text: 'F'.repeat(1000) }] },
|
||||
]);
|
||||
|
||||
const goldenState = await harness.getGoldenState();
|
||||
|
||||
// Verify snapshots for token trajectory and final projection
|
||||
expect(goldenState).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -604,10 +604,10 @@ export class GeminiClient {
|
||||
return resolveModel(
|
||||
this.config.getActiveModel(),
|
||||
this.config.getGemini31LaunchedSync?.() ?? false,
|
||||
this.config.getGemini31FlashLiteLaunchedSync?.() ?? false,
|
||||
false,
|
||||
this.config.getHasAccessToPreviewModel?.() ?? true,
|
||||
this.config,
|
||||
this.config.hasGemini35FlashGAAccess?.() ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,13 @@ import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
||||
import { ModelMappingContentGenerator } from './modelMappingContentGenerator.js';
|
||||
import { CCPA_AI_MODEL_MAPPINGS } from '../config/models.js';
|
||||
import { loadApiKey } from './apiKeyCredentialStorage.js';
|
||||
import { FakeContentGenerator } from './fakeContentGenerator.js';
|
||||
import { RecordingContentGenerator } from './recordingContentGenerator.js';
|
||||
import { resetVersionCache } from '../utils/version.js';
|
||||
import type { LlmRole } from '../telemetry/llmRole.js';
|
||||
|
||||
vi.mock('../code_assist/codeAssist.js');
|
||||
vi.mock('@google/genai');
|
||||
@@ -36,6 +39,14 @@ const mockConfig = {
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(true),
|
||||
getTelemetryTracesEnabled: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
refreshUserQuotaIfStale: vi.fn().mockResolvedValue(undefined),
|
||||
setLatestApiRequest: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
describe('getAuthTypeFromEnv', () => {
|
||||
@@ -142,7 +153,10 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
expect(createCodeAssistContentGenerator).toHaveBeenCalled();
|
||||
expect(generator).toEqual(
|
||||
new LoggingContentGenerator(mockGenerator, mockConfig),
|
||||
new LoggingContentGenerator(
|
||||
new ModelMappingContentGenerator(mockGenerator, CCPA_AI_MODEL_MAPPINGS),
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,7 +173,10 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
expect(createCodeAssistContentGenerator).toHaveBeenCalled();
|
||||
expect(generator).toEqual(
|
||||
new LoggingContentGenerator(mockGenerator, mockConfig),
|
||||
new LoggingContentGenerator(
|
||||
new ModelMappingContentGenerator(mockGenerator, CCPA_AI_MODEL_MAPPINGS),
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1095,6 +1112,178 @@ describe('createContentGenerator', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not apply model mapping for Vertex AI', async () => {
|
||||
const mockModels = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
const mockGenerator = {
|
||||
models: mockModels,
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
const generator = await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
vertexai: true,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'gemini-3-flash',
|
||||
contents: [],
|
||||
},
|
||||
'prompt-id',
|
||||
'user' as LlmRole,
|
||||
);
|
||||
|
||||
expect(mockModels.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gemini-3-flash',
|
||||
}),
|
||||
'prompt-id',
|
||||
'user',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not apply model mapping for Gemini API', async () => {
|
||||
const mockModels = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
const mockGenerator = {
|
||||
models: mockModels,
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
const generator = await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'gemini-3-flash',
|
||||
contents: [],
|
||||
},
|
||||
'prompt-id',
|
||||
'user' as LlmRole,
|
||||
);
|
||||
|
||||
expect(mockModels.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gemini-3-flash',
|
||||
}),
|
||||
'prompt-id',
|
||||
'user',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not apply model mapping for GATEWAY', async () => {
|
||||
const mockModels = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
const mockGenerator = {
|
||||
models: mockModels,
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
const generator = await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.GATEWAY,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'gemini-3.5-flash',
|
||||
contents: [],
|
||||
},
|
||||
'prompt-id',
|
||||
'user' as LlmRole,
|
||||
);
|
||||
|
||||
expect(mockModels.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gemini-3.5-flash',
|
||||
}),
|
||||
'prompt-id',
|
||||
'user',
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply model mapping for LOGIN_WITH_GOOGLE', async () => {
|
||||
const mockInnerGenerator = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as ContentGenerator;
|
||||
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
|
||||
mockInnerGenerator as never,
|
||||
);
|
||||
|
||||
const generator = await createContentGenerator(
|
||||
{
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'gemini-3.5-flash',
|
||||
contents: [],
|
||||
},
|
||||
'prompt-id',
|
||||
'user' as LlmRole,
|
||||
);
|
||||
|
||||
expect(mockInnerGenerator.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gemini-3-flash',
|
||||
}),
|
||||
'prompt-id',
|
||||
'user',
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply model mapping for COMPUTE_ADC', async () => {
|
||||
const mockInnerGenerator = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as ContentGenerator;
|
||||
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
|
||||
mockInnerGenerator as never,
|
||||
);
|
||||
|
||||
const generator = await createContentGenerator(
|
||||
{
|
||||
authType: AuthType.COMPUTE_ADC,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'gemini-3.5-flash',
|
||||
contents: [],
|
||||
},
|
||||
'prompt-id',
|
||||
'user' as LlmRole,
|
||||
);
|
||||
|
||||
expect(mockInnerGenerator.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gemini-3-flash',
|
||||
}),
|
||||
'prompt-id',
|
||||
'user',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContentGeneratorConfig', () => {
|
||||
|
||||
@@ -30,6 +30,8 @@ import { determineSurface } from '../utils/surface.js';
|
||||
import { RecordingContentGenerator } from './recordingContentGenerator.js';
|
||||
import { getVersion, resolveModel } from '../../index.js';
|
||||
import type { LlmRole } from '../telemetry/llmRole.js';
|
||||
import { ModelMappingContentGenerator } from './modelMappingContentGenerator.js';
|
||||
import { CCPA_AI_MODEL_MAPPINGS } from '../config/models.js';
|
||||
|
||||
/**
|
||||
* Interface abstracting the core functionalities for generating content and counting tokens.
|
||||
@@ -218,12 +220,10 @@ export async function createContentGenerator(
|
||||
config.authType === AuthType.USE_GEMINI ||
|
||||
config.authType === AuthType.USE_VERTEX_AI ||
|
||||
((await gcConfig.getGemini31Launched?.()) ?? false),
|
||||
config.authType === AuthType.USE_GEMINI ||
|
||||
config.authType === AuthType.USE_VERTEX_AI ||
|
||||
((await gcConfig.getGemini31FlashLiteLaunched?.()) ?? false),
|
||||
false,
|
||||
gcConfig.getHasAccessToPreviewModel?.() ?? true,
|
||||
gcConfig,
|
||||
gcConfig.hasGemini35FlashGAAccess?.() ?? false,
|
||||
);
|
||||
const customHeadersEnv =
|
||||
process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined;
|
||||
@@ -284,11 +284,14 @@ export async function createContentGenerator(
|
||||
) {
|
||||
const httpOptions = { headers: baseHeaders };
|
||||
return new LoggingContentGenerator(
|
||||
await createCodeAssistContentGenerator(
|
||||
httpOptions,
|
||||
config.authType,
|
||||
gcConfig,
|
||||
sessionId,
|
||||
new ModelMappingContentGenerator(
|
||||
await createCodeAssistContentGenerator(
|
||||
httpOptions,
|
||||
config.authType,
|
||||
gcConfig,
|
||||
sessionId,
|
||||
),
|
||||
CCPA_AI_MODEL_MAPPINGS,
|
||||
),
|
||||
gcConfig,
|
||||
);
|
||||
|
||||
@@ -159,6 +159,7 @@ describe('GeminiChat', () => {
|
||||
getTelemetryLogPromptsEnabled: () => true,
|
||||
getTelemetryTracesEnabled: () => false,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
hasGemini35FlashGAAccess: vi.fn().mockReturnValue(false),
|
||||
getDebugMode: () => false,
|
||||
getContentGeneratorConfig: vi.fn().mockImplementation(() => ({
|
||||
authType: 'oauth-personal',
|
||||
|
||||
@@ -719,19 +719,16 @@ export class GeminiChat {
|
||||
const apiCall = async () => {
|
||||
const useGemini3_1 =
|
||||
(await this.context.config.getGemini31Launched?.()) ?? false;
|
||||
const useGemini3_1FlashLite =
|
||||
(await this.context.config.getGemini31FlashLiteLaunched?.()) ?? false;
|
||||
const hasAccessToPreview =
|
||||
this.context.config.getHasAccessToPreviewModel?.() ?? true;
|
||||
|
||||
// Default to the last used model (which respects arguments/availability selection)
|
||||
let modelToUse = resolveModel(
|
||||
lastModelToUse,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
false,
|
||||
hasAccessToPreview,
|
||||
this.context.config,
|
||||
this.context.config.hasGemini35FlashGAAccess?.() ?? false,
|
||||
);
|
||||
|
||||
// If the active model has changed (e.g. due to a fallback updating the config),
|
||||
@@ -740,10 +737,10 @@ export class GeminiChat {
|
||||
modelToUse = resolveModel(
|
||||
this.context.config.getActiveModel(),
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
false,
|
||||
hasAccessToPreview,
|
||||
this.context.config,
|
||||
this.context.config.hasGemini35FlashGAAccess?.() ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -803,10 +800,10 @@ export class GeminiChat {
|
||||
modelToUse = resolveModel(
|
||||
beforeModelResult.modifiedModel,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
false,
|
||||
hasAccessToPreview,
|
||||
this.context.config,
|
||||
this.context.config.hasGemini35FlashGAAccess?.() ?? false,
|
||||
);
|
||||
lastModelToUse = modelToUse;
|
||||
// Re-evaluate contentsToUse based on the new model's feature support
|
||||
|
||||
@@ -98,6 +98,7 @@ describe('GeminiChat Network Retries', () => {
|
||||
getTelemetryLogPromptsEnabled: () => true,
|
||||
getTelemetryTracesEnabled: () => false,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
hasGemini35FlashGAAccess: vi.fn().mockReturnValue(false),
|
||||
getDebugMode: () => false,
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'oauth-personal',
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ModelMappingContentGenerator } from './modelMappingContentGenerator.js';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import { LlmRole } from '../telemetry/llmRole.js';
|
||||
import type { GenerateContentParameters } from '@google/genai';
|
||||
|
||||
describe('ModelMappingContentGenerator', () => {
|
||||
const mockMappings = {
|
||||
'gemini-3.5-flash': 'gemini-3-flash',
|
||||
'gemini-pro': 'gemini-1.5-pro',
|
||||
};
|
||||
|
||||
it('delegates userTier, userTierName, and paidTier properties', () => {
|
||||
const mockWrapped = {
|
||||
userTier: 'free',
|
||||
userTierName: 'Free Tier',
|
||||
paidTier: { id: 'paid' },
|
||||
} as unknown as ContentGenerator;
|
||||
|
||||
const generator = new ModelMappingContentGenerator(
|
||||
mockWrapped,
|
||||
mockMappings,
|
||||
);
|
||||
|
||||
expect(generator.userTier).toBe('free');
|
||||
expect(generator.userTierName).toBe('Free Tier');
|
||||
expect(generator.paidTier).toEqual({ id: 'paid' });
|
||||
});
|
||||
|
||||
it('maps matching model without prefix', async () => {
|
||||
const mockWrapped = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as ContentGenerator;
|
||||
|
||||
const generator = new ModelMappingContentGenerator(
|
||||
mockWrapped,
|
||||
mockMappings,
|
||||
);
|
||||
const req = { model: 'gemini-3.5-flash', contents: [] };
|
||||
|
||||
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
|
||||
|
||||
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
|
||||
{ model: 'gemini-3-flash', contents: [] },
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps matching model with models/ prefix', async () => {
|
||||
const mockWrapped = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as ContentGenerator;
|
||||
|
||||
const generator = new ModelMappingContentGenerator(
|
||||
mockWrapped,
|
||||
mockMappings,
|
||||
);
|
||||
const req = { model: 'models/gemini-3.5-flash', contents: [] };
|
||||
|
||||
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
|
||||
|
||||
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
|
||||
{ model: 'models/gemini-3-flash', contents: [] },
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves unmapped model unchanged', async () => {
|
||||
const mockWrapped = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as ContentGenerator;
|
||||
|
||||
const generator = new ModelMappingContentGenerator(
|
||||
mockWrapped,
|
||||
mockMappings,
|
||||
);
|
||||
const req = { model: 'unknown-model', contents: [] };
|
||||
|
||||
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
|
||||
|
||||
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
|
||||
{ model: 'unknown-model', contents: [] },
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves model with prefix unchanged if no match after normalization', async () => {
|
||||
const mockWrapped = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as ContentGenerator;
|
||||
|
||||
const generator = new ModelMappingContentGenerator(
|
||||
mockWrapped,
|
||||
mockMappings,
|
||||
);
|
||||
const req = { model: 'models/unknown-model', contents: [] };
|
||||
|
||||
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
|
||||
|
||||
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
|
||||
{ model: 'models/unknown-model', contents: [] },
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles missing/undefined model property safely', async () => {
|
||||
const mockWrapped = {
|
||||
generateContent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as ContentGenerator;
|
||||
|
||||
const generator = new ModelMappingContentGenerator(
|
||||
mockWrapped,
|
||||
mockMappings,
|
||||
);
|
||||
const req = { contents: [] } as unknown as GenerateContentParameters;
|
||||
|
||||
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
|
||||
|
||||
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
|
||||
{ contents: [] },
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type CountTokensResponse,
|
||||
type GenerateContentResponse,
|
||||
type GenerateContentParameters,
|
||||
type CountTokensParameters,
|
||||
type EmbedContentResponse,
|
||||
type EmbedContentParameters,
|
||||
} from '@google/genai';
|
||||
import { type ContentGenerator } from './contentGenerator.js';
|
||||
import type { LlmRole } from '../telemetry/llmRole.js';
|
||||
import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
|
||||
import { normalizeModelId } from '../utils/modelUtils.js';
|
||||
|
||||
export class ModelMappingContentGenerator implements ContentGenerator {
|
||||
constructor(
|
||||
private readonly wrapped: ContentGenerator,
|
||||
private readonly mappings: Record<string, string>,
|
||||
) {}
|
||||
|
||||
getWrapped(): ContentGenerator {
|
||||
return this.wrapped;
|
||||
}
|
||||
|
||||
get userTier(): UserTierId | undefined {
|
||||
return this.wrapped.userTier;
|
||||
}
|
||||
|
||||
get userTierName(): string | undefined {
|
||||
return this.wrapped.userTierName;
|
||||
}
|
||||
|
||||
get paidTier(): GeminiUserTier | undefined {
|
||||
return this.wrapped.paidTier;
|
||||
}
|
||||
|
||||
private mapModel<T extends { model?: string }>(req: T): T {
|
||||
if (req.model) {
|
||||
const normalizedModel = normalizeModelId(req.model);
|
||||
if (this.mappings[normalizedModel]) {
|
||||
return {
|
||||
...req,
|
||||
model: req.model.startsWith('models/')
|
||||
? `models/${this.mappings[normalizedModel]}`
|
||||
: this.mappings[normalizedModel],
|
||||
};
|
||||
}
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
generateContent(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
role: LlmRole,
|
||||
): Promise<GenerateContentResponse> {
|
||||
return this.wrapped.generateContent(
|
||||
this.mapModel(request),
|
||||
userPromptId,
|
||||
role,
|
||||
);
|
||||
}
|
||||
|
||||
generateContentStream(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
role: LlmRole,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
return this.wrapped.generateContentStream(
|
||||
this.mapModel(request),
|
||||
userPromptId,
|
||||
role,
|
||||
);
|
||||
}
|
||||
|
||||
countTokens(request: CountTokensParameters): Promise<CountTokensResponse> {
|
||||
return this.wrapped.countTokens(this.mapModel(request));
|
||||
}
|
||||
|
||||
embedContent(request: EmbedContentParameters): Promise<EmbedContentResponse> {
|
||||
return this.wrapped.embedContent(this.mapModel(request));
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
@@ -253,9 +252,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
});
|
||||
|
||||
it('should use legacy system prompt for non-preview model', () => {
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
);
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(DEFAULT_GEMINI_MODEL);
|
||||
const prompt = getCoreSystemPrompt(mockConfig);
|
||||
expect(prompt).toContain(
|
||||
'You are an interactive CLI agent specializing in software engineering tasks.',
|
||||
@@ -270,9 +267,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
});
|
||||
|
||||
it('should include the TASK MANAGEMENT PROTOCOL in legacy prompt when task tracker is enabled', () => {
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
);
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(DEFAULT_GEMINI_MODEL);
|
||||
vi.mocked(mockConfig.isTrackerEnabled).mockReturnValue(true);
|
||||
const prompt = getCoreSystemPrompt(mockConfig);
|
||||
expect(prompt).toContain('# TASK MANAGEMENT PROTOCOL');
|
||||
@@ -667,7 +662,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
it('should include Windows-specific shell efficiency commands on win32', () => {
|
||||
mockPlatform('win32');
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
const prompt = getCoreSystemPrompt(mockConfig);
|
||||
expect(prompt).toContain(
|
||||
@@ -681,7 +676,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
it('should include generic shell efficiency commands on non-Windows', () => {
|
||||
mockPlatform('linux');
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
const prompt = getCoreSystemPrompt(mockConfig);
|
||||
expect(prompt).toContain("using commands like 'grep', 'tail', 'head'");
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
type PolicyEngineConfig,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
} from '../confirmation-bus/types.js';
|
||||
import { type MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
|
||||
import {
|
||||
SHELL_TOOL_NAME,
|
||||
@@ -794,6 +794,7 @@ export function createPolicyUpdater(
|
||||
|
||||
if (message.persist) {
|
||||
persistenceQueue = persistenceQueue.then(async () => {
|
||||
let tmpFile: string | undefined;
|
||||
try {
|
||||
const policyFile =
|
||||
message.persistScope === 'workspace'
|
||||
@@ -814,11 +815,27 @@ export function createPolicyUpdater(
|
||||
existingData = parsed as { rule?: TomlRule[] };
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isNodeError(error) || error.code !== 'ENOENT') {
|
||||
debugLogger.warn(
|
||||
`Failed to parse ${policyFile}, overwriting with new policy.`,
|
||||
error,
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
// File doesn't exist yet, start fresh
|
||||
} else if (!isNodeError(error)) {
|
||||
// TOML parse error — back up corrupted file and recover
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`Syntax error found in policy file. Backing up corrupted file to ${policyFile}.bak and starting fresh.`,
|
||||
);
|
||||
if (
|
||||
!(
|
||||
await fs.lstat(policyFile).catch(() => null)
|
||||
)?.isSymbolicLink()
|
||||
) {
|
||||
await fs
|
||||
.copyFile(policyFile, `${policyFile}.bak`)
|
||||
.catch(() => {});
|
||||
}
|
||||
existingData = {};
|
||||
} else {
|
||||
// Real filesystem error (e.g. EACCES) — throw to prevent silent failure
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,7 +883,7 @@ export function createPolicyUpdater(
|
||||
// Using a unique suffix avoids race conditions where concurrent processes
|
||||
// overwrite each other's temporary files, leading to ENOENT errors on rename.
|
||||
const tmpSuffix = crypto.randomBytes(8).toString('hex');
|
||||
const tmpFile = `${policyFile}.${tmpSuffix}.tmp`;
|
||||
tmpFile = `${policyFile}.${tmpSuffix}.tmp`;
|
||||
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
@@ -876,11 +893,37 @@ export function createPolicyUpdater(
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
await fs.rename(tmpFile, policyFile);
|
||||
try {
|
||||
await fs.rename(tmpFile, policyFile);
|
||||
} catch (renameError) {
|
||||
// Cross-device rename fails with EXDEV on some Linux mount configurations.
|
||||
// Fall back to copy + unlink which works across filesystems.
|
||||
if (
|
||||
isNodeError(renameError) &&
|
||||
(renameError.code === 'EXDEV' || renameError.code === 'EBUSY')
|
||||
) {
|
||||
if (
|
||||
(
|
||||
await fs.lstat(policyFile).catch(() => null)
|
||||
)?.isSymbolicLink()
|
||||
)
|
||||
throw renameError;
|
||||
await fs.copyFile(tmpFile, policyFile);
|
||||
await fs.unlink(tmpFile).catch(() => {});
|
||||
} else {
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Clean up orphaned tmp file if it was created
|
||||
if (tmpFile) {
|
||||
await fs.unlink(tmpFile).catch(() => {});
|
||||
}
|
||||
const reason =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to persist policy for ${toolName}`,
|
||||
`Failed to persist policy for ${toolName}: ${reason}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
createPolicyUpdater,
|
||||
@@ -16,10 +17,20 @@ import { MessageBusType } from '../confirmation-bus/types.js';
|
||||
import { Storage, AUTO_SAVED_POLICY_FILENAME } from '../config/storage.js';
|
||||
import { ApprovalMode } from './types.js';
|
||||
import { vol, fs as memfs } from 'memfs';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
|
||||
// Use memfs for all fs operations in this test
|
||||
vi.mock('node:fs/promises', () => import('memfs').then((m) => m.fs.promises));
|
||||
|
||||
/**
|
||||
* Creates a Node.js-style error with a `code` property.
|
||||
*/
|
||||
function makeNodeError(message: string, code: string): NodeJS.ErrnoException {
|
||||
const err = new Error(message) as NodeJS.ErrnoException;
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
vi.mock('../config/storage.js');
|
||||
|
||||
describe('createPolicyUpdater', () => {
|
||||
@@ -57,8 +68,6 @@ describe('createPolicyUpdater', () => {
|
||||
persist: true,
|
||||
});
|
||||
|
||||
// Policy updater handles persistence asynchronously in a promise queue.
|
||||
// We use advanceTimersByTimeAsync to yield to the microtask queue.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const fileExists = memfs.existsSync(policyFile);
|
||||
@@ -243,6 +252,147 @@ decision = "deny"
|
||||
expect(content).toContain('toolName = "test_tool"');
|
||||
});
|
||||
|
||||
it('should include error details in feedback message on persistence failure', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
vi.spyOn(fs, 'mkdir').mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test_tool',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringContaining('Permission denied'),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean up tmp file on write failure', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as never);
|
||||
vi.spyOn(fs, 'readFile').mockRejectedValue(
|
||||
makeNodeError('ENOENT: no such file or directory', 'ENOENT'),
|
||||
);
|
||||
|
||||
const mockFileHandle = {
|
||||
writeFile: vi.fn().mockRejectedValue(new Error('Disk full')),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.spyOn(fs, 'open').mockResolvedValue(mockFileHandle as never);
|
||||
vi.spyOn(fs, 'unlink').mockResolvedValue(undefined as never);
|
||||
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test_tool',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(fs.unlink).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/));
|
||||
});
|
||||
|
||||
it('should abort persistence on non-ENOENT read errors', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as never);
|
||||
vi.spyOn(fs, 'readFile').mockRejectedValue(
|
||||
makeNodeError('Permission denied', 'EACCES'),
|
||||
);
|
||||
const openSpy = vi.spyOn(fs, 'open');
|
||||
|
||||
const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test_tool',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringContaining('Permission denied'),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to copy+unlink when rename fails with EXDEV', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as never);
|
||||
vi.spyOn(fs, 'readFile').mockRejectedValue(
|
||||
makeNodeError('ENOENT: no such file or directory', 'ENOENT'),
|
||||
);
|
||||
|
||||
const mockFileHandle = {
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.spyOn(fs, 'open').mockResolvedValue(mockFileHandle as never);
|
||||
vi.spyOn(fs, 'rename').mockRejectedValue(
|
||||
makeNodeError('EXDEV: cross-device link not permitted', 'EXDEV'),
|
||||
);
|
||||
vi.spyOn(fs, 'copyFile').mockResolvedValue(undefined as never);
|
||||
vi.spyOn(fs, 'unlink').mockResolvedValue(undefined as never);
|
||||
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test_tool',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(fs.copyFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\.tmp$/),
|
||||
policyFile,
|
||||
);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/));
|
||||
});
|
||||
|
||||
it('should include modes if provided', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
@@ -295,4 +445,78 @@ modes = [ "autoEdit", "yolo" ]
|
||||
expect(ruleCount).toBe(1);
|
||||
expect(content).toContain('modes = [ "default", "autoEdit", "yolo" ]');
|
||||
});
|
||||
|
||||
it('should fall back to copy+unlink when rename fails with EBUSY', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined);
|
||||
vi.spyOn(fs, 'readFile').mockRejectedValue(
|
||||
makeNodeError('ENOENT: no such file or directory', 'ENOENT'),
|
||||
);
|
||||
|
||||
const mockFileHandle = {
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.spyOn(fs, 'open').mockResolvedValue(
|
||||
mockFileHandle as unknown as fs.FileHandle,
|
||||
);
|
||||
vi.spyOn(fs, 'rename').mockRejectedValue(
|
||||
makeNodeError('EBUSY: resource busy or locked', 'EBUSY'),
|
||||
);
|
||||
vi.spyOn(fs, 'copyFile').mockResolvedValue(undefined);
|
||||
vi.spyOn(fs, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test_tool',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(fs.copyFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\.tmp$/),
|
||||
policyFile,
|
||||
);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/));
|
||||
});
|
||||
|
||||
it('should back up corrupted TOML file and recover', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
|
||||
const dir = path.dirname(policyFile);
|
||||
memfs.mkdirSync(dir, { recursive: true });
|
||||
memfs.writeFileSync(policyFile, 'this is not valid toml ][[[');
|
||||
|
||||
const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test_tool',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('.bak'),
|
||||
);
|
||||
|
||||
expect(memfs.existsSync(policyFile)).toBe(true);
|
||||
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
|
||||
expect(content).toContain('toolName = "test_tool"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,7 +121,11 @@ describe('createPolicyUpdater', () => {
|
||||
|
||||
it('should persist mcpName to TOML', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
|
||||
vi.mocked(fs.readFile).mockRejectedValue(
|
||||
Object.assign(new Error('ENOENT: no such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
}),
|
||||
);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
const mockFileHandle = {
|
||||
@@ -142,9 +146,9 @@ describe('createPolicyUpdater', () => {
|
||||
});
|
||||
|
||||
// Wait for the async listener to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(fs.open).toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(fs.open).toHaveBeenCalled();
|
||||
});
|
||||
const [content] = mockFileHandle.writeFile.mock.calls[0] as [
|
||||
string,
|
||||
string,
|
||||
@@ -199,7 +203,11 @@ describe('createPolicyUpdater', () => {
|
||||
|
||||
it('should persist multiple rules correctly to TOML', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
|
||||
const enoentError = Object.assign(
|
||||
new Error('ENOENT: no such file or directory'),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
vi.mocked(fs.readFile).mockRejectedValue(enoentError);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
const mockFileHandle = {
|
||||
@@ -219,17 +227,17 @@ describe('createPolicyUpdater', () => {
|
||||
});
|
||||
|
||||
// Wait for the async listener to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await vi.waitFor(() => {
|
||||
expect(fs.open).toHaveBeenCalled();
|
||||
const [content] = mockFileHandle.writeFile.mock.calls[0] as [
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const parsed = toml.parse(content) as unknown as ParsedPolicy;
|
||||
|
||||
expect(fs.open).toHaveBeenCalled();
|
||||
const [content] = mockFileHandle.writeFile.mock.calls[0] as [
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const parsed = toml.parse(content) as unknown as ParsedPolicy;
|
||||
|
||||
expect(parsed.rule).toHaveLength(1);
|
||||
expect(parsed.rule![0].commandPrefix).toEqual(['echo', 'ls']);
|
||||
expect(parsed.rule).toHaveLength(1);
|
||||
expect(parsed.rule![0].commandPrefix).toEqual(['echo', 'ls']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject unsafe regex patterns', async () => {
|
||||
|
||||
@@ -73,10 +73,10 @@ export class PromptProvider {
|
||||
const desiredModel = resolveModel(
|
||||
context.config.getActiveModel(),
|
||||
context.config.getGemini31LaunchedSync?.() ?? false,
|
||||
context.config.getGemini31FlashLiteLaunchedSync?.() ?? false,
|
||||
false,
|
||||
context.config.getHasAccessToPreviewModel?.() ?? true,
|
||||
context.config,
|
||||
context.config.hasGemini35FlashGAAccess?.() ?? false,
|
||||
);
|
||||
const isModernModel = supportsModernFeatures(desiredModel);
|
||||
const activeSnippets = isModernModel ? snippets : legacySnippets;
|
||||
@@ -297,10 +297,10 @@ export class PromptProvider {
|
||||
const desiredModel = resolveModel(
|
||||
context.config.getActiveModel(),
|
||||
context.config.getGemini31LaunchedSync?.() ?? false,
|
||||
context.config.getGemini31FlashLiteLaunchedSync?.() ?? false,
|
||||
false,
|
||||
context.config.getHasAccessToPreviewModel?.() ?? true,
|
||||
context.config,
|
||||
context.config.hasGemini35FlashGAAccess?.() ?? false,
|
||||
);
|
||||
const isModernModel = supportsModernFeatures(desiredModel);
|
||||
const activeSnippets = isModernModel ? snippets : legacySnippets;
|
||||
|
||||
@@ -43,7 +43,6 @@ describe('ApprovalModeStrategy', () => {
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true),
|
||||
getGemini31Launched: vi.fn().mockResolvedValue(false),
|
||||
getGemini31FlashLiteLaunched: vi.fn().mockResolvedValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
|
||||
getUseCustomToolModel: vi.fn().mockImplementation(async () => {
|
||||
const launched = await mockConfig.getGemini31Launched();
|
||||
@@ -243,4 +242,22 @@ describe('ApprovalModeStrategy', () => {
|
||||
// Should resolve to Preview Flash (3.0) because resolveClassifierModel uses preview variants for Gemini 3
|
||||
expect(decision?.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should route to DEFAULT_GEMINI_FLASH_MODEL when hasGemini35FlashGAAccess is true and plan is approved', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(GEMINI_MODEL_ALIAS_AUTO);
|
||||
mockConfig.hasGemini35FlashGAAccess = vi.fn().mockReturnValue(true);
|
||||
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
|
||||
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(
|
||||
'/path/to/plan.md',
|
||||
);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
);
|
||||
|
||||
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,17 +48,13 @@ export class ApprovalModeStrategy implements RoutingStrategy {
|
||||
const approvalMode = config.getApprovalMode();
|
||||
const approvedPlanPath = config.getApprovedPlanPath();
|
||||
|
||||
const [
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
] = await Promise.all([
|
||||
config.getGemini31Launched(),
|
||||
config.getGemini31FlashLiteLaunched(),
|
||||
config.getUseCustomToolModel(),
|
||||
config.getHasAccessToPreviewModel(),
|
||||
]);
|
||||
const [useGemini3_1, useCustomToolModel, hasAccessToPreview] =
|
||||
await Promise.all([
|
||||
config.getGemini31Launched(),
|
||||
config.getUseCustomToolModel(),
|
||||
config.getHasAccessToPreviewModel(),
|
||||
]);
|
||||
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
|
||||
|
||||
// 1. Planning Phase: If ApprovalMode === PLAN, explicitly route to the Pro model.
|
||||
if (approvalMode === ApprovalMode.PLAN) {
|
||||
@@ -66,10 +62,10 @@ export class ApprovalModeStrategy implements RoutingStrategy {
|
||||
model,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
config,
|
||||
useGemini3_5Flash,
|
||||
);
|
||||
return {
|
||||
model: proModel,
|
||||
@@ -85,10 +81,10 @@ export class ApprovalModeStrategy implements RoutingStrategy {
|
||||
model,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
config,
|
||||
useGemini3_5Flash,
|
||||
);
|
||||
return {
|
||||
model: flashModel,
|
||||
|
||||
@@ -60,7 +60,6 @@ describe('ClassifierStrategy', () => {
|
||||
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
|
||||
getGemini31Launched: vi.fn().mockResolvedValue(false),
|
||||
getGemini31FlashLiteLaunched: vi.fn().mockResolvedValue(false),
|
||||
getUseCustomToolModel: vi.fn().mockImplementation(async () => {
|
||||
const launched = await mockConfig.getGemini31Launched();
|
||||
const authType = mockConfig.getContentGeneratorConfig().authType;
|
||||
@@ -387,6 +386,97 @@ describe('ClassifierStrategy', () => {
|
||||
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return null (bypass classifier) if history is only tool turns and request is a function response', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
|
||||
},
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
mockContext.request = [
|
||||
{ functionResponse: { name: 'tool2', response: { ok: true } } },
|
||||
];
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision).toBeNull();
|
||||
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null (bypass classifier) if history has text turns and request is a function response', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'some task' }] },
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
mockContext.request = [
|
||||
{ functionResponse: { name: 'tool', response: { ok: true } } },
|
||||
];
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision).toBeNull();
|
||||
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still route if history is only tool turns but request is text', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
|
||||
},
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
|
||||
];
|
||||
mockContext.history = history;
|
||||
mockContext.request = [{ text: 'simple task' }];
|
||||
|
||||
const mockApiResponse = {
|
||||
reasoning: 'Simple.',
|
||||
model_choice: 'flash',
|
||||
};
|
||||
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
|
||||
mockApiResponse,
|
||||
);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision).not.toBeNull();
|
||||
expect(mockBaseLlmClient.generateJson).toHaveBeenCalled();
|
||||
|
||||
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
|
||||
.calls[0][0];
|
||||
const contents = generateJsonCall.contents;
|
||||
|
||||
// History should be empty because all turns were tool turns and stripped.
|
||||
// Request should be present.
|
||||
const expectedContents = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'simple task' }],
|
||||
},
|
||||
];
|
||||
expect(contents).toEqual(expectedContents);
|
||||
});
|
||||
|
||||
describe('Gemini 3.1 and Custom Tools Routing', () => {
|
||||
it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => {
|
||||
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
|
||||
@@ -432,5 +522,27 @@ describe('ClassifierStrategy', () => {
|
||||
|
||||
expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
|
||||
});
|
||||
|
||||
it('should route to DEFAULT_GEMINI_FLASH_MODEL when hasGemini35FlashGAAccess is true', async () => {
|
||||
mockConfig.hasGemini35FlashGAAccess = vi.fn().mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
|
||||
|
||||
const mockApiResponse = {
|
||||
reasoning: 'Simple task',
|
||||
model_choice: 'flash',
|
||||
};
|
||||
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
|
||||
mockApiResponse,
|
||||
);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
mockLocalLiteRtLmClient,
|
||||
);
|
||||
|
||||
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,12 +145,22 @@ export class ClassifierStrategy implements RoutingStrategy {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO - Consider using function req/res if they help accuracy.
|
||||
// Bypass the classifier if the request is a function response.
|
||||
// Since we prune all tool turns from history, sending a function response
|
||||
// request would result in an invalid payload (missing the preceding function call).
|
||||
if (isFunctionResponse(createUserContent(context.request))) {
|
||||
debugLogger.log(
|
||||
'[Routing] Bypassing Classifier: request is FunctionResponse.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const promptId = getPromptIdWithFallback('classifier-router');
|
||||
|
||||
const historySlice = context.history.slice(-HISTORY_SEARCH_WINDOW);
|
||||
|
||||
// Filter out tool-related turns.
|
||||
// TODO - Consider using function req/res if they help accuracy.
|
||||
const cleanHistory = historySlice.filter(
|
||||
(content) => !isFunctionCall(content) && !isFunctionResponse(content),
|
||||
);
|
||||
@@ -172,21 +182,20 @@ export class ClassifierStrategy implements RoutingStrategy {
|
||||
|
||||
const reasoning = routerResponse.reasoning;
|
||||
const latencyMs = Date.now() - startTime;
|
||||
const [useGemini3_1, useGemini3_1FlashLite, useCustomToolModel] =
|
||||
await Promise.all([
|
||||
config.getGemini31Launched(),
|
||||
config.getGemini31FlashLiteLaunched(),
|
||||
config.getUseCustomToolModel(),
|
||||
]);
|
||||
const [useGemini3_1, useCustomToolModel] = await Promise.all([
|
||||
config.getGemini31Launched(),
|
||||
config.getUseCustomToolModel(),
|
||||
]);
|
||||
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
|
||||
const selectedModel = normalizeModelId(
|
||||
resolveClassifierModel(
|
||||
normalizeModelId(model),
|
||||
routerResponse.model_choice,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
config.getHasAccessToPreviewModel?.() ?? true,
|
||||
config,
|
||||
useGemini3_5Flash,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ export class DefaultStrategy implements TerminalStrategy {
|
||||
const defaultModel = resolveModel(
|
||||
config.getModel(),
|
||||
config.getGemini31LaunchedSync?.() ?? false,
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false,
|
||||
false,
|
||||
config.getHasAccessToPreviewModel?.() ?? true,
|
||||
config,
|
||||
config.hasGemini35FlashGAAccess?.() ?? false,
|
||||
);
|
||||
return {
|
||||
model: defaultModel,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user