Compare commits

..

14 Commits

Author SHA1 Message Date
Jack Wotherspoon a69d422a83 docs(resources): add guide for migrating to Antigravity CLI 2026-05-19 14:54:49 -04:00
Rajesh patel 7478859502 fix(cli): Prevent unmapped keys in Vim Normal mode from inserting text into prompt Input. (#25139)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-19 00:17:55 +00:00
Tirth Naik f09d45d133 fix(core): prevent isBinary false-positive on Windows PTY streams (#26565) 2026-05-18 23:36:23 +00:00
Daniel Weis 792654c88b refactor: decouple auto model description and configuration from releaseChannel (#27227)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-05-18 20:15:55 +00:00
Adam Weidman 6973b963ae feat(core): add LocalSessionInvocation (#26665) 2026-05-18 19:30:21 +00:00
kaluchi dc47aaa2d9 fix(core): prefer pwsh.exe over Windows PowerShell 5.1 (#25859) (#25900)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-18 17:44:05 +00:00
Dev Randalpura 7ff60809ef fix(core): add unit tests for stableStringify (#27212) 2026-05-18 17:23:05 +00:00
joshualitt 055e0f6452 fix(context): Fix snapshot recovery across sessions. (#26939) 2026-05-18 16:44:59 +00:00
Adam Weidman 9d01958cdb feat(skills): add agent-tui and tui-tester skills (#27121) 2026-05-18 15:46:02 +00:00
Adam Weidman 84423e6ea1 fix(core): enforce compile-time exhaustiveness in content-utils (#27207) 2026-05-18 15:16:49 +00:00
Adam Weidman 5611ff40e7 feat(core): add adk.agentSessionSubagentEnabled flag (#26947) 2026-05-17 17:38:34 +00:00
David Pierce 77e65c0db5 fix(core): use hasAccessToPreview for auto model resolution and fix disappearing models (#27112) 2026-05-15 17:26:59 +00:00
Anish Sabharwal b36788eb2a fix(core): add aliases and thinking config for gemini-3.1 models (#27007) 2026-05-15 16:29:15 +00:00
PROTHAM d32c9b77df Fix/web fetch ctrl c abort (#24320)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-15 16:26:03 +00:00
106 changed files with 6221 additions and 1086 deletions
+330
View File
@@ -0,0 +1,330 @@
---
name: agent-tui
description: >
Main Agents: Do NOT use this skill directly. If you need to test the TUI, invoke the `tui_tester` subagent.
Drive terminal UI (TUI) applications programmatically for testing, automation, and inspection.
Use when: automating CLI/TUI interactions, regression testing terminal apps, or verifying interactive behavior.
Also use when: user asks "what is agent-tui", "what does agent-tui do", "demo agent-tui", "show me agent-tui", "how does agent-tui work", or wants to see it in action.
---
## 🚨 CRITICAL: macOS Daemon Workaround & Gemini CLI Usage 🚨
When using `agent-tui` in this macOS environment, the default background daemonization process crashes, causing `Connection refused (os error 61)` errors.
**You MUST start the daemon manually shielded from TTY hangups before running any `agent-tui` commands.** Using `nohup` is insufficient; you must use `tmux` to provide a fully isolated pseudo-terminal.
To support parallel runs, **only restart the daemon if it is not currently running:**
```bash
# Check if daemon is alive, start it in tmux if it is not
if ! agent-tui sessions >/dev/null 2>&1; then
tmux kill-session -t agent-tui 2>/dev/null || true
agent-tui daemon stop 2>/dev/null || true
rm -f /tmp/agent-tui*
tmux new-session -d -s agent-tui 'agent-tui daemon start --foreground > /tmp/agent-tui-daemon.log 2>&1'
sleep 1
fi
```
### Session ID vs PID (Crucial for Reconnection)
When `agent-tui run` returns JSON, it includes both a `session_id` and a `pid`. The `pid` is purely informational (the OS process ID of the child command). You **do not** use the `pid` to reconnect or issue commands. You must always use the `session_id` (e.g., `--session <id>`).
If the daemon crashes (`os error 61`), the pseudo-terminal is destroyed. Even if the child `pid` survives as an orphan, you cannot reconnect to it. You must restart the daemon using the workaround above and start a completely new session.
### Testing the Gemini CLI
When testing the Gemini CLI with `agent-tui`, there are several strict requirements to ensure deterministic and accurate behavior:
1. **Build Before Running**: `agent-tui` runs the built JS files, not TypeScript. You **MUST** run `npm run build` or `npm run build:all` after making code changes and before launching the CLI with `agent-tui`.
2. **Bypass Trust Modals**: Always pass `GEMINI_CLI_TRUST_WORKSPACE=true` in the environment. If you don't, any new project-level agents or extensions will trigger a full-screen "Acknowledge and Enable" modal. This modal steals focus, swallows automation keystrokes, and causes `agent-tui wait` commands to time out.
3. **Isolated Environments**: If you need to test without real user credentials or existing agents interfering, isolate the global settings using `GEMINI_CLI_HOME=<some-test-dir>`.
4. **Testing State Deltas (e.g., Reloads)**: If you are testing features that report deltas (e.g., `/agents reload` outputting "1 new local subagent"), you **MUST**:
- Start the CLI *first* so it establishes its baseline registry.
- Use a separate shell command (outside of `agent-tui`) to write the new agent `.md`/`.toml` file.
- Use `agent-tui type` and `press` to trigger the `/agents reload` command inside the running session.
- (If you add the files before starting the CLI, they become part of the baseline and won't trigger the delta logic).
```bash
# Example: Standard isolated run (sandboxed config + bypass trust modals)
env GEMINI_CLI_TRUST_WORKSPACE=true GEMINI_CLI_HOME=test-gemini-home agent-tui run -d "$(pwd)" node packages/cli/dist/index.js
```
# Terminal Automation Mastery
## Prerequisites
- **Supported OS**: macOS or Linux (Windows not supported yet).
- **Verify install**:
```bash
agent-tui --version
```
If not installed, use one of:
```bash
# Recommended: one-line install (macOS/Linux)
curl -fsSL https://raw.githubusercontent.com/pproenca/agent-tui/master/install.sh | sh
```
```bash
# Package manager
npm i -g agent-tui
pnpm add -g agent-tui
bun add -g agent-tui
```
```bash
# Build from source
cargo install --git https://github.com/pproenca/agent-tui.git --path cli/crates/agent-tui
```
If you used the install script, ensure `~/.local/bin` is on your PATH.
## Philosophy: Why Terminal Automation Is Different
Terminal UIs are **stateless from the observer's perspective**. Unlike web browsers with a persistent DOM, terminal automation works with a constantly-refreshed character grid. This fundamental difference shapes everything:
| Web Automation | Terminal Automation |
|----------------|---------------------|
| DOM persists across interactions | Screen buffer is redrawn constantly |
| Selectors are stable | Text positions may shift |
| Query once, act many times | Must re-verify before EVERY action |
| Network events signal completion | Must detect visual stability |
**The Core Insight**: agent-tui gives you vision without memory. Each screenshot is a fresh observation. Previous state means nothing after the UI changes. This isn't a limitation—it's the nature of terminal interaction.
## Mental Model: The Feedback Loop
Think of terminal automation as a **closed-loop control system**:
```
┌──────────────────────────────────────────────┐
│ │
▼ │
OBSERVE ──► DECIDE ──► ACT ──► WAIT ──► VERIFY ───┘
│ │
│ │
└─────── NEVER skip ◄────────────────────┘
```
**Each phase is mandatory.** Skipping verification is the #1 cause of flaky automation.
### The "Fresh Eyes" Principle
Every time you need to interact with the UI:
1. **Take a fresh screenshot** — your previous one is now stale
2. **Locate your target visually** — text positions may have changed
3. **Verify the state** — the UI may have changed unexpectedly
4. **Act only when stable** — animations and loading states cause failures
This feels slower, but it's the only reliable approach. Optimistic reuse of stale state causes intermittent failures that are painful to debug.
## Critical Rules (Non-Negotiable)
> **RULE 1: Atomic Execution (No Pipelining)**
> You are FORBIDDEN from chaining commands with `&&` (e.g., `type "x" && press Enter && wait`). Modals or UI updates can intercept your keystrokes. You MUST execute one atomic action, wait, screenshot, and verify before taking the next action in a new turn.
> **RULE 2: Re-snapshot after EVERY action**
> The UI state is invalidated by any change. Always take a fresh screenshot before acting again.
> **RULE 3: Never act on unstable UI**
> If the UI is animating, loading, or transitioning, `wait --stable` first. Acting during transitions because race conditions.
> **RULE 4: Verify before claiming success**
> Use `wait "expected text" --assert` to confirm outcomes. Don't assume an action worked—prove it.
> **RULE 5: Error Recovery**
> If a `wait` command times out, DO NOT blindly restart or kill the session. Execute `screenshot` to visually diagnose what unexpected UI element (modal, error dialog, lost focus) intercepted the flow.
> **RULE 6: Clean up sessions**
> Always end with `agent-tui kill`. Orphaned sessions consume resources and can interfere with future runs.
## Decision Framework
### Which Screenshot Mode?
Use `screenshot --format json` when parsing automation output, or plain `screenshot` for human readable text.
### How to Wait?
```
What are you waiting for?
├─► Specific text to appear
│ └─► `wait "text" --assert` (fails if not found)
├─► Specific text to disappear
│ └─► `wait "text" --gone --assert`
├─► UI to stop changing (animations, loading)
│ └─► `wait --stable`
└─► Multiple conditions
└─► Chain waits sequentially
```
### How to Act?
```
What do you need to do?
├─► Type text into the terminal
│ └─► `type "text"`
├─► Send keyboard shortcuts/navigation
│ └─► `press Ctrl+C` or `press ArrowDown Enter`
```
## Core Workflow
The canonical automation loop:
```bash
# 1. START: Launch the TUI app
agent-tui run <command> [-- args...]
# 2. OBSERVE: Get current UI state
agent-tui screenshot --format json
# 3. DECIDE: Based on text, determine next action
# (This happens in your head/code)
# 4. ACT: Execute the action
agent-tui type "text"
agent-tui press Enter
# 5. WAIT: Synchronize with UI changes
agent-tui wait "Expected" --assert # or wait --stable
# 6. VERIFY: Confirm the outcome (often combined with step 5)
# If verification fails, handle the error
# 7. REPEAT: Go back to step 2 until done
# 8. CLEANUP: Always clean up
agent-tui kill
```
## Anti-Patterns (What NOT to Do)
### ❌ Acting During Animation/Loading
```bash
# WRONG: Acting immediately on dynamic UI
agent-tui run my-app
agent-tui screenshot --format json # UI might still be loading!
agent-tui type "value" # ❌ Might miss the input field
# RIGHT: Wait for stability first
agent-tui run my-app
agent-tui wait --stable # Let UI settle
agent-tui screenshot --format json # Now it's reliable
agent-tui type "value"
```
### ❌ Assuming Success Without Verification
```bash
# WRONG: Assuming the type worked
agent-tui type "value"
agent-tui press Enter
# ...proceed as if success... # ❌ What if it failed silently?
# RIGHT: Verify the outcome
agent-tui type "value"
agent-tui press Enter
agent-tui wait "Success" --assert # ✓ Proves the action worked
```
### ❌ Skipping Cleanup
```bash
# WRONG: Forgetting to kill the session
agent-tui run my-app
# ...do stuff...
# script ends # ❌ Session left running!
# RIGHT: Always clean up
agent-tui run my-app
# ...do stuff...
agent-tui kill # ✓ Clean exit
```
## Before You Start: Clarify Requirements
Before automating any TUI, gather this information:
1. **Command**: What exactly to run? (`my-app --flag` or `npm start`?)
2. **Success criteria**: What text/state indicates success?
3. **Input sequence**: What keystrokes/data to enter, in what order?
4. **Safety**: Is it safe to submit forms, delete data, etc.?
5. **Auth**: Does it need login? Test credentials?
6. **Live preview**: Does the user want to watch? (`agent-tui live start --open`)
If any of these are unclear, ask before running.
## Demo Mode: Showing What agent-tui Can Do
When a user asks what agent-tui is, wants a demo, or asks "show me how it works":
1. **Don't explain—demonstrate.** Actions speak louder than words.
2. **Use the live preview** so they can watch in real-time.
3. **Run `top`**—it's universal and shows dynamic real-time updates.
**Quick demo trigger phrases:**
- "What is agent-tui?" / "What does agent-tui do?"
- "Demo agent-tui" / "Show me agent-tui"
- "How does agent-tui work?" / "See it in action"
## Failure Recovery
| Symptom | Diagnosis | Solution |
|---------|-----------|----------|
| "Text not found" | Stale view or text moved | Re-snapshot, locate text again |
| Wait times out | UI didn't reach expected state | Check screenshot, verify expectations |
| "Daemon not running" | Daemon crashed or not started | `agent-tui daemon start` |
| Unexpected layout | Wrong terminal size | `agent-tui resize --cols 120 --rows 40` |
| Session unresponsive | App crashed or hung | `agent-tui kill`, then re-run |
| Repeated failures | Something fundamentally wrong | Stop after 3-5 attempts, ask user |
## Self-Discovery: Use --help
You don't need to memorize every flag. The CLI is self-documenting:
```bash
agent-tui --help # List all commands
agent-tui run --help # Options for 'run'
agent-tui screenshot --help # Options for 'screenshot'
agent-tui wait --help # Options for 'wait'
```
**When in doubt, ask the CLI.** This skill teaches *when* and *why* to use commands. For exact flags and syntax, `--help` is authoritative.
## Quick Reference
```bash
# Start app
agent-tui run <cmd> [-- args] # Launch TUI under control
# Observe
agent-tui screenshot # Plain text view
agent-tui screenshot --format json # Machine-readable output
# Act
agent-tui press Enter # Press key(s)
agent-tui press Ctrl+C # Keyboard shortcuts
agent-tui type "text" # Type text
# Wait/Verify
agent-tui wait "text" --assert # Wait for text, fail if not found
agent-tui wait "text" --gone --assert # Wait for text to disappear
agent-tui wait --stable # Wait for UI to stop changing
# Manage
agent-tui sessions # List active sessions
agent-tui live start --open # Start live preview
agent-tui kill # End current session
```
+65
View File
@@ -0,0 +1,65 @@
---
name: tui-tester
description: Expert guidance for testing Gemini CLI behavior and visual output using terminal automation.
---
# TUI Tester Skill
This skill provides the operational manual for verifying Gemini CLI behavioral changes and visual output using terminal automation.
## Core Responsibilities
- **Verify Behavior**: Confirm that code changes result in the expected terminal interactions.
- **Visual Validation**: Ensure the TUI renders correctly across different terminal sizes and states.
- **Regression Testing**: Use automation to prevent breaking existing interactive workflows.
## Critical Protocol
When performing TUI testing, you must adhere to these strict rules:
### 1. Initialization
**YOUR ABSOLUTE FIRST ACTION MUST BE:**
Activate the `agent-tui` skill. This provides the underlying tools needed for terminal automation.
### 2. Environment Setup (macOS / Parallel Safe)
Ensure the global daemon is running and the live preview is open:
```bash
if ! agent-tui sessions >/dev/null 2>&1; then
tmux kill-session -t agent-tui 2>/dev/null || true
agent-tui daemon stop 2>/dev/null || true
rm -f /tmp/agent-tui*
tmux new-session -d -s agent-tui 'agent-tui daemon start --foreground > /tmp/agent-tui-daemon.log 2>&1'
sleep 1
fi
agent-tui live start --open
```
### 3. Session Management
- **Session IDs**: Always use the `session_id` returned by `agent-tui run` for subsequent interactions.
- **Atomic Execution**: Execute exactly one command per turn. Do not pipeline actions.
- **The Loop**: Action -> Wait -> Screenshot -> Verify -> Next Action.
### 4. Gemini CLI Specifics
- **Build First**: Always run `npm run build` or `npm run build:all` before testing local changes.
- **Bypass Trust**: Set `GEMINI_CLI_TRUST_WORKSPACE=true` to avoid focus-stealing modals.
- **Isolate Config**: Use `GEMINI_CLI_HOME` to prevent interference with your personal settings.
## Workflow Example
```bash
# Start the CLI
env GEMINI_CLI_TRUST_WORKSPACE=true agent-tui run node packages/cli/dist/index.js
# Wait for the prompt
agent-tui wait "│" --assert
# Send a command
agent-tui type "/help"
agent-tui press Enter
# Verify output
agent-tui wait "Available Commands" --assert
```
## Error Recovery
If a wait times out, take a fresh screenshot to diagnose the state. If you see `os error 61`, restart the daemon using the tmux method.
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

+24 -13
View File
@@ -550,6 +550,24 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-flash-preview"
}
},
"gemini-3.1-pro-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview"
}
},
"gemini-3.1-pro-preview-customtools": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview-customtools"
}
},
"gemini-3.1-flash-lite-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-flash-lite-preview"
}
},
"gemini-2.5-pro": {
"extends": "chat-base-2.5",
"modelConfig": {
@@ -1019,12 +1037,6 @@ their corresponding top-level category object in your `settings.json` file.
"auto": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"releaseChannel": "stable"
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"hasAccessToPreview": false
@@ -1168,13 +1180,6 @@ their corresponding top-level category object in your `settings.json` file.
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"releaseChannel": "stable",
"requestedModels": ["auto"]
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"requestedModels": ["gemini-2.5-pro", "auto-gemini-2.5"]
@@ -1836,6 +1841,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.adk.agentSessionSubagentEnabled`** (boolean):
- **Description:** Route subagent invocations through the AgentSession
protocol instead of legacy executors.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
@@ -0,0 +1,367 @@
# Migrating to Antigravity CLI
If youre an existing Gemini CLI user looking to migrate your workflow to
Antigravity CLI, youve come to the right place. This guide will help you get
familiar and up and running quickly in Antigravity CLI.
## TL;DR
Antigravity CLI supports the majority of Gemini CLIs features. While there
isnt 100% feature parity, workflow-defining features like _Gemini CLI
extensions_ (Antigravity plugins), _Agent Skills_, _MCP servers_, _hooks_, and
_subagents_ are supported in Antigravity CLI.
On the first launch of Antigravity CLI you should see **Migration Options**
where you can choose to migrate your existing Gemini CLI extensions to the
equivalent _Antigravity Plugins_.
![](/docs/assets/migration-options.png)
_Note: Some Gemini CLI extensions can not be migrated 1:1 to Antigravity plugins
as some components (e.g. custom themes, etc) may not be supported._
For the majority of users, you can now get started using Antigravity CLI with
the workflows youve come to love in Gemini CLI. Antigravity CLI loads in the
same context files, global Agent Skills, etc, as Gemini CLI does.
If you notice something not working the way it should or how you expect, come
back to this guide for **specific details below**.
## Extensions → Antigravity Plugins
Since Gemini CLI launched extensions (a way to extend Gemini CLI by bundling and
sharing capabilities), the industry has standardized on the term **plugins**.
[Antigravity Plugins](https://antigravity.google/docs/plugins) are supported in
Antigravity CLI.
Users should be prompted on first launch of Antigravity CLI to have their
extensions automatically migrated to plugins.
There is also an explicit command that can be run from the terminal to migrate
them:
```shell
agy plugin import gemini
```
Running the above `agy plugin import` command will find each locally installed
extension and convert them to an Antigravity plugin:
```shell
[ok] conductor
- skills : skipped (not found)
- agents : skipped (not found)
✔ commands : 6 processed (converted to skills)
- mcpServers : skipped (not found)
- hooks : skipped (not found)
[ok] google-workspace
✔ skills : 6 processed
- agents : skipped (not found)
✔ commands : 4 processed (converted to skills)
✔ mcpServers : 1 processed
- hooks : skipped (not found)
```
## Context Files (Rules)
Antigravity CLI supports the same context files as Gemini CLI. It supports
reading both `GEMINI.md` and `AGENTS.md` from your workspace and allows you to
have a global context file located at `~/.gemini/GEMINI.md`.
## Agent Skills
Agent Skills work in Antigravity CLI just as they do in Gemini CLI. They can be
managed with the same `/skills` command and are also converted into slash
commands allowing them to be manually invoked.
Global skills for Gemini CLI were located in `~/.gemini/skills/` and are shared
with Antigravity CLI across all workspaces. No action is needed for global
skills, they are picked up automatically.
Workspace specific skills for Antigravity CLI are stored in `.agents/skills`
which means if you have project/workspace skills in a given project within
the`.gemini/skills` folder they will need to be moved from to `.agents/skills`
| | Gemini CLI | Antigravity CLI |
| :------------- | :----------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- |
| **Location** | Global: \~/.gemini/skills/ Workspace: .gemini/skills/ or .agents/skills/ | Global: \~/.gemini/antigravity-cli/skills/ or \~/.gemini/skills Workspace: .agents/skills/ |
| **Management** | /skills | /skills |
| **Behavior** | Skills become slash commands | Skills become slash commands |
_Note: Antigravity CLI does not currently have an equivalent to the
`gemini skills` commands for managing Agent Skills. You can create your own
skills files or use `npx skills install`._
## MCP Servers
Antigravity CLI supports both local and remote MCP servers and provides the same
`/mcp` command to manage MCP servers. The main difference from Gemini CLI is the
file location where `mcpServers` are defined.
![](/docs/assets/mcp-config.png)
Antigravity and Antigravity CLI store MCP server configurations in a distinct
`mcp_config.json` file whereas Gemini CLI stores them inline in your
`settings.json`.
_Note:_ Antigravity CLI uses `serverUrl` field instead of `url` (or deprecated
`httpUrl`) for remote MCP servers.
| | Gemini CLI | Antigravity CLI |
| :------------- | :---------------------------------------------------------------- | :------------------------------------------------------------------------------------ |
| **Location** | Global: \~/.gemini/settings.json Workspace: .gemini/settings.json | Global: \~/.gemini/antigravity-cli/mcp_config.json Workspace: .agents/mcp_config.json |
| **Management** | /mcp | /mcp |
### Local MCP server example
For local MCP servers, your `mcpServers` fields should be the same from Gemini
CLIs `settings.json` as they are in Antigravitys `mcp_config.json`.
For example, to configure the
[Firebase MCP server](https://firebase.google.com/docs/ai-assistance/mcp-server),
put the following in \~/.gemini/antigravity-cli/mcp_config.json to use across
all workspaces (global) or in .agents/mcp_config.json for a single workspace.
```json
{
"mcpServers": {
"firebase": {
"command": "npx",
"args": ["-y", "firebase-tools@latest", "mcp"]
}
}
}
```
### Remote MCP server example
For remote MCP servers you can copy your `mcpServers` field but need to switch
`url` (or deprecated `httpUrl`) to be `serverUrl` which is the field Antigravity
CLI uses.
For example, here is the
[Google Developer Knowledge MCP server](https://developers.google.com/knowledge/mcp)
configuration for your `mcp_config.json` using the `serverUrl` field for
Antigravity CLI:
```json
{
"mcpServers": {
"google-developer-knowledge": {
"serverUrl": "https://developerknowledge.googleapis.com/mcp",
"authProviderType": "google_credentials",
"oauth": {
"scopes": ["https://www.googleapis.com/auth/cloud-platform"]
},
"headers": {
"X-goog-user-project": "$GOOGLE_CLOUD_PROJECT"
}
}
}
}
```
## Subagents
[Subagents in Antigravity CLI](https://antigravity.google/docs/subagents) serve
the same fundamental purpose as in Gemini CLI, acting as **specialized expert
personas** with their _own contexts_ and _tailored toolsets_.
The most critical difference between Gemini CLI and Antigravity CLI is execution
flow:
- **Gemini CLI:** Subagent delegation is **synchronous**. When the main agent
delegates a task to a subagent, your active conversation is blocked until the
subagent completes its work and reports back.
- **Antigravity CLI:** Subagents are **asynchronous by default**. The main agent
can delegate parallel workloads or run background research without blocking
your prompt interface. You can continue conversing with the main agent while
subagents work in the background.
Antigravity CLI also has a `define_subagent` tool. This allows you to spin up
specialized assistants dynamically during an active session.
- _Example Prompt:_
`"Create a frontend specialist subagent that excels at modern website design and accessibility audits."`
### Configuring Custom Subagents
Gemini CLI defines subagents using a single Markdown file containing YAML
frontmatter. Antigravity CLI uses a directory structure separating metadata
manifest (`agent.json`) from declarative configuration (`config.yaml`).
```
agents/
└── frontend-specialist/
├── agent.json # Required marker file (metadata & routing manifest)
└── config.yaml # Declarative configuration (prompts, tools, MCP servers)
```
| Feature | Gemini CLI | Antigravity CLI |
| :--------------------- | :------------------------------ | :---------------------------------------------- |
| **Global Location** | `~/.gemini/agents/*.md` | `~/.gemini/antigravity-cli/agents/*/agent.json` |
| **Workspace Location** | `.gemini/agents/*.md` | `.agents/agents/*/agent.json` |
| **Config Format** | Single `.md` (YAML Frontmatter) | Split: `agent.json` \+ `config.yaml` |
| **Execution Mode** | Synchronous (Blocking) | Asynchronous (Non-blocking) |
| **CLI Management** | `/agents` | `/agents` |
### Example Migration: `frontend-specialist`
Below is a complete walkthrough demonstrating how to migrate a custom Gemini CLI
subagent to Antigravity CLI.
#### Gemini CLI
The subagent for Gemini CLI is configured in
`.gemini/agents/frontend-specialist.md`.
```
---
name: frontend-specialist
description: Frontend specialist in building high-performance, accessible, and
scalable web applications using modern frameworks and standards.
tools:
- read_file
- grep_search
- glob
- list_directory
- web_fetch
- google_web_search
model: inherit
---
You are a Senior Frontend Specialist Your goal is to...
<instructions>
```
#### Antigravity CLI
To migrate this agent, create a new directory named `frontend-specialist` inside
your workspace directory (`.agents/agents/frontend-specialist/`) or your global
directory.
##### 1\. Manifest File (`agent.json`)
This file acts as the discovery marker and registers the agent's metadata.
```json
{
"name": "frontend-specialist",
"description": "Frontend specialist in building high-performance, accessible, and scalable web applications.",
"configPath": {
"relativePathToConfig": "config.yaml"
}
}
```
##### 2\. Declarative Config (`config.yaml`)
This file contains the core instructions and tools available. Notice that
Antigravity CLI uses updated tool names (e.g., `read_file` becomes `view_file`).
```
custom_agent:
system_prompt_sections:
- title: "Role and Core Principles"
content: |
You are a Senior Frontend Specialist Your goal is to...<instructions>
tool_names:
- view_file # Migrated from read_file
- grep_search # Identical
- find_by_name # Migrated from glob
- list_dir # Migrated from list_directory
- read_url_content # Migrated from web_fetch
- search_web # Migrated from google_web_search
# Append standard CLI context sections for smooth execution
system_prompt_config:
include_sections:
- user_information
- messaging
- artifacts
```
#### Notable Tool Mappings During Migration
When migrating your tool lists from Gemini CLI to Antigravity CLI, ensure you
update them to match the new tool names:
| Gemini CLI Tool | Antigravity CLI Equivalent | Description |
| :------------------ | :------------------------- | :--------------------------------------- |
| `read_file` | `view_file` | Reads local file contents. |
| `grep_search` | `grep_search` | Pattern searching within files. |
| `glob` | `find_by_name` | Directory and file discovery by pattern. |
| `list_directory` | `list_dir` | Lists directory structures. |
| `web_fetch` | `read_url_content` | Fetches raw web page content. |
| `google_web_search` | `search_web` | Performs web searches. |
## Hooks
Hooks in Antigravity CLI serve the same core purpose as in Gemini CLI, allowing
you to intercept the agentic loop to customize it to your liking and inject
context, block tools, have the agent continue, etc.
Migrating from Gemini CLI involves moving to a standalone configuration file,
adopting a simplified event model, and utilizing the interactive `/hooks`
command.
| Feature | Gemini CLI | Antigravity CLI |
| :--------------------- | :--------------------------- | :------------------------------------- |
| **Global Location** | `~/.gemini/settings.json` | `~/.gemini/antigravity-cli/hooks.json` |
| **Workspace Location** | `.gemini/settings.json` | `.agents/hooks.json` |
| **Config Format** | Embedded in `settings.json` | Standalone `hooks.json` |
| **Top-Level Grouping** | By Event Type (`BeforeTool`) | By Hook Name (`my-hook-suite`) |
| **Timeout Units** | Milliseconds (e.g., `5000`) | Seconds (e.g., `5`) |
| **CLI Management** | `/hooks` | `/hooks` |
### Supported Hook Events
Here are the supported hook event types in Antigravity and their Gemini CLI
equivalent:
| Antigravity CLI Event | Gemini CLI Equivalent | Lifecycle Timing & Behavior |
| :-------------------- | :-------------------- | :------------------------------------ |
| `PreToolUse` | `BeforeTool` | Executes before a tool call. |
| `PostToolUse` | `AfterTool` | Executes after a tool call completes. |
| `PreInvocation` | `BeforeModel` | Executes before every model call. |
| `PostInvocation` | `AfterModel` | Executes after every model call . |
| `Stop` | `AfterAgent` | Executes when the agent finishes. |
_Note: Antigravity CLI currently supports fewer hook types than Gemini CLI._
If you are migrating custom hook scripts, update them to match Antigravity's
updated JSON contract:
1. **Input Naming:** Payloads use `camelCase` (e.g., `toolCall.name`,
`toolCall.args`) instead of `snake_case`.
2. **Output Flags (`PreToolUse`):** To block a tool, output
`{"allowTool": false, "denyReason": "..."}` instead of
`{"decision": "deny", "reason": "..."}`.
### Interactive Management (`/hooks`)
Rather than manually authoring JSON files and managing syntax formatting,
Antigravity CLI provides a built-in interactive terminal interface to view,
create, and manage hooks through the `/hooks` command.
![](/docs/assets/hooks-ui.png)
1. **Open the Panel:** Type **`/hooks`** into your prompt and press Enter.
2. **Browse Events:** Use the arrow keys to navigate through the available hook
events (`PreToolUse`, `PreInvocation`, etc.).
3. **Create & Edit:** Select an event to add a new regex matcher, assign shell
commands, and set timeout limits directly within the UI dialog.
4. **Toggle State:** Press **`e`** on any configured hook to instantly toggle it
enabled or disabled without losing your configuration data.
5. **Save & Apply:** Changes made in the dialog are automatically validated and
saved back to your `hooks.json` file.
## Migration FAQs
**Q: Will AGY CLI work in headless mode?**
_A:_ Yes, just run `agy -p “Your awesome prompt”`
**Q: Will my chat history be migrated? How will my user context be preserved?**
_A:_ No, Gemini CLI chat sessions and history will not be migrated.
**Q: Will the policies I installed in Gemini CLI be migrated to AGY CLI?**
_A:_ No, Antigravity does not use the same policy engine as Gemini CLI. It uses
its own permissions system which lets you set tools and commands as
`allow|deny|ask` in your Antigravity CLI `settings.json` file.
+4
View File
@@ -261,6 +261,10 @@
"label": "Resources",
"items": [
{ "label": "FAQ", "slug": "docs/resources/faq" },
{
"label": "Migrating to Antigravity CLI",
"slug": "docs/resources/migrating-to-antigravity-cli"
},
{
"label": "Quota and pricing",
"slug": "docs/resources/quota-and-pricing"
+232
View File
@@ -0,0 +1,232 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { FinishReason, GenerateContentResponse } from '@google/genai';
import type { FakeResponse, HistoryTurn } from '@google/gemini-cli-core';
describe('Context Management Fidelity E2E', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
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: 50000 },
};
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 massivePayload = 'X'.repeat(50000);
const traceDir = path.join(rig.testDir!, 'traces');
fs.mkdirSync(traceDir, { recursive: true });
const traceLog = path.join(traceDir, 'trace.log');
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 runMocks: FakeResponse[] = [
streamResponse('Ack 1'),
streamResponse('Ack 2'),
streamResponse('Ack 3'),
streamResponse('Ack 4'),
streamResponse('Ack 5'),
];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}
// Turn 1: Initial massive payload to put pressure
await rig.run({
args: [
'--debug',
'--fake-responses-non-strict',
setupResponses('resp1.json', runMocks),
],
stdin: 'Turn 1: ' + massivePayload,
env: commonEnv,
});
// Turn 2: Another turn, resuming Turn 1
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 2: ' + massivePayload,
env: commonEnv,
});
// Turn 3: Third turn to force GC, resuming Turn 2
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
],
stdin: 'Turn 3: ' + massivePayload,
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;
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),
);
// 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.id && t.id.length === 32,
); // deriveStableId produces 32-char hex
expect(syntheticTurns.length).toBeGreaterThan(0);
const syntheticTurnsAfter = contextAfterResume!.filter(
(t: HistoryTurn) => 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);
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { FinishReason, GenerateContentResponse } from '@google/genai';
import type { FakeResponse } from '@google/gemini-cli-core';
describe('Context Management Resume E2E', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should preserve and utilize GC snapshot boundaries when resuming a session', async () => {
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: 50000 },
};
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('resume-gc-snapshot', {
settings: {
experimental: {
stressTestProfile: true,
},
},
});
const massivePayload = 'X'.repeat(40000);
const logFile = 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 commonEnv = {
GEMINI_API_KEY: 'mock-key',
GEMINI_DEBUG_LOG_FILE: logFile,
GEMINI_CONTEXT_TRACE_DIR: traceDir,
};
// Provide a massive pool of responses to prevent exhaustion
const runMocks: FakeResponse[] = [streamResponse('Acknowledged block.')];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}
// Use stdin for the massive payload to avoid ENAMETOOLONG on Windows
await rig.run({
args: [
'--debug',
'--fake-responses-non-strict',
setupResponses('resp1.json', runMocks),
],
stdin: 'Turn 1: ' + massivePayload,
env: commonEnv,
});
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 2: ' + massivePayload,
env: commonEnv,
});
const result3 = await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
'continue',
],
env: commonEnv,
});
expect(result3).toContain('Acknowledged block');
const traces = fs.readFileSync(traceLog, 'utf-8');
expect(traces).toContain('Hitting Synchronous Pressure Barrier');
expect(traces).toContain('GC Triggered.');
});
});
+4 -5
View File
@@ -22,7 +22,6 @@ import {
getDisplayString,
AuthType,
ToolConfirmationOutcome,
getChannelFromVersion,
getAutoModelDescription,
} from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
@@ -272,8 +271,6 @@ export function buildAvailableModels(
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
const releaseChannel = getChannelFromVersion(config.clientVersion);
// --- DYNAMIC PATH ---
if (
config.getExperimentalDynamicModelConfiguration?.() === true &&
@@ -284,7 +281,6 @@ export function buildAvailableModels(
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
releaseChannel,
});
return {
@@ -298,7 +294,10 @@ export function buildAvailableModels(
{
value: GEMINI_MODEL_ALIAS_AUTO,
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
description: getAutoModelDescription(releaseChannel, useGemini31),
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
),
},
];
+8
View File
@@ -103,6 +103,7 @@ export interface CliArgs {
useWriteTodos: boolean | undefined;
outputFormat: string | undefined;
fakeResponses: string | undefined;
fakeResponsesNonStrict?: string | undefined;
recordResponses: string | undefined;
startupMessages?: string[];
rawOutput: boolean | undefined;
@@ -474,6 +475,12 @@ export async function parseArguments(
description: 'Path to a file with fake model responses for testing.',
hidden: true,
})
.option('fake-responses-non-strict', {
type: 'string',
description:
'Path to a file with fake model responses for testing (non-strict mode).',
hidden: true,
})
.option('record-responses', {
type: 'string',
description: 'Path to a file to record model responses for testing.',
@@ -1074,6 +1081,7 @@ export async function loadCliConfig(
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
adk: settings.experimental?.adk,
fakeResponses: argv.fakeResponses,
fakeResponsesNonStrict: argv.fakeResponsesNonStrict,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
billing: settings.billing,
@@ -571,6 +571,18 @@ describe('SettingsSchema', () => {
expect(agentSessionNoninteractiveEnabled.description).toBe(
'Enable non-interactive agent sessions.',
);
const agentSessionSubagentEnabled =
adk.properties.agentSessionSubagentEnabled;
expect(agentSessionSubagentEnabled).toBeDefined();
expect(agentSessionSubagentEnabled.type).toBe('boolean');
expect(agentSessionSubagentEnabled.category).toBe('Experimental');
expect(agentSessionSubagentEnabled.default).toBe(false);
expect(agentSessionSubagentEnabled.requiresRestart).toBe(true);
expect(agentSessionSubagentEnabled.showInDialog).toBe(false);
expect(agentSessionSubagentEnabled.description).toBe(
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
);
});
});
+10
View File
@@ -2194,6 +2194,16 @@ const SETTINGS_SCHEMA = {
'Enable the agent session implementation for the interactive CLI.',
showInDialog: false,
},
agentSessionSubagentEnabled: {
type: 'boolean',
label: 'Agent Session Subagent Enabled',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
showInDialog: false,
},
},
},
enableAgents: {
@@ -14,7 +14,6 @@ import { type HistoryItem } from '../types.js';
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
import { revertFileChanges } from '../utils/rewindFileOps.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import type { Content } from '@google/genai';
import {
checkExhaustive,
coreEvents,
@@ -58,7 +57,7 @@ async function rewindConversation(
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
const clientHistory = convertSessionToClientHistory(conversation.messages);
client.setHistory(clientHistory as Content[]);
client.setHistory(clientHistory);
// Reset context manager as we are rewinding history
await context.services.agentContext?.config
@@ -154,6 +154,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
vimEnabled={vimEnabled}
vimMode={vimMode}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
@@ -4898,6 +4898,60 @@ describe('InputPrompt', () => {
unmount();
});
it('should NOT open shortcuts help with ? in vim NORMAL mode', async () => {
const setShortcutsHelpVisible = vi.fn();
const vimHandleInput = vi.fn().mockReturnValue(true);
const { stdin, unmount } = await renderWithProviders(
<TestInputPrompt
{...props}
vimEnabled={true}
vimMode="NORMAL"
vimHandleInput={vimHandleInput}
/>,
{
uiActions: { setShortcutsHelpVisible },
},
);
await act(async () => {
stdin.write('?');
});
expect(setShortcutsHelpVisible).not.toHaveBeenCalled();
expect(vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
unmount();
});
it('should open shortcuts help with ? in vim INSERT mode', async () => {
const setShortcutsHelpVisible = vi.fn();
const vimHandleInput = vi.fn().mockReturnValue(false);
const { stdin, unmount } = await renderWithProviders(
<TestInputPrompt
{...props}
vimEnabled={true}
vimMode="INSERT"
vimHandleInput={vimHandleInput}
/>,
{
uiActions: { setShortcutsHelpVisible },
},
);
await act(async () => {
stdin.write('?');
});
await waitFor(() => {
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
});
unmount();
});
it.each([
{
name: 'terminal paste event occurs',
+14 -2
View File
@@ -92,6 +92,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import type { VimMode } from '../contexts/VimModeContext.js';
const SCROLLBAR_GUTTER_WIDTH = 1;
@@ -126,6 +127,8 @@ export interface InputPromptProps {
onEscapePromptChange?: (showPrompt: boolean) => void;
onSuggestionsVisibilityChange?: (visible: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
vimEnabled?: boolean;
vimMode?: VimMode;
isEmbeddedShellFocused?: boolean;
setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState;
@@ -214,6 +217,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onEscapePromptChange,
onSuggestionsVisibilityChange,
vimHandleInput,
vimEnabled,
vimMode,
isEmbeddedShellFocused,
setQueueErrorMessage,
streamingState,
@@ -859,7 +864,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (shortcutsHelpVisible) {
if (key.sequence === '?' && key.insertable) {
if (
key.sequence === '?' &&
key.insertable &&
(!vimEnabled || vimMode === 'INSERT')
) {
setShortcutsHelpVisible(false);
buffer.handleInput(key);
return true;
@@ -879,7 +888,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
key.sequence === '?' &&
key.insertable &&
!shortcutsHelpVisible &&
buffer.text.length === 0
buffer.text.length === 0 &&
(!vimEnabled || vimMode === 'INSERT')
) {
setShortcutsHelpVisible(true);
return true;
@@ -1374,6 +1384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState,
resetEscapeState,
vimHandleInput,
vimEnabled,
vimMode,
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
@@ -34,6 +34,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getAutoModelDescription: (
hasAccessToPreview: boolean,
useGemini3_1?: boolean,
) =>
`Auto Model Description (preview: ${hasAccessToPreview}, 3.1: ${useGemini3_1})`,
getDisplayString: (val: string) => mockGetDisplayString(val),
logModelSlashCommand: (config: Config, event: ModelSlashCommandEvent) =>
mockLogModelSlashCommand(config, event),
+5 -13
View File
@@ -26,7 +26,6 @@ import {
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isProModel,
getChannelFromVersion,
getAutoModelDescription,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -66,7 +65,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || GEMINI_MODEL_ALIAS_AUTO;
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel() ?? false;
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
@@ -122,12 +121,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
{ isActive: true },
);
const releaseChannel = useMemo(
() => getChannelFromVersion(config?.clientVersion ?? ''),
[config?.clientVersion],
);
const mainOptions = useMemo(() => {
// --- DYNAMIC PATH ---
if (
@@ -142,7 +135,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
releaseChannel,
});
const list = allOptions
@@ -170,7 +162,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
{
value: GEMINI_MODEL_ALIAS_AUTO,
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
description: getAutoModelDescription(releaseChannel, useGemini31),
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
),
key: GEMINI_MODEL_ALIAS_AUTO,
},
{
@@ -192,7 +187,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useGemini31FlashLite,
useCustomToolModel,
hasAccessToProModel,
releaseChannel,
]);
const manualOptions = useMemo(() => {
@@ -209,7 +203,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
releaseChannel,
});
return allOptions
@@ -302,7 +295,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useGemini31FlashLite,
useCustomToolModel,
hasAccessToProModel,
releaseChannel,
config,
]);
@@ -12,7 +12,6 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
getShellConfiguration,
isWindows,
type WorktreeSettings,
} from '@google/gemini-cli-core';
@@ -22,7 +21,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getShellConfiguration: vi.fn(),
isWindows: vi.fn(),
};
});
@@ -45,7 +43,6 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
};
});
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
const isWindowsMock = vi.mocked(isWindows);
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
@@ -104,11 +101,6 @@ describe('<SessionSummaryDisplay />', () => {
};
beforeEach(() => {
getShellConfigurationMock.mockReturnValue({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
});
isWindowsMock.mockReturnValue(false);
});
@@ -173,11 +165,6 @@ describe('<SessionSummaryDisplay />', () => {
it('renders a standard UUID-formatted session ID in the footer (powershell) on Windows', async () => {
isWindowsMock.mockReturnValue(true);
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
});
const uuidSessionId = '1234-abcd-5678-efgh';
const { lastFrame, unmount } = await renderWithMockedStats(
@@ -192,11 +179,7 @@ describe('<SessionSummaryDisplay />', () => {
});
it('sanitizes a malicious session ID in the footer (powershell)', async () => {
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
});
isWindowsMock.mockReturnValue(true);
const maliciousSessionId = "'; rm -rf / #";
const { lastFrame, unmount } = await renderWithMockedStats(
@@ -10,8 +10,8 @@ import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import {
escapeShellArg,
getShellConfiguration,
isWindows,
type ShellType,
} from '@google/gemini-cli-core';
interface SessionSummaryDisplayProps {
@@ -23,7 +23,7 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
}) => {
const { stats } = useSessionStats();
const config = useConfig();
const { shell } = getShellConfiguration();
const shell: ShellType = isWindows() ? 'powershell' : 'bash';
const worktreeSettings = config.getWorktreeSettings();
@@ -194,14 +194,16 @@ describe('convertSessionToHistoryFormats', () => {
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(2);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Hello' }],
});
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [{ text: 'Hi there' }],
});
expect(clientHistory.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'Hello' }],
},
{
role: 'model',
parts: [{ text: 'Hi there' }],
},
]);
});
it('should convert thinking tokens (thoughts) to thinking history items', () => {
@@ -254,10 +256,12 @@ describe('convertSessionToHistoryFormats', () => {
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(1);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Expanded content' }],
});
expect(clientHistory.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'Expanded content' }],
},
]);
});
it('should filter out slash commands from client history but keep in UI', () => {
@@ -316,33 +320,35 @@ describe('convertSessionToHistoryFormats', () => {
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'What time is it?' }],
});
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [
{
functionCall: {
name: 'get_time',
args: {},
id: 'call_1',
expect(clientHistory.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'What time is it?' }],
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'get_time',
args: {},
id: 'call_1',
},
},
},
],
});
expect(clientHistory[2]).toEqual({
role: 'user',
parts: [
{
functionResponse: {
id: 'call_1',
name: 'get_time',
response: { output: '12:00' },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'get_time',
response: { output: '12:00' },
id: 'call_1',
},
},
},
],
});
],
},
]);
});
});
+10 -4
View File
@@ -12,22 +12,28 @@ import {
convertSessionToClientHistory,
uiTelemetryService,
loadConversationRecord,
type Config,
type ResumedSessionData,
} from '@google/gemini-cli-core';
import type {
HistoryTurn,
Config,
ResumedSessionData,
} from '@google/gemini-cli-core';
import {
convertSessionToHistoryFormats,
type SessionInfo,
} from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
export { convertSessionToHistoryFormats };
import type { Part } from '@google/genai';
export const useSessionBrowser = (
config: Config,
onLoadHistory: (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
clientHistory: Array<
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
>,
resumedSessionData: ResumedSessionData,
) => Promise<void>,
) => {
@@ -13,6 +13,7 @@ import type {
ResumedSessionData,
ConversationRecord,
MessageRecord,
HistoryTurn,
} from '@google/gemini-cli-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { HistoryItemWithoutId } from '../types.js';
@@ -527,10 +528,12 @@ describe('useSessionResume', () => {
// Should only have the non-slash-command message
expect(clientHistory).toHaveLength(1);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Regular message' }],
});
expect(clientHistory.map((h: HistoryTurn) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'Regular message' }],
},
]);
// But UI history should have both
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
@@ -7,14 +7,17 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
coreEvents,
type Config,
type ResumedSessionData,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import type {
HistoryTurn,
Config,
ResumedSessionData,
} from '@google/gemini-cli-core';
import type { HistoryItemWithoutId } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
import type { Part } from '@google/genai';
interface UseSessionResumeParams {
config: Config;
@@ -54,7 +57,9 @@ export function useSessionResume({
const loadHistoryForResume = useCallback(
async (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
clientHistory: Array<
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
>,
resumedData: ResumedSessionData,
) => {
// Wait for the client.
+74
View File
@@ -2258,6 +2258,80 @@ describe('useVim hook', async () => {
});
});
describe('should handle unmapped keys in Normal mode', () => {
type UnmappedKeyCase = {
char: string;
insertable: boolean;
};
it.each<UnmappedKeyCase>([
{ char: 'm', insertable: true },
{ char: 'n', insertable: true },
{ char: 'p', insertable: true },
{ char: 'q', insertable: true },
{ char: 's', insertable: true },
{ char: 'v', insertable: true },
{ char: 'y', insertable: true },
{ char: 'z', insertable: true },
{ char: 'H', insertable: true },
{ char: 'J', insertable: true },
{ char: 'K', insertable: true },
{ char: 'L', insertable: true },
{ char: 'M', insertable: true },
{ char: 'N', insertable: true },
{ char: 'P', insertable: true },
{ char: 'Q', insertable: true },
{ char: 'R', insertable: true },
{ char: 'S', insertable: true },
{ char: 'U', insertable: true },
{ char: 'V', insertable: true },
{ char: 'Y', insertable: true },
{ char: 'Z', insertable: true },
{ char: '/', insertable: true },
{ char: '#', insertable: true },
{ char: '%', insertable: true },
{ char: '&', insertable: true },
{ char: "'", insertable: true },
{ char: '(', insertable: true },
{ char: ')', insertable: true },
{ char: '*', insertable: true },
{ char: '+', insertable: true },
{ char: '-', insertable: true },
{ char: '/', insertable: true },
{ char: ':', insertable: true },
{ char: '<', insertable: true },
{ char: '=', insertable: true },
{ char: '>', insertable: true },
{ char: '@', insertable: true },
{ char: '[', insertable: true },
{ char: '\\', insertable: true },
{ char: ']', insertable: true },
{ char: '_', insertable: true },
{ char: '`', insertable: true },
{ char: '{', insertable: true },
{ char: '|', insertable: true },
{ char: '}', insertable: true },
])(
'$char: should be swallowed and do nothing in Normal mode',
async ({ char, insertable }) => {
const { result } = await renderVimHook();
exitInsertMode(result);
let handled = false;
act(() => {
handled = result.current.handleInput(
createKey({ sequence: char, name: char, insertable }),
);
});
expect(handled).toBe(true);
expect(mockVimContext.setVimMode).not.toHaveBeenCalledWith('INSERT');
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
},
);
});
describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', async () => {
it('df{char}: executes delete-to-char, not a dangling operator', async () => {
const { result } = await renderVimHook();
+5
View File
@@ -1486,6 +1486,11 @@ 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) {
return true;
}
// Not handled by vim so allow other handlers to process it.
return false;
}
+4 -1
View File
@@ -93,13 +93,16 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
// References are converted to text for the model
result.push({ text: part.text });
break;
default:
default: {
const _exhaustiveCheck: never = part;
void _exhaustiveCheck;
debugLogger.warn(
`Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`,
);
// Serialize unknown ContentPart variants instead of dropping them
result.push({ text: JSON.stringify(part) });
break;
}
}
}
return result;
@@ -9,16 +9,21 @@ import {
supersedeStaleSnapshots,
SNAPSHOT_SUPERSEDED_PLACEHOLDER,
} from './snapshotSuperseder.js';
import type { GeminiChat } from '../../core/geminiChat.js';
import type { GeminiChat, HistoryTurn } from '../../core/geminiChat.js';
import type { Content } from '@google/genai';
import { randomUUID } from 'node:crypto';
/** Builds a minimal mock GeminiChat around a mutable history array. */
function createMockChat(history: Content[]): GeminiChat {
const getTurns = () => history.map((c) => ({ id: randomUUID(), content: c }));
return {
getHistory: vi.fn(() => [...history]),
setHistory: vi.fn((newHistory: readonly Content[]) => {
getHistoryTurns: vi.fn(() => getTurns()),
setHistory: vi.fn((newHistory: ReadonlyArray<Content | HistoryTurn>) => {
history.length = 0;
history.push(...newHistory);
for (const item of newHistory) {
history.push('content' in item ? item.content : item);
}
}),
} as unknown as GeminiChat;
}
@@ -14,8 +14,8 @@
* model call so the model only ever sees the most recent snapshot in full.
*/
import type { GeminiChat } from '../../core/geminiChat.js';
import type { Content, Part } from '@google/genai';
import type { GeminiChat, HistoryTurn } from '../../core/geminiChat.js';
import type { Part } from '@google/genai';
import { debugLogger } from '../../utils/debugLogger.js';
const TAKE_SNAPSHOT_TOOL_NAME = 'take_snapshot';
@@ -39,7 +39,7 @@ export const SNAPSHOT_SUPERSEDED_PLACEHOLDER =
* Uses {@link GeminiChat.setHistory} to apply the modified history.
*/
export function supersedeStaleSnapshots(chat: GeminiChat): void {
const history = chat.getHistory();
const history = chat.getHistoryTurns();
// Locate all (contentIndex, partIndex) tuples for take_snapshot responses.
const snapshotLocations: Array<{
@@ -48,7 +48,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
}> = [];
for (let i = 0; i < history.length; i++) {
const parts = history[i].parts;
const parts = history[i].content.parts;
if (!parts) continue;
for (let j = 0; j < parts.length; j++) {
const part = parts[j];
@@ -71,7 +71,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
const staleLocations = snapshotLocations.slice(0, -1);
const needsUpdate = staleLocations.some(({ contentIdx, partIdx }) => {
const output = getResponseOutput(
history[contentIdx].parts![partIdx].functionResponse?.response,
history[contentIdx].content.parts![partIdx].functionResponse?.response,
);
return !output.includes(SNAPSHOT_SUPERSEDED_PLACEHOLDER);
});
@@ -81,15 +81,18 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
}
// Shallow-copy the history and replace stale snapshots.
const newHistory: Content[] = history.map((content) => ({
...content,
parts: content.parts ? [...content.parts] : undefined,
const newHistory: HistoryTurn[] = history.map((turn) => ({
id: turn.id,
content: {
...turn.content,
parts: turn.content.parts ? [...turn.content.parts] : undefined,
},
}));
let replacedCount = 0;
for (const { contentIdx, partIdx } of staleLocations) {
const originalPart = newHistory[contentIdx].parts![partIdx];
const originalPart = newHistory[contentIdx].content.parts![partIdx];
if (!originalPart.functionResponse) continue;
// Check if already superseded
@@ -106,7 +109,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
},
};
newHistory[contentIdx].parts![partIdx] = replacementPart;
newHistory[contentIdx].content.parts![partIdx] = replacementPart;
replacedCount++;
}
@@ -756,12 +756,19 @@ describe('LocalAgentExecutor', () => {
expect(startHistory).toBeDefined();
expect(startHistory).toHaveLength(2);
const history = startHistory!;
// Perform checks on defined objects to satisfy TS
const firstPart = startHistory?.[0]?.parts?.[0];
const firstPart =
'content' in history[0]
? history[0].content.parts?.[0]
: history[0].parts?.[0];
expect(firstPart?.text).toBe('Goal: TestGoal');
const secondPart = startHistory?.[1]?.parts?.[0];
const secondPart =
'content' in history[1]
? history[1].content.parts?.[0]
: history[1].parts?.[0];
expect(secondPart?.text).toBe('OK, starting on TestGoal.');
});
@@ -3601,7 +3608,14 @@ describe('LocalAgentExecutor', () => {
expect(mockCompress).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
// History turns are now wrapped with IDs
expect(mockSetHistory).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({ role: 'user' }),
}),
]),
);
});
it('should pass hasFailedCompressionAttempt=true to compression after a failure', async () => {
@@ -3706,7 +3720,14 @@ describe('LocalAgentExecutor', () => {
expect(mockCompress.mock.calls[2][5]).toBe(false);
expect(mockSetHistory).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
// History turns are now wrapped with IDs
expect(mockSetHistory).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({ role: 'user' }),
}),
]),
);
});
});
+10 -2
View File
@@ -919,12 +919,20 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.hasFailedCompressionAttempt = true;
} else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
if (newHistory) {
chat.setHistory(newHistory);
const turns = newHistory.map((c) => ({
id: randomUUID(),
content: c,
}));
chat.setHistory(turns);
this.hasFailedCompressionAttempt = false;
}
} else if (info.compressionStatus === CompressionStatus.CONTENT_TRUNCATED) {
if (newHistory) {
chat.setHistory(newHistory);
const turns = newHistory.map((c) => ({
id: randomUUID(),
content: c,
}));
chat.setHistory(turns);
// Do NOT reset hasFailedCompressionAttempt.
// We only truncated content because summarization previously failed.
// We want to keep avoiding expensive summarization calls.
@@ -121,7 +121,7 @@ describe('LocalSubagentInvocation', () => {
);
});
it('should truncate long input values', () => {
it('should not truncate long input values', () => {
const longTask = 'A'.repeat(100);
const params = { task: longTask };
const invocation = new LocalSubagentInvocation(
@@ -131,13 +131,12 @@ describe('LocalSubagentInvocation', () => {
mockMessageBus,
);
const description = invocation.getDescription();
// Default INPUT_PREVIEW_MAX_LENGTH is 50
expect(description).toBe(
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(50)} }`,
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`,
);
});
it('should truncate the overall description if it exceeds the limit', () => {
it('should not truncate the overall description', () => {
// Create a definition and inputs that result in a very long description
const longNameDef: LocalAgentDefinition = {
...testDefinition,
@@ -154,8 +153,7 @@ describe('LocalSubagentInvocation', () => {
mockMessageBus,
);
const description = invocation.getDescription();
// Default DESCRIPTION_MAX_LENGTH is 200
expect(description.length).toBe(200);
expect(description.length).toBeGreaterThan(300);
expect(
description.startsWith(
"Running subagent 'VeryLongAgentNameThatTakesUpSpace'",
+35 -42
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -35,9 +35,6 @@ import {
} from '../utils/agent-sanitization-utils.js';
import { debugLogger } from '../utils/debugLogger.js';
const INPUT_PREVIEW_MAX_LENGTH = 50;
const DESCRIPTION_MAX_LENGTH = 200;
/**
* Represents a validated, executable instance of a subagent tool.
*
@@ -80,14 +77,10 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
*/
getDescription(): string {
const inputSummary = Object.entries(this.params)
.map(
([key, value]) =>
`${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`,
)
.map(([key, value]) => `${key}: ${String(value)}`)
.join(', ');
const description = `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
return description.slice(0, DESCRIPTION_MAX_LENGTH);
return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
}
private publishActivity(activity: SubagentActivityItem): void {
@@ -168,8 +161,11 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
const args = JSON.stringify(
sanitizeToolArgs(activity.data['args']),
);
const callId = activity.data['callId']
? String(activity.data['callId'])
: randomUUID();
recentActivity.push({
id: randomUUID(),
id: callId,
type: 'tool_call',
content: name,
displayName,
@@ -186,23 +182,28 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
break;
}
case 'TOOL_CALL_END': {
const name = String(activity.data['name']);
const data = activity.data['data'];
const isError = isToolActivityError(data);
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === name &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
updated = true;
const callId = activity.data['id']
? String(activity.data['id'])
: undefined;
this.publishActivity(recentActivity[i]);
break;
if (callId) {
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
updated = true;
this.publishActivity(recentActivity[i]);
break;
}
}
}
break;
@@ -218,31 +219,23 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
errorType === SubagentActivityErrorType.REJECTED ||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
const toolName = activity.data['name']
? String(activity.data['name'])
const callId = activity.data['callId']
? String(activity.data['callId'])
: undefined;
if (toolName && (isCancellation || isRejection)) {
if (callId) {
const targetStatus =
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR;
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = SubagentState.CANCELLED;
updated = true;
break;
}
}
} else if (toolName) {
// Mark non-rejection/non-cancellation errors as 'error'
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = SubagentState.ERROR;
recentActivity[i].status = targetStatus;
updated = true;
break;
}
@@ -0,0 +1,817 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
AgentTerminateMode,
SubagentActivityErrorType,
SUBAGENT_REJECTED_ERROR_PREFIX,
SUBAGENT_CANCELLED_ERROR_MESSAGE,
type SubagentProgress,
type LocalAgentDefinition,
type AgentInputs,
type SubagentActivityEvent,
} from './types.js';
import { LocalSessionInvocation } from './local-session-invocation.js';
import { LocalSubagentSession } from './local-subagent-protocol.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
vi.mock('./local-subagent-protocol.js');
const MockLocalSubagentSession = vi.mocked(LocalSubagentSession);
let capturedActivityCallback:
| ((activity: SubagentActivityEvent) => void)
| undefined;
const testDefinition: LocalAgentDefinition = {
kind: 'local',
name: 'MockAgent',
description: 'A mock agent for testing.',
inputConfig: {
inputSchema: {
type: 'object',
properties: { task: { type: 'string' } },
},
},
modelConfig: { model: 'test-model', generateContentConfig: {} },
runConfig: { maxTimeMinutes: 1 },
promptConfig: { systemPrompt: 'test' },
};
function setupMockSession(config: {
output?: { result: string; terminate_reason: AgentTerminateMode };
error?: Error;
}) {
const mockSession = {
send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }),
getResult: config.error
? vi.fn().mockRejectedValue(config.error)
: vi.fn().mockResolvedValue(
config.output ?? {
result: 'done',
terminate_reason: AgentTerminateMode.GOAL,
},
),
abort: vi.fn(),
subscribe: vi.fn().mockReturnValue(vi.fn()),
};
MockLocalSubagentSession.mockImplementation(
(
_def: LocalAgentDefinition,
_ctx: AgentLoopContext,
_bus: MessageBus,
rawCallback?: (activity: SubagentActivityEvent) => void,
) => {
capturedActivityCallback = rawCallback;
return mockSession as unknown as LocalSubagentSession;
},
);
return mockSession;
}
describe('LocalSessionInvocation', () => {
let mockContext: AgentLoopContext;
let mockMessageBus: MessageBus;
beforeEach(() => {
vi.clearAllMocks();
capturedActivityCallback = undefined;
mockContext = makeFakeConfig() as unknown as AgentLoopContext;
mockMessageBus = createMockMessageBus();
});
it('should pass the messageBus to the parent constructor', () => {
setupMockSession({});
const params = { task: 'Analyze data' };
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
expect(
(invocation as unknown as { messageBus: MessageBus }).messageBus,
).toBe(mockMessageBus);
});
describe('getDescription', () => {
it('should format the description with inputs', () => {
setupMockSession({});
const params = { task: 'Analyze data', priority: 5 };
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const description = invocation.getDescription();
expect(description).toBe(
"Running subagent 'MockAgent' with inputs: { task: Analyze data, priority: 5 }",
);
});
it('should not truncate long input values', () => {
setupMockSession({});
const longTask = 'A'.repeat(100);
const params = { task: longTask };
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const description = invocation.getDescription();
expect(description).toBe(
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`,
);
});
it('should not truncate the overall description', () => {
setupMockSession({});
const longNameDef: LocalAgentDefinition = {
...testDefinition,
name: 'VeryLongAgentNameThatTakesUpSpace',
};
const params: AgentInputs = {};
for (let i = 0; i < 20; i++) {
params[`input${i}`] = `value${i}`;
}
const invocation = new LocalSessionInvocation(
longNameDef,
mockContext,
params,
mockMessageBus,
);
const description = invocation.getDescription();
expect(description.length).toBeGreaterThan(300);
});
});
describe('execute', () => {
it('should create session and run successfully', async () => {
const mockOutput = {
result: 'Analysis complete.',
terminate_reason: AgentTerminateMode.GOAL,
};
const mockSession = setupMockSession({ output: mockOutput });
const params = { query: 'Execute task' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
expect(MockLocalSubagentSession).toHaveBeenCalledWith(
testDefinition,
mockContext,
mockMessageBus,
expect.any(Function),
);
expect(mockSession.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'Execute task' }] },
});
expect(result.llmContent).toEqual([
{
text: expect.stringContaining(
"Subagent 'MockAgent' finished.\nTermination Reason: GOAL\nResult:\nAnalysis complete.",
),
},
]);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe('completed');
expect(display.result).toBe('Analysis complete.');
expect(display.terminateReason).toBe(AgentTerminateMode.GOAL);
});
it('should stream THOUGHT_CHUNK activity', async () => {
const mockSession = setupMockSession({});
const params = { query: 'think' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
// Wait for send to be called so the activity callback is wired
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
// Emit a thought chunk via captured callback
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'Analyzing...' },
});
await executePromise;
// Find an updateOutput call containing the thought
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasThought = progressCalls.some(
(p) =>
p.recentActivity &&
p.recentActivity.some(
(a) => a.type === 'thought' && a.content === 'Analyzing...',
),
);
expect(hasThought).toBe(true);
});
it('should stream TOOL_CALL_START and TOOL_CALL_END', async () => {
const mockSession = setupMockSession({});
const params = { query: 'run tool' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'ls', args: {}, callId: 'call-123' },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_END',
data: { name: 'ls', data: {}, id: 'call-123' },
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// After TOOL_CALL_START, the immediate updateOutput call should show running
const runningCalls = progressCalls.filter((p) => p.state === 'running');
// The first running call with a tool_call should show 'running'
const firstToolCall = runningCalls.find((p) =>
p.recentActivity?.some(
(a) => a.type === 'tool_call' && a.content === 'ls',
),
);
expect(firstToolCall).toBeDefined();
// After TOOL_CALL_END, the tool should be completed
const hasCompleted = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'tool_call' &&
a.content === 'ls' &&
a.status === 'completed',
),
);
expect(hasCompleted).toBe(true);
});
it('should handle ERROR activity', async () => {
const mockSession = setupMockSession({});
const params = { query: 'fail' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'ERROR',
data: { error: 'Something broke' },
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasError = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'thought' &&
a.content === 'Error: Something broke' &&
a.status === 'error',
),
);
expect(hasError).toBe(true);
});
it('should handle cancelled errors', async () => {
const mockSession = setupMockSession({});
const params = { query: 'cancel' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'ERROR',
data: {
error: SUBAGENT_CANCELLED_ERROR_MESSAGE,
errorType: SubagentActivityErrorType.CANCELLED,
},
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasCancelled = progressCalls.some((p) =>
p.recentActivity?.some(
(a) => a.type === 'thought' && a.status === 'cancelled',
),
);
expect(hasCancelled).toBe(true);
});
it('should handle rejected errors', async () => {
const mockSession = setupMockSession({});
const params = { query: 'reject' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'dangerous_tool', args: {}, callId: 'call-rej' },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'ERROR',
data: {
name: 'dangerous_tool',
error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Rethink approach.`,
errorType: SubagentActivityErrorType.REJECTED,
callId: 'call-rej',
},
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// Tool call should be marked cancelled
const hasToolCancelled = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'tool_call' &&
a.content === 'dangerous_tool' &&
a.status === 'cancelled',
),
);
expect(hasToolCancelled).toBe(true);
});
it('should trim recentActivity to MAX_RECENT_ACTIVITY', async () => {
const mockSession = setupMockSession({});
const params = { query: 'trim' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
// Emit 4+ activities to exceed MAX_RECENT_ACTIVITY (3)
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool1', args: {} },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool2', args: {} },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool3', args: {} },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool4', args: {} },
});
await executePromise;
// After the 4th activity, the last updateOutput call before completion
// should have only 3 items in recentActivity
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// Find the call right after the 4th activity (before completion)
const afterFourthActivity = progressCalls.filter(
(p) => p.state === 'running' && p.recentActivity.length > 0,
);
const lastRunning = afterFourthActivity[afterFourthActivity.length - 1];
expect(lastRunning.recentActivity.length).toBeLessThanOrEqual(3);
// Should contain tool4 (the latest)
expect(
lastRunning.recentActivity.some((a) => a.content === 'tool4'),
).toBe(true);
// Should NOT contain tool1 (trimmed away)
expect(
lastRunning.recentActivity.some((a) => a.content === 'tool1'),
).toBe(false);
});
it('should handle executor errors', async () => {
const error = new Error('Model failed during execution.');
setupMockSession({ error });
const params = { query: 'fail hard' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
expect(result.llmContent).toBe(
`Subagent 'MockAgent' failed. Error: ${error.message}`,
);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe('error');
expect(display.recentActivity).toContainEqual(
expect.objectContaining({
type: 'thought',
content: `Error: ${error.message}`,
status: 'error',
}),
);
});
it('should handle abort', async () => {
const mockOutput = {
result: '',
terminate_reason: AgentTerminateMode.ABORTED,
};
setupMockSession({ output: mockOutput });
const params = { query: 'abort me' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
await expect(
invocation.execute({ abortSignal: signal, updateOutput }),
).rejects.toThrow('Operation cancelled by user');
// Verify cancelled state was published
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasCancelledState = progressCalls.some(
(p) => p.state === 'cancelled',
);
expect(hasCancelledState).toBe(true);
});
it('should wire abort signal to session.abort', async () => {
const mockSession = setupMockSession({});
const params = { query: 'abort wire' };
const controller = new AbortController();
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
// Trigger abort
controller.abort();
// The execute should complete (getResult returned GOAL by default)
await executePromise.catch(() => {
/* abort may throw */
});
expect(mockSession.abort).toHaveBeenCalled();
});
it('should send non-query params as config update before query', async () => {
const mockSession = setupMockSession({});
const params = { query: 'Do something', extra_config: 'value123' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
await invocation.execute({ abortSignal: signal, updateOutput });
// First send: config update with non-query params
expect(mockSession.send).toHaveBeenCalledWith({
update: { config: { extra_config: 'value123' } },
});
// Second send: message with query
expect(mockSession.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'Do something' }] },
});
// Config update should come before message
const sendCalls = mockSession.send.mock.calls;
const configIdx = sendCalls.findIndex((c) => c[0]?.update?.config);
const messageIdx = sendCalls.findIndex((c) => c[0]?.message);
expect(configIdx).toBeLessThan(messageIdx);
});
it('should publish SUBAGENT_ACTIVITY on messageBus', async () => {
const mockSession = setupMockSession({});
const params = { query: 'publish test' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'Thinking...' },
});
await executePromise;
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: 'MockAgent',
activity: expect.objectContaining({
type: 'thought',
content: 'Thinking...',
}),
}),
);
});
it('should clean up abort listener in finally', async () => {
setupMockSession({});
const params = { query: 'cleanup' };
const controller = new AbortController();
const removeEventListenerSpy = vi.spyOn(
controller.signal,
'removeEventListener',
);
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
await invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'abort',
expect.any(Function),
);
});
it('should unsubscribe parent observer in finally', async () => {
const unsubscribeFn = vi.fn();
const mockSession = setupMockSession({});
mockSession.subscribe.mockReturnValue(unsubscribeFn);
const params = { query: 'unsub test' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const onAgentEvent = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
{ onAgentEvent },
);
await invocation.execute({ abortSignal: signal, updateOutput });
expect(mockSession.subscribe).toHaveBeenCalledWith(onAgentEvent);
expect(unsubscribeFn).toHaveBeenCalled();
});
it('should handle TOOL_CALL_END with error data', async () => {
const mockSession = setupMockSession({});
const params = { query: 'tool error' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'failing_tool', args: {}, callId: 'call-err' },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_END',
data: { name: 'failing_tool', data: { isError: true }, id: 'call-err' },
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasToolError = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'tool_call' &&
a.content === 'failing_tool' &&
a.status === 'error',
),
);
expect(hasToolError).toBe(true);
});
it('should mark running items as cancelled on abort', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
const mockSession = setupMockSession({ error: abortError });
const params = { query: 'mark cancelled' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
// Emit a running tool call before the abort
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'running_tool', args: {} },
});
await expect(executePromise).rejects.toThrow('Aborted');
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// The final progress should show the tool as cancelled
const lastProgress = progressCalls[progressCalls.length - 1];
expect(lastProgress.state).toBe('cancelled');
expect(lastProgress.recentActivity).toContainEqual(
expect.objectContaining({
type: 'tool_call',
content: 'running_tool',
status: 'cancelled',
}),
);
});
});
});
@@ -0,0 +1,416 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import {
BaseToolInvocation,
type ToolResult,
type ExecuteOptions,
} from '../tools/tools.js';
import {
type LocalAgentDefinition,
type AgentInputs,
type SubagentActivityEvent,
type SubagentProgress,
type SubagentActivityItem,
AgentTerminateMode,
SubagentActivityErrorType,
SUBAGENT_REJECTED_ERROR_PREFIX,
SUBAGENT_CANCELLED_ERROR_MESSAGE,
isToolActivityError,
SubagentState,
} from './types.js';
import { randomUUID } from 'node:crypto';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import {
sanitizeThoughtContent,
sanitizeToolArgs,
sanitizeErrorMessage,
} from '../utils/agent-sanitization-utils.js';
import { checkExhaustive } from '../utils/checks.js';
import { LocalSubagentSession } from './local-subagent-protocol.js';
import type { AgentEvent } from '../agent/types.js';
const MAX_RECENT_ACTIVITY = 3;
/** Optional configuration for subagent invocations. */
export interface SubagentInvocationOptions {
toolName?: string;
toolDisplayName?: string;
onAgentEvent?: (event: AgentEvent) => void;
}
/**
* Session-based local subagent invocation.
*
* This class orchestrates the execution of a defined agent by:
* 1. Using {@link LocalSubagentSession} as the execution engine.
* 2. Bridging the agent's streaming activity (e.g., thoughts) to the tool's
* live output stream via the session's rawActivityCallback.
* 3. Formatting the final result into a {@link ToolResult}.
*/
export class LocalSessionInvocation extends BaseToolInvocation<
AgentInputs,
ToolResult
> {
private readonly _onAgentEvent?: (event: AgentEvent) => void;
/**
* @param definition The definition object that configures the agent.
* @param context The agent loop context.
* @param params The validated input parameters for the agent.
* @param messageBus Message bus for policy enforcement.
* @param options Optional overrides for tool name, display name, and event callback.
*/
constructor(
private readonly definition: LocalAgentDefinition,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
options?: SubagentInvocationOptions,
) {
super(
params,
messageBus,
options?.toolName ?? definition.name,
options?.toolDisplayName ?? definition.displayName,
);
this._onAgentEvent = options?.onAgentEvent;
}
/**
* Returns a concise, human-readable description of the invocation.
* Used for logging and display purposes.
*/
getDescription(): string {
const inputSummary = Object.entries(this.params)
.map(([key, value]) => `${key}: ${String(value)}`)
.join(', ');
return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
}
private publishActivity(activity: SubagentActivityItem): void {
void this.messageBus.publish({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: this.definition.displayName ?? this.definition.name,
activity,
});
}
/**
* Executes the subagent.
*
* @param options Options for tool execution including signal and output updates.
* @returns A `Promise` that resolves with the final `ToolResult`.
*/
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: signal, updateOutput } = options;
let recentActivity: SubagentActivityItem[] = [];
// Raw SubagentActivityEvent handler — preserves all existing progress display logic.
// Passed as rawActivityCallback to LocalSubagentSession so the protocol can call it
// before translating to AgentEvents.
const onActivity = (activity: SubagentActivityEvent): void => {
if (!updateOutput) return;
let updated = false;
switch (activity.type) {
case 'THOUGHT_CHUNK': {
const rawText = activity.data['text'];
const text = typeof rawText === 'string' ? rawText : '';
const lastItem = recentActivity[recentActivity.length - 1];
if (
lastItem &&
lastItem.type === 'thought' &&
lastItem.status === SubagentState.RUNNING
) {
lastItem.content = sanitizeThoughtContent(text);
} else {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: sanitizeThoughtContent(text),
status: SubagentState.RUNNING,
});
}
updated = true;
const latestThought = recentActivity[recentActivity.length - 1];
if (latestThought) {
this.publishActivity(latestThought);
}
break;
}
case 'TOOL_CALL_START': {
const rawName = activity.data['name'];
const name = typeof rawName === 'string' ? rawName.trim() : '';
const displayName = activity.data['displayName']
? sanitizeErrorMessage(String(activity.data['displayName']).trim())
: undefined;
const description = activity.data['description']
? sanitizeErrorMessage(String(activity.data['description']))
: undefined;
const args = JSON.stringify(sanitizeToolArgs(activity.data['args']));
const callId = activity.data['callId']
? String(activity.data['callId'])
: randomUUID();
recentActivity.push({
id: callId,
type: 'tool_call',
content: name,
displayName,
description,
args,
status: SubagentState.RUNNING,
});
updated = true;
const latestTool = recentActivity[recentActivity.length - 1];
if (latestTool) {
this.publishActivity(latestTool);
}
break;
}
case 'TOOL_CALL_END': {
const data = activity.data['data'];
const isError = isToolActivityError(data);
const callId = activity.data['id']
? String(activity.data['id'])
: undefined;
if (callId) {
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
updated = true;
this.publishActivity(recentActivity[i]);
break;
}
}
}
break;
}
case 'ERROR': {
const rawError = activity.data['error'];
const error = typeof rawError === 'string' ? rawError.trim() : '';
const errorType = activity.data['errorType'];
const sanitizedError = sanitizeErrorMessage(error);
const isCancellation =
errorType === SubagentActivityErrorType.CANCELLED ||
error === SUBAGENT_CANCELLED_ERROR_MESSAGE;
const isRejection =
errorType === SubagentActivityErrorType.REJECTED ||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
const callId = activity.data['callId']
? String(activity.data['callId'])
: undefined;
if (callId) {
const targetStatus =
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR;
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = targetStatus;
updated = true;
break;
}
}
}
recentActivity.push({
id: randomUUID(),
type: 'thought',
content:
isCancellation || isRejection
? sanitizedError
: `Error: ${sanitizedError}`,
status:
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR,
});
updated = true;
break;
}
default:
checkExhaustive(activity.type);
break;
}
if (updated) {
// Keep only the last N items
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity], // Copy to avoid mutation issues
state: SubagentState.RUNNING,
};
updateOutput(progress);
}
};
// Create session with the raw activity callback for rich progress display
const session = new LocalSubagentSession(
this.definition,
this.context,
this.messageBus,
onActivity,
);
// Subscribe for parent session observability
let unsubscribeParent: (() => void) | undefined;
if (this._onAgentEvent) {
unsubscribeParent = session.subscribe(this._onAgentEvent);
}
// Wire external abort signal to session abort
const abortListener = () => void session.abort();
signal.addEventListener('abort', abortListener, { once: true });
try {
if (updateOutput) {
const initialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [],
state: SubagentState.RUNNING,
};
updateOutput(initialProgress);
}
// Buffer non-query params, then send query as message to start execution
const query = String(this.params['query'] ?? '');
const otherParams = { ...this.params } as Record<string, unknown>;
delete otherParams['query'];
if (Object.keys(otherParams).length > 0) {
await session.send({ update: { config: otherParams } });
}
await session.send({
message: { content: [{ type: 'text', text: query }] },
});
const output = await session.getResult();
if (output.terminate_reason === AgentTerminateMode.ABORTED) {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: SubagentState.CANCELLED,
};
if (updateOutput) {
updateOutput(progress);
}
const cancelError = new Error('Operation cancelled by user');
cancelError.name = 'AbortError';
throw cancelError;
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: SubagentState.COMPLETED,
result: output.result,
terminateReason: output.terminate_reason,
};
if (updateOutput) {
updateOutput(progress);
}
const resultContent = `Subagent '${this.definition.name}' finished.
Termination Reason: ${output.terminate_reason}
Result:
${output.result}`;
return {
llmContent: [{ text: resultContent }],
returnDisplay: progress,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const isAbort =
(error instanceof Error && error.name === 'AbortError') ||
errorMessage.includes('Aborted');
// Mark any running items as error/cancelled
for (const item of recentActivity) {
if (item.status === SubagentState.RUNNING) {
item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
}
}
// Ensure the error is reflected in the recent activity for display
if (!isAbort) {
const lastActivity = recentActivity[recentActivity.length - 1];
if (!lastActivity || lastActivity.status !== SubagentState.ERROR) {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: `Error: ${errorMessage}`,
status: SubagentState.ERROR,
});
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
}
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR,
};
if (updateOutput) {
updateOutput(progress);
}
if (isAbort) {
throw error;
}
return {
llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`,
returnDisplay: progress,
};
} finally {
signal.removeEventListener('abort', abortListener);
unsubscribeParent?.();
}
}
}
@@ -220,18 +220,7 @@ describe('policyHelpers', () => {
];
testCases.forEach(
({
name,
model,
useGemini31,
hasAccess,
authType,
wrapsAround,
...rest
}) => {
const releaseChannel = (rest as Record<string, unknown>)[
'releaseChannel'
] as string | undefined;
({ name, model, useGemini31, hasAccess, authType, wrapsAround }) => {
it(`achieves parity for: ${name}`, () => {
const createBaseConfig = (dynamic: boolean) =>
createMockConfig({
@@ -241,7 +230,7 @@ describe('policyHelpers', () => {
getGemini31FlashLiteLaunchedSync: () => false,
getHasAccessToPreviewModel: () => hasAccess ?? true,
getContentGeneratorConfig: () => ({ authType }),
getReleaseChannel: () => releaseChannel ?? 'preview',
getReleaseChannel: () => 'preview',
modelConfigService: new ModelConfigService(DEFAULT_MODEL_CONFIGS),
});
@@ -86,7 +86,6 @@ export function resolvePolicyChain(
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
releaseChannel: config.getReleaseChannel?.(),
};
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
+3 -36
View File
@@ -324,38 +324,6 @@ describe('Server Config (config.ts)', () => {
});
});
describe('model fallback', () => {
it('should fallback to default model when an obsolete model is provided', () => {
const config = new Config({
...baseParams,
model: 'gemini-pro-latest',
});
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
});
it('should fallback to default model when an invalid gemini model is provided', () => {
const config = new Config({
...baseParams,
model: 'gemini-invalid-model',
});
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
});
it('should fallback to default model when setModel is called with an invalid gemini model', () => {
const config = new Config(baseParams);
config.setModel('gemini-invalid-model');
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
});
it('should allow custom models (non-gemini prefixed)', () => {
const config = new Config({
...baseParams,
model: 'custom-model',
});
expect(config.getModel()).toBe('custom-model');
});
});
describe('setShellExecutionConfig', () => {
it('should preserve existing shell execution fields that are not being updated', () => {
const config = new Config({
@@ -2051,7 +2019,6 @@ describe('Server Config (config.ts)', () => {
expect(configInternal.lastEmittedQuotaRemaining).toBeUndefined();
expect(configInternal.lastEmittedQuotaLimit).toBeUndefined();
expect(configInternal.lastQuotaFetchTime).toBe(0);
expect(configInternal.hasAccessToPreviewModel).toBeNull();
// Event emission
expect(emitQuotaSpy).toHaveBeenCalledWith(undefined, undefined, undefined);
@@ -2645,7 +2612,7 @@ describe('Config getHooks', () => {
targetDir: '/path/to/target',
debugMode: false,
sessionId: 'test-session-id',
model: 'gemini-2.5-flash',
model: 'gemini-pro',
usageStatisticsEnabled: false,
};
@@ -2894,7 +2861,7 @@ describe('Config getExperiments', () => {
targetDir: '/path/to/target',
debugMode: false,
sessionId: 'test-session-id',
model: 'gemini-2.5-flash',
model: 'gemini-pro',
usageStatisticsEnabled: false,
};
@@ -2939,7 +2906,7 @@ describe('Config setExperiments logging', () => {
targetDir: '/path/to/target',
debugMode: false,
sessionId: 'test-session-id',
model: 'gemini-2.5-flash',
model: 'gemini-pro',
usageStatisticsEnabled: false,
};
+18 -15
View File
@@ -80,12 +80,10 @@ import { tokenLimit } from '../core/tokenLimits.js';
import {
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
isAutoModel,
isPreviewModel,
isGemini2Model,
isValidModel,
PREVIEW_GEMINI_FLASH_MODEL,
resolveModel,
} from './models.js';
@@ -239,6 +237,7 @@ export interface GemmaModelRouterSettings {
export interface ADKSettings {
agentSessionNoninteractiveEnabled?: boolean;
agentSessionInteractiveEnabled?: boolean;
agentSessionSubagentEnabled?: boolean;
}
export interface ExtensionSetting {
@@ -690,6 +689,7 @@ export interface ConfigParameters {
enableShellOutputEfficiency?: boolean;
shellToolInactivityTimeout?: number;
fakeResponses?: string;
fakeResponsesNonStrict?: string;
recordResponses?: string;
ptyInfo?: string;
disableYoloMode?: boolean;
@@ -915,12 +915,14 @@ export class Config implements McpContext, AgentLoopContext {
private readonly gemmaModelRouter: GemmaModelRouterSettings;
private readonly agentSessionNoninteractiveEnabled: boolean;
private readonly agentSessionInteractiveEnabled: boolean;
private readonly agentSessionSubagentEnabled: boolean;
private readonly retryFetchErrors: boolean;
private readonly maxAttempts: number;
private readonly enableShellOutputEfficiency: boolean;
private readonly shellToolInactivityTimeout: number;
readonly fakeResponses?: string;
readonly fakeResponsesNonStrict?: string;
readonly recordResponses?: string;
private readonly disableYoloMode: boolean;
private readonly disableAlwaysAllow: boolean;
@@ -1116,11 +1118,9 @@ export class Config implements McpContext, AgentLoopContext {
this.cwd = params.cwd ?? process.cwd();
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.model = isValidModel(params.model)
? params.model
: DEFAULT_GEMINI_MODEL;
this.model = params.model;
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = this.model;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? true;
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
@@ -1303,6 +1303,7 @@ export class Config implements McpContext, AgentLoopContext {
this.storage.setCustomPlansDir(params.planSettings?.directory);
this.fakeResponses = params.fakeResponses;
this.fakeResponsesNonStrict = params.fakeResponsesNonStrict;
this.recordResponses = params.recordResponses;
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
@@ -1363,6 +1364,8 @@ export class Config implements McpContext, AgentLoopContext {
params.adk?.agentSessionNoninteractiveEnabled ?? false;
this.agentSessionInteractiveEnabled =
params.adk?.agentSessionInteractiveEnabled ?? false;
this.agentSessionSubagentEnabled =
params.adk?.agentSessionSubagentEnabled ?? false;
this.retryFetchErrors = params.retryFetchErrors ?? true;
this.maxAttempts = Math.min(
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
@@ -1837,7 +1840,6 @@ export class Config implements McpContext, AgentLoopContext {
this.modelQuotas.clear();
this.lastRetrievedQuota = undefined;
this.lastQuotaFetchTime = 0;
this.hasAccessToPreviewModel = null;
// Force an event emission to clear the UI display
coreEvents.emitQuotaChanged(undefined, undefined, undefined);
@@ -1906,20 +1908,17 @@ export class Config implements McpContext, AgentLoopContext {
}
setModel(newModel: string, isTemporary: boolean = true): void {
const validatedModel = isValidModel(newModel)
? newModel
: DEFAULT_GEMINI_MODEL;
if (this.model !== validatedModel || this._activeModel !== validatedModel) {
this.model = validatedModel;
if (this.model !== newModel || this._activeModel !== newModel) {
this.model = newModel;
// When the user explicitly sets a model, that becomes the active model.
this._activeModel = validatedModel;
coreEvents.emitModelChanged(validatedModel);
this._activeModel = newModel;
coreEvents.emitModelChanged(newModel);
this.lastEmittedQuotaRemaining = undefined;
this.lastEmittedQuotaLimit = undefined;
this.emitQuotaChangedEvent();
}
if (this.onModelChange && !isTemporary) {
this.onModelChange(validatedModel);
this.onModelChange(newModel);
}
this.modelAvailabilityService.reset();
}
@@ -2581,6 +2580,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.contextManagement.enabled;
}
isAgentSessionSubagentEnabled(): boolean {
return this.agentSessionSubagentEnabled;
}
getMemoryBoundaryMarkers(): readonly string[] {
return this.memoryBoundaryMarkers;
}
@@ -71,6 +71,24 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-flash-preview',
},
},
'gemini-3.1-pro-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview',
},
},
'gemini-3.1-pro-preview-customtools': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview-customtools',
},
},
'gemini-3.1-flash-lite-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-flash-lite-preview',
},
},
'gemini-2.5-pro': {
extends: 'chat-base-2.5',
modelConfig: {
@@ -449,7 +467,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
auto: {
default: 'gemini-3-pro-preview',
contexts: [
{ condition: { releaseChannel: 'stable' }, target: 'gemini-2.5-pro' },
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
{
condition: { useGemini3_1: true, useCustomTools: true },
@@ -541,10 +558,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
condition: { hasAccessToPreview: false },
target: 'gemini-2.5-pro',
},
{
condition: { releaseChannel: 'stable', requestedModels: ['auto'] },
target: 'gemini-2.5-pro',
},
{
condition: { requestedModels: ['gemini-2.5-pro', 'auto-gemini-2.5'] },
target: 'gemini-2.5-pro',
+53
View File
@@ -34,6 +34,7 @@ import {
isProModel,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
getAutoModelDescription,
} from './models.js';
import type { Config } from './config.js';
import { ModelConfigService } from '../services/modelConfigService.js';
@@ -672,3 +673,55 @@ describe('isActiveModel', () => {
).toBe(false);
});
});
describe('Gemini 3.1 Config Resolution', () => {
it('PREVIEW_GEMINI_3_1_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).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();
});
});
describe('getAutoModelDescription', () => {
it('should return Gemini 2.5 description when hasAccessToPreview is false', () => {
const desc = getAutoModelDescription(false, false);
expect(desc).toContain('gemini-2.5-pro');
expect(desc).toContain('gemini-2.5-flash');
});
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');
});
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');
});
});
+5 -32
View File
@@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { normalizeModelId } from '../utils/modelUtils.js';
export interface ModelResolutionContext {
useGemini3_1?: boolean;
useGemini3_1FlashLite?: boolean;
@@ -51,7 +49,6 @@ export interface IModelConfigService {
export interface ModelCapabilityContext {
readonly modelConfigService: IModelConfigService;
getExperimentalDynamicModelConfiguration(): boolean;
getReleaseChannel?(): string;
}
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
@@ -99,33 +96,18 @@ export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
export const DEFAULT_THINKING_MODE = 8192;
export function getAutoModelDescription(
releaseChannel: string = 'stable',
hasAccessToPreview: boolean,
useGemini3_1: boolean = false,
) {
const isPreview = releaseChannel === 'preview';
const proModel = isPreview
const proModel = hasAccessToPreview
? useGemini3_1
? 'gemini-3.1-pro'
: 'gemini-3-pro'
: 'gemini-2.5-pro';
const flashModel = isPreview ? 'gemini-3-flash' : 'gemini-2.5-flash';
const flashModel = hasAccessToPreview ? 'gemini-3-flash' : 'gemini-2.5-flash';
return `Let Gemini CLI decide the best model for the task: ${proModel}, ${flashModel}`;
}
export function isValidModel(model: string): boolean {
const normalized = normalizeModelId(model);
return (
VALID_GEMINI_MODELS.has(normalized) ||
normalized === GEMINI_MODEL_ALIAS_AUTO ||
normalized === GEMINI_MODEL_ALIAS_PRO ||
normalized === GEMINI_MODEL_ALIAS_FLASH ||
normalized === GEMINI_MODEL_ALIAS_FLASH_LITE ||
normalized === PREVIEW_GEMINI_MODEL_AUTO ||
normalized === DEFAULT_GEMINI_MODEL_AUTO ||
!normalized.startsWith('gemini-')
);
}
/**
* Resolves the requested model alias (e.g., 'auto', 'pro', 'flash', 'flash-lite')
* to a concrete model name.
@@ -142,7 +124,6 @@ export function resolveModel(
useCustomToolModel: boolean = false,
hasAccessToPreview: boolean = true,
config?: ModelCapabilityContext,
releaseChannel?: string,
): string {
// Defensive check against non-string inputs at runtime
const normalizedModel = Array.isArray(requestedModel)
@@ -151,15 +132,12 @@ export function resolveModel(
? String(requestedModel ?? '').trim() || ''
: requestedModel.trim() || '';
const currentReleaseChannel = releaseChannel ?? config?.getReleaseChannel?.();
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const resolved = config.modelConfigService.resolveModelId(normalizedModel, {
useGemini3_1,
useGemini3_1FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview,
releaseChannel: currentReleaseChannel,
});
if (!hasAccessToPreview && isPreviewModel(resolved, config)) {
@@ -180,7 +158,7 @@ export function resolveModel(
switch (normalizedModel) {
case GEMINI_MODEL_ALIAS_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
if (currentReleaseChannel === 'stable') {
if (!hasAccessToPreview) {
resolved = DEFAULT_GEMINI_MODEL;
break;
}
@@ -212,12 +190,7 @@ export function resolveModel(
break;
}
default: {
if (!isValidModel(normalizedModel)) {
// Fallback to stable default for unknown/obsolete models.
resolved = DEFAULT_GEMINI_MODEL;
} else {
resolved = normalizedModel;
}
resolved = normalizedModel;
break;
}
}
@@ -196,7 +196,7 @@ describe('ChatCompressionService', () => {
} as unknown as Config;
vi.mocked(getInitialChatHistory).mockImplementation(
async (_config, extraHistory) => extraHistory || [],
async (_config, extraHistory) => (extraHistory ? [...extraHistory] : []),
);
});
@@ -442,7 +442,9 @@ export class ChatCompressionService {
const fullNewHistory = await getInitialChatHistory(config, extraHistory);
const newTokenCount = await calculateRequestTokenCount(
fullNewHistory.flatMap((c) => c.parts || []),
fullNewHistory.flatMap(
(c) => ('content' in c ? c.content.parts : c.parts) || [],
),
config.getContentGenerator(),
model,
);
@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { randomUUID } from 'node:crypto';
import { testTruncateProfile } from './testing/testProfile.js';
import {
createSyntheticHistory,
@@ -32,20 +33,35 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
// 2. Add System Prompt (Episode 0 - Protected)
chatHistory.set([
{ role: 'user', parts: [{ text: 'System prompt' }] },
{ role: 'model', parts: [{ text: 'Understood.' }] },
{
id: 'h1',
content: { role: 'user', parts: [{ text: 'System prompt' }] },
},
{
id: 'h2',
content: { role: 'model', parts: [{ text: 'Understood.' }] },
},
]);
// 3. Add massive history that blows past the 150k maxTokens limit
// 20 turns * ~20,000 tokens/turn (10k user + 10k model) = ~400,000 tokens
const massiveHistory = createSyntheticHistory(20, 10000);
const massiveHistory = createSyntheticHistory(20, 10000).map((c) => ({
id: randomUUID(),
content: c,
}));
chatHistory.set([...chatHistory.get(), ...massiveHistory]);
// 4. Add the Latest Turn (Protected)
chatHistory.set([
...chatHistory.get(),
{ role: 'user', parts: [{ text: 'Final question.' }] },
{ role: 'model', parts: [{ text: 'Final answer.' }] },
{
id: 'h-last-user',
content: { role: 'user', parts: [{ text: 'Final question.' }] },
},
{
id: 'h-last-model',
content: { role: 'model', parts: [{ text: 'Final answer.' }] },
},
]);
const rawHistoryLength = chatHistory.get().length;
@@ -59,21 +75,22 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
expect(projection.length).toBeLessThan(rawHistoryLength);
// Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation
expect(projection[0].role).toBe('user');
expect(projection[0].content.role).toBe('user');
const projectionString = JSON.stringify(projection);
expect(projectionString).toContain('User turn 17');
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
const contentNodes = projection.filter(
(p) =>
p.parts && p.parts.some((part) => part.text && part.text !== 'Yield'),
p.content.parts &&
p.content.parts.some((part) => part.text && part.text !== 'Yield'),
);
// Verify the latest turn is perfectly preserved at the back
// Note: The HistoryHardener appends a "Please continue." user turn if we end on model,
// so we look at the turns before the sentinel.
const lastSentinel = contentNodes[contentNodes.length - 1];
const lastModel = contentNodes[contentNodes.length - 2];
const lastUser = contentNodes[contentNodes.length - 3];
const lastSentinel = contentNodes[contentNodes.length - 1].content;
const lastModel = contentNodes[contentNodes.length - 2].content;
const lastUser = contentNodes[contentNodes.length - 3].content;
expect(lastSentinel.role).toBe('user');
expect(lastSentinel.parts![0].text).toBe('Please continue.');
@@ -47,7 +47,9 @@ describe('ContextManager - Hot Start Calibration', () => {
const emitGroundTruthSpy = vi.spyOn(env.eventBus, 'emitTokenGroundTruth');
// Add a node to make the buffer non-empty
chatHistory.set([{ role: 'user', parts: [{ text: 'Hello' }] }]);
chatHistory.set([
{ id: 'h1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
]);
// First render should trigger calibration
await contextManager.renderHistory();
@@ -81,7 +83,9 @@ describe('ContextManager - Hot Start Calibration', () => {
);
// Add a node
chatHistory.set([{ role: 'user', parts: [{ text: 'Hello' }] }]);
chatHistory.set([
{ id: 'h1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
]);
// Render should succeed without throwing
const result = await contextManager.renderHistory();
+86 -36
View File
@@ -5,8 +5,11 @@
*/
import type { Content } from '@google/genai';
import type { AgentChatHistory } from '../core/agentChatHistory.js';
import { isToolExecution, type ConcreteNode } from './graph/types.js';
import type {
AgentChatHistory,
HistoryTurn,
} from '../core/agentChatHistory.js';
import type { ConcreteNode } from './graph/types.js';
import type { ContextEventBus } from './eventBus.js';
import type { ContextTracer } from './tracer.js';
import type { ContextEnvironment } from './pipeline/environment.js';
@@ -38,9 +41,11 @@ export class ContextManager {
private lastRenderCache?: {
nodesHash: string;
result: {
history: Content[];
history: HistoryTurn[];
apiHistory: Content[];
didApplyManagement: boolean;
baseUnits: number;
processedNodes: readonly ConcreteNode[];
};
};
@@ -75,6 +80,21 @@ export class ContextManager {
this.evaluateTriggers(event.newNodes);
});
this.eventBus.onProcessorResult((event) => {
// Defensive: Verify all targets are still present in the buffer.
// If a synchronous render or a previous async task already removed them,
// this result is stale and should be dropped.
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.`,
);
return;
}
this.buffer = this.buffer.applyProcessorResult(
event.processorId,
event.targets,
@@ -127,11 +147,11 @@ export class ContextManager {
const agedOutNodes = new Set<string>();
let rollingTokens = 0;
// Identify active tool calls that must NEVER be truncated
// Identify nodes that must NEVER be truncated
const protectedIds = this.getProtectedNodeIds(this.buffer.nodes);
if (protectedIds.size > 0) {
debugLogger.log(
`[ContextManager] Pinning ${protectedIds.size} active tool call nodes to prevent truncation.`,
`[ContextManager] Pinning ${protectedIds.size} nodes (recent_turn or external_active_task) to prevent truncation.`,
);
}
@@ -215,24 +235,7 @@ export class ContextManager {
}
}
// 2. Identify active tool calls that must NEVER be truncated
const calls = nodes.filter((n) => isToolExecution(n) && n.role === 'model');
const responses = new Set(
nodes
.filter((n) => isToolExecution(n) && n.role === 'user')
.map((n) => n.payload.functionResponse?.id)
.filter((id): id is string => !!id),
);
for (const call of calls) {
const id = call.payload.functionCall?.id;
// If we have a call but no response in the current graph, it's 'in flight'
if (id && !responses.has(id)) {
protectionMap.set(call.id, 'in_flight_tool_call');
}
}
// 3. Any externally requested protections
// 2. Any externally requested protections
for (const id of extraProtectedIds) {
protectionMap.set(id, 'external_active_task');
}
@@ -278,13 +281,15 @@ export class ContextManager {
* This is the primary method called by the agent framework before sending a request.
*/
async renderHistory(
pendingRequest?: Content,
pendingRequest?: HistoryTurn,
activeTaskIds: Set<string> = new Set(),
abortSignal?: AbortSignal,
): Promise<{
history: Content[];
history: HistoryTurn[];
apiHistory: Content[];
didApplyManagement: boolean;
baseUnits: number;
processedNodes: readonly ConcreteNode[];
}> {
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
@@ -302,6 +307,7 @@ export class ContextManager {
const hotStartPromise = (async () => {
if (!this.hasPerformedHotStart) {
this.hasPerformedHotStart = true;
if (this.buffer.nodes.length > 0) {
const nodesForHotStart = [...this.buffer.nodes, ...previewNodes];
await this.performHotStartCalibration(nodesForHotStart, abortSignal);
@@ -345,11 +351,7 @@ export class ContextManager {
const protectionReasons = this.getProtectedNodeIds(nodes, activeTaskIds);
// Apply final GC Backstop pressure barrier synchronously before mapping
const {
history: renderedHistory,
didApplyManagement,
baseUnits,
} = await render(
const renderResult = await render(
nodes,
this.orchestrator,
this.sidecar,
@@ -361,21 +363,68 @@ export class ContextManager {
previewNodeIds,
);
const {
history: renderedHistory,
didApplyManagement,
baseUnits,
processedNodes,
} = renderResult;
if (didApplyManagement) {
// Commit the GC backstop results back to the master buffer.
// We filter out preview nodes because they are ephemeral and will be
// added to history naturally by the client after the turn completes.
this.buffer = this.buffer.applyProcessorResult(
'sync_backstop',
this.buffer.nodes,
processedNodes.filter((n) => !previewNodeIds.has(n.id)),
);
}
// Structural validation in debug mode
checkContextInvariants(this.buffer.nodes, 'RenderHistory');
this.tracer.logEvent('ContextManager', 'Finished rendering');
const combinedHistory = header
? [header, ...renderedHistory]
// We must temporarily append the pendingRequest (if any) before hardening.
// Otherwise, the hardener will see dangling functionCalls and inject sentinels
// even though the pendingRequest provides the required functionResponses.
const fullHistoryToHarden = pendingRequest
? [...renderedHistory, pendingRequest]
: renderedHistory;
const hardenedHistory = hardenHistory(fullHistoryToHarden, {
sentinels: this.sidecar.sentinels,
});
if (pendingRequest) {
const last = hardenedHistory[hardenedHistory.length - 1];
if (last && last.content.parts) {
const numPartsToRemove = pendingRequest.content.parts?.length || 0;
if (
numPartsToRemove > 0 &&
last.content.parts.length > numPartsToRemove
) {
last.content.parts.splice(-numPartsToRemove);
} else {
hardenedHistory.pop();
}
} else {
hardenedHistory.pop();
}
}
const apiHistory = hardenedHistory.map((h) => h.content);
if (header) {
apiHistory.unshift(header);
}
const result = {
history: hardenHistory(combinedHistory, {
sentinels: this.sidecar.sentinels,
}),
history: hardenedHistory,
apiHistory,
didApplyManagement,
baseUnits,
processedNodes,
};
// Update cache
@@ -394,10 +443,11 @@ export class ContextManager {
);
const contents = this.env.graphMapper.fromGraph(nodes);
const rawContents = contents.map((h) => h.content);
const header = this.headerProvider
? await this.headerProvider()
: undefined;
const combinedHistory = header ? [header, ...contents] : contents;
const combinedHistory = header ? [header, ...rawContents] : rawContents;
const baseUnits =
this.advancedTokenCalculator.getRawBaseUnits(nodes) +
@@ -0,0 +1,202 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { fromGraph } from './fromGraph.js';
import { NodeType, type ConcreteNode } from './types.js';
import { NodeIdService } from './nodeIdService.js';
describe('fromGraph', () => {
it('should reconstruct an empty history from empty nodes', () => {
expect(fromGraph([])).toEqual([]);
});
it('should reconstruct a single turn from a single node', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
];
const history = fromGraph(nodes);
expect(history).toEqual([
{
id: 'durable_1',
content: {
role: 'user',
parts: [{ text: 'hello' }],
},
},
]);
});
it('should coalesce adjacent nodes with the same turnId into a single turn', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
{
id: 'node_2',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'world' },
timestamp: 101,
},
];
const history = fromGraph(nodes);
expect(history).toEqual([
{
id: 'durable_1',
content: {
role: 'user',
parts: [{ text: 'hello' }, { text: 'world' }],
},
},
]);
});
it('should split turns when the role changes', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
{
id: 'node_2',
turnId: 'turn_durable_2',
role: 'model',
type: NodeType.AGENT_THOUGHT,
payload: { text: 'hi' },
timestamp: 101,
},
];
const history = fromGraph(nodes);
expect(history).toEqual([
{
id: 'durable_1',
content: {
role: 'user',
parts: [{ text: 'hello' }],
},
},
{
id: 'durable_2',
content: {
role: 'model',
parts: [{ text: 'hi' }],
},
},
]);
});
it('should split turns when the turnId changes, even if role is the same', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
{
id: 'node_2',
turnId: 'turn_durable_2',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'world' },
timestamp: 101,
},
];
const history = fromGraph(nodes);
expect(history).toEqual([
{
id: 'durable_1',
content: {
role: 'user',
parts: [{ text: 'hello' }],
},
},
{
id: 'durable_2',
content: {
role: 'user',
parts: [{ text: 'world' }],
},
},
]);
});
it('should correctly strip the turn_ prefix from turnId', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_my_stable_id_123',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
];
const history = fromGraph(nodes);
expect(history[0].id).toBe('my_stable_id_123');
});
it('should handle orphan nodes gracefully', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'orphan part' },
timestamp: 100,
} as unknown as ConcreteNode,
];
const history = fromGraph(nodes);
expect(history[0].id).toBe('orphan');
expect(history[0].content.parts).toEqual([{ text: 'orphan part' }]);
});
it('should register identities with the NodeIdService if provided', () => {
const idService = new NodeIdService();
const payload = { text: 'hello' };
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload,
timestamp: 100,
},
];
fromGraph(nodes, idService);
// The payload object reference should map to the node ID
expect(idService.get(payload)).toBe('node_1');
});
});
+29 -18
View File
@@ -7,22 +7,34 @@
import type { Content } from '@google/genai';
import type { ConcreteNode } from './types.js';
import { debugLogger } from '../../utils/debugLogger.js';
import type { NodeIdService } from './nodeIdService.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
/**
* Reconstructs a valid Gemini Chat History from a list of Concrete Nodes.
* Reconstructs a list of HistoryTurns from a list of Concrete Nodes.
* This process is "role-alternation-aware" and uses turnId to
* preserve original turn boundaries even if multiple turns have the same role.
* preserve original turn boundaries and IDs.
*/
export function fromGraph(nodes: readonly ConcreteNode[]): Content[] {
export function fromGraph(
nodes: readonly ConcreteNode[],
idService?: NodeIdService,
): HistoryTurn[] {
debugLogger.log(
`[fromGraph] Reconstructing history from ${nodes.length} nodes`,
);
const history: Content[] = [];
let currentTurn: (Content & { _turnId?: string }) | null = null;
const history: HistoryTurn[] = [];
let currentTurn: { id: string; content: Content } | null = null;
for (const node of nodes) {
const turnId = node.turnId;
const turnId = node.turnId || 'orphan';
const durableId = turnId.startsWith('turn_') ? turnId.slice(5) : turnId;
// Register the payload in the identity service to ensure stability
// even if the turn content changes (e.g. after GC backstop).
if (idService) {
idService.set(node.payload, node.id);
}
// We start a new turn if:
// 1. We don't have a current turn.
@@ -30,26 +42,25 @@ export function fromGraph(nodes: readonly ConcreteNode[]): Content[] {
// 3. The turnId changes (Preserving distinct turns of the same role).
if (
!currentTurn ||
currentTurn.role !== node.role ||
currentTurn._turnId !== turnId
currentTurn.content.role !== node.role ||
currentTurn.id !== durableId
) {
currentTurn = {
role: node.role,
parts: [node.payload],
_turnId: turnId,
id: durableId,
content: {
role: node.role,
parts: [node.payload],
},
};
history.push(currentTurn);
} else {
currentTurn.parts = [...(currentTurn.parts || []), node.payload];
currentTurn.content.parts = [
...(currentTurn.content.parts || []),
node.payload,
];
}
}
// Final cleanup: remove our internal tracking field
for (const turn of history) {
const t = turn as Content & { _turnId?: string };
delete t._turnId;
}
debugLogger.log(`[fromGraph] Reconstructed ${history.length} turns`);
return history;
}
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ContextGraphMapper } from './mapper.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
import { hardenHistory } from '../../utils/historyHardening.js';
describe('ContextGraphMapper (Round-Trip Fidelity)', () => {
it('should flawlessly round-trip a complex history containing parallel tool calls and responses', () => {
// 1. Define a complex, worst-case scenario history
const originalHistory: HistoryTurn[] = [
{
id: 'system_prompt_id',
content: {
role: 'user',
parts: [{ text: '<session_context>\nSystem Prompt here' }],
},
},
{
id: 'user_turn_1',
content: {
role: 'user',
parts: [{ text: 'Please read file A and file B at the same time.' }],
},
},
{
id: 'model_turn_1',
content: {
role: 'model',
parts: [
{ text: 'I will read both files concurrently.' },
{
functionCall: {
id: 'call_A',
name: 'read_file',
args: { path: 'A.txt' },
},
thoughtSignature: 'synthetic_sig_xyz',
},
{
functionCall: {
id: 'call_B',
name: 'read_file',
args: { path: 'B.txt' },
},
},
],
},
},
// Note: GeminiChat records these as separate sequential user turns initially
{
id: 'tool_resp_B_id',
content: {
role: 'user',
parts: [
{
functionResponse: {
id: 'call_B',
name: 'read_file',
response: { content: 'File B' },
},
},
],
},
},
{
id: 'tool_resp_A_id',
content: {
role: 'user',
parts: [
{
functionResponse: {
id: 'call_A',
name: 'read_file',
response: { content: 'File A' },
},
},
],
},
},
];
// 2. We harden the original history first. The core agent loop feeds the hardener the pure history.
// We want our round-tripped history to match what the hardener WOULD have produced natively.
const hardenedOriginal = hardenHistory(originalHistory);
// 3. Translate History -> Graph
const mapper = new ContextGraphMapper();
// Simulate the HistoryObserver capturing the push
const nodes = mapper.applyEvent({
type: 'SYNC_FULL',
payload: originalHistory,
});
// 4. Translate Graph -> History
const reconstructedHistory = mapper.fromGraph(nodes);
// 5. Harden the reconstructed history (as the ContextManager does before sending to API)
const hardenedReconstructed = hardenHistory(reconstructedHistory);
// 6. Assert Absolute Equality
// The round-trip through the Context Graph and Hardener must exactly equal
// the original history put through the Hardener.
expect(hardenedReconstructed).toEqual(hardenedOriginal);
});
});
+10 -6
View File
@@ -5,23 +5,27 @@
*/
import type { ConcreteNode } from './types.js';
import { ContextGraphBuilder } from './toGraph.js';
import type { Content } from '@google/genai';
import type { HistoryEvent } from '../../core/agentChatHistory.js';
import type { HistoryEvent, HistoryTurn } from '../../core/agentChatHistory.js';
import { fromGraph } from './fromGraph.js';
import { NodeIdService } from './nodeIdService.js';
export class ContextGraphMapper {
private readonly nodeIdentityMap = new WeakMap<object, string>();
private readonly idService = new NodeIdService();
private readonly builder: ContextGraphBuilder;
constructor() {
this.builder = new ContextGraphBuilder(this.nodeIdentityMap);
this.builder = new ContextGraphBuilder(this.idService);
}
applyEvent(event: HistoryEvent): ConcreteNode[] {
return this.builder.processHistory(event.payload);
}
fromGraph(nodes: readonly ConcreteNode[]): Content[] {
return fromGraph(nodes);
fromGraph(nodes: readonly ConcreteNode[]): HistoryTurn[] {
return fromGraph(nodes, this.idService);
}
getIdService(): NodeIdService {
return this.idService;
}
}
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Provides a durable mapping between history object references and their
* corresponding graph node IDs. This ensures that context management logic
* can track the identity of turns even after they are transformed (e.g. scrubbed
* or hardened) without polluting the raw JSON sent to the Gemini API.
*/
export class NodeIdService {
constructor(private readonly map: WeakMap<object, string> = new WeakMap()) {}
get(obj: object): string | undefined {
return this.map.get(obj);
}
set(obj: object, id: string): void {
this.map.set(obj, id);
}
}
+22 -5
View File
@@ -12,9 +12,10 @@ import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
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';
/**
* Maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
* Maps the Episodic Context Graph back into a list of HistoryTurns for transmission.
* It applies synchronous context management (GC backstop) if the budget is exceeded.
*/
export async function render(
@@ -28,9 +29,10 @@ export async function render(
header?: Content,
previewNodeIds: ReadonlySet<string> = new Set(),
): Promise<{
history: Content[];
history: HistoryTurn[];
didApplyManagement: boolean;
baseUnits: number;
processedNodes: readonly ConcreteNode[];
}> {
let headerTokens = 0;
let headerBaseUnits = 0;
@@ -52,7 +54,12 @@ export async function render(
const baseUnits =
advancedTokenCalculator.getRawBaseUnits(nodes) + headerBaseUnits;
return { history: contents, didApplyManagement: false, baseUnits };
return {
history: contents,
didApplyManagement: false,
baseUnits,
processedNodes: nodes,
};
}
const maxTokens = sidecar.config.budget.maxTokens;
@@ -92,11 +99,16 @@ export async function render(
tracer.logEvent('Render', 'Render Context for LLM', {
renderedContext: contents,
});
performCalibration(env, visibleNodes, contents);
performCalibration(
env,
visibleNodes,
contents.map((h) => h.content),
);
return {
history: contents,
didApplyManagement: false,
baseUnits: graphBaseUnits + headerBaseUnits,
processedNodes: nodes,
};
}
const targetDelta = currentTokens - sidecar.config.budget.retainedTokens;
@@ -145,11 +157,16 @@ export async function render(
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
renderedContextSanitized: contents,
});
performCalibration(env, visibleNodes, contents);
performCalibration(
env,
visibleNodes,
contents.map((h) => h.content),
);
return {
history: contents,
didApplyManagement: true,
baseUnits:
advancedTokenCalculator.getRawBaseUnits(visibleNodes) + headerBaseUnits,
processedNodes,
};
}
+102 -13
View File
@@ -4,29 +4,42 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { ContextGraphBuilder } from './toGraph.js';
import type { Content } from '@google/genai';
import type { BaseConcreteNode } from './types.js';
import { NodeIdService } from './nodeIdService.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
describe('ContextGraphBuilder', () => {
describe('toGraph', () => {
it('should skip legacy <session_context> headers even if they appear later in the history', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Message 1' }] },
{ role: 'model', parts: [{ text: 'Reply 1' }] },
const history: HistoryTurn[] = [
{
role: 'user',
parts: [
{
text: '<session_context>\nThis is the Gemini CLI\nSome context...',
},
],
id: '1',
content: { role: 'user', parts: [{ text: 'Message 1' }] },
},
{
id: '2',
content: { role: 'model', parts: [{ text: 'Reply 1' }] },
},
{
id: '3',
content: {
role: 'user',
parts: [
{
text: '<session_context>\nThis is the Gemini CLI\nSome context...',
},
],
},
},
{
id: '4',
content: { role: 'user', parts: [{ text: 'Message 2' }] },
},
{ role: 'user', parts: [{ text: 'Message 2' }] },
];
const builder = new ContextGraphBuilder();
const builder = new ContextGraphBuilder(new NodeIdService());
const nodes = builder.processHistory(history);
// We expect the first two messages and the last one to be present
@@ -36,5 +49,81 @@ describe('ContextGraphBuilder', () => {
expect((nodes[1] as BaseConcreteNode).payload.text).toBe('Reply 1');
expect((nodes[2] as BaseConcreteNode).payload.text).toBe('Message 2');
});
it('should generate completely deterministic graph structure and UUIDs across JSON serialization cycles', () => {
vi.spyOn(Date, 'now').mockReturnValue(0);
const complexHistory: HistoryTurn[] = [
{
id: 'turn-1',
content: {
role: 'user',
parts: [{ text: 'Step 1: complex analysis' }],
},
},
{
id: 'turn-2',
content: {
role: 'model',
parts: [
{ text: 'Thinking about the tool to use.' },
{
functionCall: {
name: 'fetch_data',
args: { query: 'test data' },
},
},
],
},
},
{
id: 'turn-3',
content: {
role: 'user',
parts: [
{
functionResponse: {
name: 'fetch_data',
response: { status: 'success', data: [1, 2, 3] },
},
},
],
},
},
{
id: 'turn-4',
content: { role: 'model', parts: [{ text: 'Analysis complete.' }] },
},
];
// 1. Initial Graph Generation
const builder1 = new ContextGraphBuilder(new NodeIdService());
const nodes1 = builder1.processHistory(complexHistory);
// 2. Serialize and Deserialize (Simulating saving and loading from disk)
const serializedHistory = JSON.stringify(complexHistory);
const parsedHistory = JSON.parse(serializedHistory) as HistoryTurn[];
// 3. Second Graph Generation from parsed JSON
const builder2 = new ContextGraphBuilder(new NodeIdService());
const nodes2 = builder2.processHistory(parsedHistory);
// Assertion: The arrays must be completely identical, including all generated UUIDs
expect(nodes1).toEqual(nodes2);
// Sanity check to ensure IDs are actually populated and consistent
expect(nodes1.length).toBeGreaterThan(0);
nodes1.forEach((node, index) => {
expect(node.id).toBeDefined();
expect(node.id).toBe(nodes2[index].id);
expect(node.timestamp).toBe(0);
if ('turnId' in node) {
expect(node.turnId).toBeDefined();
expect(node.turnId).toBe((nodes2[index] as BaseConcreteNode).turnId);
}
});
vi.restoreAllMocks();
});
});
});
+80 -42
View File
@@ -4,14 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, Part } from '@google/genai';
import type { Part } from '@google/genai';
import { type ConcreteNode, NodeType } from './types.js';
import { randomUUID, createHash } from 'node:crypto';
import { createHash } from 'node:crypto';
import { debugLogger } from '../../utils/debugLogger.js';
interface PartWithSynthId extends Part {
_synthId?: string;
}
import type { NodeIdService } from './nodeIdService.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
import { isSnapshotState } from '../utils/snapshotGenerator.js';
// Global WeakMap to cache hashes for Part objects.
// This optimizes getStableId by avoiding redundant stringify/hash operations
@@ -62,34 +61,53 @@ function isFunctionResponsePart(
);
}
function isExecutableCodePart(
part: Part,
): part is Part & { executableCode: { code: string; language: string } } {
return (
typeof part.executableCode === 'object' &&
part.executableCode !== null &&
typeof part.executableCode.code === 'string' &&
typeof part.executableCode.language === 'string'
);
}
function isCodeExecutionResultPart(
part: Part,
): part is Part & { codeExecutionResult: { outcome: string; output: string } } {
return (
typeof part.codeExecutionResult === 'object' &&
part.codeExecutionResult !== null &&
typeof part.codeExecutionResult.output === 'string' &&
typeof part.codeExecutionResult.outcome === 'string'
);
}
/**
* Generates a stable ID for an object reference using a WeakMap.
* Generates a stable ID for an object reference using a NodeIdService.
* Falls back to content-based hashing for Part-like objects to ensure
* stability across object re-creations (e.g. during history mapping).
*/
export function getStableId(
obj: object,
nodeIdentityMap: WeakMap<object, string>,
idService: NodeIdService,
turnSalt: string = '',
partIdx: number = 0,
): string {
let id = nodeIdentityMap.get(obj);
let id = idService.get(obj);
if (id) return id;
const cachedHash = PART_HASH_CACHE.get(obj);
if (cachedHash) {
id = `${cachedHash}_${turnSalt}_${partIdx}`;
nodeIdentityMap.set(obj, id);
idService.set(obj, id);
return id;
}
const part = obj as PartWithSynthId;
const part = obj as Part;
let contentHash: string | undefined;
// If the object already has a synthetic ID property, use it.
if (typeof part._synthId === 'string') {
id = part._synthId;
} else if (isTextPart(part)) {
if (isTextPart(part)) {
contentHash = createHash('sha256').update(part.text).digest('hex');
id = `text_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isInlineDataPart(part)) {
@@ -116,6 +134,20 @@ export function getStableId(
)
.digest('hex');
id = `resp_h_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isExecutableCodePart(part)) {
contentHash = createHash('sha256')
.update(
`exec:${part.executableCode.language}:${part.executableCode.code}`,
)
.digest('hex');
id = `exec_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isCodeExecutionResultPart(part)) {
contentHash = createHash('sha256')
.update(
`result:${part.codeExecutionResult.outcome}:${part.codeExecutionResult.output}`,
)
.digest('hex');
id = `result_${contentHash}_${turnSalt}_${partIdx}`;
}
if (contentHash) {
@@ -123,10 +155,14 @@ export function getStableId(
}
if (!id) {
id = randomUUID();
if (turnSalt && partIdx === -1) {
id = `turn_${turnSalt}`;
} else {
id = `${turnSalt}_f_${partIdx}`;
}
}
nodeIdentityMap.set(obj, id);
idService.set(obj, id);
return id;
}
@@ -135,18 +171,14 @@ export function getStableId(
* Every Part in history is mapped to exactly one ConcreteNode.
*/
export class ContextGraphBuilder {
constructor(
private readonly nodeIdentityMap: WeakMap<object, string> = new WeakMap(),
) {}
constructor(private readonly idService: NodeIdService) {}
processHistory(history: readonly Content[]): ConcreteNode[] {
processHistory(history: readonly HistoryTurn[]): ConcreteNode[] {
const nodes: ConcreteNode[] = [];
// Tracks occurrences of identical turn content to ensure unique stable IDs
const seenHashes = new Map<string, number>();
for (let turnIdx = 0; turnIdx < history.length; turnIdx++) {
const msg = history[turnIdx];
const turn = history[turnIdx];
const msg = turn.content;
if (!msg.parts) continue;
// Defensive: Skip legacy environment header regardless of where it appears.
@@ -164,15 +196,8 @@ export class ContextGraphBuilder {
}
}
// Generate a stable salt for this turn based on its role and content
const turnContent = JSON.stringify(msg.parts);
const h = createHash('md5')
.update(`${msg.role}:${turnContent}`)
.digest('hex');
const occurrence = (seenHashes.get(h) || 0) + 1;
seenHashes.set(h, occurrence);
const turnSalt = `${h}_${occurrence}`;
const turnId = getStableId(msg, this.nodeIdentityMap, turnSalt, -1);
const turnSalt = turn.id;
const turnId = `turn_${turnSalt}`;
if (msg.role === 'user') {
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
@@ -180,34 +205,46 @@ export class ContextGraphBuilder {
const apiId =
isFunctionResponsePart(part) &&
typeof part.functionResponse.id === 'string'
? `resp_${part.functionResponse.id}_${turnSalt}_${partIdx}`
? part.functionResponse.id
: isFunctionCallPart(part) &&
typeof part.functionCall.id === 'string'
? `call_${part.functionCall.id}_${turnSalt}_${partIdx}`
? part.functionCall.id
: undefined;
const id =
apiId || getStableId(part, this.nodeIdentityMap, turnSalt, partIdx);
const isSnapshot = isTextPart(part) && isSnapshotState(part.text);
// Use stable API ID if available, otherwise anchor to the turn and index.
const id = apiId
? `${apiId}_${turnSalt}_${partIdx}`
: `${turnSalt}_${partIdx}`;
const node: ConcreteNode = {
id,
timestamp: Date.now(),
type: isFunctionResponsePart(part)
? NodeType.TOOL_EXECUTION
: NodeType.USER_PROMPT,
: isSnapshot
? NodeType.SNAPSHOT
: NodeType.USER_PROMPT,
role: 'user',
payload: part,
turnId,
};
nodes.push(node);
this.idService.set(part, id);
}
} else if (msg.role === 'model') {
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
const part = msg.parts[partIdx];
const apiId =
isFunctionCallPart(part) && typeof part.functionCall.id === 'string'
? `call_${part.functionCall.id}_${turnSalt}_${partIdx}`
? part.functionCall.id
: undefined;
const id =
apiId || getStableId(part, this.nodeIdentityMap, turnSalt, partIdx);
const id = apiId
? `${apiId}_${turnSalt}_${partIdx}`
: `${turnSalt}_${partIdx}`;
const node: ConcreteNode = {
id,
timestamp: Date.now(),
@@ -219,6 +256,7 @@ export class ContextGraphBuilder {
turnId,
};
nodes.push(node);
this.idService.set(part, id);
}
}
}
+17
View File
@@ -24,6 +24,7 @@ import { StateSnapshotAsyncProcessorOptionsSchema } from './processors/stateSnap
import { RollingSummaryProcessorOptionsSchema } from './processors/rollingSummaryProcessor.js';
import { getEnvironmentContext } from '../utils/environmentContext.js';
import { AdaptiveTokenCalculator } from './utils/adaptiveTokenCalculator.js';
import { estimateContextBreakdown } from '../core/loggingContentGenerator.js';
import { NodeBehaviorRegistry } from './graph/behaviorRegistry.js';
import { registerBuiltInBehaviors } from './graph/builtinBehaviors.js';
@@ -92,10 +93,26 @@ export async function initializeContextManager(
const behaviorRegistry = new NodeBehaviorRegistry();
registerBuiltInBehaviors(behaviorRegistry);
const getOverheadTokens = () => {
const breakdown = estimateContextBreakdown([], {
systemInstruction: {
role: 'system',
parts: [{ text: chat.getSystemInstruction() }],
},
tools: chat.getTools(),
});
return (
breakdown.system_instructions +
breakdown.tool_definitions +
breakdown.mcp_servers
);
};
const calculator = new AdaptiveTokenCalculator(
charsPerToken,
behaviorRegistry,
eventBus,
getOverheadTokens,
);
const env = new ContextEnvironmentImpl(
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type { ProcessArgs, ContextProcessor } from '../pipeline.js';
import * as fs from 'node:fs/promises';
@@ -62,7 +62,8 @@ export function createBlobDegradationProcessor(
if (payload.inlineData?.data && payload.inlineData?.mimeType) {
await ensureDir();
const ext = payload.inlineData.mimeType.split('/')[1] || 'bin';
const fileName = `blob_${Date.now()}_${randomUUID()}.${ext}`;
// Use a stable filename based on the node ID
const fileName = `blob_${deriveStableId([node.id])}.${ext}`;
const filePath = path.join(blobOutputsDir, fileName);
const buffer = Buffer.from(payload.inlineData.data, 'base64');
@@ -92,7 +93,7 @@ export function createBlobDegradationProcessor(
if (newText && tokensSaved > 0) {
returnedNodes.push({
...node,
id: randomUUID(),
id: deriveStableId([node.id, 'degraded']),
payload: { text: newText },
replacesId: node.id,
turnId: node.turnId,
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import { type ConcreteNode, NodeType } from '../graph/types.js';
@@ -99,9 +99,10 @@ export function createNodeDistillationProcessor(
if (newTokens < oldTokens) {
const distilledPayload = updatePart(payload, { text: summary });
const newId = deriveStableId([node.id, 'distilled']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: distilledPayload,
replacesId: node.id,
timestamp: node.timestamp,
@@ -158,9 +159,10 @@ export function createNodeDistillationProcessor(
functionResponse: newFR,
});
const newId = deriveStableId([node.id, 'distilled']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: distilledPayload,
replacesId: node.id,
timestamp: node.timestamp,
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
@@ -79,9 +79,10 @@ export function createNodeTruncationProcessor(
if (text) {
const squashResult = tryApplySquash(text, limitChars);
if (squashResult) {
const newId = deriveStableId([node.id, 'truncated']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: { ...payload, text: squashResult.text },
replacesId: node.id,
turnId: node.turnId,
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type {
ContextProcessor,
@@ -112,7 +112,8 @@ export function createRollingSummaryProcessor(
try {
// Synthesize the rolling summary synchronously
const snapshotText = await generateRollingSummary(nodesToSummarize);
const newId = randomUUID();
const consumedIds = nodesToSummarize.map((n) => n.id);
const newId = deriveStableId(consumedIds);
const summaryNode: RollingSummary = {
id: newId,
@@ -121,10 +122,9 @@ export function createRollingSummaryProcessor(
timestamp: nodesToSummarize[nodesToSummarize.length - 1].timestamp,
role: 'user',
payload: { text: snapshotText },
abstractsIds: nodesToSummarize.map((n) => n.id),
abstractsIds: consumedIds,
};
const consumedIds = nodesToSummarize.map((n) => n.id);
const returnedNodes = targets.filter(
(t) => !consumedIds.includes(t.id),
);
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type {
ContextProcessor,
@@ -90,8 +90,11 @@ export function createStateSnapshotProcessor(
const isValid = consumedIds.every((id) => targetIds.has(id));
if (isValid) {
debugLogger.log(
`[StateSnapshotProcessor] Successfully spliced PROPOSED_SNAPSHOT from Inbox into Graph. Consumed ${consumedIds.length} nodes.`,
);
// If valid, apply it!
const newId = randomUUID();
const newId = deriveStableId(consumedIds);
const snapshotNode: Snapshot = {
id: newId,
@@ -120,6 +123,10 @@ export function createStateSnapshotProcessor(
inbox.consume(proposed.id);
return returnedNodes;
} else {
debugLogger.log(
`[StateSnapshotProcessor] Rejected PROPOSED_SNAPSHOT from Inbox because one or more target IDs were missing from the current graph window.`,
);
}
}
}
@@ -179,11 +186,11 @@ export function createStateSnapshotProcessor(
maxStateTokens: options.maxStateTokens,
},
);
const newId = randomUUID();
const consumedIds = nodesToSummarize.map((n) => n.id);
if (baselineIdToConsume && !consumedIds.includes(baselineIdToConsume)) {
consumedIds.push(baselineIdToConsume);
}
const newId = deriveStableId(consumedIds);
const snapshotNode: Snapshot = {
id: newId,
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import * as fs from 'node:fs/promises';
@@ -120,7 +120,7 @@ export function createToolMaskingProcessor(
directoryCreated = true;
}
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${randomUUID()}.txt`;
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${deriveStableId([content])}.txt`;
const filePath = path.join(toolOutputsDir, fileName);
await fs.writeFile(filePath, content);
@@ -214,9 +214,10 @@ export function createToolMaskingProcessor(
functionCall: newFC,
});
const newId = deriveStableId([node.id, 'masked']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: maskedPart,
replacesId: node.id,
turnId: node.turnId,
@@ -242,9 +243,10 @@ export function createToolMaskingProcessor(
functionResponse: newFR,
});
const newId = deriveStableId([node.id, 'masked']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: maskedPart,
replacesId: node.id,
turnId: node.turnId,
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ import { SimulationHarness } from './simulationHarness.js';
import { createMockLlmClient } from '../testing/contextTestUtils.js';
import type { ContextProfile } from '../config/profiles.js';
import { generalistProfile } from '../config/profiles.js';
import type { Content } from '@google/genai';
import { type HistoryTurn } from '../../core/agentChatHistory.js';
describe('Context Manager Hysteresis Tests', () => {
const mockLlmClient = createMockLlmClient(['<SNAPSHOT>']);
@@ -18,6 +18,7 @@ describe('Context Manager Hysteresis Tests', () => {
...generalistProfile,
name: 'Hysteresis Stress Test',
config: {
...generalistProfile.config,
budget: {
maxTokens: 5000,
retainedTokens: 1000,
@@ -26,9 +27,13 @@ describe('Context Manager Hysteresis Tests', () => {
},
});
const getProjectionTokens = (proj: Content[], harness: SimulationHarness) =>
const getProjectionTokens = (
proj: HistoryTurn[],
harness: SimulationHarness,
) =>
proj.reduce(
(sum, c) => sum + harness.env.tokenCalculator.calculateContentTokens(c),
(sum, c) =>
sum + harness.env.tokenCalculator.calculateContentTokens(c.content),
0,
);
@@ -57,7 +62,7 @@ describe('Context Manager Hysteresis Tests', () => {
// No snapshot because maxTokens (5000) not exceeded, and deficit < threshold.
expect(
state.finalProjection.some((c) =>
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
c.content.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
),
).toBe(false);
@@ -79,7 +84,7 @@ describe('Context Manager Hysteresis Tests', () => {
state = await harness.getGoldenState();
expect(
state.finalProjection.some((c) =>
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
c.content.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
),
).toBe(true);
});
@@ -108,7 +113,7 @@ describe('Context Manager Hysteresis Tests', () => {
let state = await harness.getGoldenState();
expect(
state.finalProjection.some((c) =>
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
c.content.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
),
).toBe(true);
@@ -17,6 +17,7 @@ expect.addSnapshotSerializer({
(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(
val,
) ||
/^[0-9a-f]{32}$/i.test(val) ||
/[\\/]tmp[\\/]sim/.test(val)),
print: (val) => {
if (typeof val !== 'string') return `"${val}"`;
@@ -25,6 +26,7 @@ expect.addSnapshotSerializer({
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
'<UUID>',
)
.replace(/\b[0-9a-f]{32}\b/gi, '<UUID>')
.replace(/[\\/]tmp[\\/]sim[^\s"'\]]*/g, '<MOCKED_DIR>');
// Also scrub timestamps in filenames like blob_1234567890_...
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { ContextManager } from '../contextManager.js';
import { AgentChatHistory } from '../../core/agentChatHistory.js';
import type { Content } from '@google/genai';
@@ -98,7 +99,8 @@ export class SimulationHarness {
async simulateTurn(messages: Content[]) {
// 1. Append the new messages
const currentHistory = this.chatHistory.get();
this.chatHistory.set([...currentHistory, ...messages]);
const turns = messages.map((m) => ({ id: randomUUID(), content: m }));
this.chatHistory.set([...currentHistory, ...turns]);
// 2. Measure tokens immediately after append
const tokensBefore = this.env.tokenCalculator.calculateConcreteListTokens(
@@ -122,4 +122,69 @@ describe('AdaptiveTokenCalculator', () => {
expect(calculator.getLearnedWeight()).toBe(1.0);
});
it('should subtract overhead tokens from actual tokens when determining target weight', () => {
const eventBus = new ContextEventBus();
const getOverheadTokens = () => 40;
const calculator = new AdaptiveTokenCalculator(
charsPerToken,
registry,
eventBus,
getOverheadTokens,
);
// Initial state: weight = 1.0
// Simulate an event where the API reported 100 tokens, and our base units were 100
// But overhead is 40.
// actualGraphTokens = 100 - 40 = 60
// rawTargetWeight = 60 / 100 = 0.6
// targetWeight = Math.max(0.5, 0.6) = 0.6
// newWeight = 1.0 * 0.8 + 0.6 * 0.2 = 0.8 + 0.12 = 0.92
eventBus.emitTokenGroundTruth({
actualTokens: 100,
promptBaseUnits: 100,
});
expect(calculator.getLearnedWeight()).toBeCloseTo(0.92, 5);
});
it('should enforce the maxStep limit to prevent violent oscillation from massive outliers', () => {
const eventBus = new ContextEventBus();
const maxStep = 0.05; // Tight limit
const calculator = new AdaptiveTokenCalculator(
charsPerToken,
registry,
eventBus,
undefined,
{ maxStep },
);
// Initial state: weight = 1.0
// Simulate a massive outlier where the API reports 10,000 tokens for 100 base units.
// rawTargetWeight = 100
// targetWeight = Math.min(100, 1.0 * 2.0) = 2.0
// emaWeight = 1.0 * 0.8 + 2.0 * 0.2 = 1.2
// BUT maxStep is 0.05, so the actual step is clamped.
// finalWeight = 1.0 + 0.05 = 1.05
eventBus.emitTokenGroundTruth({
actualTokens: 10000,
promptBaseUnits: 100,
});
expect(calculator.getLearnedWeight()).toBeCloseTo(1.05, 5);
// Simulate a massive under-estimation
// rawTargetWeight = 0
// targetWeight = Math.max(0, 1.05 * 0.5) = 0.525
// emaWeight = 1.05 * 0.8 + 0.525 * 0.2 = 0.84 + 0.105 = 0.945
// BUT maxStep is 0.05, so step is clamped: 1.05 - 0.05 = 1.0
eventBus.emitTokenGroundTruth({
actualTokens: 0,
promptBaseUnits: 100,
});
expect(calculator.getLearnedWeight()).toBeCloseTo(1.0, 5);
});
});
@@ -14,6 +14,13 @@ import type { NodeBehaviorRegistry } from '../graph/behaviorRegistry.js';
import type { ContextEventBus, TokenGroundTruthEvent } from '../eventBus.js';
import { debugLogger } from '../../utils/debugLogger.js';
export interface AdaptiveLearningConfig {
/** The momentum factor for the Exponential Moving Average (EMA). Defaults to 0.2. */
learningRate?: number;
/** The absolute maximum change allowed to the weight in a single turn. Defaults to 0.15. */
maxStep?: number;
}
/**
* An Adaptive Token Calculator that dynamically learns the true token cost of the user's
* conversation by applying an Exponential Moving Average (EMA) gradient descent to
@@ -26,12 +33,18 @@ import { debugLogger } from '../../utils/debugLogger.js';
export class AdaptiveTokenCalculator implements AdvancedTokenCalculator {
private learnedWeight = 1.0;
private readonly baseCalculator: StaticTokenCalculator;
private readonly learningRate: number;
private readonly maxStep: number;
constructor(
charsPerToken: number,
registry: NodeBehaviorRegistry,
eventBus: ContextEventBus,
private readonly getOverheadTokens?: () => number,
config?: AdaptiveLearningConfig,
) {
this.learningRate = config?.learningRate ?? 0.2;
this.maxStep = config?.maxStep ?? 0.15;
this.baseCalculator = new StaticTokenCalculator(charsPerToken, registry);
eventBus.onTokenGroundTruth((event: TokenGroundTruthEvent) => {
this.handleGroundTruth(event.actualTokens, event.promptBaseUnits);
@@ -41,21 +54,44 @@ export class AdaptiveTokenCalculator implements AdvancedTokenCalculator {
private handleGroundTruth(actualTokens: number, promptBaseUnits: number) {
if (promptBaseUnits <= 0) return;
const overheadTokens = this.getOverheadTokens
? this.getOverheadTokens()
: 0;
// The Gemini API token count includes the static overhead (system instruction + tools)
// and the dynamic chat history (which we measure as promptBaseUnits).
// We subtract the overhead so the adaptive calculator is comparing "apples to apples"
// when learning the weight multiplier for the graph nodes.
const actualGraphTokens = Math.max(0, actualTokens - overheadTokens);
// Determine what ratio we should have used
const targetWeight = actualTokens / promptBaseUnits;
const rawTargetWeight = actualGraphTokens / promptBaseUnits;
const oldWeight = this.learnedWeight;
// Apply Momentum (Learning Rate)
const learningRate = 0.2;
const newWeight =
oldWeight * (1 - learningRate) + targetWeight * learningRate;
// Dampen extreme outliers *before* applying the EMA by capping the target weight
// to a reasonable multiple of the current weight. This prevents a single massive
// anomaly from destroying the running average.
const targetWeight = Math.max(
oldWeight * 0.5,
Math.min(rawTargetWeight, oldWeight * 2.0),
);
// Clamp to reasonable safety bounds to prevent rogue metadata poisoning the system
// Apply Momentum (Learning Rate)
let newWeight =
oldWeight * (1 - this.learningRate) + targetWeight * this.learningRate;
// Hard limit the maximum step size per turn to prevent violent oscillation
if (newWeight > oldWeight + this.maxStep)
newWeight = oldWeight + this.maxStep;
if (newWeight < oldWeight - this.maxStep)
newWeight = oldWeight - this.maxStep;
// Clamp to reasonable absolute safety bounds
this.learnedWeight = Math.max(0.5, Math.min(newWeight, 2.0));
debugLogger.log(
`[AdaptiveTokenCalculator] Learned weight updated to ${this.learnedWeight.toFixed(3)} ` +
`(API Tokens: ${actualTokens}, Base Units: ${promptBaseUnits}, Target Ratio: ${targetWeight.toFixed(3)})`,
`(API Tokens: ${actualTokens}, Overhead: ${overheadTokens}, Graph Tokens: ${actualGraphTokens}, Base Units: ${promptBaseUnits}, Target Ratio: ${targetWeight.toFixed(3)})`,
);
}
@@ -5,12 +5,132 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SnapshotGenerator, type SnapshotState } from './snapshotGenerator.js';
import {
SnapshotGenerator,
type SnapshotState,
SnapshotStateHelper,
} from './snapshotGenerator.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import { NodeType, type ConcreteNode } from '../graph/types.js';
import type { Mock } from 'vitest';
describe('SnapshotStateHelper', () => {
describe('exportState', () => {
it('should flatten nested abstractsIds to pristine IDs', () => {
// Setup a graph with nested snapshots
// S3 abstracts [S2, N5]
// S2 abstracts [S1, N3, N4]
// S1 abstracts [N1, N2]
const nodes: ConcreteNode[] = [
{
id: 'N1',
type: NodeType.USER_PROMPT,
timestamp: 10,
role: 'user',
payload: { text: '1' },
turnId: 'T1',
},
{
id: 'N2',
type: NodeType.AGENT_THOUGHT,
timestamp: 20,
role: 'model',
payload: { text: '2' },
turnId: 'T1',
},
{
id: 'S1',
type: NodeType.SNAPSHOT,
timestamp: 30,
role: 'user',
payload: { text: 'State 1' },
turnId: 'S1',
abstractsIds: ['N1', 'N2'],
},
{
id: 'N3',
type: NodeType.USER_PROMPT,
timestamp: 40,
role: 'user',
payload: { text: '3' },
turnId: 'T2',
},
{
id: 'N4',
type: NodeType.AGENT_THOUGHT,
timestamp: 50,
role: 'model',
payload: { text: '4' },
turnId: 'T2',
},
{
id: 'S2',
type: NodeType.SNAPSHOT,
timestamp: 60,
role: 'user',
payload: { text: 'State 2' },
turnId: 'S2',
abstractsIds: ['S1', 'N3', 'N4'],
},
{
id: 'N5',
type: NodeType.USER_PROMPT,
timestamp: 70,
role: 'user',
payload: { text: '5' },
turnId: 'T3',
},
{
id: 'S3',
type: NodeType.SNAPSHOT,
timestamp: 80,
role: 'user',
payload: { text: 'State 3' },
turnId: 'S3',
abstractsIds: ['S2', 'N5'],
},
];
const state = SnapshotStateHelper.exportState(nodes);
expect(state.snapshot).toBeDefined();
expect(state.snapshot?.text).toBe('State 3');
// Should be flattened to only the "pristine" (non-snapshot) IDs
const consumedIds = state.snapshot?.consumedIds;
expect(consumedIds).toContain('N1');
expect(consumedIds).toContain('N2');
expect(consumedIds).toContain('N3');
expect(consumedIds).toContain('N4');
expect(consumedIds).toContain('N5');
// Should NOT contain the intermediate snapshot IDs
expect(consumedIds).not.toContain('S1');
expect(consumedIds).not.toContain('S2');
expect(consumedIds?.length).toBe(5);
});
it('should return empty state if no snapshot baseline is found', () => {
const nodes: ConcreteNode[] = [
{
id: 'N1',
type: NodeType.USER_PROMPT,
timestamp: 10,
role: 'user',
payload: { text: '1' },
turnId: 'T1',
},
];
const state = SnapshotStateHelper.exportState(nodes);
expect(state).toEqual({});
});
});
});
describe('SnapshotGenerator', () => {
let mockEnv: ContextEnvironment;
let mockGenerateJson: Mock;
@@ -48,10 +48,39 @@ export interface SnapshotState {
recent_arc: string[];
}
import { debugLogger } from '../../utils/debugLogger.js';
export function isSnapshotState(text: string): boolean {
const trimmed = text.trim();
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
return false;
}
try {
const parsed: unknown = JSON.parse(trimmed);
if (!isRecord(parsed)) return false;
const isSnap =
Array.isArray(parsed['active_tasks']) &&
Array.isArray(parsed['discovered_facts']) &&
Array.isArray(parsed['constraints_and_preferences']) &&
Array.isArray(parsed['recent_arc']);
if (!isSnap) {
debugLogger.log(
'[isSnapshotState] FAILED FOR JSON:',
JSON.stringify(parsed),
);
}
return isSnap;
} catch {
debugLogger.log('[isSnapshotState] PARSE FAILED FOR:', trimmed);
return false;
}
}
export interface BaselineSnapshotInfo {
text: string;
abstractsIds: string[];
id: string;
timestamp: number;
}
/**
@@ -61,6 +90,20 @@ export interface BaselineSnapshotInfo {
export function findLatestSnapshotBaseline(
targets: readonly ConcreteNode[],
): BaselineSnapshotInfo | undefined {
debugLogger.log(
'[findLatestSnapshotBaseline] Targets:',
targets.map((t) => ({
id: t.id,
type: t.type,
text:
t.payload &&
typeof t.payload === 'object' &&
'text' in t.payload &&
typeof t.payload.text === 'string'
? t.payload.text.substring(0, 20)
: '',
})),
);
const lastSnapshotNode = [...targets]
.reverse()
.find((n) => n.type === NodeType.SNAPSHOT && n.payload.text);
@@ -72,8 +115,10 @@ export function findLatestSnapshotBaseline(
? [...lastSnapshotNode.abstractsIds]
: [],
id: lastSnapshotNode.id,
timestamp: lastSnapshotNode.timestamp,
};
}
return undefined;
}
@@ -326,3 +371,61 @@ ${formatNodesForLlm(nodes)}`;
return JSON.stringify(newState);
}
}
/**
* Shared logic for working with Snapshot node state.
*/
export class SnapshotStateHelper {
/**
* Flatten nested abstract IDs to only the "pristine" (non-snapshot) IDs.
*/
static flattenAbstracts(
nodes: ConcreteNode[],
abstractsIds: readonly string[],
): string[] {
const pristineIds: string[] = [];
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const walk = (ids: readonly string[]) => {
for (const id of ids) {
const node = nodeMap.get(id);
if (!node) {
// Fallback: if node not in map, treat as pristine ID
pristineIds.push(id);
continue;
}
if (node.type === NodeType.SNAPSHOT && node.abstractsIds) {
walk(node.abstractsIds);
} else {
pristineIds.push(id);
}
}
};
walk(abstractsIds);
return Array.from(new Set(pristineIds)); // Dedupe
}
/**
* Helper to extract state from the most recent snapshot in a list of nodes.
*/
static exportState(nodes: ConcreteNode[]): {
snapshot?: { text: string; consumedIds: string[] };
} {
const baseline = findLatestSnapshotBaseline(nodes);
if (!baseline) return {};
const node = nodes.find((n) => n.id === baseline.id);
if (!node || node.type !== NodeType.SNAPSHOT) return {};
const consumedIds = this.flattenAbstracts(nodes, node.abstractsIds || []);
return {
snapshot: {
text: baseline.text,
consumedIds,
},
};
}
}
+45 -16
View File
@@ -6,21 +6,35 @@
import type { Content } from '@google/genai';
/**
* A durable wrapper for Gemini Content that carries a stable ID.
* This ID is preserved across all transformations and is used as the anchor
* for context graph node identity.
*/
export interface HistoryTurn {
readonly id: string;
readonly content: Content;
}
export type HistoryEventType = 'PUSH' | 'SYNC_FULL' | 'CLEAR' | 'SILENT_SYNC';
export interface HistoryEvent {
type: HistoryEventType;
payload: readonly Content[];
payload: readonly HistoryTurn[];
}
export type HistoryListener = (event: HistoryEvent) => void;
/**
* The 'Strong Owner' of chat history turns.
* It ensures that every turn in the session is associated with a durable ID.
*/
export class AgentChatHistory {
private history: Content[];
private history: HistoryTurn[] = [];
private listeners: Set<HistoryListener> = new Set();
constructor(initialHistory: Content[] = []) {
this.history = [...initialHistory];
constructor(initialTurns: HistoryTurn[] = []) {
this.history = [...initialTurns];
}
subscribe(listener: HistoryListener): () => void {
@@ -30,20 +44,27 @@ export class AgentChatHistory {
return () => this.listeners.delete(listener);
}
private notify(type: HistoryEventType, payload: readonly Content[]) {
private notify(type: HistoryEventType, payload: readonly HistoryTurn[]) {
const event: HistoryEvent = { type, payload };
for (const listener of this.listeners) {
listener(event);
}
}
push(content: Content) {
this.history.push(content);
this.notify('PUSH', [content]);
/**
* Adds a new turn to the history.
* Every turn must have a durable ID, usually provided by the ChatRecordingService.
*/
push(turn: HistoryTurn) {
this.history.push(turn);
this.notify('PUSH', [turn]);
}
set(history: readonly Content[], options: { silent?: boolean } = {}) {
this.history = [...history];
/**
* Overwrites the entire history with a new list of turns.
*/
set(turns: readonly HistoryTurn[], options: { silent?: boolean } = {}) {
this.history = [...turns];
this.notify(options.silent ? 'SILENT_SYNC' : 'SYNC_FULL', this.history);
}
@@ -52,20 +73,28 @@ export class AgentChatHistory {
this.notify('CLEAR', []);
}
get(): readonly Content[] {
get(): readonly HistoryTurn[] {
return this.history;
}
map(callback: (value: Content, index: number, array: Content[]) => Content) {
this.history = this.history.map(callback);
this.notify('SYNC_FULL', this.history);
/**
* Returns a copy of the raw Gemini Content[] for API consumption.
*/
getContents(): Content[] {
return this.history.map((h) => h.content);
}
map<U>(
callback: (value: HistoryTurn, index: number, array: HistoryTurn[]) => U,
): U[] {
return this.history.map(callback);
}
flatMap<U>(
callback: (
value: Content,
value: HistoryTurn,
index: number,
array: Content[],
array: HistoryTurn[],
) => U | readonly U[],
): U[] {
return this.history.flatMap(callback);
+16 -6
View File
@@ -46,6 +46,7 @@ import { LoopDetectionService } from '../services/loopDetectionService.js';
import { ChatCompressionService } from '../context/chatCompressionService.js';
import { AgentHistoryProvider } from '../context/agentHistoryProvider.js';
import type { ContextManager } from '../context/contextManager.js';
import type { HistoryTurn } from './agentChatHistory.js';
import { ideContextStore } from '../ide/ideContext.js';
import { logNextSpeakerCheck } from '../telemetry/loggers.js';
import type {
@@ -67,6 +68,7 @@ import {
} from '../availability/policyHelpers.js';
import { getDisplayString, resolveModel } from '../config/models.js';
import { partToString } from '../utils/partUtils.js';
import { randomUUID } from 'node:crypto';
import {
coreEvents,
CoreEvent,
@@ -293,7 +295,7 @@ export class GeminiClient {
this.getChat().stripThoughtsFromHistory();
}
setHistory(history: readonly Content[]) {
setHistory(history: ReadonlyArray<Content | HistoryTurn>) {
this.getChat().setHistory(history);
this.updateTelemetryTokenCount();
this.forceFullIdeContext = true;
@@ -335,7 +337,7 @@ export class GeminiClient {
}
async resumeChat(
history: Content[],
history: ReadonlyArray<Content | HistoryTurn>,
resumedSessionData?: ResumedSessionData,
): Promise<void> {
this.chat = await this.startChat(history, resumedSessionData);
@@ -376,7 +378,7 @@ export class GeminiClient {
}
async startChat(
extraHistory?: Content[],
extraHistory?: ReadonlyArray<Content | HistoryTurn>,
resumedSessionData?: ResumedSessionData,
): Promise<GeminiChat> {
this.forceFullIdeContext = true;
@@ -398,7 +400,7 @@ export class GeminiClient {
this.config,
systemInstruction,
tools,
history,
[...history],
resumedSessionData,
async (modelId: string) => {
this.lastUsedModelId = modelId;
@@ -419,7 +421,7 @@ export class GeminiClient {
await reportError(
error,
'Error initializing Gemini chat session.',
history,
[...history],
'startChat',
);
throw new Error(`Failed to initialize chat: ${getErrorMessage(error)}`);
@@ -641,7 +643,15 @@ export class GeminiClient {
if (this.config.getContextManagementConfig().enabled) {
if (this.contextManager) {
const pendingRequest = createUserContent(request);
const rawPendingRequest = createUserContent(request);
const pendingRequest = {
id:
this.getChatRecordingService()?.recordSyntheticMessage(
'user',
rawPendingRequest.parts || [],
) || randomUUID(),
content: rawPendingRequest,
};
const {
history: newHistory,
didApplyManagement,
@@ -199,6 +199,13 @@ export async function createContentGenerator(
sessionId?: string,
): Promise<ContentGenerator> {
const generator = await (async () => {
if (gcConfig.fakeResponsesNonStrict) {
const fakeGenerator = await FakeContentGenerator.fromFile(
gcConfig.fakeResponsesNonStrict,
{ nonStrict: true },
);
return new LoggingContentGenerator(fakeGenerator, gcConfig);
}
if (gcConfig.fakeResponses) {
const fakeGenerator = await FakeContentGenerator.fromFile(
gcConfig.fakeResponses,
+38 -3
View File
@@ -36,6 +36,18 @@ export type FakeResponse =
response: EmbedContentResponse;
};
/**
* Options for the FakeContentGenerator.
*/
export interface FakeContentGeneratorOptions {
/**
* If true, the generator will find the first available response that matches
* the requested method, rather than strictly following the input order.
* Useful for non-deterministic background tasks.
*/
nonStrict?: boolean;
}
// A ContentGenerator that responds with canned responses.
//
// Typically these would come from a file, provided by the `--fake-responses`
@@ -46,22 +58,45 @@ export class FakeContentGenerator implements ContentGenerator {
userTierName?: string;
paidTier?: GeminiUserTier;
constructor(private readonly responses: FakeResponse[]) {}
private readonly responses: FakeResponse[];
static async fromFile(filePath: string): Promise<FakeContentGenerator> {
constructor(
responses: FakeResponse[],
private readonly options: FakeContentGeneratorOptions = {},
) {
this.responses = structuredClone(responses);
}
static async fromFile(
filePath: string,
options: FakeContentGeneratorOptions = {},
): Promise<FakeContentGenerator> {
const fileContent = await promises.readFile(filePath, 'utf-8');
const responses = fileContent
.split('\n')
.filter((line) => line.trim() !== '')
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
.map((line) => JSON.parse(line) as FakeResponse);
return new FakeContentGenerator(responses);
return new FakeContentGenerator(responses, options);
}
private getNextResponse<
M extends FakeResponse['method'],
R = Extract<FakeResponse, { method: M }>['response'],
>(method: M, request: unknown): R {
if (this.options.nonStrict) {
const index = this.responses.findIndex((r) => r.method === method);
if (index === -1) {
throw new Error(
`No more mock responses for ${method}, got request:\n` +
safeJsonStringify(request),
);
}
const response = this.responses.splice(index, 1)[0];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return response.response as R;
}
const response = this.responses[this.callCounter++];
if (!response) {
throw new Error(
+128 -54
View File
@@ -19,6 +19,7 @@ import {
SYNTHETIC_THOUGHT_SIGNATURE,
type StreamEvent,
stripToolCallIdPrefixes,
type HistoryTurn,
} from './geminiChat.js';
import {
type CompletedToolCall,
@@ -40,6 +41,7 @@ import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils
import type { HookSystem } from '../hooks/hookSystem.js';
import { LlmRole } from '../telemetry/types.js';
import { BINARY_INJECTION_KEY } from '../utils/generateContentResponseUtilities.js';
import type { ResumedSessionData } from '../services/chatRecordingTypes.js';
// Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>();
@@ -234,9 +236,9 @@ describe('GeminiChat', () => {
describe('constructor', () => {
it('should initialize lastPromptTokenCount based on history size', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
{ id: '2', content: { role: 'model', parts: [{ text: 'Hi there' }] } },
];
const chatWithHistory = new GeminiChat(mockConfig, '', [], history);
// 'Hello': 5 chars * 0.25 = 1.25
@@ -249,12 +251,66 @@ describe('GeminiChat', () => {
const chatEmpty = new GeminiChat(mockConfig);
expect(chatEmpty.getLastPromptTokenCount()).toBe(0);
});
it('should prioritize in-memory history over resumedSessionData', () => {
// This test simulates a "hot restart" after a context management operation
// like compression, where the in-memory history is shorter and more up-to-date
// than the session data that might be on disk.
// 1. A stale, longer history from a persisted session record
const resumedSessionData = {
conversation: {
messages: [
{
id: 'a',
type: 'user',
content: [{ text: 'turn 1' }],
create_time: new Date(),
},
{
id: 'b',
type: 'gemini',
content: [{ text: 'turn 2' }],
create_time: new Date(),
},
{
id: 'c',
type: 'user',
content: [{ text: 'turn 3' }],
create_time: new Date(),
},
],
},
} as unknown as ResumedSessionData;
// 2. A fresh, compressed in-memory history
const compressedHistory: HistoryTurn[] = [
{
id: 'summary-1',
content: { role: 'user', parts: [{ text: 'summary of turns 1-3' }] },
},
];
// 3. Instantiate the chat, providing both.
const chat = new GeminiChat(
mockConfig,
'',
[],
compressedHistory, // This should be prioritized
resumedSessionData, // This should be ignored
);
// 4. Assert that the shorter, in-memory history was used.
const finalHistory = chat.getHistoryTurns();
expect(finalHistory).toHaveLength(1);
expect(finalHistory[0].id).toBe('summary-1');
});
});
describe('setHistory', () => {
it('should recalculate lastPromptTokenCount when history is updated', () => {
const initialHistory: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
const initialHistory: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
];
const chatWithHistory = new GeminiChat(
mockConfig,
@@ -264,14 +320,17 @@ describe('GeminiChat', () => {
);
const initialCount = chatWithHistory.getLastPromptTokenCount();
const newHistory: Content[] = [
const newHistory: HistoryTurn[] = [
{
role: 'user',
parts: [
{
text: 'This is a much longer history item that should result in more tokens than just hello.',
},
],
id: '2',
content: {
role: 'user',
parts: [
{
text: 'This is a much longer history item that should result in more tokens than just hello.',
},
],
},
},
];
chatWithHistory.setHistory(newHistory);
@@ -331,9 +390,9 @@ describe('GeminiChat', () => {
).resolves.not.toThrow();
// 3. Verify history was recorded correctly
const history = chat.getHistory();
const history = chat.getHistoryTurns();
expect(history.length).toBe(2); // user turn + model turn
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn?.parts?.length).toBe(1); // The empty part is discarded
expect(modelTurn?.parts![0].functionCall).toBeDefined();
});
@@ -433,9 +492,9 @@ describe('GeminiChat', () => {
).resolves.not.toThrow();
// 3. Verify history was recorded correctly with only the valid part.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
expect(history.length).toBe(2); // user turn + model turn
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn?.parts?.length).toBe(1);
expect(modelTurn?.parts![0].text).toBe('Initial valid content...');
});
@@ -478,9 +537,9 @@ describe('GeminiChat', () => {
}
// 3. Assert: Check that the final history was correctly consolidated.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
expect(history.length).toBe(2);
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn?.parts?.length).toBe(1);
expect(modelTurn?.parts![0].text).toBe('Hello World!');
});
@@ -538,12 +597,12 @@ describe('GeminiChat', () => {
}
// 3. Assert: Check that the final history was correctly consolidated.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
// The history should contain the user's turn and ONE consolidated model turn.
expect(history.length).toBe(2);
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn.role).toBe('model');
// The model turn should have 3 distinct parts: the merged text, the function call, and the final text.
@@ -599,10 +658,10 @@ describe('GeminiChat', () => {
}
// 3. Assert: Check that the final history contains both function calls.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
expect(history.length).toBe(2);
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn.role).toBe('model');
expect(modelTurn.parts?.length).toBe(2);
expect(modelTurn.parts![0].functionCall?.name).toBe('tool_A');
@@ -647,8 +706,8 @@ describe('GeminiChat', () => {
// Consume the stream to trigger history recording
}
const history = chat.getHistory();
const modelTurn = history[1];
const history = chat.getHistoryTurns();
const modelTurn = history[1].content;
expect(modelTurn.parts?.length).toBe(2);
expect(modelTurn.parts![0].functionCall?.name).toBe('tool_X');
expect(modelTurn.parts![0].functionCall?.args).toEqual({ id: 1 });
@@ -694,12 +753,12 @@ describe('GeminiChat', () => {
}
// 3. Assert: Check the final state of the history.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
// The history should contain two turns: the user's message and the model's response.
expect(history.length).toBe(2);
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn.role).toBe('model');
// CRUCIAL ASSERTION:
@@ -713,21 +772,27 @@ describe('GeminiChat', () => {
it('should throw an error when a tool call is followed by an empty stream response', async () => {
// 1. Setup: A history where the model has just made a function call.
const initialHistory: Content[] = [
const initialHistory: HistoryTurn[] = [
{
role: 'user',
parts: [{ text: 'Find a good Italian restaurant for me.' }],
id: '1',
content: {
role: 'user',
parts: [{ text: 'Find a good Italian restaurant for me.' }],
},
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'find_restaurant',
args: { cuisine: 'Italian' },
id: '2',
content: {
role: 'model',
parts: [
{
functionCall: {
name: 'find_restaurant',
args: { cuisine: 'Italian' },
},
},
},
],
],
},
},
];
chat.setHistory(initialHistory);
@@ -1251,31 +1316,40 @@ describe('GeminiChat', () => {
describe('addHistory', () => {
it('should add a new content item to the history', () => {
const newContent: Content = {
role: 'user',
parts: [{ text: 'A new message' }],
const newTurn: HistoryTurn = {
id: '1',
content: {
role: 'user',
parts: [{ text: 'A new message' }],
},
};
chat.addHistory(newContent);
const history = chat.getHistory();
chat.addHistory(newTurn);
const history = chat.getHistoryTurns();
expect(history.length).toBe(1);
expect(history[0]).toEqual(newContent);
expect(history[0]).toEqual(newTurn);
});
it('should add multiple items correctly', () => {
const content1: Content = {
role: 'user',
parts: [{ text: 'Message 1' }],
const turn1: HistoryTurn = {
id: '1',
content: {
role: 'user',
parts: [{ text: 'Message 1' }],
},
};
const content2: Content = {
role: 'model',
parts: [{ text: 'Message 2' }],
const turn2: HistoryTurn = {
id: '2',
content: {
role: 'model',
parts: [{ text: 'Message 2' }],
},
};
chat.addHistory(content1);
chat.addHistory(content2);
const history = chat.getHistory();
chat.addHistory(turn1);
chat.addHistory(turn2);
const history = chat.getHistoryTurns();
expect(history.length).toBe(2);
expect(history[0]).toEqual(content1);
expect(history[1]).toEqual(content2);
expect(history[0]).toEqual(turn1);
expect(history[1]).toEqual(turn2);
});
});
+184 -74
View File
@@ -19,7 +19,10 @@ import {
type GenerateContentParameters,
type FunctionCall,
} from '@google/genai';
import { AgentChatHistory } from './agentChatHistory.js';
export { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
import { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
import { randomUUID } from 'node:crypto';
import { toParts } from '../code_assist/converter.js';
import {
retryWithBackoff,
@@ -159,8 +162,9 @@ function isValidContent(content: Content): boolean {
* @throws Error if the history does not start with a user turn.
* @throws Error if the history contains an invalid role.
*/
function validateHistory(history: Content[]) {
for (const content of history) {
function validateHistory(history: Array<Content | HistoryTurn>) {
for (const item of history) {
const content = 'content' in item ? item.content : item;
if (content.role !== 'user' && content.role !== 'model') {
throw new Error(`Role must be user or model, but got ${content.role}.`);
}
@@ -175,23 +179,25 @@ function validateHistory(history: Content[]) {
* filters or recitation). Extracting valid turns from the history
* ensures that subsequent requests could be accepted by the model.
*/
function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
function extractCuratedHistory(
comprehensiveHistory: readonly HistoryTurn[],
): HistoryTurn[] {
if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
return [];
}
const curatedHistory: Content[] = [];
const curatedHistory: HistoryTurn[] = [];
const length = comprehensiveHistory.length;
let i = 0;
while (i < length) {
if (comprehensiveHistory[i].role === 'user') {
if (comprehensiveHistory[i].content.role === 'user') {
curatedHistory.push(comprehensiveHistory[i]);
i++;
} else {
const modelOutput: Content[] = [];
const modelOutput: HistoryTurn[] = [];
let isValid = true;
while (i < length && comprehensiveHistory[i].role === 'model') {
while (i < length && comprehensiveHistory[i].content.role === 'model') {
modelOutput.push(comprehensiveHistory[i]);
if (isValid && !isValidContent(comprehensiveHistory[i])) {
if (isValid && !isValidContent(comprehensiveHistory[i].content)) {
isValid = false;
}
i++;
@@ -272,15 +278,43 @@ export class GeminiChat {
readonly context: AgentLoopContext,
private systemInstruction: string = '',
private tools: Tool[] = [],
history: Content[] = [],
history: Array<Content | HistoryTurn> = [],
resumedSessionData?: ResumedSessionData,
private readonly onModelChanged?: (modelId: string) => Promise<Tool[]>,
) {
validateHistory(history);
this.agentHistory = new AgentChatHistory(history);
let initialHistory: HistoryTurn[];
// If history is passed, it is the most up-to-date in-memory state and takes precedence.
// This is critical for hot-restarts after operations like context compression.
if (history.length > 0) {
initialHistory = history.map((item) =>
'id' in item && 'content' in item
? item
: { id: randomUUID(), content: item },
);
} else if (resumedSessionData) {
// Otherwise, if resuming from disk, build from the persisted record.
initialHistory = resumedSessionData.conversation.messages
.filter((m) => m.type === 'user' || m.type === 'gemini')
.map((m) => ({
id: m.id,
content: {
role: m.type === 'user' ? 'user' : 'model',
parts: Array.isArray(m.content)
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(m.content as Part[])
: [{ text: String(m.content) }],
},
}));
} else {
initialHistory = [];
}
this.agentHistory = new AgentChatHistory(initialHistory);
this.chatRecordingService = new ChatRecordingService(context);
this.lastPromptTokenCount = estimateTokenCountSync(
this.agentHistory.flatMap((c) => c.parts || []),
this.agentHistory.flatMap((c) => c.content.parts || []),
);
}
@@ -293,12 +327,21 @@ export class GeminiChat {
kind: 'main' | 'subagent' = 'main',
) {
await this.chatRecordingService.initialize(resumedSessionData, kind);
// Sync initial history with the recorder to ensure all turns (even bootstrapped ones)
// are durable and coordinated.
this.chatRecordingService.updateMessagesFromHistory(
this.agentHistory.get(),
);
}
setSystemInstruction(sysInstr: string) {
this.systemInstruction = sysInstr;
}
getSystemInstruction(): string {
return this.systemInstruction;
}
/**
* Sends a message to the model and returns the response in chunks.
*
@@ -362,41 +405,67 @@ export class GeminiChat {
}
}
this.chatRecordingService.recordMessage({
const id = this.chatRecordingService.recordMessage({
model,
type: 'user',
content: userMessageParts,
displayContent: finalDisplayContent,
});
}
this.agentHistory.push({ id, content: userContent });
} else {
// Record tool response as a message to ensure durable ID and linear history for resume.
const id = this.chatRecordingService.recordSyntheticMessage(
'user',
userContent.parts || [],
);
// Add user content to history ONCE before any attempts.
const binaryInjections = this.extractBinaryInjections(userContent.parts);
if (binaryInjections) {
// Turn 1: The original tool response (now cleaned)
this.agentHistory.push(userContent);
// Binary injections: If the tool output contains binary data, we expand the history.
const binaryParts = this.extractBinaryInjections(userContent.parts);
if (binaryParts) {
// Turn 1: The original tool response (now cleaned)
this.agentHistory.push({ id, content: userContent });
// Turn 2: Synthetic Model Acknowledgment
this.agentHistory.push({
role: 'model',
parts: [
{
text: 'Binary content received. Proceeding with analysis.',
thought: true,
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
// Turn 2: Synthetic Model Acknowledgment
const modelId = this.chatRecordingService.recordSyntheticMessage(
'gemini',
[
{
text: 'Binary content received. Proceeding with analysis.',
thought: true,
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
},
],
);
this.agentHistory.push({
id: modelId,
content: {
role: 'model',
parts: [
{
text: 'Binary content received. Proceeding with analysis.',
thought: true,
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
},
],
},
],
});
});
// Turn 3: The actual binary data (becomes the current request message)
userContent = {
role: 'user',
parts: binaryInjections,
};
// Turn 3: The actual binary data (becomes the current request message)
const binaryId = this.chatRecordingService.recordSyntheticMessage(
'info',
binaryParts,
);
userContent = {
role: 'user',
parts: binaryParts,
};
this.agentHistory.push({ id: binaryId, content: userContent });
} else {
this.agentHistory.push({ id, content: userContent });
}
}
this.agentHistory.push(userContent);
const requestContents = this.getHistory(true);
const requestHistory = this.getHistoryTurns(true);
const streamWithRetries = async function* (
this: GeminiChat,
@@ -420,7 +489,7 @@ export class GeminiChat {
isConnectionPhase = true;
const stream = await this.makeApiCallAndProcessStream(
currentConfigKey,
requestContents,
requestHistory,
prompt_id,
signal,
role,
@@ -542,45 +611,44 @@ export class GeminiChat {
private extractBinaryInjections(
parts: Part[] | undefined,
): Part[] | undefined {
if (!parts) {
return undefined;
}
const binaryInjections: Part[] = [];
for (const part of parts) {
const response = part.functionResponse?.response;
if (response && BINARY_INJECTION_KEY in response) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const binaryParts = response[BINARY_INJECTION_KEY] as Part[];
delete response[BINARY_INJECTION_KEY];
if (Array.isArray(binaryParts)) {
binaryInjections.push(...binaryParts);
const binaryParts: Part[] = [];
if (parts) {
for (const part of parts) {
const response = part.functionResponse?.response;
if (response && BINARY_INJECTION_KEY in response) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const injected = response[BINARY_INJECTION_KEY] as Part[];
delete response[BINARY_INJECTION_KEY];
if (Array.isArray(injected)) {
binaryParts.push(...injected);
}
}
}
}
return binaryInjections.length > 0 ? binaryInjections : undefined;
return binaryParts.length > 0 ? binaryParts : undefined;
}
private async makeApiCallAndProcessStream(
modelConfigKey: ModelConfigKey,
requestContents: readonly Content[],
requestHistory: readonly HistoryTurn[],
prompt_id: string,
abortSignal: AbortSignal,
role: LlmRole,
): Promise<AsyncGenerator<GenerateContentResponse>> {
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
// before sending to the Gemini API. This whitelists only standard Gemini fields.
const scrubbedContents = this.context.config.isContextManagementEnabled()
? scrubHistory([...requestContents])
: [...requestContents];
const scrubbedHistory = this.context.config.isContextManagementEnabled()
? scrubHistory([...requestHistory])
: [...requestHistory];
const scrubbedContents = scrubbedHistory.map((h) => h.content);
const contentsForPreviewModel =
this.ensureActiveLoopHasThoughtSignatures(scrubbedContents);
const requestContents = scrubbedContents;
// Track final request parameters for AfterModel hooks
const {
model: availabilityFinalModel,
@@ -829,14 +897,21 @@ export class GeminiChat {
* @return History contents alternating between user and model for the entire
* chat session.
*/
getHistory(curated: boolean = false): readonly Content[] {
getHistory(curated: boolean = false): Content[] {
return this.getHistoryTurns(curated).map((h) => h.content);
}
/**
* Returns the chat history as HistoryTurns.
*/
getHistoryTurns(curated: boolean = false): HistoryTurn[] {
const history = curated
? extractCuratedHistory([...this.agentHistory.get()])
: this.agentHistory.get();
? extractCuratedHistory(this.agentHistory.get())
: [...this.agentHistory.get()];
return this.context.config.isContextManagementEnabled()
? scrubHistory([...history])
: [...history];
? scrubHistory(history)
: history;
}
/**
@@ -849,24 +924,44 @@ export class GeminiChat {
/**
* Adds a new entry to the chat history.
*/
addHistory(content: Content): void {
this.agentHistory.push(content);
addHistory(content: Content | HistoryTurn): void {
if ('id' in content && 'content' in content) {
this.agentHistory.push(content);
} else {
const id = this.chatRecordingService.recordSyntheticMessage(
content.role === 'user' ? 'user' : 'gemini',
content.parts || [],
);
this.agentHistory.push({ id, content });
}
}
setHistory(
history: readonly Content[],
history: ReadonlyArray<Content | HistoryTurn>,
options: { silent?: boolean } = {},
): void {
this.agentHistory.set(history, options);
const wrappedHistory: HistoryTurn[] = history.map((item) => {
if ('id' in item && 'content' in item) {
return item;
}
const id = this.chatRecordingService.recordSyntheticMessage(
item.role === 'user' ? 'user' : 'gemini',
item.parts || [],
);
return { id, content: item };
});
this.agentHistory.set(wrappedHistory, options);
this.lastPromptTokenCount = estimateTokenCountSync(
this.agentHistory.flatMap((c) => c.parts || []),
this.agentHistory.flatMap((c) => c.content.parts || []),
);
this.chatRecordingService.updateMessagesFromHistory(
this.agentHistory.get(),
);
this.chatRecordingService.updateMessagesFromHistory(history);
}
stripThoughtsFromHistory(): void {
this.agentHistory.map((content) => {
const newContent = { ...content };
const newHistory = this.agentHistory.map((turn) => {
const newContent = { ...turn.content };
if (newContent.parts) {
newContent.parts = newContent.parts.map((part) => {
if (part && typeof part === 'object' && 'thoughtSignature' in part) {
@@ -877,8 +972,9 @@ export class GeminiChat {
return part;
});
}
return newContent;
return { id: turn.id, content: newContent };
});
this.agentHistory.set(newHistory);
}
// To ensure our requests validate, the first function call in every model
@@ -936,6 +1032,10 @@ export class GeminiChat {
this.tools = tools;
}
getTools(): Tool[] {
return this.tools;
}
async maybeIncludeSchemaDepthContext(error: StructuredError): Promise<void> {
// Check for potentially problematic cyclic tools with cyclic schemas
// and include a recommendation to remove potentially problematic tools.
@@ -1162,15 +1262,22 @@ export class GeminiChat {
.join('')
.trim();
let id: string;
// Record model response text from the collected parts.
// Also flush when there are thoughts or a tool call (even with no text)
// so that BeforeTool hooks always see the latest transcript state.
if (responseText || hasThoughts || hasToolCall) {
this.chatRecordingService.recordMessage({
id = this.chatRecordingService.recordMessage({
model,
type: 'gemini',
content: responseText,
});
} else {
// Still need a durable ID even if response is empty (e.g. only tool calls)
id = this.chatRecordingService.recordSyntheticMessage(
'gemini',
consolidatedParts,
);
}
// Stream validation logic: A stream is considered successful if:
@@ -1208,7 +1315,10 @@ export class GeminiChat {
}
}
this.agentHistory.push({ role: 'model', parts: consolidatedParts });
this.agentHistory.push({
id,
content: { role: 'model', parts: consolidatedParts },
});
}
getLastPromptTokenCount(): number {
+3 -3
View File
@@ -365,7 +365,7 @@ describe('HookRunner', () => {
);
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.stringMatching(/bash|pwsh|powershell/),
expect.arrayContaining([
expect.stringMatching(/['"]?\/test\/project['"]?\/hooks\/test\.sh/),
]),
@@ -408,7 +408,7 @@ describe('HookRunner', () => {
);
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.stringMatching(/bash|pwsh|powershell/),
expect.arrayContaining([
expect.stringMatching(
/ls ['"]\/test\/project\/plans with spaces['"]/,
@@ -447,7 +447,7 @@ describe('HookRunner', () => {
// If secure, spawn will be called with the shell executable and escaped command
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.stringMatching(/bash|pwsh|powershell/),
expect.arrayContaining([
expect.stringMatching(/ls (['"]).*echo.*pwned.*\1/),
]),
+1
View File
@@ -36,6 +36,7 @@ export * from './commands/types.js';
export * from './core/baseLlmClient.js';
export * from './core/client.js';
export * from './core/contentGenerator.js';
export * from './core/fakeContentGenerator.js';
export * from './core/loggingContentGenerator.js';
export * from './core/geminiChat.js';
export * from './core/logger.js';
@@ -0,0 +1,181 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { stableStringify } from './stable-stringify.js';
describe('stableStringify', () => {
it('should stringify basic primitives', () => {
expect(stableStringify(null)).toBe('null');
expect(stableStringify(true)).toBe('true');
expect(stableStringify(false)).toBe('false');
expect(stableStringify(123)).toBe('123');
expect(stableStringify('hello')).toBe('"hello"');
});
it('should sort object keys alphabetically', () => {
const obj1 = { b: 2, a: 1, c: 3 };
const obj2 = { c: 3, b: 2, a: 1 };
// Note: Top-level properties are wrapped in \0
const expected = '{\0"a":1\0,\0"b":2\0,\0"c":3\0}';
expect(stableStringify(obj1)).toBe(expected);
expect(stableStringify(obj2)).toBe(expected);
});
it('should handle nested objects (only top-level gets \0)', () => {
const obj = { b: { d: 4, c: 3 }, a: 1 };
const expected = '{\0"a":1\0,\0"b":{"c":3,"d":4}\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle arrays', () => {
const arr = [3, 1, 2];
// Top-level arrays don't get \0 because they don't have "keys" in the same way objects do in this implementation
expect(stableStringify(arr)).toBe('[3,1,2]');
});
it('should handle nested arrays and objects', () => {
const obj = {
b: [{ y: 2, x: 1 }, 3],
a: 1,
};
const expected = '{\0"a":1\0,\0"b":[{"x":1,"y":2},3]\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle circular references by replacing them with "[Circular]"', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = { a: 1 };
obj.self = obj;
const expected = '{\0"a":1\0,\0"self":"[Circular]"\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle deep circular references', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = { a: { b: {} } };
obj.a.b.parent = obj.a;
obj.root = obj;
// ancestors: {obj}
// "a": stringify({b: ...}, {obj}, false)
// ancestors: {obj, obj.a}
// "b": stringify({parent: ...}, {obj, obj.a}, false)
// ancestors: {obj, obj.a, obj.a.b}
// "parent": ancestors.has(obj.a) -> "[Circular]"
const expected =
'{\0"a":{"b":{"parent":"[Circular]"}}\0,\0"root":"[Circular]"\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should correctly handle multiple references to the same non-circular object', () => {
const shared = { x: 1 };
const obj = { a: shared, b: shared };
// This is NOT circular, so it should be stringified twice
const expected = '{\0"a":{"x":1}\0,\0"b":{"x":1}\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should respect toJSON methods', () => {
const obj = {
a: 1,
toJSON: () => ({ b: 2 }),
};
// stableStringify calls toJSON, then stringifies the result.
// If it's top-level, it should still have \0 for the resulting object's keys.
const expected = '{\0"b":2\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle toJSON that returns a primitive', () => {
const obj = {
toJSON: () => 'json-val',
};
expect(stableStringify(obj)).toBe('"json-val"');
});
it('should handle toJSON that throws by treating it as a regular object', () => {
const obj = {
a: 1,
toJSON: () => {
throw new Error('fail');
},
};
// It should skip toJSON and proceed to stringify the object
// Wait, if it treats it as a regular object, it will try to stringify 'toJSON' property?
// But 'toJSON' is a function, so it should be omitted in objects.
const expected = '{\0"a":1\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should omit undefined and functions in objects', () => {
const obj = {
a: 1,
b: undefined,
c: () => {},
d: 2,
};
const expected = '{\0"a":1\0,\0"d":2\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should convert undefined and functions to null in arrays', () => {
const arr = [1, undefined, () => {}, 2];
expect(stableStringify(arr)).toBe('[1,null,null,2]');
});
it('should handle Symbols in arrays (should ideally be null like undefined)', () => {
const arr = [1, Symbol('foo'), 2];
// If it behaves like JSON.stringify, it should be [1,null,2]
// Let's see what it actually does.
expect(stableStringify(arr)).toBe('[1,null,2]');
});
it('should handle top-level undefined and functions', () => {
expect(stableStringify(undefined)).toBe('null');
expect(stableStringify(() => {})).toBe('null');
});
it('should handle empty objects and arrays', () => {
expect(stableStringify({})).toBe('{}');
expect(stableStringify([])).toBe('[]');
});
it('should handle special characters in keys (they should be escaped by JSON.stringify)', () => {
const obj = { 'key\0with\0null': 1 };
// JSON.stringify handles escaping \0 to \u0000
// So it should be {\0"key\u0000with\u0000null":1\0}
const expected = '{\0"key\\u0000with\\u0000null":1\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle repeated non-circular objects at different levels', () => {
const shared = { x: 1 };
const obj = {
a: shared,
b: {
c: shared,
},
};
const expected = '{\0"a":{"x":1}\0,\0"b":{"c":{"x":1}}\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle Symbols (return "null" consistently with undefined)', () => {
// JSON.stringify(Symbol('foo')) is undefined, but stableStringify returns 'null' for consistency and type safety
expect(stableStringify(Symbol('foo'))).toBe('null');
});
it('should omit Symbols in objects', () => {
const obj = { a: 1, b: Symbol('foo') };
expect(stableStringify(obj)).toBe('{\0"a":1\0}');
});
it('should handle BigInt (JSON.stringify throws, so stableStringify will throw)', () => {
expect(() => stableStringify(BigInt(123))).toThrow();
});
});
+14 -6
View File
@@ -63,8 +63,8 @@ export function stableStringify(obj: unknown): string {
isTopLevel = false,
): string => {
// Handle primitives and null
if (currentObj === undefined) {
return 'null'; // undefined in arrays becomes null in JSON
if (currentObj === undefined || typeof currentObj === 'symbol') {
return 'null'; // undefined and symbols in arrays become null in JSON
}
if (currentObj === null) {
return 'null';
@@ -104,8 +104,12 @@ export function stableStringify(obj: unknown): string {
if (Array.isArray(currentObj)) {
const items = currentObj.map((item) => {
// undefined and functions in arrays become null
if (item === undefined || typeof item === 'function') {
// undefined, functions and symbols in arrays become null
if (
item === undefined ||
typeof item === 'function' ||
typeof item === 'symbol'
) {
return 'null';
}
return stringify(item, ancestors, false);
@@ -120,8 +124,12 @@ export function stableStringify(obj: unknown): string {
for (const key of sortedKeys) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const value = (currentObj as Record<string, unknown>)[key];
// Skip undefined and function values in objects (per JSON spec)
if (value !== undefined && typeof value !== 'function') {
// Skip undefined, function and symbol values in objects (per JSON spec)
if (
value !== undefined &&
typeof value !== 'function' &&
typeof value !== 'symbol'
) {
let pairStr =
JSON.stringify(key) + ':' + stringify(value, ancestors, false);
@@ -47,9 +47,10 @@ import {
} from './chatRecordingService.js';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
import { CoreToolCallStatus } from '../scheduler/types.js';
import type { Content, Part } from '@google/genai';
import type { Part } from '@google/genai';
import type { Config } from '../config/config.js';
import { getProjectHash } from '../utils/paths.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
vi.mock('../utils/paths.js');
vi.mock('node:crypto', async (importOriginal) => {
@@ -1065,7 +1066,7 @@ describe('ChatRecordingService', () => {
it('should update tool results from API history (masking sync)', async () => {
// 1. Record an initial message and tool call
chatRecordingService.recordMessage({
const modelMsgId = chatRecordingService.recordMessage({
type: 'gemini',
content: 'I will list the files.',
model: 'gemini-pro',
@@ -1087,24 +1088,30 @@ describe('ChatRecordingService', () => {
// 2. Prepare mock history with masked content
const maskedSnippet =
'<tool_output_masked>short preview</tool_output_masked>';
const history: Content[] = [
const history: HistoryTurn[] = [
{
role: 'model',
parts: [
{ functionCall: { name: 'list_files', args: { path: '.' } } },
],
id: modelMsgId,
content: {
role: 'model',
parts: [
{ functionCall: { name: 'list_files', args: { path: '.' } } },
],
},
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'list_files',
id: callId,
response: { output: maskedSnippet },
id: 'user-id',
content: {
role: 'user',
parts: [
{
functionResponse: {
name: 'list_files',
id: callId,
response: { output: maskedSnippet },
},
},
},
],
],
},
},
];
@@ -1132,8 +1139,15 @@ describe('ChatRecordingService', () => {
output: maskedSnippet,
});
});
it('should preserve multi-modal sibling parts during sync', async () => {
await chatRecordingService.initialize();
const modelMsgId = chatRecordingService.recordMessage({
type: 'gemini',
content: '',
model: 'gemini-pro',
});
const callId = 'multi-modal-call';
const originalResult: Part[] = [
{
@@ -1146,12 +1160,6 @@ describe('ChatRecordingService', () => {
{ inlineData: { mimeType: 'image/png', data: 'base64...' } },
];
chatRecordingService.recordMessage({
type: 'gemini',
content: '',
model: 'gemini-pro',
});
chatRecordingService.recordToolCalls('gemini-pro', [
{
id: callId,
@@ -1164,19 +1172,26 @@ describe('ChatRecordingService', () => {
]);
const maskedSnippet = '<masked>';
const history: Content[] = [
const history: HistoryTurn[] = [
{
role: 'user',
parts: [
{
functionResponse: {
name: 'read_file',
id: callId,
response: { output: maskedSnippet },
id: modelMsgId,
content: { role: 'model', parts: [] },
},
{
id: 'user-id',
content: {
role: 'user',
parts: [
{
functionResponse: {
name: 'read_file',
id: callId,
response: { output: maskedSnippet },
},
},
},
{ inlineData: { mimeType: 'image/png', data: 'base64...' } },
],
{ inlineData: { mimeType: 'image/png', data: 'base64...' } },
],
},
},
];
@@ -1201,14 +1216,14 @@ describe('ChatRecordingService', () => {
it('should handle parts appearing BEFORE the functionResponse in a content block', async () => {
await chatRecordingService.initialize();
const callId = 'prefix-part-call';
chatRecordingService.recordMessage({
const modelMsgId = chatRecordingService.recordMessage({
type: 'gemini',
content: '',
model: 'gemini-pro',
});
const callId = 'prefix-part-call';
chatRecordingService.recordToolCalls('gemini-pro', [
{
id: callId,
@@ -1220,19 +1235,26 @@ describe('ChatRecordingService', () => {
},
]);
const history: Content[] = [
const history: HistoryTurn[] = [
{
role: 'user',
parts: [
{ text: 'Prefix metadata or text' },
{
functionResponse: {
name: 'read_file',
id: callId,
response: { output: 'file content' },
id: modelMsgId,
content: { role: 'model', parts: [] },
},
{
id: 'user-id',
content: {
role: 'user',
parts: [
{ text: 'Prefix metadata or text' },
{
functionResponse: {
name: 'read_file',
id: callId,
response: { output: 'file content' },
},
},
},
],
],
},
},
];
@@ -1263,25 +1285,30 @@ describe('ChatRecordingService', () => {
appendFileSyncSpy.mockClear();
// History with a tool call ID that doesn't exist in the conversation
const history: Content[] = [
const history: HistoryTurn[] = [
{
role: 'user',
parts: [
{
functionResponse: {
name: 'read_file',
id: 'nonexistent-call-id',
response: { output: 'some content' },
id: 'user-id',
content: {
role: 'user',
parts: [
{
functionResponse: {
name: 'read_file',
id: 'nonexistent-call-id',
response: { output: 'some content' },
},
},
},
],
],
},
},
];
chatRecordingService.updateMessagesFromHistory(history);
// No tool calls matched, so writeFileSync should NOT have been called
expect(appendFileSyncSpy).not.toHaveBeenCalled();
// In the new 'Strong Owner' architecture, updateMessagesFromHistory ensures that
// all turns in history (including new/synthetic ones) are recorded.
// Since 'user-id' was not in the original conversation, it is added.
expect(appendFileSyncSpy).toHaveBeenCalled();
});
});
@@ -1315,4 +1342,69 @@ describe('ChatRecordingService', () => {
mkdirSyncSpy.mockRestore();
});
});
describe('recordSyntheticMessage and history sync', () => {
it('should correctly record synthetic messages with durable IDs', async () => {
await chatRecordingService.initialize(undefined, 'main');
const parts = [{ text: 'Synthetic Turn' }];
// Implicit ID generation
const id1 = chatRecordingService.recordSyntheticMessage('user', parts);
expect(id1).toBeDefined();
expect(id1).toMatch(/test-uuid-/);
// Explicit ID registration (e.g. from context processor)
const customId = 'stable-hash-123';
const id2 = chatRecordingService.recordSyntheticMessage(
'gemini',
parts,
customId,
);
expect(id2).toBe(customId);
const record = await loadConversationRecord(
chatRecordingService.getConversationFilePath()!,
);
expect(record!.messages).toHaveLength(2);
expect(record!.messages[0].id).toBe(id1);
expect(record!.messages[0].type).toBe('user');
expect(record!.messages[1].id).toBe(customId);
expect(record!.messages[1].type).toBe('gemini');
});
it('should synchronize history turns and maintain their durable identity', async () => {
await chatRecordingService.initialize(undefined, 'main');
const history: HistoryTurn[] = [
{ id: 'h1', content: { role: 'user', parts: [{ text: 'msg1' }] } },
{ id: 'h2', content: { role: 'model', parts: [{ text: 'msg2' }] } },
];
chatRecordingService.updateMessagesFromHistory(history);
const record = await loadConversationRecord(
chatRecordingService.getConversationFilePath()!,
);
expect(record!.messages).toHaveLength(2);
expect(record!.messages[0].id).toBe('h1');
expect(record!.messages[1].id).toBe('h2');
// Update with a summary
const summaryId = 'summary-123';
const updatedHistory: HistoryTurn[] = [
{
id: summaryId,
content: { role: 'user', parts: [{ text: 'summary' }] },
},
...history.slice(1),
];
chatRecordingService.updateMessagesFromHistory(updatedHistory);
const record2 = await loadConversationRecord(
chatRecordingService.getConversationFilePath()!,
);
expect(record2!.messages).toHaveLength(2);
expect(record2!.messages[0].id).toBe(summaryId);
expect(record2!.messages[1].id).toBe('h2');
});
});
});
@@ -17,13 +17,12 @@ import {
import readline from 'node:readline';
import { randomUUID } from 'node:crypto';
import type {
Content,
Part,
PartListUnion,
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import { debugLogger } from '../utils/debugLogger.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
import {
SESSION_FILE_PREFIX,
type TokensSummary,
@@ -497,9 +496,10 @@ export class ChatRecordingService {
type: ConversationRecordExtra['type'],
content: PartListUnion,
displayContent?: PartListUnion,
id?: string,
): MessageRecord {
return {
id: randomUUID(),
id: id || randomUUID(),
timestamp: new Date().toISOString(),
type,
content,
@@ -512,14 +512,17 @@ export class ChatRecordingService {
type: ConversationRecordExtra['type'];
content: PartListUnion;
displayContent?: PartListUnion;
}): void {
if (!this.conversationFile || !this.cachedConversation) return;
id?: string;
}): string {
if (!this.conversationFile || !this.cachedConversation)
return message.id || randomUUID();
try {
const msg = this.newMessage(
message.type,
message.content,
message.displayContent,
message.id,
);
if (msg.type === 'gemini') {
msg.thoughts = this.queuedThoughts;
@@ -530,12 +533,30 @@ export class ChatRecordingService {
}
this.pushMessage(msg);
this.updateMetadata({ lastUpdated: new Date().toISOString() });
return msg.id;
} catch (error) {
debugLogger.error('Error saving message to chat history.', error);
throw error;
}
}
/**
* Records a synthetic message (e.g. Binary Received, Snapshot/Summary)
* and returns its durable ID.
*/
recordSyntheticMessage(
type: ConversationRecordExtra['type'],
content: PartListUnion,
id?: string,
): string {
return this.recordMessage({
model: undefined,
type,
content,
id,
});
}
recordThought(thought: ThoughtSummary): void {
if (!this.conversationFile) return;
this.queuedThoughts.push({
@@ -869,48 +890,83 @@ export class ChatRecordingService {
return this.cachedConversation;
}
updateMessagesFromHistory(history: readonly Content[]): void {
updateMessagesFromHistory(history: readonly HistoryTurn[]): void {
if (!this.conversationFile || !this.cachedConversation) return;
try {
const partsMap = new Map<string, Part[]>();
for (const content of history) {
if (content.role === 'user' && content.parts) {
const callIds = content.parts
.map((p) => p.functionResponse?.id)
.filter((id): id is string => !!id);
let updated = false;
if (callIds.length === 0) continue;
// 1. Sync content and IDs
const newMessages: MessageRecord[] = history.map((turn) => {
const existing = this.cachedConversation?.messages.find(
(m) => m.id === turn.id,
);
let currentCallId = callIds[0];
for (const part of content.parts) {
if (part.functionResponse?.id) {
currentCallId = part.functionResponse.id;
if (existing) {
// If content parts have changed (e.g. masking), update them
if (
JSON.stringify(existing.content) !==
JSON.stringify(turn.content.parts)
) {
updated = true;
}
return {
...existing,
content: turn.content.parts || [],
};
}
// It's a new (possibly synthetic) turn like a summary
updated = true;
return this.newMessage(
turn.content.role === 'user' ? 'user' : 'gemini',
turn.content.parts || [],
undefined,
turn.id,
);
});
// 2. Specialized 'Masking Sync' for tool call results
// If a user turn in history contains a functionResponse, we update the
// corresponding ToolCallRecord in the preceding gemini message.
for (const turn of history) {
if (turn.content.role !== 'user') continue;
for (const part of turn.content.parts || []) {
if (part.functionResponse) {
const callId = part.functionResponse.id;
// Find the gemini message that contains this tool call
const geminiMsg = newMessages.find(
(m) =>
m.type === 'gemini' &&
m.toolCalls?.some((tc) => tc.id === callId),
);
if (geminiMsg && geminiMsg.type === 'gemini') {
const tc = geminiMsg.toolCalls!.find((tc) => tc.id === callId);
if (tc) {
// If the history version is different (e.g. masked), sync it into the record
// We sync the entire parts array of the user turn to ensure sibling parts are preserved
if (
JSON.stringify(tc.result) !==
JSON.stringify(turn.content.parts)
) {
tc.result = turn.content.parts || [];
updated = true;
}
}
}
if (!partsMap.has(currentCallId)) {
partsMap.set(currentCallId, []);
}
partsMap.get(currentCallId)!.push(part);
}
}
}
for (const message of this.cachedConversation.messages) {
let msgChanged = false;
if (message.type === 'gemini' && message.toolCalls) {
for (const toolCall of message.toolCalls) {
const newParts = partsMap.get(toolCall.id);
if (newParts !== undefined) {
toolCall.result = newParts;
msgChanged = true;
}
}
}
if (msgChanged) {
// Push updated message to log
this.pushMessage(message);
}
if (
updated ||
newMessages.length !== this.cachedConversation.messages.length
) {
this.cachedConversation.messages = newMessages;
this.updateMetadata({
messages: newMessages,
lastUpdated: new Date().toISOString(),
});
}
} catch (error) {
debugLogger.error(
@@ -102,7 +102,6 @@ export interface ResolutionContext {
hasAccessToPreview?: boolean;
hasAccessToProModel?: boolean;
requestedModel?: string;
releaseChannel?: string;
}
/** The requirements defined in the registry. */
@@ -113,7 +112,6 @@ export interface ResolutionCondition {
hasAccessToPreview?: boolean;
/** Matches if the current model is in this list. */
requestedModels?: string[];
releaseChannel?: string;
}
export interface ModelConfigServiceConfig {
@@ -159,7 +157,6 @@ export class ModelConfigService {
const shouldShowPreviewModels = context.hasAccessToPreview ?? false;
const useGemini31 = context.useGemini3_1 ?? false;
const useGemini31FlashLite = context.useGemini3_1FlashLite ?? false;
const releaseChannel = context.releaseChannel ?? 'stable';
const mainOptions = Object.entries(definitions)
.filter(([_, m]) => {
@@ -171,7 +168,10 @@ export class ModelConfigService {
.map(([id, m]) => {
let description = m.dialogDescription ?? '';
if (id === 'auto') {
description = getAutoModelDescription(releaseChannel, useGemini31);
description = getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
);
} else if (id === 'auto-gemini-3' && useGemini31) {
description = description.replace('gemini-3-pro', 'gemini-3.1-pro');
}
@@ -265,8 +265,6 @@ export class ModelConfigService {
!!context.requestedModel &&
value.includes(context.requestedModel)
);
case 'releaseChannel':
return value === context.releaseChannel;
default:
return false;
}
@@ -213,7 +213,7 @@ describe('ShellExecutionService', () => {
mockSerializeTerminalToObject.mockReturnValue([]);
mockIsBinary.mockReturnValue(false);
mockPlatform.mockReturnValue('linux');
mockResolveExecutable.mockImplementation(async (exe: string) => exe);
mockResolveExecutable.mockImplementation((exe: string) => exe);
process.env['PATH'] = '/test/path';
mockGetPty.mockResolvedValue({
module: { spawn: mockPtySpawn },
@@ -2064,7 +2064,7 @@ describe('ShellExecutionService environment variables', () => {
sandboxManager: mockSandboxManager,
};
mockResolveExecutable.mockResolvedValue('/bin/bash/resolved');
mockResolveExecutable.mockReturnValue('/bin/bash/resolved');
const mockChild = new EventEmitter() as unknown as ChildProcess;
mockChild.stdout = new EventEmitter() as unknown as Readable;
mockChild.stderr = new EventEmitter() as unknown as Readable;
@@ -414,8 +414,7 @@ export class ShellExecutionService {
executable = 'cmd.exe';
}
const resolvedExecutable =
(await resolveExecutable(executable)) ?? executable;
const resolvedExecutable = resolveExecutable(executable) ?? executable;
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
const spawnArgs = [...argsPrefix, guardedCommand];
@@ -1134,7 +1133,7 @@ export class ShellExecutionService {
const sniffBuffer = Buffer.concat(sniffChunks);
sniffedBytes = sniffBuffer.length;
if (isBinary(sniffBuffer)) {
if (isBinary(sniffBuffer, 512, true)) {
isStreamingRawContent = false;
binaryBytesReceived = sniffBuffer.length;
const event: ShellOutputEvent = { type: 'binary_detected' };
@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import os from 'node:os';
import { ShellExecutionService } from './shellExecutionService.js';
import { NoopSandboxManager } from './sandboxManager.js';
const isWindows = os.platform() === 'win32';
/**
* Real-shell integration tests that reproduce the regression class from
* issue #25859: commands with inline double quotes executed on Windows
* lose their quotes when they reach the native executable, because
* Windows PowerShell 5.1 mangles embedded " during native-command
* argument passing. PowerShell 7 (pwsh.exe) passes arguments correctly.
*
* These tests exercise the full pipeline end-to-end. They pass when
* gemini-cli selects pwsh.exe from PATH; they fail when the pipeline
* routes through Windows PowerShell 5.1.
*/
describe.skipIf(!isWindows)(
'ShellExecutionService Windows quoting (real shell)',
() => {
const baseConfig = {
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
sandboxManager: new NoopSandboxManager(),
};
async function runReal(command: string) {
const controller = new AbortController();
const handle = await ShellExecutionService.execute(
command,
process.cwd(),
() => {},
controller.signal,
false,
baseConfig,
);
const result = await handle.result;
return { result, output: result.output };
}
it('should preserve inline double quotes through node -e', async () => {
const { result, output } = await runReal(
`node -e 'console.log("preserved")'`,
);
expect(result.exitCode).toBe(0);
expect(output).toBe('preserved');
});
it('should preserve double quotes inside JSON output', async () => {
const { result, output } = await runReal(
`node -e 'console.log(JSON.stringify({ok:"yes"}))'`,
);
expect(result.exitCode).toBe(0);
expect(output).toBe('{"ok":"yes"}');
});
it('should handle quoted argument containing a space', async () => {
const { result, output } = await runReal(
`node -e "console.log('hello world')"`,
);
expect(result.exitCode).toBe(0);
expect(output).toBe('hello world');
});
it('should handle a mixed-quote regex literal', async () => {
const { result, output } = await runReal(
`node -e 'console.log(String("a").match(/"/))'`,
);
expect(result.exitCode).toBe(0);
expect(output).toBe('null');
});
it('should pass a literal double-quote byte through to stdout', async () => {
const { result, output } = await runReal(`node -e 'console.log("\\"")'`);
expect(result.exitCode).toBe(0);
expect(output).toBe('"');
});
},
);
@@ -61,6 +61,42 @@
"topK": 64
}
},
"gemini-3.1-pro-preview": {
"model": "gemini-3.1-pro-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-pro-preview-customtools": {
"model": "gemini-3.1-pro-preview-customtools",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-flash-lite-preview": {
"model": "gemini-3.1-flash-lite-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"generateContentConfig": {
@@ -61,6 +61,42 @@
"topK": 64
}
},
"gemini-3.1-pro-preview": {
"model": "gemini-3.1-pro-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-pro-preview-customtools": {
"model": "gemini-3.1-pro-preview-customtools",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-flash-lite-preview": {
"model": "gemini-3.1-flash-lite-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"generateContentConfig": {
+6 -6
View File
@@ -1825,7 +1825,7 @@ describe('resolveRipgrepPath', () => {
it('should fall back to system PATH if both bundled paths are missing and system is trusted', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
vi.mocked(resolveExecutable).mockResolvedValue('/usr/bin/rg');
vi.mocked(resolveExecutable).mockReturnValue('/usr/bin/rg');
vi.mocked(resolveToRealPath).mockReturnValue('/usr/bin/rg');
const resolvedPath = await resolveRipgrepPath();
@@ -1836,7 +1836,7 @@ describe('resolveRipgrepPath', () => {
it('should reject system PATH if it is in the current working directory', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
const unsafePath = path.join(process.cwd(), 'rg');
vi.mocked(resolveExecutable).mockResolvedValue(unsafePath);
vi.mocked(resolveExecutable).mockReturnValue(unsafePath);
vi.mocked(resolveToRealPath).mockReturnValue(unsafePath);
const resolvedPath = await resolveRipgrepPath();
@@ -1848,7 +1848,7 @@ describe('resolveRipgrepPath', () => {
const trustedLink = '/usr/local/bin/rg';
const trustedRealPath = '/opt/homebrew/Cellar/ripgrep/13.0.0/bin/rg';
vi.mocked(resolveExecutable).mockResolvedValue(trustedLink);
vi.mocked(resolveExecutable).mockReturnValue(trustedLink);
vi.mocked(resolveToRealPath).mockReturnValue(trustedRealPath);
const resolvedPath = await resolveRipgrepPath();
@@ -1857,7 +1857,7 @@ describe('resolveRipgrepPath', () => {
it('should return null if binary is missing from both bundled paths and system PATH', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
vi.mocked(resolveExecutable).mockResolvedValue(undefined);
vi.mocked(resolveExecutable).mockReturnValue(undefined);
const resolvedPath = await resolveRipgrepPath();
expect(resolvedPath).toBeNull();
@@ -1883,7 +1883,7 @@ describe('resolveRipgrepPath', () => {
it('should fall back to system PATH if system is trusted on Windows', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
vi.mocked(resolveExecutable).mockResolvedValue(
vi.mocked(resolveExecutable).mockReturnValue(
'C:\\Windows\\System32\\rg.exe',
);
vi.mocked(resolveToRealPath).mockReturnValue(
@@ -1898,7 +1898,7 @@ describe('resolveRipgrepPath', () => {
it('should reject system PATH if it is untrusted on Windows', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
const unsafePath = 'D:\\Downloads\\rg.exe';
vi.mocked(resolveExecutable).mockResolvedValue(unsafePath);
vi.mocked(resolveExecutable).mockReturnValue(unsafePath);
vi.mocked(resolveToRealPath).mockReturnValue(unsafePath);
const resolvedPath = await resolveRipgrepPath();
+1 -1
View File
@@ -72,7 +72,7 @@ export async function resolveRipgrepPath(): Promise<string | null> {
}
// 3. Fallback: check system PATH
const systemRg = await resolveExecutable('rg');
const systemRg = resolveExecutable('rg');
if (systemRg) {
// Security: Validate the system executable to prevent Search Path Interruption.
const realPath = resolveToRealPath(systemRg);
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { deriveStableId } from './cryptoUtils.js';
describe('cryptoUtils', () => {
describe('deriveStableId', () => {
it('should be deterministic regardless of input order', () => {
const id1 = deriveStableId(['a', 'b', 'c']);
const id2 = deriveStableId(['c', 'b', 'a']);
expect(id1).toBe(id2);
expect(id1).toMatch(/^[0-9a-f]{32}$/);
});
it('should produce different IDs for different inputs', () => {
const id1 = deriveStableId(['a', 'b', 'c']);
const id2 = deriveStableId(['a', 'b', 'd']);
expect(id1).not.toBe(id2);
});
it('should handle single inputs', () => {
const id = deriveStableId(['only-one']);
expect(id).toMatch(/^[0-9a-f]{32}$/);
});
it('should be consistent across calls with same data', () => {
const input = ['id-123', 'id-456'];
expect(deriveStableId(input)).toBe(deriveStableId(input));
});
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
/**
* Derives a stable, deterministic ID from a list of source IDs.
* Used for synthetic turns like summaries to ensure that re-summarizing the same
* content produces a consistent identity.
*/
export function deriveStableId(sourceIds: string[]): string {
const sortedIds = [...sourceIds].sort();
return createHash('sha256')
.update(sortedIds.join('|'))
.digest('hex')
.slice(0, 32);
}
@@ -7,6 +7,7 @@
import type { Part, Content } from '@google/genai';
import type { Config } from '../config/config.js';
import { getFolderStructure } from './getFolderStructure.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
export const INITIAL_HISTORY_LENGTH = 1;
@@ -81,8 +82,8 @@ ${environmentMemory}
export async function getInitialChatHistory(
config: Config,
extraHistory?: Content[],
): Promise<Content[]> {
extraHistory?: ReadonlyArray<Content | HistoryTurn>,
): Promise<Array<Content | HistoryTurn>> {
const envParts = await getEnvironmentContext(config);
const envContextString = envParts.map((part) => part.text || '').join('\n\n');
+46 -10
View File
@@ -5,7 +5,7 @@
*/
import { updateGlobalFetchTimeouts } from './fetch.js';
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as dnsPromises from 'node:dns/promises';
import type { LookupAddress, LookupAllOptions } from 'node:dns';
import ipaddr from 'ipaddr.js';
@@ -34,18 +34,14 @@ const {
fetchWithTimeout,
setGlobalProxy,
} = await import('./fetch.js');
// Mock global fetch
const originalFetch = global.fetch;
global.fetch = vi.fn();
interface ErrorWithCode extends Error {
code?: string;
}
describe('fetch utils', () => {
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(global, 'fetch').mockImplementation(vi.fn() as any);
// Default DNS lookup to return a public IP, or the IP itself if valid
vi.mocked(
dnsPromises.lookup as (
@@ -60,8 +56,8 @@ describe('fetch utils', () => {
});
});
afterAll(() => {
global.fetch = originalFetch;
afterEach(() => {
vi.restoreAllMocks();
});
describe('isAddressPrivate', () => {
@@ -177,7 +173,7 @@ describe('fetch utils', () => {
});
describe('fetchWithTimeout', () => {
it('should handle timeouts', async () => {
it('should throw FetchError with ETIMEDOUT on an internal timeout', async () => {
vi.mocked(global.fetch).mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
@@ -198,6 +194,46 @@ describe('fetch utils', () => {
'Request timed out after 50ms',
);
});
it('should throw an AbortError (not ETIMEDOUT) when the caller signal is aborted', async () => {
vi.mocked(global.fetch).mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
const rejectWithAbortError = () => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
// @ts-expect-error - for mocking purposes
error.code = 'ABORT_ERR';
reject(error);
};
// Handle the case where the signal is already aborted before
// fetch is called (e.g. controller.abort() called synchronously).
if (init?.signal?.aborted) {
rejectWithAbortError();
return;
}
if (init?.signal) {
init.signal.addEventListener('abort', rejectWithAbortError, {
once: true,
});
}
}),
);
const controller = new AbortController();
// Abort the external signal before the request even starts
controller.abort();
const rejection = fetchWithTimeout('http://example.com', 10_000, {
signal: controller.signal,
});
await expect(rejection).rejects.toMatchObject({ name: 'AbortError' });
// Must NOT be classified as a timeout
await expect(rejection).rejects.not.toThrow('timed out');
});
});
describe('setGlobalProxy', () => {
+10 -2
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getErrorMessage, isNodeError } from './errors.js';
import { getErrorMessage, isAbortError } from './errors.js';
import { URL } from 'node:url';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
import ipaddr from 'ipaddr.js';
@@ -202,7 +202,15 @@ export async function fetchWithTimeout(
});
return response;
} catch (error) {
if (isNodeError(error) && error.code === 'ABORT_ERR') {
if (isAbortError(error)) {
// If the caller's own signal was already aborted, this is a user-initiated
// cancellation (e.g. Ctrl+C), not an internal timeout. Re-throw as a plain
// AbortError so the retry layer does NOT treat it as a retryable ETIMEDOUT.
if (options?.signal?.aborted) {
// Rethrow the original abort reason or the caught error to preserve
// the stack trace and any custom abort reason (e.g. from Ctrl+C).
throw options.signal.reason ?? error;
}
throw new FetchError(`Request timed out after ${timeout}ms`, 'ETIMEDOUT');
}
throw new FetchError(getErrorMessage(error), undefined, { cause: error });
@@ -0,0 +1,378 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
hardenHistory,
SYNTHETIC_THOUGHT_SIGNATURE,
} from './historyHardening.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
import { deriveStableId } from './cryptoUtils.js';
import type { Part } from '@google/genai';
describe('hardenHistory', () => {
it('should return an empty array if input is empty', () => {
expect(hardenHistory([])).toEqual([]);
});
it('should coalesce adjacent turns of the same role', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
];
const hardened = hardenHistory(history);
expect(hardened.length).toBe(1);
expect(hardened[0].content.parts).toEqual([
{ text: 'hello' },
{ text: 'world' },
]);
expect(hardened[0].id).toBe('1'); // Inherits ID of the first turn in the sequence
});
it('should inject thoughtSignature into the first functionCall of a model turn if missing', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
{
id: '2',
content: {
role: 'model',
parts: [{ functionCall: { name: 'myTool', args: {} } }],
},
},
{
id: '3',
content: {
role: 'user',
parts: [
{
functionResponse: {
name: 'myTool',
response: { ok: true },
},
},
],
},
},
];
const hardened = hardenHistory(history);
const modelPart = hardened[1].content.parts![0];
expect(modelPart).toHaveProperty(
'thoughtSignature',
SYNTHETIC_THOUGHT_SIGNATURE,
);
});
it('should inject a sentinel user turn if history ends with a model turn', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
];
const hardened = hardenHistory(history);
expect(hardened.length).toBe(3);
expect(hardened[2].content.role).toBe('user');
expect(hardened[2].content.parts![0]).toEqual({ text: 'Please continue.' });
expect(hardened[2].id).toBe(deriveStableId(['2', 'sentinel_end']));
});
it('should inject a sentinel user turn if history starts with a model turn', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'model', parts: [{ text: 'hi' }] } },
{ id: '2', content: { role: 'user', parts: [{ text: 'hello' }] } },
];
const hardened = hardenHistory(history, {
sentinels: { continuation: 'Custom start' },
});
expect(hardened.length).toBe(3);
expect(hardened[0].content.role).toBe('user');
expect(hardened[0].content.parts![0]).toEqual({ text: 'Custom start' });
expect(hardened[0].id).toBe(deriveStableId(['1', 'sentinel_start']));
});
it('should inject sentinel responses for missing functionResponses', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
{
id: '2',
content: {
role: 'model',
parts: [
{
functionCall: { id: 'call_1', name: 'toolA', args: {} },
thoughtSignature: 'sig',
},
{ functionCall: { id: 'call_2', name: 'toolB', args: {} } },
],
},
},
// Note: Turn 3 is missing, so toolA and toolB have no responses
];
const hardened = hardenHistory(history, {
sentinels: { lostToolResponse: 'Lost.' },
});
// The history should now be: User -> Model -> User (sentinel responses) -> User (sentinel end)
// Wait, the sentinel responses turn will satisfy the "ends with user" rule.
expect(hardened.length).toBe(3);
expect(hardened[2].content.role).toBe('user');
expect(hardened[2].content.parts).toHaveLength(2);
const resp1 = hardened[2].content.parts![0].functionResponse;
expect(resp1?.id).toBe('call_1');
expect(resp1?.response).toEqual({ error: 'Lost.' });
const resp2 = hardened[2].content.parts![1].functionResponse;
expect(resp2?.id).toBe('call_2');
expect(resp2?.response).toEqual({ error: 'Lost.' });
expect(hardened[2].id).toBe(deriveStableId(['2', 'sentinel_resp']));
});
it('should successfully match parallel tool calls and responses even if responses are originally split across separate user turns', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
{
id: '2',
content: {
role: 'model',
parts: [
{
functionCall: { id: 'call_1', name: 'toolA', args: {} },
thoughtSignature: 'sig',
},
{ functionCall: { id: 'call_2', name: 'toolB', args: {} } },
],
},
},
// Responses arrive as separate user turns
{
id: '3',
content: {
role: 'user',
parts: [
{
functionResponse: {
id: 'call_1',
name: 'toolA',
response: { ok: true },
},
},
],
},
},
{
id: '4',
content: {
role: 'user',
parts: [
{
functionResponse: {
id: 'call_2',
name: 'toolB',
response: { ok: true },
},
},
],
},
},
];
// The hardener should coalesce Turn 3 and Turn 4 *before* it tries to pair them with Turn 2.
// Otherwise, it would look at Turn 3, see 'call_2' is missing, inject a sentinel for 'call_2',
// and then look at Turn 4 and consider 'call_2' to be orphaned.
const hardened = hardenHistory(history);
// Total turns: User(1), Model(2), User(3+4 merged)
expect(hardened.length).toBe(3);
const userResponseTurn = hardened[2];
expect(userResponseTurn.content.role).toBe('user');
expect(userResponseTurn.content.parts).toHaveLength(2);
// Verify no sentinels were injected and original responses were preserved
expect(userResponseTurn.content.parts![0].functionResponse?.id).toBe(
'call_1',
);
expect(userResponseTurn.content.parts![1].functionResponse?.id).toBe(
'call_2',
);
// Ensure no error properties exist
expect(
userResponseTurn.content.parts![0].functionResponse?.response,
).toEqual({ ok: true });
expect(
userResponseTurn.content.parts![1].functionResponse?.response,
).toEqual({ ok: true });
});
it('should synthesize a functionCall for a singleton orphaned functionResponse', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
{
id: '3',
content: {
role: 'user',
parts: [
{ text: 'text is kept' },
{
functionResponse: { id: 'orphan_1', name: 'toolA', response: {} },
},
],
},
},
];
const hardened = hardenHistory(history);
// Turn 1: user, Turn 2: model (with synthetic call), Turn 3: user
expect(hardened.length).toBe(3);
const modelTurn = hardened[1];
expect(modelTurn.content.role).toBe('model');
expect(modelTurn.content.parts).toHaveLength(2); // text + synthetic call
expect(modelTurn.content.parts![1].functionCall).toBeDefined();
expect(modelTurn.content.parts![1].functionCall?.id).toBe('orphan_1');
expect(
(modelTurn.content.parts![1] as unknown as { thoughtSignature: string })
.thoughtSignature,
).toBe(SYNTHETIC_THOUGHT_SIGNATURE);
const userTurn = hardened[2];
expect(userTurn.content.parts).toHaveLength(2); // hoisted response + text
expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_1');
expect(userTurn.content.parts![1]).toEqual({ text: 'text is kept' });
});
it('should synthesize functionCalls for multiple orphaned functionResponses in parallel', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: { role: 'user', parts: [{ text: 'Parallel action' }] },
},
// Previous model turn exists but has NO tool calls
{
id: '2',
content: { role: 'model', parts: [{ text: 'I will do nothing' }] },
},
{
id: '3',
content: {
role: 'user',
parts: [
{
functionResponse: { id: 'orphan_A', name: 'toolA', response: {} },
},
{
functionResponse: { id: 'orphan_B', name: 'toolB', response: {} },
},
{
functionResponse: { id: 'orphan_C', name: 'toolC', response: {} },
},
],
},
},
];
const hardened = hardenHistory(history);
expect(hardened.length).toBe(3);
const modelTurn = hardened[1];
expect(modelTurn.content.role).toBe('model');
expect(modelTurn.content.parts).toHaveLength(4); // original text + 3 synthetic calls
// Only the FIRST function call should get the synthetic signature
const callA = modelTurn.content.parts![1];
expect(callA.functionCall?.id).toBe('orphan_A');
expect(
(callA as unknown as { thoughtSignature?: string }).thoughtSignature,
).toBe(SYNTHETIC_THOUGHT_SIGNATURE);
const callB = modelTurn.content.parts![2];
expect(callB.functionCall?.id).toBe('orphan_B');
expect(
(callB as unknown as { thoughtSignature?: string }).thoughtSignature,
).toBeUndefined();
const callC = modelTurn.content.parts![3];
expect(callC.functionCall?.id).toBe('orphan_C');
expect(
(callC as unknown as { thoughtSignature?: string }).thoughtSignature,
).toBeUndefined();
const userTurn = hardened[2];
expect(userTurn.content.parts).toHaveLength(3);
expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_A');
expect(userTurn.content.parts![1].functionResponse?.id).toBe('orphan_B');
expect(userTurn.content.parts![2].functionResponse?.id).toBe('orphan_C');
});
it('should hoist and re-order tool responses to match functionCall order', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
{
id: '2',
content: {
role: 'model',
parts: [
{
functionCall: { id: 'c1', name: 'toolA', args: {} },
thoughtSignature: 'sig',
},
{ functionCall: { id: 'c2', name: 'toolB', args: {} } },
],
},
},
{
id: '3',
content: {
role: 'user',
parts: [
{ text: 'some text' },
{ functionResponse: { id: 'c2', name: 'toolB', response: {} } },
{ functionResponse: { id: 'c1', name: 'toolA', response: {} } },
],
},
},
];
const hardened = hardenHistory(history);
expect(hardened[2].content.parts).toHaveLength(3);
// Order should be: resp(c1) -> resp(c2) -> text
const p0 = hardened[2].content.parts![0];
const p1 = hardened[2].content.parts![1];
const p2 = hardened[2].content.parts![2];
expect(p0.functionResponse?.id).toBe('c1');
expect(p1.functionResponse?.id).toBe('c2');
expect(p2.text).toBe('some text');
});
it('should scrub non-standard properties from parts', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'user',
parts: [
{
text: 'hello',
extraProp: 'should be removed',
} as unknown as Part,
],
},
},
];
const hardened = hardenHistory(history);
expect(hardened[0].content.parts![0]).not.toHaveProperty('extraProp');
expect(hardened[0].content.parts![0]).toHaveProperty('text', 'hello');
});
});
+101 -48
View File
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, Part } from '@google/genai';
import { type Part } from '@google/genai';
import { debugLogger } from './debugLogger.js';
import { type HistoryTurn } from '../core/agentChatHistory.js';
import { deriveStableId } from './cryptoUtils.js';
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
@@ -35,9 +37,9 @@ const DEFAULT_SENTINELS = {
* 5. Signatures: The first functionCall in a model turn must have a thoughtSignature.
*/
export function hardenHistory(
history: Content[],
history: HistoryTurn[],
options: HardeningOptions = {},
): Content[] {
): HistoryTurn[] {
if (history.length === 0) return history;
const sentinels = { ...DEFAULT_SENTINELS, ...options.sentinels };
@@ -63,17 +65,20 @@ export function hardenHistory(
/**
* Combines adjacent turns with the same role and removes empty turns.
*/
function coalesce(history: Content[]): Content[] {
const result: Content[] = [];
function coalesce(history: HistoryTurn[]): HistoryTurn[] {
const result: HistoryTurn[] = [];
for (const turn of history) {
if (!turn.parts || turn.parts.length === 0) continue;
if (!turn.content.parts || turn.content.parts.length === 0) continue;
const last = result[result.length - 1];
if (last && last.role === turn.role) {
last.parts = [...(last.parts || []), ...(turn.parts || [])];
if (last && last.content.role === turn.content.role) {
last.content.parts = [
...(last.content.parts || []),
...(turn.content.parts || []),
];
} else {
// Shallow clone the turn so we don't mutate the original history array structure
result.push({ ...turn });
// Shallow clone the turn and content so we don't mutate the original history array structure
result.push({ id: turn.id, content: { ...turn.content } });
}
}
return result;
@@ -83,10 +88,10 @@ function coalesce(history: Content[]): Content[] {
* Ensures tool calls have matching responses and model turns have required signatures.
*/
function pairToolsAndEnforceSignatures(
history: Content[],
history: HistoryTurn[],
sentinels: Required<NonNullable<HardeningOptions['sentinels']>>,
): Content[] {
const result: Content[] = [];
): HistoryTurn[] {
const result: HistoryTurn[] = [];
// We work on a copy to allow splicing in sentinel turns
const work = [...history];
@@ -94,8 +99,8 @@ function pairToolsAndEnforceSignatures(
for (let i = 0; i < work.length; i++) {
const turn = work[i];
if (turn.role === 'model') {
const parts = turn.parts || [];
if (turn.content.role === 'model') {
const parts = turn.content.parts || [];
// A. Signatures
let foundCall = false;
@@ -123,8 +128,8 @@ function pairToolsAndEnforceSignatures(
const name = call.functionCall!.name || 'unknown';
const hasResponse =
nextTurn?.role === 'user' &&
nextTurn.parts?.some(
nextTurn?.content.role === 'user' &&
nextTurn.content.parts?.some(
(p) =>
p.functionResponse?.id === id &&
p.functionResponse?.name === name,
@@ -143,17 +148,20 @@ function pairToolsAndEnforceSignatures(
`[HistoryHardener] Detected ${missing.length} tool calls without responses. Injecting sentinel responses.`,
);
let targetUserTurn: Content;
if (nextTurn?.role === 'user') {
let targetUserTurn: HistoryTurn;
if (nextTurn?.content.role === 'user') {
targetUserTurn = nextTurn;
} else {
targetUserTurn = { role: 'user', parts: [] };
targetUserTurn = {
id: deriveStableId([turn.id, 'sentinel_resp']),
content: { role: 'user', parts: [] },
};
work.splice(i + 1, 0, targetUserTurn);
}
for (const m of missing) {
targetUserTurn.parts = targetUserTurn.parts || [];
targetUserTurn.parts.push({
targetUserTurn.content.parts = targetUserTurn.content.parts || [];
targetUserTurn.content.parts.push({
functionResponse: {
name: m.name,
id: m.id,
@@ -165,20 +173,21 @@ function pairToolsAndEnforceSignatures(
}
}
}
} else if (turn.role === 'user') {
} else if (turn.content.role === 'user') {
// C. Orphaned Responses
// A user response MUST follow a model call.
const prevTurn = result[result.length - 1];
const parts = turn.parts || [];
const parts = turn.content.parts || [];
const validParts: Part[] = [];
const orphanedResponses: Part[] = [];
for (const p of parts) {
if (p.functionResponse) {
const id = p.functionResponse.id;
const name = p.functionResponse.name;
const hasCall =
prevTurn?.role === 'model' &&
prevTurn.parts?.some(
prevTurn?.content.role === 'model' &&
prevTurn.content.parts?.some(
(cp) =>
cp.functionCall?.id === id && cp.functionCall?.name === name,
);
@@ -187,17 +196,51 @@ function pairToolsAndEnforceSignatures(
validParts.push(p);
} else {
debugLogger.log(
`[HistoryHardener] Dropping orphaned functionResponse id='${id}' (name='${name}')`,
`[HistoryHardener] Orphaned functionResponse id='${id}' (name='${name}'). Injecting synthetic functionCall.`,
);
orphanedResponses.push(p);
validParts.push(p);
}
} else {
validParts.push(p);
}
}
turn.parts = validParts;
if (orphanedResponses.length > 0) {
let targetModelTurn: HistoryTurn;
if (prevTurn?.content.role === 'model') {
targetModelTurn = prevTurn;
} else {
targetModelTurn = {
id: deriveStableId([turn.id, 'sentinel_call']),
content: { role: 'model', parts: [] },
};
result.push(targetModelTurn);
}
for (const orph of orphanedResponses) {
targetModelTurn.content.parts = targetModelTurn.content.parts || [];
const hasExistingCall = targetModelTurn.content.parts.some(
(p) => !!p.functionCall,
);
const callPart: Part = {
functionCall: {
name: orph.functionResponse!.name,
id: orph.functionResponse!.id,
args: {},
},
};
if (!hasExistingCall) {
callPart.thoughtSignature = SYNTHETIC_THOUGHT_SIGNATURE;
}
targetModelTurn.content.parts.push(callPart);
}
}
turn.content.parts = validParts;
}
if (turn.parts && turn.parts.length > 0) {
if (turn.content.parts && turn.content.parts.length > 0) {
result.push(turn);
}
}
@@ -208,21 +251,22 @@ function pairToolsAndEnforceSignatures(
/**
* Hoists and re-orders tool responses within user turns to match preceding model turns.
*/
function refineToolResponses(history: Content[]): Content[] {
function refineToolResponses(history: HistoryTurn[]): HistoryTurn[] {
for (let i = 1; i < history.length; i++) {
const turn = history[i];
const prev = history[i - 1];
if (turn.role === 'user' && prev.role === 'model') {
if (turn.content.role === 'user' && prev.content.role === 'model') {
const callOrder =
prev.parts
prev.content.parts
?.filter((p) => !!p.functionCall)
.map((p) => p.functionCall!.id) || [];
if (callOrder.length > 0) {
const responseParts =
turn.parts?.filter((p) => !!p.functionResponse) || [];
const otherParts = turn.parts?.filter((p) => !p.functionResponse) || [];
turn.content.parts?.filter((p) => !!p.functionResponse) || [];
const otherParts =
turn.content.parts?.filter((p) => !p.functionResponse) || [];
if (responseParts.length > 0) {
// 1. Re-order: Sort responses to match the model's call order
@@ -240,7 +284,7 @@ function refineToolResponses(history: Content[]): Content[] {
});
// 2. Hoisting: Place all sorted responses BEFORE text or other parts
turn.parts = [...responseParts, ...otherParts];
turn.content.parts = [...responseParts, ...otherParts];
}
}
}
@@ -252,36 +296,42 @@ function refineToolResponses(history: Content[]): Content[] {
* Final pass to ensure start/end roles and alternation are correct.
*/
function enforceRoleConstraints(
history: Content[],
history: HistoryTurn[],
sentinels: Required<NonNullable<HardeningOptions['sentinels']>>,
): Content[] {
): HistoryTurn[] {
if (history.length === 0) return [];
// Re-coalesce first to catch any empty turns or adjacent roles introduced by pairing
const base = coalesce(history);
if (base.length === 0) return [];
const result: Content[] = [...base];
const result: HistoryTurn[] = [...base];
// 1. Ensure starts with user
if (result[0].role === 'model') {
if (result[0].content.role === 'model') {
debugLogger.log(
'[HistoryHardener] Final history starts with model role. Prepending sentinel user turn.',
);
result.unshift({
role: 'user',
parts: [{ text: sentinels.continuation }],
id: deriveStableId([result[0].id, 'sentinel_start']),
content: {
role: 'user',
parts: [{ text: sentinels.continuation }],
},
});
}
// 2. Ensure ends with user
if (result[result.length - 1].role === 'model') {
if (result[result.length - 1].content.role === 'model') {
debugLogger.log(
'[HistoryHardener] Final history ends with model role. Appending sentinel user turn.',
);
result.push({
role: 'user',
parts: [{ text: 'Please continue.' }],
id: deriveStableId([result[result.length - 1].id, 'sentinel_end']),
content: {
role: 'user',
parts: [{ text: 'Please continue.' }],
},
});
}
@@ -293,10 +343,13 @@ function enforceRoleConstraints(
* Deep-scrubs the history to remove any non-standard properties from Content and Part objects.
* This ensures compatibility with strict APIs (like Vertex AI) that reject unknown fields.
*/
export function scrubHistory(history: Content[]): Content[] {
return history.map((content) => ({
role: content.role,
parts: (content.parts || []).map(scrubPart),
export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] {
return history.map((turn) => ({
id: turn.id,
content: {
role: turn.content.role,
parts: (turn.content.parts || []).map((p) => scrubPart(p)),
},
}));
}
+5 -5
View File
@@ -27,7 +27,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
]);
@@ -58,7 +58,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{ role: 'user', parts: [{ text: 'Hello' }] },
{
role: 'model',
@@ -100,7 +100,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{ role: 'user', parts: [{ text: 'Actual query' }] },
]);
});
@@ -133,7 +133,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{ role: 'user', parts: [{ text: 'List files' }] },
{
role: 'model',
@@ -172,7 +172,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [

Some files were not shown because too many files have changed in this diff Show More