mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a69d422a83 | |||
| 7478859502 | |||
| f09d45d133 | |||
| 792654c88b | |||
| 6973b963ae | |||
| dc47aaa2d9 | |||
| 7ff60809ef | |||
| 055e0f6452 | |||
| 9d01958cdb | |||
| 84423e6ea1 | |||
| 5611ff40e7 | |||
| 77e65c0db5 | |||
| b36788eb2a | |||
| d32c9b77df |
@@ -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
|
||||
```
|
||||
@@ -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 |
@@ -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 you’re an existing Gemini CLI user looking to migrate your workflow to
|
||||
Antigravity CLI, you’ve 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 CLI’s features. While there
|
||||
isn’t 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_.
|
||||

|
||||
_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 you’ve 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.
|
||||

|
||||
|
||||
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
|
||||
CLI’s `settings.json` as they are in Antigravity’s `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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+298
-198
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/),
|
||||
]),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user