Compare commits

...

21 Commits

Author SHA1 Message Date
Aishanee Shah d8a1a1e527 fix: resolve SimpleGitOptions TS error in original combined branch 2026-05-19 19:57:53 +00:00
Aishanee Shah ccee31d133 feat: implement unified session bundle format (v2.0) and history reconstruction 2026-05-19 19:33:36 +00:00
Aishanee Shah 91c592f0c0 feat: include full message records to link subagent trajectories 2026-05-19 19:32:05 +00:00
Aishanee Shah 395fe8ffbc test: add test case for subagent trajectories in bug report 2026-05-19 19:32:04 +00:00
Aishanee Shah 413416d587 feat: include subagent trajectories in chat save and bug reports 2026-05-19 19:32:03 +00:00
Coco Sheng 1a024f30a3 fix: allow configured MCP servers in non-interactive mode (#27215) 2026-05-19 14:44:09 +00:00
Adam Weidman 5650fa90d7 feat(core): add RemoteSessionInvocation (#26937) 2026-05-19 14:17:24 +00:00
Keith Schaab 85566a73f6 fix(a2a-server): Implement default policy loading for parity with CLI (#27073) 2026-05-19 14:09:57 +00:00
Rajesh patel 7478859502 fix(cli): Prevent unmapped keys in Vim Normal mode from inserting text into prompt Input. (#25139)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-19 00:17:55 +00:00
Tirth Naik f09d45d133 fix(core): prevent isBinary false-positive on Windows PTY streams (#26565) 2026-05-18 23:36:23 +00:00
Daniel Weis 792654c88b refactor: decouple auto model description and configuration from releaseChannel (#27227)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-05-18 20:15:55 +00:00
Adam Weidman 6973b963ae feat(core): add LocalSessionInvocation (#26665) 2026-05-18 19:30:21 +00:00
kaluchi dc47aaa2d9 fix(core): prefer pwsh.exe over Windows PowerShell 5.1 (#25859) (#25900)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-18 17:44:05 +00:00
Dev Randalpura 7ff60809ef fix(core): add unit tests for stableStringify (#27212) 2026-05-18 17:23:05 +00:00
joshualitt 055e0f6452 fix(context): Fix snapshot recovery across sessions. (#26939) 2026-05-18 16:44:59 +00:00
Adam Weidman 9d01958cdb feat(skills): add agent-tui and tui-tester skills (#27121) 2026-05-18 15:46:02 +00:00
Adam Weidman 84423e6ea1 fix(core): enforce compile-time exhaustiveness in content-utils (#27207) 2026-05-18 15:16:49 +00:00
Adam Weidman 5611ff40e7 feat(core): add adk.agentSessionSubagentEnabled flag (#26947) 2026-05-17 17:38:34 +00:00
David Pierce 77e65c0db5 fix(core): use hasAccessToPreview for auto model resolution and fix disappearing models (#27112) 2026-05-15 17:26:59 +00:00
Anish Sabharwal b36788eb2a fix(core): add aliases and thinking config for gemini-3.1 models (#27007) 2026-05-15 16:29:15 +00:00
PROTHAM d32c9b77df Fix/web fetch ctrl c abort (#24320)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-15 16:26:03 +00:00
126 changed files with 7808 additions and 1121 deletions
+330
View File
@@ -0,0 +1,330 @@
---
name: agent-tui
description: >
Main Agents: Do NOT use this skill directly. If you need to test the TUI, invoke the `tui_tester` subagent.
Drive terminal UI (TUI) applications programmatically for testing, automation, and inspection.
Use when: automating CLI/TUI interactions, regression testing terminal apps, or verifying interactive behavior.
Also use when: user asks "what is agent-tui", "what does agent-tui do", "demo agent-tui", "show me agent-tui", "how does agent-tui work", or wants to see it in action.
---
## 🚨 CRITICAL: macOS Daemon Workaround & Gemini CLI Usage 🚨
When using `agent-tui` in this macOS environment, the default background daemonization process crashes, causing `Connection refused (os error 61)` errors.
**You MUST start the daemon manually shielded from TTY hangups before running any `agent-tui` commands.** Using `nohup` is insufficient; you must use `tmux` to provide a fully isolated pseudo-terminal.
To support parallel runs, **only restart the daemon if it is not currently running:**
```bash
# Check if daemon is alive, start it in tmux if it is not
if ! agent-tui sessions >/dev/null 2>&1; then
tmux kill-session -t agent-tui 2>/dev/null || true
agent-tui daemon stop 2>/dev/null || true
rm -f /tmp/agent-tui*
tmux new-session -d -s agent-tui 'agent-tui daemon start --foreground > /tmp/agent-tui-daemon.log 2>&1'
sleep 1
fi
```
### Session ID vs PID (Crucial for Reconnection)
When `agent-tui run` returns JSON, it includes both a `session_id` and a `pid`. The `pid` is purely informational (the OS process ID of the child command). You **do not** use the `pid` to reconnect or issue commands. You must always use the `session_id` (e.g., `--session <id>`).
If the daemon crashes (`os error 61`), the pseudo-terminal is destroyed. Even if the child `pid` survives as an orphan, you cannot reconnect to it. You must restart the daemon using the workaround above and start a completely new session.
### Testing the Gemini CLI
When testing the Gemini CLI with `agent-tui`, there are several strict requirements to ensure deterministic and accurate behavior:
1. **Build Before Running**: `agent-tui` runs the built JS files, not TypeScript. You **MUST** run `npm run build` or `npm run build:all` after making code changes and before launching the CLI with `agent-tui`.
2. **Bypass Trust Modals**: Always pass `GEMINI_CLI_TRUST_WORKSPACE=true` in the environment. If you don't, any new project-level agents or extensions will trigger a full-screen "Acknowledge and Enable" modal. This modal steals focus, swallows automation keystrokes, and causes `agent-tui wait` commands to time out.
3. **Isolated Environments**: If you need to test without real user credentials or existing agents interfering, isolate the global settings using `GEMINI_CLI_HOME=<some-test-dir>`.
4. **Testing State Deltas (e.g., Reloads)**: If you are testing features that report deltas (e.g., `/agents reload` outputting "1 new local subagent"), you **MUST**:
- Start the CLI *first* so it establishes its baseline registry.
- Use a separate shell command (outside of `agent-tui`) to write the new agent `.md`/`.toml` file.
- Use `agent-tui type` and `press` to trigger the `/agents reload` command inside the running session.
- (If you add the files before starting the CLI, they become part of the baseline and won't trigger the delta logic).
```bash
# Example: Standard isolated run (sandboxed config + bypass trust modals)
env GEMINI_CLI_TRUST_WORKSPACE=true GEMINI_CLI_HOME=test-gemini-home agent-tui run -d "$(pwd)" node packages/cli/dist/index.js
```
# Terminal Automation Mastery
## Prerequisites
- **Supported OS**: macOS or Linux (Windows not supported yet).
- **Verify install**:
```bash
agent-tui --version
```
If not installed, use one of:
```bash
# Recommended: one-line install (macOS/Linux)
curl -fsSL https://raw.githubusercontent.com/pproenca/agent-tui/master/install.sh | sh
```
```bash
# Package manager
npm i -g agent-tui
pnpm add -g agent-tui
bun add -g agent-tui
```
```bash
# Build from source
cargo install --git https://github.com/pproenca/agent-tui.git --path cli/crates/agent-tui
```
If you used the install script, ensure `~/.local/bin` is on your PATH.
## Philosophy: Why Terminal Automation Is Different
Terminal UIs are **stateless from the observer's perspective**. Unlike web browsers with a persistent DOM, terminal automation works with a constantly-refreshed character grid. This fundamental difference shapes everything:
| Web Automation | Terminal Automation |
|----------------|---------------------|
| DOM persists across interactions | Screen buffer is redrawn constantly |
| Selectors are stable | Text positions may shift |
| Query once, act many times | Must re-verify before EVERY action |
| Network events signal completion | Must detect visual stability |
**The Core Insight**: agent-tui gives you vision without memory. Each screenshot is a fresh observation. Previous state means nothing after the UI changes. This isn't a limitation—it's the nature of terminal interaction.
## Mental Model: The Feedback Loop
Think of terminal automation as a **closed-loop control system**:
```
┌──────────────────────────────────────────────┐
│ │
▼ │
OBSERVE ──► DECIDE ──► ACT ──► WAIT ──► VERIFY ───┘
│ │
│ │
└─────── NEVER skip ◄────────────────────┘
```
**Each phase is mandatory.** Skipping verification is the #1 cause of flaky automation.
### The "Fresh Eyes" Principle
Every time you need to interact with the UI:
1. **Take a fresh screenshot** — your previous one is now stale
2. **Locate your target visually** — text positions may have changed
3. **Verify the state** — the UI may have changed unexpectedly
4. **Act only when stable** — animations and loading states cause failures
This feels slower, but it's the only reliable approach. Optimistic reuse of stale state causes intermittent failures that are painful to debug.
## Critical Rules (Non-Negotiable)
> **RULE 1: Atomic Execution (No Pipelining)**
> You are FORBIDDEN from chaining commands with `&&` (e.g., `type "x" && press Enter && wait`). Modals or UI updates can intercept your keystrokes. You MUST execute one atomic action, wait, screenshot, and verify before taking the next action in a new turn.
> **RULE 2: Re-snapshot after EVERY action**
> The UI state is invalidated by any change. Always take a fresh screenshot before acting again.
> **RULE 3: Never act on unstable UI**
> If the UI is animating, loading, or transitioning, `wait --stable` first. Acting during transitions because race conditions.
> **RULE 4: Verify before claiming success**
> Use `wait "expected text" --assert` to confirm outcomes. Don't assume an action worked—prove it.
> **RULE 5: Error Recovery**
> If a `wait` command times out, DO NOT blindly restart or kill the session. Execute `screenshot` to visually diagnose what unexpected UI element (modal, error dialog, lost focus) intercepted the flow.
> **RULE 6: Clean up sessions**
> Always end with `agent-tui kill`. Orphaned sessions consume resources and can interfere with future runs.
## Decision Framework
### Which Screenshot Mode?
Use `screenshot --format json` when parsing automation output, or plain `screenshot` for human readable text.
### How to Wait?
```
What are you waiting for?
├─► Specific text to appear
│ └─► `wait "text" --assert` (fails if not found)
├─► Specific text to disappear
│ └─► `wait "text" --gone --assert`
├─► UI to stop changing (animations, loading)
│ └─► `wait --stable`
└─► Multiple conditions
└─► Chain waits sequentially
```
### How to Act?
```
What do you need to do?
├─► Type text into the terminal
│ └─► `type "text"`
├─► Send keyboard shortcuts/navigation
│ └─► `press Ctrl+C` or `press ArrowDown Enter`
```
## Core Workflow
The canonical automation loop:
```bash
# 1. START: Launch the TUI app
agent-tui run <command> [-- args...]
# 2. OBSERVE: Get current UI state
agent-tui screenshot --format json
# 3. DECIDE: Based on text, determine next action
# (This happens in your head/code)
# 4. ACT: Execute the action
agent-tui type "text"
agent-tui press Enter
# 5. WAIT: Synchronize with UI changes
agent-tui wait "Expected" --assert # or wait --stable
# 6. VERIFY: Confirm the outcome (often combined with step 5)
# If verification fails, handle the error
# 7. REPEAT: Go back to step 2 until done
# 8. CLEANUP: Always clean up
agent-tui kill
```
## Anti-Patterns (What NOT to Do)
### ❌ Acting During Animation/Loading
```bash
# WRONG: Acting immediately on dynamic UI
agent-tui run my-app
agent-tui screenshot --format json # UI might still be loading!
agent-tui type "value" # ❌ Might miss the input field
# RIGHT: Wait for stability first
agent-tui run my-app
agent-tui wait --stable # Let UI settle
agent-tui screenshot --format json # Now it's reliable
agent-tui type "value"
```
### ❌ Assuming Success Without Verification
```bash
# WRONG: Assuming the type worked
agent-tui type "value"
agent-tui press Enter
# ...proceed as if success... # ❌ What if it failed silently?
# RIGHT: Verify the outcome
agent-tui type "value"
agent-tui press Enter
agent-tui wait "Success" --assert # ✓ Proves the action worked
```
### ❌ Skipping Cleanup
```bash
# WRONG: Forgetting to kill the session
agent-tui run my-app
# ...do stuff...
# script ends # ❌ Session left running!
# RIGHT: Always clean up
agent-tui run my-app
# ...do stuff...
agent-tui kill # ✓ Clean exit
```
## Before You Start: Clarify Requirements
Before automating any TUI, gather this information:
1. **Command**: What exactly to run? (`my-app --flag` or `npm start`?)
2. **Success criteria**: What text/state indicates success?
3. **Input sequence**: What keystrokes/data to enter, in what order?
4. **Safety**: Is it safe to submit forms, delete data, etc.?
5. **Auth**: Does it need login? Test credentials?
6. **Live preview**: Does the user want to watch? (`agent-tui live start --open`)
If any of these are unclear, ask before running.
## Demo Mode: Showing What agent-tui Can Do
When a user asks what agent-tui is, wants a demo, or asks "show me how it works":
1. **Don't explain—demonstrate.** Actions speak louder than words.
2. **Use the live preview** so they can watch in real-time.
3. **Run `top`**—it's universal and shows dynamic real-time updates.
**Quick demo trigger phrases:**
- "What is agent-tui?" / "What does agent-tui do?"
- "Demo agent-tui" / "Show me agent-tui"
- "How does agent-tui work?" / "See it in action"
## Failure Recovery
| Symptom | Diagnosis | Solution |
|---------|-----------|----------|
| "Text not found" | Stale view or text moved | Re-snapshot, locate text again |
| Wait times out | UI didn't reach expected state | Check screenshot, verify expectations |
| "Daemon not running" | Daemon crashed or not started | `agent-tui daemon start` |
| Unexpected layout | Wrong terminal size | `agent-tui resize --cols 120 --rows 40` |
| Session unresponsive | App crashed or hung | `agent-tui kill`, then re-run |
| Repeated failures | Something fundamentally wrong | Stop after 3-5 attempts, ask user |
## Self-Discovery: Use --help
You don't need to memorize every flag. The CLI is self-documenting:
```bash
agent-tui --help # List all commands
agent-tui run --help # Options for 'run'
agent-tui screenshot --help # Options for 'screenshot'
agent-tui wait --help # Options for 'wait'
```
**When in doubt, ask the CLI.** This skill teaches *when* and *why* to use commands. For exact flags and syntax, `--help` is authoritative.
## Quick Reference
```bash
# Start app
agent-tui run <cmd> [-- args] # Launch TUI under control
# Observe
agent-tui screenshot # Plain text view
agent-tui screenshot --format json # Machine-readable output
# Act
agent-tui press Enter # Press key(s)
agent-tui press Ctrl+C # Keyboard shortcuts
agent-tui type "text" # Type text
# Wait/Verify
agent-tui wait "text" --assert # Wait for text, fail if not found
agent-tui wait "text" --gone --assert # Wait for text to disappear
agent-tui wait --stable # Wait for UI to stop changing
# Manage
agent-tui sessions # List active sessions
agent-tui live start --open # Start live preview
agent-tui kill # End current session
```
+65
View File
@@ -0,0 +1,65 @@
---
name: tui-tester
description: Expert guidance for testing Gemini CLI behavior and visual output using terminal automation.
---
# TUI Tester Skill
This skill provides the operational manual for verifying Gemini CLI behavioral changes and visual output using terminal automation.
## Core Responsibilities
- **Verify Behavior**: Confirm that code changes result in the expected terminal interactions.
- **Visual Validation**: Ensure the TUI renders correctly across different terminal sizes and states.
- **Regression Testing**: Use automation to prevent breaking existing interactive workflows.
## Critical Protocol
When performing TUI testing, you must adhere to these strict rules:
### 1. Initialization
**YOUR ABSOLUTE FIRST ACTION MUST BE:**
Activate the `agent-tui` skill. This provides the underlying tools needed for terminal automation.
### 2. Environment Setup (macOS / Parallel Safe)
Ensure the global daemon is running and the live preview is open:
```bash
if ! agent-tui sessions >/dev/null 2>&1; then
tmux kill-session -t agent-tui 2>/dev/null || true
agent-tui daemon stop 2>/dev/null || true
rm -f /tmp/agent-tui*
tmux new-session -d -s agent-tui 'agent-tui daemon start --foreground > /tmp/agent-tui-daemon.log 2>&1'
sleep 1
fi
agent-tui live start --open
```
### 3. Session Management
- **Session IDs**: Always use the `session_id` returned by `agent-tui run` for subsequent interactions.
- **Atomic Execution**: Execute exactly one command per turn. Do not pipeline actions.
- **The Loop**: Action -> Wait -> Screenshot -> Verify -> Next Action.
### 4. Gemini CLI Specifics
- **Build First**: Always run `npm run build` or `npm run build:all` before testing local changes.
- **Bypass Trust**: Set `GEMINI_CLI_TRUST_WORKSPACE=true` to avoid focus-stealing modals.
- **Isolate Config**: Use `GEMINI_CLI_HOME` to prevent interference with your personal settings.
## Workflow Example
```bash
# Start the CLI
env GEMINI_CLI_TRUST_WORKSPACE=true agent-tui run node packages/cli/dist/index.js
# Wait for the prompt
agent-tui wait "│" --assert
# Send a command
agent-tui type "/help"
agent-tui press Enter
# Verify output
agent-tui wait "Available Commands" --assert
```
## Error Recovery
If a wait times out, take a fresh screenshot to diagnose the state. If you see `os error 61`, restart the daemon using the tmux method.
+24 -13
View File
@@ -550,6 +550,24 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-flash-preview"
}
},
"gemini-3.1-pro-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview"
}
},
"gemini-3.1-pro-preview-customtools": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview-customtools"
}
},
"gemini-3.1-flash-lite-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-flash-lite-preview"
}
},
"gemini-2.5-pro": {
"extends": "chat-base-2.5",
"modelConfig": {
@@ -1019,12 +1037,6 @@ their corresponding top-level category object in your `settings.json` file.
"auto": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"releaseChannel": "stable"
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"hasAccessToPreview": false
@@ -1168,13 +1180,6 @@ their corresponding top-level category object in your `settings.json` file.
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"releaseChannel": "stable",
"requestedModels": ["auto"]
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"requestedModels": ["gemini-2.5-pro", "auto-gemini-2.5"]
@@ -1836,6 +1841,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.adk.agentSessionSubagentEnabled`** (boolean):
- **Description:** Route subagent invocations through the AgentSession
protocol instead of legacy executors.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
+232
View File
@@ -0,0 +1,232 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { FinishReason, GenerateContentResponse } from '@google/genai';
import type { FakeResponse, HistoryTurn } from '@google/gemini-cli-core';
describe('Context Management Fidelity E2E', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should reproduce the exact context working buffer on resume', async () => {
// Mock responses to trigger GC (summarization)
const snapshotResponse: FakeResponse = {
method: 'generateContent',
response: {
candidates: [
{
content: {
parts: [
{
text: JSON.stringify({
new_facts: ['GC Triggered.'],
new_constraints: [],
new_tasks: [],
resolved_task_ids: [],
obsolete_fact_indices: [],
obsolete_constraint_indices: [],
chronological_summary: 'Snapshot created.',
}),
},
],
role: 'model',
},
finishReason: FinishReason.STOP,
index: 0,
},
],
} as unknown as GenerateContentResponse,
};
const countTokensResponse: FakeResponse = {
method: 'countTokens',
response: { totalTokens: 50000 },
};
const streamResponse = (text: string): FakeResponse => ({
method: 'generateContentStream',
response: [
{
candidates: [
{
content: { parts: [{ text }], role: 'model' },
finishReason: FinishReason.STOP,
index: 0,
},
],
},
] as unknown as GenerateContentResponse[],
});
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
const filePath = path.join(rig.testDir!, fileName);
fs.writeFileSync(
filePath,
mocks.map((m) => JSON.stringify(m)).join('\n'),
);
return filePath;
};
await rig.setup('context-fidelity', {
settings: {
experimental: {
stressTestProfile: true, // Lowers thresholds to trigger GC easily
},
},
});
const massivePayload = 'X'.repeat(50000);
const traceDir = path.join(rig.testDir!, 'traces');
fs.mkdirSync(traceDir, { recursive: true });
const traceLog = path.join(traceDir, 'trace.log');
const commonEnv = {
GEMINI_API_KEY: 'mock-key',
GEMINI_CONTEXT_TRACE_DIR: traceDir,
GEMINI_CONTEXT_TRACE_ENABLED: 'true',
GEMINI_DEBUG_LOG_FILE: path.join(rig.testDir!, 'debug.log'),
};
const runMocks: FakeResponse[] = [
streamResponse('Ack 1'),
streamResponse('Ack 2'),
streamResponse('Ack 3'),
streamResponse('Ack 4'),
streamResponse('Ack 5'),
];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}
// Turn 1: Initial massive payload to put pressure
await rig.run({
args: [
'--debug',
'--fake-responses-non-strict',
setupResponses('resp1.json', runMocks),
],
stdin: 'Turn 1: ' + massivePayload,
env: commonEnv,
});
// Turn 2: Another turn, resuming Turn 1
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 2: ' + massivePayload,
env: commonEnv,
});
// Turn 3: Third turn to force GC, resuming Turn 2
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
],
stdin: 'Turn 3: ' + massivePayload,
env: commonEnv,
});
// Extract the rendered context asset from the log
const getRenderedContext = (logContent: string): HistoryTurn[] | null => {
const lines = logContent.split('\n');
const renderLines = lines.filter(
(l) =>
l.includes('[Render] Render Sanitized Context for LLM') ||
l.includes('[Render] Render Context for LLM'),
);
if (renderLines.length === 0) return null;
const lastRender = renderLines[renderLines.length - 1];
const detailsMatch = lastRender.match(/\| Details: (.*)$/);
if (!detailsMatch) return null;
const details = JSON.parse(detailsMatch[1]);
const assetInfo =
details.renderedContextSanitized || details.renderedContext;
if (assetInfo && assetInfo.$asset) {
const assetPath = path.join(traceDir, 'assets', assetInfo.$asset);
return JSON.parse(fs.readFileSync(assetPath, 'utf-8'));
}
return assetInfo;
};
const log1 = fs.readFileSync(traceLog, 'utf-8');
const contextBeforeExit = getRenderedContext(log1);
expect(contextBeforeExit).toBeDefined();
console.log(
'Context Before Exit (First 2 turns):',
JSON.stringify(contextBeforeExit!.slice(0, 2), null, 2),
);
// Turn 4: Resume and run a small command
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp4.json', runMocks),
'continue',
],
env: commonEnv,
});
const log2 = fs.readFileSync(traceLog, 'utf-8');
const contextAfterResume = getRenderedContext(log2);
expect(contextAfterResume).toBeDefined();
console.log(
'Context After Resume (First 2 turns):',
JSON.stringify(contextAfterResume!.slice(0, 2), null, 2),
);
expect(contextAfterResume!.length).toBeGreaterThanOrEqual(
contextBeforeExit!.length,
);
for (let i = 0; i < contextBeforeExit!.length; i++) {
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
expect(contextAfterResume![i].content).toEqual(
contextBeforeExit![i].content,
);
}
// Most importantly, synthetic IDs (like summaries) must be stable.
const syntheticTurns = contextBeforeExit!.filter(
(t: HistoryTurn) => t.id && t.id.length === 32,
); // deriveStableId produces 32-char hex
expect(syntheticTurns.length).toBeGreaterThan(0);
const syntheticTurnsAfter = contextAfterResume!.filter(
(t: HistoryTurn) => t.id && t.id.length === 32,
);
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
syntheticTurns.length,
);
// Check if the first synthetic turn is identical
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content);
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { FinishReason, GenerateContentResponse } from '@google/genai';
import type { FakeResponse } from '@google/gemini-cli-core';
describe('Context Management Resume E2E', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should preserve and utilize GC snapshot boundaries when resuming a session', async () => {
const snapshotResponse: FakeResponse = {
method: 'generateContent',
response: {
candidates: [
{
content: {
parts: [
{
text: JSON.stringify({
new_facts: ['GC Triggered.'],
new_constraints: [],
new_tasks: [],
resolved_task_ids: [],
obsolete_fact_indices: [],
obsolete_constraint_indices: [],
chronological_summary: 'Snapshot created.',
}),
},
],
role: 'model',
},
finishReason: FinishReason.STOP,
index: 0,
},
],
} as unknown as GenerateContentResponse,
};
const countTokensResponse: FakeResponse = {
method: 'countTokens',
response: { totalTokens: 50000 },
};
const streamResponse = (text: string): FakeResponse => ({
method: 'generateContentStream',
response: [
{
candidates: [
{
content: { parts: [{ text }], role: 'model' },
finishReason: FinishReason.STOP,
index: 0,
},
],
},
] as unknown as GenerateContentResponse[],
});
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
const filePath = path.join(rig.testDir!, fileName);
fs.writeFileSync(
filePath,
mocks.map((m) => JSON.stringify(m)).join('\n'),
);
return filePath;
};
await rig.setup('resume-gc-snapshot', {
settings: {
experimental: {
stressTestProfile: true,
},
},
});
const massivePayload = 'X'.repeat(40000);
const logFile = path.join(rig.testDir!, 'debug.log');
const traceDir = path.join(rig.testDir!, 'traces');
fs.mkdirSync(traceDir, { recursive: true });
const traceLog = path.join(traceDir, 'trace.log');
const commonEnv = {
GEMINI_API_KEY: 'mock-key',
GEMINI_DEBUG_LOG_FILE: logFile,
GEMINI_CONTEXT_TRACE_DIR: traceDir,
};
// Provide a massive pool of responses to prevent exhaustion
const runMocks: FakeResponse[] = [streamResponse('Acknowledged block.')];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}
// Use stdin for the massive payload to avoid ENAMETOOLONG on Windows
await rig.run({
args: [
'--debug',
'--fake-responses-non-strict',
setupResponses('resp1.json', runMocks),
],
stdin: 'Turn 1: ' + massivePayload,
env: commonEnv,
});
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 2: ' + massivePayload,
env: commonEnv,
});
const result3 = await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
'continue',
],
env: commonEnv,
});
expect(result3).toContain('Acknowledged block');
const traces = fs.readFileSync(traceLog, 'utf-8');
expect(traces).toContain('Hitting Synchronous Pressure Barrier');
expect(traces).toContain('GC Triggered.');
});
});
+8 -2
View File
@@ -93,10 +93,16 @@ export class CoderAgentExecutor implements AgentExecutor {
taskId: string,
): Promise<Config> {
const workspaceRoot = setTargetDir(agentSettings);
const isTrusted = agentSettings.isTrusted ?? false;
loadEnvironment(); // Will override any global env with workspace envs
const settings = loadSettings(workspaceRoot);
const settings = loadSettings(workspaceRoot, isTrusted);
const extensions = loadExtensions(workspaceRoot);
return loadConfig(settings, new SimpleExtensionLoader(extensions), taskId);
return loadConfig(
settings,
new SimpleExtensionLoader(extensions),
taskId,
isTrusted,
);
}
/**
+114 -2
View File
@@ -19,7 +19,9 @@ import {
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
ApprovalMode,
PRIORITY_YOLO_ALLOW_ALL,
createPolicyEngineConfig,
} from '@google/gemini-cli-core';
// Mock dependencies
@@ -53,6 +55,32 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
isHeadlessMode: vi.fn().mockReturnValue(false),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
createPolicyEngineConfig: vi
.fn()
.mockImplementation(
(_settings, mode, _defaultPoliciesDir, _interactive) => ({
rules:
mode === actual.ApprovalMode.YOLO
? [
{
toolName: '*',
decision: actual.PolicyDecision.ALLOW,
priority: actual.PRIORITY_YOLO_ALLOW_ALL,
modes: [actual.ApprovalMode.YOLO],
allowRedirection: true,
},
]
: [
{
toolName: 'read_file',
decision: actual.PolicyDecision.ALLOW,
priority: 1.05,
source: 'Default: read-only.toml',
},
],
checkers: [],
}),
),
coreEvents: {
emitAdminSettingsChanged: vi.fn(),
},
@@ -261,6 +289,85 @@ describe('loadConfig', () => {
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
});
describe('policy engine configuration', () => {
it('should merge V1 and V2 tool settings into policySettings', async () => {
const settings: Settings = {
allowedTools: ['v1-allowed'],
tools: {
allowed: ['v2-allowed'],
exclude: ['v2-exclude'],
core: ['v2-core'],
},
mcpServers: {
test: { command: 'test', args: [] },
},
policyPaths: ['/path/to/policy'],
adminPolicyPaths: ['/path/to/admin/policy'],
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
core: ['v2-core'],
exclude: ['v2-exclude'],
allowed: ['v1-allowed'],
},
mcpServers: settings.mcpServers,
policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths,
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
it('should use V2 tool settings when V1 is missing', async () => {
const settings: Settings = {
tools: {
allowed: ['v2-allowed'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
allowed: ['v2-allowed'],
}),
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
it('should use V1 tool settings when V2 is also present', async () => {
const settings: Settings = {
allowedTools: ['v1-allowed'],
tools: {
allowed: ['v2-allowed'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
allowed: ['v1-allowed'],
}),
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
});
describe('tool configuration', () => {
it('should pass V1 allowedTools to Config properly', async () => {
const settings: Settings = {
@@ -385,14 +492,19 @@ describe('loadConfig', () => {
);
});
it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => {
it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => {
vi.stubEnv('GEMINI_YOLO_MODE', 'false');
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: 'default',
policyEngineConfig: expect.objectContaining({
rules: [],
rules: expect.arrayContaining([
expect.objectContaining({
toolName: 'read_file',
decision: PolicyDecision.ALLOW,
}),
]),
}),
}),
);
+23 -17
View File
@@ -23,8 +23,8 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
createPolicyEngineConfig,
type PolicySettings,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
@@ -38,6 +38,7 @@ export async function loadConfig(
settings: Settings,
extensionLoader: ExtensionLoader,
taskId: string,
trusted: boolean = false,
): Promise<Config> {
const workspaceDir = process.cwd();
@@ -63,6 +64,24 @@ export async function loadConfig(
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT;
const policySettings: PolicySettings = {
mcpServers: settings.mcpServers,
tools: {
core: settings.coreTools || settings.tools?.core,
exclude: settings.excludeTools || settings.tools?.exclude,
allowed: settings.allowedTools || settings.tools?.allowed,
},
policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths,
};
const policyEngineConfig = await createPolicyEngineConfig(
policySettings,
approvalMode,
undefined,
true,
);
const configParams: ConfigParameters = {
sessionId: taskId,
clientName: 'a2a-server',
@@ -78,20 +97,7 @@ export async function loadConfig(
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
showMemoryUsage: settings.showMemoryUsage || false,
approvalMode,
policyEngineConfig: {
rules:
approvalMode === ApprovalMode.YOLO
? [
{
toolName: '*',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_YOLO_ALLOW_ALL,
modes: [ApprovalMode.YOLO],
allowRedirection: true,
},
]
: [],
},
policyEngineConfig,
mcpServers: settings.mcpServers,
cwd: workspaceDir,
telemetry: {
@@ -118,7 +124,7 @@ export async function loadConfig(
},
ideMode: false,
folderTrust,
trustedFolder: true,
trustedFolder: trusted,
extensionLoader,
checkpointing,
interactive: true,
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, checkPathTrust } from '@google/gemini-cli-core';
const mocks = vi.hoisted(() => {
const suffix = Math.random().toString(36).slice(2);
@@ -40,6 +40,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
},
getErrorMessage: (error: unknown) => String(error),
homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`),
checkPathTrust: vi.fn(() => ({ isTrusted: false })),
isHeadlessMode: vi.fn(() => true),
};
});
@@ -146,7 +148,7 @@ describe('loadSettings', () => {
);
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
const result = loadSettings(mockWorkspaceDir);
const result = loadSettings(mockWorkspaceDir, true);
// Primitive value overwritten
expect(result.showMemoryUsage).toBe(true);
@@ -154,4 +156,78 @@ describe('loadSettings', () => {
expect(result.fileFiltering?.respectGitIgnore).toBe(false);
expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined();
});
describe('security', () => {
it('should NOT load workspace settings if workspace is NOT trusted', () => {
const userSettings = { showMemoryUsage: false };
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = { showMemoryUsage: true };
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
// checkPathTrust is mocked to return isTrusted: false by default
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(false);
});
it('should load workspace settings if workspace IS trusted', () => {
vi.mocked(checkPathTrust).mockReturnValueOnce({
isTrusted: true,
source: 'file',
});
const userSettings = { showMemoryUsage: false };
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = { showMemoryUsage: true };
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(true);
});
it('should NOT allow workspace settings to override adminPolicyPaths or policyPaths even if trusted', () => {
vi.mocked(checkPathTrust).mockReturnValueOnce({
isTrusted: true,
source: 'file',
});
const userSettings = {
adminPolicyPaths: ['/trusted/admin'],
policyPaths: ['/trusted/user'],
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = {
adminPolicyPaths: ['./malicious/admin'],
policyPaths: ['./malicious/user'],
showMemoryUsage: true,
};
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(true);
expect(result.adminPolicyPaths).toEqual(['/trusted/admin']);
expect(result.policyPaths).toEqual(['/trusted/user']);
});
});
});
+44 -17
View File
@@ -14,6 +14,8 @@ import {
getErrorMessage,
type TelemetrySettings,
homedir,
checkPathTrust,
isHeadlessMode,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
@@ -51,6 +53,8 @@ export interface Settings {
experimental?: {
enableAgents?: boolean;
};
policyPaths?: string[];
adminPolicyPaths?: string[];
}
export interface SettingsError {
@@ -64,13 +68,16 @@ export interface CheckpointingSettings {
/**
* Loads settings from user and workspace directories.
* Project settings override user settings.
* Project settings override user settings if the workspace is trusted.
*
* How is it different to gemini-cli/cli: Returns already merged settings rather
* than `LoadedSettings` (unnecessary since we are not modifying users
* settings.json).
*/
export function loadSettings(workspaceDir: string): Settings {
export function loadSettings(
workspaceDir: string,
isTrustedOverride?: boolean,
): Settings {
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
@@ -92,27 +99,39 @@ export function loadSettings(workspaceDir: string): Settings {
});
}
let isTrusted = isTrustedOverride;
if (isTrusted === undefined) {
const { isTrusted: trustResult } = checkPathTrust({
path: workspaceDir,
isFolderTrustEnabled: userSettings.folderTrust ?? true,
isHeadless: isHeadlessMode(),
});
isTrusted = trustResult ?? false;
}
const workspaceSettingsPath = path.join(
workspaceDir,
GEMINI_DIR,
'settings.json',
);
// Load workspace settings
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
// Load workspace settings only if trusted
if (isTrusted) {
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
if (settingsErrors.length > 0) {
@@ -125,10 +144,18 @@ export function loadSettings(workspaceDir: string): Settings {
// If there are overlapping keys, the values of workspaceSettings will
// override values from userSettings
return {
const mergedSettings = {
...userSettings,
...workspaceSettings,
};
// Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level
// configuration and cannot be overridden by workspace-level settings, even if the
// workspace is trusted.
mergedSettings.policyPaths = userSettings.policyPaths;
mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths;
return mergedSettings;
}
function resolveEnvVarsInString(value: string): string {
+14 -1
View File
@@ -30,6 +30,8 @@ import {
debugLogger,
SimpleExtensionLoader,
GitService,
checkPathTrust,
isHeadlessMode,
} from '@google/gemini-cli-core';
import type { Command, CommandArgument } from '../commands/types.js';
@@ -197,12 +199,23 @@ export async function createApp() {
// Load the server configuration once on startup.
const workspaceRoot = setTargetDir(undefined);
loadEnvironment();
const settings = loadSettings(workspaceRoot);
// Use a temporary settings load to check if folder trust is enabled.
// This is similar to how the CLI handles the initial trust check.
const initialSettings = loadSettings(workspaceRoot, false);
const { isTrusted } = checkPathTrust({
path: workspaceRoot,
isFolderTrustEnabled: initialSettings.folderTrust ?? true,
isHeadless: isHeadlessMode(),
});
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
const extensions = loadExtensions(workspaceRoot);
const config = await loadConfig(
settings,
new SimpleExtensionLoader(extensions),
'a2a-server',
isTrusted ?? false,
);
let git: GitService | undefined;
+1
View File
@@ -47,6 +47,7 @@ export interface AgentSettings {
kind: CoderAgentEvent.StateAgentSettingsEvent;
workspacePath: string;
autoExecute?: boolean;
isTrusted?: boolean;
}
export interface ToolCallConfirmation {
+4 -5
View File
@@ -22,7 +22,6 @@ import {
getDisplayString,
AuthType,
ToolConfirmationOutcome,
getChannelFromVersion,
getAutoModelDescription,
} from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
@@ -272,8 +271,6 @@ export function buildAvailableModels(
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
const releaseChannel = getChannelFromVersion(config.clientVersion);
// --- DYNAMIC PATH ---
if (
config.getExperimentalDynamicModelConfiguration?.() === true &&
@@ -284,7 +281,6 @@ export function buildAvailableModels(
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
releaseChannel,
});
return {
@@ -298,7 +294,10 @@ export function buildAvailableModels(
{
value: GEMINI_MODEL_ALIAS_AUTO,
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
description: getAutoModelDescription(releaseChannel, useGemini31),
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
),
},
];
+8
View File
@@ -103,6 +103,7 @@ export interface CliArgs {
useWriteTodos: boolean | undefined;
outputFormat: string | undefined;
fakeResponses: string | undefined;
fakeResponsesNonStrict?: string | undefined;
recordResponses: string | undefined;
startupMessages?: string[];
rawOutput: boolean | undefined;
@@ -474,6 +475,12 @@ export async function parseArguments(
description: 'Path to a file with fake model responses for testing.',
hidden: true,
})
.option('fake-responses-non-strict', {
type: 'string',
description:
'Path to a file with fake model responses for testing (non-strict mode).',
hidden: true,
})
.option('record-responses', {
type: 'string',
description: 'Path to a file to record model responses for testing.',
@@ -1074,6 +1081,7 @@ export async function loadCliConfig(
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
adk: settings.experimental?.adk,
fakeResponses: argv.fakeResponses,
fakeResponsesNonStrict: argv.fakeResponsesNonStrict,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
billing: settings.billing,
@@ -571,6 +571,18 @@ describe('SettingsSchema', () => {
expect(agentSessionNoninteractiveEnabled.description).toBe(
'Enable non-interactive agent sessions.',
);
const agentSessionSubagentEnabled =
adk.properties.agentSessionSubagentEnabled;
expect(agentSessionSubagentEnabled).toBeDefined();
expect(agentSessionSubagentEnabled.type).toBe('boolean');
expect(agentSessionSubagentEnabled.category).toBe('Experimental');
expect(agentSessionSubagentEnabled.default).toBe(false);
expect(agentSessionSubagentEnabled.requiresRestart).toBe(true);
expect(agentSessionSubagentEnabled.showInDialog).toBe(false);
expect(agentSessionSubagentEnabled.description).toBe(
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
);
});
});
+10
View File
@@ -2194,6 +2194,16 @@ const SETTINGS_SCHEMA = {
'Enable the agent session implementation for the interactive CLI.',
showInDialog: false,
},
agentSessionSubagentEnabled: {
type: 'boolean',
label: 'Agent Session Subagent Enabled',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
showInDialog: false,
},
},
},
enableAgents: {
@@ -9,7 +9,11 @@ import open from 'open';
import path from 'node:path';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getVersion, type Config } from '@google/gemini-cli-core';
import {
getVersion,
type Config,
type ConversationRecord,
} from '@google/gemini-cli-core';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatBytes } from '../utils/formatters.js';
import { MessageType } from '../types.js';
@@ -157,6 +161,8 @@ describe('bugCommand', () => {
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'hi' }] },
];
const mockGetSubagentTrajectories = vi.fn().mockResolvedValue({});
const mockGetConversation = vi.fn().mockReturnValue({ messages: [] });
const mockContext = createMockCommandContext({
services: {
agentContext: {
@@ -173,6 +179,8 @@ describe('bugCommand', () => {
geminiClient: {
getChat: () => ({
getHistory: () => history,
getSubagentTrajectories: mockGetSubagentTrajectories,
getConversation: mockGetConversation,
}),
},
},
@@ -187,8 +195,10 @@ describe('bugCommand', () => {
'bug-report-history-1704067200000.json',
);
expect(exportHistoryToFile).toHaveBeenCalledWith({
history,
messages: [],
filePath: expectedPath,
trajectories: {},
history,
});
const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0];
@@ -203,6 +213,60 @@ describe('bugCommand', () => {
expect(messageText).toContain(encodeURIComponent(reminder));
});
it('should include subagent trajectories in history export if available', async () => {
const history = [
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'hi' }] },
];
const trajectories = {
'subagent-1': {
sessionId: 'subagent-1',
messages: [],
} as unknown as ConversationRecord,
};
const mockGetSubagentTrajectories = vi.fn().mockResolvedValue(trajectories);
const mockGetConversation = vi.fn().mockReturnValue({ messages: [] });
const mockContext = createMockCommandContext({
services: {
agentContext: {
config: {
getModel: () => 'gemini-pro',
getBugCommand: () => undefined,
getIdeMode: () => true,
getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
storage: {
getProjectTempDir: () => '/tmp/gemini',
},
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config,
geminiClient: {
getChat: () => ({
getHistory: () => history,
getSubagentTrajectories: mockGetSubagentTrajectories,
getConversation: mockGetConversation,
}),
},
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'Bug with trajectories');
const expectedPath = path.join(
'/tmp/gemini',
'bug-report-history-1704067200000.json',
);
expect(mockGetSubagentTrajectories).toHaveBeenCalled();
expect(exportHistoryToFile).toHaveBeenCalledWith({
history,
messages: [],
filePath: expectedPath,
trajectories,
});
});
it('should use a custom URL template from config if provided', async () => {
const customTemplate =
'https://internal.bug-tracker.com/new?desc={title}&details={info}';
+8 -1
View File
@@ -88,7 +88,14 @@ export const bugCommand: SlashCommand = {
const historyFileName = `bug-report-history-${Date.now()}.json`;
const historyFilePath = path.join(tempDir, historyFileName);
try {
await exportHistoryToFile({ history, filePath: historyFilePath });
const trajectories = await chat?.getSubagentTrajectories();
const messages = chat?.getConversation()?.messages ?? [];
await exportHistoryToFile({
messages,
filePath: historyFilePath,
trajectories,
history,
});
historyFileMessage = `\n\n--------------------------------------------------------------------------------\n\n📄 **Chat History Exported**\nTo help us debug, we've exported your current chat history to:\n${historyFilePath}\n\nPlease consider attaching this file to your GitHub issue if you feel comfortable doing so.\n\n**Privacy Disclaimer:** Please do not upload any logs containing sensitive or private information that you are not comfortable sharing publicly.`;
problemValue += `\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.`;
} catch (err) {
@@ -63,6 +63,8 @@ describe('chatCommand', () => {
mockGetHistory = vi.fn().mockReturnValue([]);
mockGetChat = vi.fn().mockReturnValue({
getHistory: mockGetHistory,
getSubagentTrajectories: vi.fn().mockResolvedValue({}),
getConversation: vi.fn().mockReturnValue({ messages: [] }),
});
mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] });
@@ -191,6 +193,15 @@ describe('chatCommand', () => {
{ role: 'user', parts: [{ text: 'Hello, how are you?' }] },
]);
result = await saveCommand?.action?.(mockContext, tag);
expect(mockSaveCheckpoint).toHaveBeenCalledWith(
{
version: '2.0',
authType: AuthType.LOGIN_WITH_GOOGLE,
trajectories: {},
messages: [],
},
tag,
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -230,7 +241,12 @@ describe('chatCommand', () => {
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
expect(mockSaveCheckpoint).toHaveBeenCalledWith(
{ history, authType: AuthType.LOGIN_WITH_GOOGLE },
{
version: '2.0',
authType: AuthType.LOGIN_WITH_GOOGLE,
trajectories: {},
messages: [],
},
tag,
);
expect(result).toEqual({
@@ -292,6 +308,8 @@ describe('chatCommand', () => {
{ type: 'gemini', text: 'hello world' },
] as HistoryItemWithoutId[],
clientHistory: conversation,
messages: undefined,
version: undefined,
});
});
@@ -332,6 +350,8 @@ describe('chatCommand', () => {
{ type: 'gemini', text: 'hello world' },
] as HistoryItemWithoutId[],
clientHistory: conversation,
messages: undefined,
version: undefined,
});
});
@@ -463,8 +483,10 @@ describe('chatCommand', () => {
'gemini-conversation-1234567890.json',
);
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
history: mockHistory,
});
expect(result).toEqual({
type: 'message',
@@ -478,8 +500,10 @@ describe('chatCommand', () => {
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
history: mockHistory,
});
expect(result).toEqual({
type: 'message',
@@ -493,8 +517,10 @@ describe('chatCommand', () => {
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
history: mockHistory,
});
expect(result).toEqual({
type: 'message',
@@ -543,8 +569,10 @@ describe('chatCommand', () => {
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
history: mockHistory,
});
});
@@ -553,8 +581,10 @@ describe('chatCommand', () => {
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
history: mockHistory,
});
});
});
+12 -3
View File
@@ -139,7 +139,12 @@ const saveCommand: SlashCommand = {
const history = chat.getHistory();
if (history.length > INITIAL_HISTORY_LENGTH) {
const authType = config?.getContentGeneratorConfig()?.authType;
await logger.saveCheckpoint({ history, authType }, tag);
const trajectories = await chat.getSubagentTrajectories();
const messages = chat.getConversation()?.messages ?? [];
await logger.saveCheckpoint(
{ version: '2.0', authType, trajectories, messages },
tag,
);
return {
type: 'message',
messageType: 'info',
@@ -178,7 +183,7 @@ const resumeCheckpointCommand: SlashCommand = {
const config = context.services.agentContext?.config;
await logger.initialize();
const checkpoint = await logger.loadCheckpoint(tag);
const conversation = checkpoint.history;
const conversation = checkpoint.history ?? [];
if (conversation.length === 0) {
return {
@@ -228,6 +233,8 @@ const resumeCheckpointCommand: SlashCommand = {
type: 'load_history',
history: uiHistory,
clientHistory: conversation,
messages: checkpoint.messages,
version: checkpoint.version,
};
},
completion: async (context, partialArg) => {
@@ -324,7 +331,9 @@ const shareCommand: SlashCommand = {
}
try {
await exportHistoryToFile({ history, filePath });
const trajectories = await chat.getSubagentTrajectories();
const messages = chat.getConversation()?.messages ?? [];
await exportHistoryToFile({ messages, filePath, trajectories, history });
return {
type: 'message',
messageType: 'info',
@@ -14,7 +14,6 @@ import { type HistoryItem } from '../types.js';
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
import { revertFileChanges } from '../utils/rewindFileOps.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import type { Content } from '@google/genai';
import {
checkExhaustive,
coreEvents,
@@ -58,7 +57,7 @@ async function rewindConversation(
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
const clientHistory = convertSessionToClientHistory(conversation.messages);
client.setHistory(clientHistory as Content[]);
client.setHistory(clientHistory);
// Reset context manager as we are rewinding history
await context.services.agentContext?.config
@@ -154,6 +154,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
vimEnabled={vimEnabled}
vimMode={vimMode}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
@@ -4898,6 +4898,60 @@ describe('InputPrompt', () => {
unmount();
});
it('should NOT open shortcuts help with ? in vim NORMAL mode', async () => {
const setShortcutsHelpVisible = vi.fn();
const vimHandleInput = vi.fn().mockReturnValue(true);
const { stdin, unmount } = await renderWithProviders(
<TestInputPrompt
{...props}
vimEnabled={true}
vimMode="NORMAL"
vimHandleInput={vimHandleInput}
/>,
{
uiActions: { setShortcutsHelpVisible },
},
);
await act(async () => {
stdin.write('?');
});
expect(setShortcutsHelpVisible).not.toHaveBeenCalled();
expect(vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
unmount();
});
it('should open shortcuts help with ? in vim INSERT mode', async () => {
const setShortcutsHelpVisible = vi.fn();
const vimHandleInput = vi.fn().mockReturnValue(false);
const { stdin, unmount } = await renderWithProviders(
<TestInputPrompt
{...props}
vimEnabled={true}
vimMode="INSERT"
vimHandleInput={vimHandleInput}
/>,
{
uiActions: { setShortcutsHelpVisible },
},
);
await act(async () => {
stdin.write('?');
});
await waitFor(() => {
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
});
unmount();
});
it.each([
{
name: 'terminal paste event occurs',
+14 -2
View File
@@ -92,6 +92,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import type { VimMode } from '../contexts/VimModeContext.js';
const SCROLLBAR_GUTTER_WIDTH = 1;
@@ -126,6 +127,8 @@ export interface InputPromptProps {
onEscapePromptChange?: (showPrompt: boolean) => void;
onSuggestionsVisibilityChange?: (visible: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
vimEnabled?: boolean;
vimMode?: VimMode;
isEmbeddedShellFocused?: boolean;
setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState;
@@ -214,6 +217,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onEscapePromptChange,
onSuggestionsVisibilityChange,
vimHandleInput,
vimEnabled,
vimMode,
isEmbeddedShellFocused,
setQueueErrorMessage,
streamingState,
@@ -859,7 +864,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (shortcutsHelpVisible) {
if (key.sequence === '?' && key.insertable) {
if (
key.sequence === '?' &&
key.insertable &&
(!vimEnabled || vimMode === 'INSERT')
) {
setShortcutsHelpVisible(false);
buffer.handleInput(key);
return true;
@@ -879,7 +888,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
key.sequence === '?' &&
key.insertable &&
!shortcutsHelpVisible &&
buffer.text.length === 0
buffer.text.length === 0 &&
(!vimEnabled || vimMode === 'INSERT')
) {
setShortcutsHelpVisible(true);
return true;
@@ -1374,6 +1384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState,
resetEscapeState,
vimHandleInput,
vimEnabled,
vimMode,
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
@@ -34,6 +34,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getAutoModelDescription: (
hasAccessToPreview: boolean,
useGemini3_1?: boolean,
) =>
`Auto Model Description (preview: ${hasAccessToPreview}, 3.1: ${useGemini3_1})`,
getDisplayString: (val: string) => mockGetDisplayString(val),
logModelSlashCommand: (config: Config, event: ModelSlashCommandEvent) =>
mockLogModelSlashCommand(config, event),
+5 -13
View File
@@ -26,7 +26,6 @@ import {
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isProModel,
getChannelFromVersion,
getAutoModelDescription,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -66,7 +65,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || GEMINI_MODEL_ALIAS_AUTO;
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel() ?? false;
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
@@ -122,12 +121,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
{ isActive: true },
);
const releaseChannel = useMemo(
() => getChannelFromVersion(config?.clientVersion ?? ''),
[config?.clientVersion],
);
const mainOptions = useMemo(() => {
// --- DYNAMIC PATH ---
if (
@@ -142,7 +135,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
releaseChannel,
});
const list = allOptions
@@ -170,7 +162,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
{
value: GEMINI_MODEL_ALIAS_AUTO,
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
description: getAutoModelDescription(releaseChannel, useGemini31),
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
),
key: GEMINI_MODEL_ALIAS_AUTO,
},
{
@@ -192,7 +187,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useGemini31FlashLite,
useCustomToolModel,
hasAccessToProModel,
releaseChannel,
]);
const manualOptions = useMemo(() => {
@@ -209,7 +203,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
releaseChannel,
});
return allOptions
@@ -302,7 +295,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useGemini31FlashLite,
useCustomToolModel,
hasAccessToProModel,
releaseChannel,
config,
]);
@@ -12,7 +12,6 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
getShellConfiguration,
isWindows,
type WorktreeSettings,
} from '@google/gemini-cli-core';
@@ -22,7 +21,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getShellConfiguration: vi.fn(),
isWindows: vi.fn(),
};
});
@@ -45,7 +43,6 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
};
});
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
const isWindowsMock = vi.mocked(isWindows);
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
@@ -104,11 +101,6 @@ describe('<SessionSummaryDisplay />', () => {
};
beforeEach(() => {
getShellConfigurationMock.mockReturnValue({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
});
isWindowsMock.mockReturnValue(false);
});
@@ -173,11 +165,6 @@ describe('<SessionSummaryDisplay />', () => {
it('renders a standard UUID-formatted session ID in the footer (powershell) on Windows', async () => {
isWindowsMock.mockReturnValue(true);
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
});
const uuidSessionId = '1234-abcd-5678-efgh';
const { lastFrame, unmount } = await renderWithMockedStats(
@@ -192,11 +179,7 @@ describe('<SessionSummaryDisplay />', () => {
});
it('sanitizes a malicious session ID in the footer (powershell)', async () => {
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
});
isWindowsMock.mockReturnValue(true);
const maliciousSessionId = "'; rm -rf / #";
const { lastFrame, unmount } = await renderWithMockedStats(
@@ -10,8 +10,8 @@ import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import {
escapeShellArg,
getShellConfiguration,
isWindows,
type ShellType,
} from '@google/gemini-cli-core';
interface SessionSummaryDisplayProps {
@@ -23,7 +23,7 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
}) => {
const { stats } = useSessionStats();
const config = useConfig();
const { shell } = getShellConfiguration();
const shell: ShellType = isWindows() ? 'powershell' : 'bash';
const worktreeSettings = config.getWorktreeSettings();
@@ -549,7 +549,16 @@ export const useSlashCommandProcessor = (
}
}
case 'load_history': {
config?.getGeminiClient()?.setHistory(result.clientHistory);
const client = config?.getGeminiClient();
if (result.version === '2.0' && client) {
await client.resumeChat(
[...result.clientHistory],
undefined,
result.messages,
);
} else {
client?.setHistory(result.clientHistory);
}
fullCommandContext.ui.clear();
result.history.forEach((item, index) => {
fullCommandContext.ui.addItem(item, index);
@@ -194,14 +194,16 @@ describe('convertSessionToHistoryFormats', () => {
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(2);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Hello' }],
});
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [{ text: 'Hi there' }],
});
expect(clientHistory.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'Hello' }],
},
{
role: 'model',
parts: [{ text: 'Hi there' }],
},
]);
});
it('should convert thinking tokens (thoughts) to thinking history items', () => {
@@ -254,10 +256,12 @@ describe('convertSessionToHistoryFormats', () => {
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(1);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Expanded content' }],
});
expect(clientHistory.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'Expanded content' }],
},
]);
});
it('should filter out slash commands from client history but keep in UI', () => {
@@ -316,33 +320,35 @@ describe('convertSessionToHistoryFormats', () => {
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'What time is it?' }],
});
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [
{
functionCall: {
name: 'get_time',
args: {},
id: 'call_1',
expect(clientHistory.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'What time is it?' }],
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'get_time',
args: {},
id: 'call_1',
},
},
},
],
});
expect(clientHistory[2]).toEqual({
role: 'user',
parts: [
{
functionResponse: {
id: 'call_1',
name: 'get_time',
response: { output: '12:00' },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'get_time',
response: { output: '12:00' },
id: 'call_1',
},
},
},
],
});
],
},
]);
});
});
+10 -4
View File
@@ -12,22 +12,28 @@ import {
convertSessionToClientHistory,
uiTelemetryService,
loadConversationRecord,
type Config,
type ResumedSessionData,
} from '@google/gemini-cli-core';
import type {
HistoryTurn,
Config,
ResumedSessionData,
} from '@google/gemini-cli-core';
import {
convertSessionToHistoryFormats,
type SessionInfo,
} from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
export { convertSessionToHistoryFormats };
import type { Part } from '@google/genai';
export const useSessionBrowser = (
config: Config,
onLoadHistory: (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
clientHistory: Array<
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
>,
resumedSessionData: ResumedSessionData,
) => Promise<void>,
) => {
@@ -13,6 +13,7 @@ import type {
ResumedSessionData,
ConversationRecord,
MessageRecord,
HistoryTurn,
} from '@google/gemini-cli-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { HistoryItemWithoutId } from '../types.js';
@@ -527,10 +528,12 @@ describe('useSessionResume', () => {
// Should only have the non-slash-command message
expect(clientHistory).toHaveLength(1);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Regular message' }],
});
expect(clientHistory.map((h: HistoryTurn) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'Regular message' }],
},
]);
// But UI history should have both
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
@@ -7,14 +7,17 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
coreEvents,
type Config,
type ResumedSessionData,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import type {
HistoryTurn,
Config,
ResumedSessionData,
} from '@google/gemini-cli-core';
import type { HistoryItemWithoutId } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
import type { Part } from '@google/genai';
interface UseSessionResumeParams {
config: Config;
@@ -54,7 +57,9 @@ export function useSessionResume({
const loadHistoryForResume = useCallback(
async (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
clientHistory: Array<
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
>,
resumedData: ResumedSessionData,
) => {
// Wait for the client.
+74
View File
@@ -2258,6 +2258,80 @@ describe('useVim hook', async () => {
});
});
describe('should handle unmapped keys in Normal mode', () => {
type UnmappedKeyCase = {
char: string;
insertable: boolean;
};
it.each<UnmappedKeyCase>([
{ char: 'm', insertable: true },
{ char: 'n', insertable: true },
{ char: 'p', insertable: true },
{ char: 'q', insertable: true },
{ char: 's', insertable: true },
{ char: 'v', insertable: true },
{ char: 'y', insertable: true },
{ char: 'z', insertable: true },
{ char: 'H', insertable: true },
{ char: 'J', insertable: true },
{ char: 'K', insertable: true },
{ char: 'L', insertable: true },
{ char: 'M', insertable: true },
{ char: 'N', insertable: true },
{ char: 'P', insertable: true },
{ char: 'Q', insertable: true },
{ char: 'R', insertable: true },
{ char: 'S', insertable: true },
{ char: 'U', insertable: true },
{ char: 'V', insertable: true },
{ char: 'Y', insertable: true },
{ char: 'Z', insertable: true },
{ char: '/', insertable: true },
{ char: '#', insertable: true },
{ char: '%', insertable: true },
{ char: '&', insertable: true },
{ char: "'", insertable: true },
{ char: '(', insertable: true },
{ char: ')', insertable: true },
{ char: '*', insertable: true },
{ char: '+', insertable: true },
{ char: '-', insertable: true },
{ char: '/', insertable: true },
{ char: ':', insertable: true },
{ char: '<', insertable: true },
{ char: '=', insertable: true },
{ char: '>', insertable: true },
{ char: '@', insertable: true },
{ char: '[', insertable: true },
{ char: '\\', insertable: true },
{ char: ']', insertable: true },
{ char: '_', insertable: true },
{ char: '`', insertable: true },
{ char: '{', insertable: true },
{ char: '|', insertable: true },
{ char: '}', insertable: true },
])(
'$char: should be swallowed and do nothing in Normal mode',
async ({ char, insertable }) => {
const { result } = await renderVimHook();
exitInsertMode(result);
let handled = false;
act(() => {
handled = result.current.handleInput(
createKey({ sequence: char, name: char, insertable }),
);
});
expect(handled).toBe(true);
expect(mockVimContext.setVimMode).not.toHaveBeenCalledWith('INSERT');
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
},
);
});
describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', async () => {
it('df{char}: executes delete-to-char, not a dangling operator', async () => {
const { result } = await renderVimHook();
+5
View File
@@ -1486,6 +1486,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Unknown command, clear count and pending states
dispatch({ type: 'CLEAR_PENDING_STATES' });
// Ignore any Insertable key in Normal Mode
if (normalizedKey.insertable) {
return true;
}
// Not handled by vim so allow other handlers to process it.
return false;
}
@@ -7,6 +7,11 @@
import * as fsPromises from 'node:fs/promises';
import path from 'node:path';
import type { Content } from '@google/genai';
import {
type ConversationRecord,
type MessageRecord,
reconstructHistory,
} from '@google/gemini-cli-core';
/**
* Serializes chat history to a Markdown string.
@@ -51,8 +56,21 @@ export function serializeHistoryToMarkdown(
* Options for exporting chat history.
*/
export interface ExportHistoryOptions {
history: readonly Content[];
/**
* Full message records which contain metadata like agentId for tool calls,
* providing the link between history and trajectories.
* This is the primary source of truth.
*/
messages: MessageRecord[];
/** The file path to export to. */
filePath: string;
/** Optional subagent trajectories to include. */
trajectories?: Record<string, ConversationRecord>;
/**
* Optional standard history array used for model requests.
* If provided, it is used for Markdown export to avoid reconstruction.
*/
history?: readonly Content[];
}
/**
@@ -61,13 +79,23 @@ export interface ExportHistoryOptions {
export async function exportHistoryToFile(
options: ExportHistoryOptions,
): Promise<void> {
const { history, filePath } = options;
const {
messages,
filePath,
trajectories,
history: providedHistory,
} = options;
const extension = path.extname(filePath).toLowerCase();
let content: string;
if (extension === '.json') {
content = JSON.stringify(history, null, 2);
content = JSON.stringify(
{ version: '2.0', messages, trajectories },
null,
2,
);
} else if (extension === '.md') {
const history = providedHistory ?? reconstructHistory(messages);
content = serializeHistoryToMarkdown(history);
} else {
throw new Error(
+4 -1
View File
@@ -93,13 +93,16 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
// References are converted to text for the model
result.push({ text: part.text });
break;
default:
default: {
const _exhaustiveCheck: never = part;
void _exhaustiveCheck;
debugLogger.warn(
`Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`,
);
// Serialize unknown ContentPart variants instead of dropping them
result.push({ text: JSON.stringify(part) });
break;
}
}
}
return result;
@@ -9,16 +9,21 @@ import {
supersedeStaleSnapshots,
SNAPSHOT_SUPERSEDED_PLACEHOLDER,
} from './snapshotSuperseder.js';
import type { GeminiChat } from '../../core/geminiChat.js';
import type { GeminiChat, HistoryTurn } from '../../core/geminiChat.js';
import type { Content } from '@google/genai';
import { randomUUID } from 'node:crypto';
/** Builds a minimal mock GeminiChat around a mutable history array. */
function createMockChat(history: Content[]): GeminiChat {
const getTurns = () => history.map((c) => ({ id: randomUUID(), content: c }));
return {
getHistory: vi.fn(() => [...history]),
setHistory: vi.fn((newHistory: readonly Content[]) => {
getHistoryTurns: vi.fn(() => getTurns()),
setHistory: vi.fn((newHistory: ReadonlyArray<Content | HistoryTurn>) => {
history.length = 0;
history.push(...newHistory);
for (const item of newHistory) {
history.push('content' in item ? item.content : item);
}
}),
} as unknown as GeminiChat;
}
@@ -14,8 +14,8 @@
* model call so the model only ever sees the most recent snapshot in full.
*/
import type { GeminiChat } from '../../core/geminiChat.js';
import type { Content, Part } from '@google/genai';
import type { GeminiChat, HistoryTurn } from '../../core/geminiChat.js';
import type { Part } from '@google/genai';
import { debugLogger } from '../../utils/debugLogger.js';
const TAKE_SNAPSHOT_TOOL_NAME = 'take_snapshot';
@@ -39,7 +39,7 @@ export const SNAPSHOT_SUPERSEDED_PLACEHOLDER =
* Uses {@link GeminiChat.setHistory} to apply the modified history.
*/
export function supersedeStaleSnapshots(chat: GeminiChat): void {
const history = chat.getHistory();
const history = chat.getHistoryTurns();
// Locate all (contentIndex, partIndex) tuples for take_snapshot responses.
const snapshotLocations: Array<{
@@ -48,7 +48,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
}> = [];
for (let i = 0; i < history.length; i++) {
const parts = history[i].parts;
const parts = history[i].content.parts;
if (!parts) continue;
for (let j = 0; j < parts.length; j++) {
const part = parts[j];
@@ -71,7 +71,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
const staleLocations = snapshotLocations.slice(0, -1);
const needsUpdate = staleLocations.some(({ contentIdx, partIdx }) => {
const output = getResponseOutput(
history[contentIdx].parts![partIdx].functionResponse?.response,
history[contentIdx].content.parts![partIdx].functionResponse?.response,
);
return !output.includes(SNAPSHOT_SUPERSEDED_PLACEHOLDER);
});
@@ -81,15 +81,18 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
}
// Shallow-copy the history and replace stale snapshots.
const newHistory: Content[] = history.map((content) => ({
...content,
parts: content.parts ? [...content.parts] : undefined,
const newHistory: HistoryTurn[] = history.map((turn) => ({
id: turn.id,
content: {
...turn.content,
parts: turn.content.parts ? [...turn.content.parts] : undefined,
},
}));
let replacedCount = 0;
for (const { contentIdx, partIdx } of staleLocations) {
const originalPart = newHistory[contentIdx].parts![partIdx];
const originalPart = newHistory[contentIdx].content.parts![partIdx];
if (!originalPart.functionResponse) continue;
// Check if already superseded
@@ -106,7 +109,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
},
};
newHistory[contentIdx].parts![partIdx] = replacementPart;
newHistory[contentIdx].content.parts![partIdx] = replacementPart;
replacedCount++;
}
@@ -756,12 +756,19 @@ describe('LocalAgentExecutor', () => {
expect(startHistory).toBeDefined();
expect(startHistory).toHaveLength(2);
const history = startHistory!;
// Perform checks on defined objects to satisfy TS
const firstPart = startHistory?.[0]?.parts?.[0];
const firstPart =
'content' in history[0]
? history[0].content.parts?.[0]
: history[0].parts?.[0];
expect(firstPart?.text).toBe('Goal: TestGoal');
const secondPart = startHistory?.[1]?.parts?.[0];
const secondPart =
'content' in history[1]
? history[1].content.parts?.[0]
: history[1].parts?.[0];
expect(secondPart?.text).toBe('OK, starting on TestGoal.');
});
@@ -3601,7 +3608,14 @@ describe('LocalAgentExecutor', () => {
expect(mockCompress).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
// History turns are now wrapped with IDs
expect(mockSetHistory).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({ role: 'user' }),
}),
]),
);
});
it('should pass hasFailedCompressionAttempt=true to compression after a failure', async () => {
@@ -3706,7 +3720,14 @@ describe('LocalAgentExecutor', () => {
expect(mockCompress.mock.calls[2][5]).toBe(false);
expect(mockSetHistory).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
// History turns are now wrapped with IDs
expect(mockSetHistory).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({ role: 'user' }),
}),
]),
);
});
});
+10 -2
View File
@@ -919,12 +919,20 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.hasFailedCompressionAttempt = true;
} else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
if (newHistory) {
chat.setHistory(newHistory);
const turns = newHistory.map((c) => ({
id: randomUUID(),
content: c,
}));
chat.setHistory(turns);
this.hasFailedCompressionAttempt = false;
}
} else if (info.compressionStatus === CompressionStatus.CONTENT_TRUNCATED) {
if (newHistory) {
chat.setHistory(newHistory);
const turns = newHistory.map((c) => ({
id: randomUUID(),
content: c,
}));
chat.setHistory(turns);
// Do NOT reset hasFailedCompressionAttempt.
// We only truncated content because summarization previously failed.
// We want to keep avoiding expensive summarization calls.
@@ -121,7 +121,7 @@ describe('LocalSubagentInvocation', () => {
);
});
it('should truncate long input values', () => {
it('should not truncate long input values', () => {
const longTask = 'A'.repeat(100);
const params = { task: longTask };
const invocation = new LocalSubagentInvocation(
@@ -131,13 +131,12 @@ describe('LocalSubagentInvocation', () => {
mockMessageBus,
);
const description = invocation.getDescription();
// Default INPUT_PREVIEW_MAX_LENGTH is 50
expect(description).toBe(
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(50)} }`,
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`,
);
});
it('should truncate the overall description if it exceeds the limit', () => {
it('should not truncate the overall description', () => {
// Create a definition and inputs that result in a very long description
const longNameDef: LocalAgentDefinition = {
...testDefinition,
@@ -154,8 +153,7 @@ describe('LocalSubagentInvocation', () => {
mockMessageBus,
);
const description = invocation.getDescription();
// Default DESCRIPTION_MAX_LENGTH is 200
expect(description.length).toBe(200);
expect(description.length).toBeGreaterThan(300);
expect(
description.startsWith(
"Running subagent 'VeryLongAgentNameThatTakesUpSpace'",
+35 -42
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -35,9 +35,6 @@ import {
} from '../utils/agent-sanitization-utils.js';
import { debugLogger } from '../utils/debugLogger.js';
const INPUT_PREVIEW_MAX_LENGTH = 50;
const DESCRIPTION_MAX_LENGTH = 200;
/**
* Represents a validated, executable instance of a subagent tool.
*
@@ -80,14 +77,10 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
*/
getDescription(): string {
const inputSummary = Object.entries(this.params)
.map(
([key, value]) =>
`${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`,
)
.map(([key, value]) => `${key}: ${String(value)}`)
.join(', ');
const description = `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
return description.slice(0, DESCRIPTION_MAX_LENGTH);
return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
}
private publishActivity(activity: SubagentActivityItem): void {
@@ -168,8 +161,11 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
const args = JSON.stringify(
sanitizeToolArgs(activity.data['args']),
);
const callId = activity.data['callId']
? String(activity.data['callId'])
: randomUUID();
recentActivity.push({
id: randomUUID(),
id: callId,
type: 'tool_call',
content: name,
displayName,
@@ -186,23 +182,28 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
break;
}
case 'TOOL_CALL_END': {
const name = String(activity.data['name']);
const data = activity.data['data'];
const isError = isToolActivityError(data);
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === name &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
updated = true;
const callId = activity.data['id']
? String(activity.data['id'])
: undefined;
this.publishActivity(recentActivity[i]);
break;
if (callId) {
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
updated = true;
this.publishActivity(recentActivity[i]);
break;
}
}
}
break;
@@ -218,31 +219,23 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
errorType === SubagentActivityErrorType.REJECTED ||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
const toolName = activity.data['name']
? String(activity.data['name'])
const callId = activity.data['callId']
? String(activity.data['callId'])
: undefined;
if (toolName && (isCancellation || isRejection)) {
if (callId) {
const targetStatus =
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR;
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = SubagentState.CANCELLED;
updated = true;
break;
}
}
} else if (toolName) {
// Mark non-rejection/non-cancellation errors as 'error'
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = SubagentState.ERROR;
recentActivity[i].status = targetStatus;
updated = true;
break;
}
@@ -0,0 +1,817 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
AgentTerminateMode,
SubagentActivityErrorType,
SUBAGENT_REJECTED_ERROR_PREFIX,
SUBAGENT_CANCELLED_ERROR_MESSAGE,
type SubagentProgress,
type LocalAgentDefinition,
type AgentInputs,
type SubagentActivityEvent,
} from './types.js';
import { LocalSessionInvocation } from './local-session-invocation.js';
import { LocalSubagentSession } from './local-subagent-protocol.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
vi.mock('./local-subagent-protocol.js');
const MockLocalSubagentSession = vi.mocked(LocalSubagentSession);
let capturedActivityCallback:
| ((activity: SubagentActivityEvent) => void)
| undefined;
const testDefinition: LocalAgentDefinition = {
kind: 'local',
name: 'MockAgent',
description: 'A mock agent for testing.',
inputConfig: {
inputSchema: {
type: 'object',
properties: { task: { type: 'string' } },
},
},
modelConfig: { model: 'test-model', generateContentConfig: {} },
runConfig: { maxTimeMinutes: 1 },
promptConfig: { systemPrompt: 'test' },
};
function setupMockSession(config: {
output?: { result: string; terminate_reason: AgentTerminateMode };
error?: Error;
}) {
const mockSession = {
send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }),
getResult: config.error
? vi.fn().mockRejectedValue(config.error)
: vi.fn().mockResolvedValue(
config.output ?? {
result: 'done',
terminate_reason: AgentTerminateMode.GOAL,
},
),
abort: vi.fn(),
subscribe: vi.fn().mockReturnValue(vi.fn()),
};
MockLocalSubagentSession.mockImplementation(
(
_def: LocalAgentDefinition,
_ctx: AgentLoopContext,
_bus: MessageBus,
rawCallback?: (activity: SubagentActivityEvent) => void,
) => {
capturedActivityCallback = rawCallback;
return mockSession as unknown as LocalSubagentSession;
},
);
return mockSession;
}
describe('LocalSessionInvocation', () => {
let mockContext: AgentLoopContext;
let mockMessageBus: MessageBus;
beforeEach(() => {
vi.clearAllMocks();
capturedActivityCallback = undefined;
mockContext = makeFakeConfig() as unknown as AgentLoopContext;
mockMessageBus = createMockMessageBus();
});
it('should pass the messageBus to the parent constructor', () => {
setupMockSession({});
const params = { task: 'Analyze data' };
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
expect(
(invocation as unknown as { messageBus: MessageBus }).messageBus,
).toBe(mockMessageBus);
});
describe('getDescription', () => {
it('should format the description with inputs', () => {
setupMockSession({});
const params = { task: 'Analyze data', priority: 5 };
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const description = invocation.getDescription();
expect(description).toBe(
"Running subagent 'MockAgent' with inputs: { task: Analyze data, priority: 5 }",
);
});
it('should not truncate long input values', () => {
setupMockSession({});
const longTask = 'A'.repeat(100);
const params = { task: longTask };
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const description = invocation.getDescription();
expect(description).toBe(
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`,
);
});
it('should not truncate the overall description', () => {
setupMockSession({});
const longNameDef: LocalAgentDefinition = {
...testDefinition,
name: 'VeryLongAgentNameThatTakesUpSpace',
};
const params: AgentInputs = {};
for (let i = 0; i < 20; i++) {
params[`input${i}`] = `value${i}`;
}
const invocation = new LocalSessionInvocation(
longNameDef,
mockContext,
params,
mockMessageBus,
);
const description = invocation.getDescription();
expect(description.length).toBeGreaterThan(300);
});
});
describe('execute', () => {
it('should create session and run successfully', async () => {
const mockOutput = {
result: 'Analysis complete.',
terminate_reason: AgentTerminateMode.GOAL,
};
const mockSession = setupMockSession({ output: mockOutput });
const params = { query: 'Execute task' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
expect(MockLocalSubagentSession).toHaveBeenCalledWith(
testDefinition,
mockContext,
mockMessageBus,
expect.any(Function),
);
expect(mockSession.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'Execute task' }] },
});
expect(result.llmContent).toEqual([
{
text: expect.stringContaining(
"Subagent 'MockAgent' finished.\nTermination Reason: GOAL\nResult:\nAnalysis complete.",
),
},
]);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe('completed');
expect(display.result).toBe('Analysis complete.');
expect(display.terminateReason).toBe(AgentTerminateMode.GOAL);
});
it('should stream THOUGHT_CHUNK activity', async () => {
const mockSession = setupMockSession({});
const params = { query: 'think' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
// Wait for send to be called so the activity callback is wired
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
// Emit a thought chunk via captured callback
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'Analyzing...' },
});
await executePromise;
// Find an updateOutput call containing the thought
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasThought = progressCalls.some(
(p) =>
p.recentActivity &&
p.recentActivity.some(
(a) => a.type === 'thought' && a.content === 'Analyzing...',
),
);
expect(hasThought).toBe(true);
});
it('should stream TOOL_CALL_START and TOOL_CALL_END', async () => {
const mockSession = setupMockSession({});
const params = { query: 'run tool' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'ls', args: {}, callId: 'call-123' },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_END',
data: { name: 'ls', data: {}, id: 'call-123' },
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// After TOOL_CALL_START, the immediate updateOutput call should show running
const runningCalls = progressCalls.filter((p) => p.state === 'running');
// The first running call with a tool_call should show 'running'
const firstToolCall = runningCalls.find((p) =>
p.recentActivity?.some(
(a) => a.type === 'tool_call' && a.content === 'ls',
),
);
expect(firstToolCall).toBeDefined();
// After TOOL_CALL_END, the tool should be completed
const hasCompleted = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'tool_call' &&
a.content === 'ls' &&
a.status === 'completed',
),
);
expect(hasCompleted).toBe(true);
});
it('should handle ERROR activity', async () => {
const mockSession = setupMockSession({});
const params = { query: 'fail' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'ERROR',
data: { error: 'Something broke' },
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasError = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'thought' &&
a.content === 'Error: Something broke' &&
a.status === 'error',
),
);
expect(hasError).toBe(true);
});
it('should handle cancelled errors', async () => {
const mockSession = setupMockSession({});
const params = { query: 'cancel' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'ERROR',
data: {
error: SUBAGENT_CANCELLED_ERROR_MESSAGE,
errorType: SubagentActivityErrorType.CANCELLED,
},
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasCancelled = progressCalls.some((p) =>
p.recentActivity?.some(
(a) => a.type === 'thought' && a.status === 'cancelled',
),
);
expect(hasCancelled).toBe(true);
});
it('should handle rejected errors', async () => {
const mockSession = setupMockSession({});
const params = { query: 'reject' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'dangerous_tool', args: {}, callId: 'call-rej' },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'ERROR',
data: {
name: 'dangerous_tool',
error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Rethink approach.`,
errorType: SubagentActivityErrorType.REJECTED,
callId: 'call-rej',
},
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// Tool call should be marked cancelled
const hasToolCancelled = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'tool_call' &&
a.content === 'dangerous_tool' &&
a.status === 'cancelled',
),
);
expect(hasToolCancelled).toBe(true);
});
it('should trim recentActivity to MAX_RECENT_ACTIVITY', async () => {
const mockSession = setupMockSession({});
const params = { query: 'trim' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
// Emit 4+ activities to exceed MAX_RECENT_ACTIVITY (3)
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool1', args: {} },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool2', args: {} },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool3', args: {} },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool4', args: {} },
});
await executePromise;
// After the 4th activity, the last updateOutput call before completion
// should have only 3 items in recentActivity
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// Find the call right after the 4th activity (before completion)
const afterFourthActivity = progressCalls.filter(
(p) => p.state === 'running' && p.recentActivity.length > 0,
);
const lastRunning = afterFourthActivity[afterFourthActivity.length - 1];
expect(lastRunning.recentActivity.length).toBeLessThanOrEqual(3);
// Should contain tool4 (the latest)
expect(
lastRunning.recentActivity.some((a) => a.content === 'tool4'),
).toBe(true);
// Should NOT contain tool1 (trimmed away)
expect(
lastRunning.recentActivity.some((a) => a.content === 'tool1'),
).toBe(false);
});
it('should handle executor errors', async () => {
const error = new Error('Model failed during execution.');
setupMockSession({ error });
const params = { query: 'fail hard' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
expect(result.llmContent).toBe(
`Subagent 'MockAgent' failed. Error: ${error.message}`,
);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe('error');
expect(display.recentActivity).toContainEqual(
expect.objectContaining({
type: 'thought',
content: `Error: ${error.message}`,
status: 'error',
}),
);
});
it('should handle abort', async () => {
const mockOutput = {
result: '',
terminate_reason: AgentTerminateMode.ABORTED,
};
setupMockSession({ output: mockOutput });
const params = { query: 'abort me' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
await expect(
invocation.execute({ abortSignal: signal, updateOutput }),
).rejects.toThrow('Operation cancelled by user');
// Verify cancelled state was published
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasCancelledState = progressCalls.some(
(p) => p.state === 'cancelled',
);
expect(hasCancelledState).toBe(true);
});
it('should wire abort signal to session.abort', async () => {
const mockSession = setupMockSession({});
const params = { query: 'abort wire' };
const controller = new AbortController();
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
// Trigger abort
controller.abort();
// The execute should complete (getResult returned GOAL by default)
await executePromise.catch(() => {
/* abort may throw */
});
expect(mockSession.abort).toHaveBeenCalled();
});
it('should send non-query params as config update before query', async () => {
const mockSession = setupMockSession({});
const params = { query: 'Do something', extra_config: 'value123' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
await invocation.execute({ abortSignal: signal, updateOutput });
// First send: config update with non-query params
expect(mockSession.send).toHaveBeenCalledWith({
update: { config: { extra_config: 'value123' } },
});
// Second send: message with query
expect(mockSession.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'Do something' }] },
});
// Config update should come before message
const sendCalls = mockSession.send.mock.calls;
const configIdx = sendCalls.findIndex((c) => c[0]?.update?.config);
const messageIdx = sendCalls.findIndex((c) => c[0]?.message);
expect(configIdx).toBeLessThan(messageIdx);
});
it('should publish SUBAGENT_ACTIVITY on messageBus', async () => {
const mockSession = setupMockSession({});
const params = { query: 'publish test' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'Thinking...' },
});
await executePromise;
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: 'MockAgent',
activity: expect.objectContaining({
type: 'thought',
content: 'Thinking...',
}),
}),
);
});
it('should clean up abort listener in finally', async () => {
setupMockSession({});
const params = { query: 'cleanup' };
const controller = new AbortController();
const removeEventListenerSpy = vi.spyOn(
controller.signal,
'removeEventListener',
);
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
await invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'abort',
expect.any(Function),
);
});
it('should unsubscribe parent observer in finally', async () => {
const unsubscribeFn = vi.fn();
const mockSession = setupMockSession({});
mockSession.subscribe.mockReturnValue(unsubscribeFn);
const params = { query: 'unsub test' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const onAgentEvent = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
{ onAgentEvent },
);
await invocation.execute({ abortSignal: signal, updateOutput });
expect(mockSession.subscribe).toHaveBeenCalledWith(onAgentEvent);
expect(unsubscribeFn).toHaveBeenCalled();
});
it('should handle TOOL_CALL_END with error data', async () => {
const mockSession = setupMockSession({});
const params = { query: 'tool error' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'failing_tool', args: {}, callId: 'call-err' },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_END',
data: { name: 'failing_tool', data: { isError: true }, id: 'call-err' },
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasToolError = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'tool_call' &&
a.content === 'failing_tool' &&
a.status === 'error',
),
);
expect(hasToolError).toBe(true);
});
it('should mark running items as cancelled on abort', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
const mockSession = setupMockSession({ error: abortError });
const params = { query: 'mark cancelled' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
// Emit a running tool call before the abort
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'running_tool', args: {} },
});
await expect(executePromise).rejects.toThrow('Aborted');
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// The final progress should show the tool as cancelled
const lastProgress = progressCalls[progressCalls.length - 1];
expect(lastProgress.state).toBe('cancelled');
expect(lastProgress.recentActivity).toContainEqual(
expect.objectContaining({
type: 'tool_call',
content: 'running_tool',
status: 'cancelled',
}),
);
});
});
});
@@ -0,0 +1,416 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import {
BaseToolInvocation,
type ToolResult,
type ExecuteOptions,
} from '../tools/tools.js';
import {
type LocalAgentDefinition,
type AgentInputs,
type SubagentActivityEvent,
type SubagentProgress,
type SubagentActivityItem,
AgentTerminateMode,
SubagentActivityErrorType,
SUBAGENT_REJECTED_ERROR_PREFIX,
SUBAGENT_CANCELLED_ERROR_MESSAGE,
isToolActivityError,
SubagentState,
} from './types.js';
import { randomUUID } from 'node:crypto';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import {
sanitizeThoughtContent,
sanitizeToolArgs,
sanitizeErrorMessage,
} from '../utils/agent-sanitization-utils.js';
import { checkExhaustive } from '../utils/checks.js';
import { LocalSubagentSession } from './local-subagent-protocol.js';
import type { AgentEvent } from '../agent/types.js';
const MAX_RECENT_ACTIVITY = 3;
/** Optional configuration for subagent invocations. */
export interface SubagentInvocationOptions {
toolName?: string;
toolDisplayName?: string;
onAgentEvent?: (event: AgentEvent) => void;
}
/**
* Session-based local subagent invocation.
*
* This class orchestrates the execution of a defined agent by:
* 1. Using {@link LocalSubagentSession} as the execution engine.
* 2. Bridging the agent's streaming activity (e.g., thoughts) to the tool's
* live output stream via the session's rawActivityCallback.
* 3. Formatting the final result into a {@link ToolResult}.
*/
export class LocalSessionInvocation extends BaseToolInvocation<
AgentInputs,
ToolResult
> {
private readonly _onAgentEvent?: (event: AgentEvent) => void;
/**
* @param definition The definition object that configures the agent.
* @param context The agent loop context.
* @param params The validated input parameters for the agent.
* @param messageBus Message bus for policy enforcement.
* @param options Optional overrides for tool name, display name, and event callback.
*/
constructor(
private readonly definition: LocalAgentDefinition,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
options?: SubagentInvocationOptions,
) {
super(
params,
messageBus,
options?.toolName ?? definition.name,
options?.toolDisplayName ?? definition.displayName,
);
this._onAgentEvent = options?.onAgentEvent;
}
/**
* Returns a concise, human-readable description of the invocation.
* Used for logging and display purposes.
*/
getDescription(): string {
const inputSummary = Object.entries(this.params)
.map(([key, value]) => `${key}: ${String(value)}`)
.join(', ');
return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
}
private publishActivity(activity: SubagentActivityItem): void {
void this.messageBus.publish({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: this.definition.displayName ?? this.definition.name,
activity,
});
}
/**
* Executes the subagent.
*
* @param options Options for tool execution including signal and output updates.
* @returns A `Promise` that resolves with the final `ToolResult`.
*/
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: signal, updateOutput } = options;
let recentActivity: SubagentActivityItem[] = [];
// Raw SubagentActivityEvent handler — preserves all existing progress display logic.
// Passed as rawActivityCallback to LocalSubagentSession so the protocol can call it
// before translating to AgentEvents.
const onActivity = (activity: SubagentActivityEvent): void => {
if (!updateOutput) return;
let updated = false;
switch (activity.type) {
case 'THOUGHT_CHUNK': {
const rawText = activity.data['text'];
const text = typeof rawText === 'string' ? rawText : '';
const lastItem = recentActivity[recentActivity.length - 1];
if (
lastItem &&
lastItem.type === 'thought' &&
lastItem.status === SubagentState.RUNNING
) {
lastItem.content = sanitizeThoughtContent(text);
} else {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: sanitizeThoughtContent(text),
status: SubagentState.RUNNING,
});
}
updated = true;
const latestThought = recentActivity[recentActivity.length - 1];
if (latestThought) {
this.publishActivity(latestThought);
}
break;
}
case 'TOOL_CALL_START': {
const rawName = activity.data['name'];
const name = typeof rawName === 'string' ? rawName.trim() : '';
const displayName = activity.data['displayName']
? sanitizeErrorMessage(String(activity.data['displayName']).trim())
: undefined;
const description = activity.data['description']
? sanitizeErrorMessage(String(activity.data['description']))
: undefined;
const args = JSON.stringify(sanitizeToolArgs(activity.data['args']));
const callId = activity.data['callId']
? String(activity.data['callId'])
: randomUUID();
recentActivity.push({
id: callId,
type: 'tool_call',
content: name,
displayName,
description,
args,
status: SubagentState.RUNNING,
});
updated = true;
const latestTool = recentActivity[recentActivity.length - 1];
if (latestTool) {
this.publishActivity(latestTool);
}
break;
}
case 'TOOL_CALL_END': {
const data = activity.data['data'];
const isError = isToolActivityError(data);
const callId = activity.data['id']
? String(activity.data['id'])
: undefined;
if (callId) {
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
updated = true;
this.publishActivity(recentActivity[i]);
break;
}
}
}
break;
}
case 'ERROR': {
const rawError = activity.data['error'];
const error = typeof rawError === 'string' ? rawError.trim() : '';
const errorType = activity.data['errorType'];
const sanitizedError = sanitizeErrorMessage(error);
const isCancellation =
errorType === SubagentActivityErrorType.CANCELLED ||
error === SUBAGENT_CANCELLED_ERROR_MESSAGE;
const isRejection =
errorType === SubagentActivityErrorType.REJECTED ||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
const callId = activity.data['callId']
? String(activity.data['callId'])
: undefined;
if (callId) {
const targetStatus =
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR;
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = targetStatus;
updated = true;
break;
}
}
}
recentActivity.push({
id: randomUUID(),
type: 'thought',
content:
isCancellation || isRejection
? sanitizedError
: `Error: ${sanitizedError}`,
status:
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR,
});
updated = true;
break;
}
default:
checkExhaustive(activity.type);
break;
}
if (updated) {
// Keep only the last N items
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity], // Copy to avoid mutation issues
state: SubagentState.RUNNING,
};
updateOutput(progress);
}
};
// Create session with the raw activity callback for rich progress display
const session = new LocalSubagentSession(
this.definition,
this.context,
this.messageBus,
onActivity,
);
// Subscribe for parent session observability
let unsubscribeParent: (() => void) | undefined;
if (this._onAgentEvent) {
unsubscribeParent = session.subscribe(this._onAgentEvent);
}
// Wire external abort signal to session abort
const abortListener = () => void session.abort();
signal.addEventListener('abort', abortListener, { once: true });
try {
if (updateOutput) {
const initialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [],
state: SubagentState.RUNNING,
};
updateOutput(initialProgress);
}
// Buffer non-query params, then send query as message to start execution
const query = String(this.params['query'] ?? '');
const otherParams = { ...this.params } as Record<string, unknown>;
delete otherParams['query'];
if (Object.keys(otherParams).length > 0) {
await session.send({ update: { config: otherParams } });
}
await session.send({
message: { content: [{ type: 'text', text: query }] },
});
const output = await session.getResult();
if (output.terminate_reason === AgentTerminateMode.ABORTED) {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: SubagentState.CANCELLED,
};
if (updateOutput) {
updateOutput(progress);
}
const cancelError = new Error('Operation cancelled by user');
cancelError.name = 'AbortError';
throw cancelError;
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: SubagentState.COMPLETED,
result: output.result,
terminateReason: output.terminate_reason,
};
if (updateOutput) {
updateOutput(progress);
}
const resultContent = `Subagent '${this.definition.name}' finished.
Termination Reason: ${output.terminate_reason}
Result:
${output.result}`;
return {
llmContent: [{ text: resultContent }],
returnDisplay: progress,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const isAbort =
(error instanceof Error && error.name === 'AbortError') ||
errorMessage.includes('Aborted');
// Mark any running items as error/cancelled
for (const item of recentActivity) {
if (item.status === SubagentState.RUNNING) {
item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
}
}
// Ensure the error is reflected in the recent activity for display
if (!isAbort) {
const lastActivity = recentActivity[recentActivity.length - 1];
if (!lastActivity || lastActivity.status !== SubagentState.ERROR) {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: `Error: ${errorMessage}`,
status: SubagentState.ERROR,
});
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
}
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR,
};
if (updateOutput) {
updateOutput(progress);
}
if (isAbort) {
throw error;
}
return {
llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`,
returnDisplay: progress,
};
} finally {
signal.removeEventListener('abort', abortListener);
unsubscribeParent?.();
}
}
}
@@ -0,0 +1,666 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RemoteSessionInvocation } from './remote-session-invocation.js';
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
import {
type RemoteAgentDefinition,
type SubagentProgress,
SubagentState,
} from './types.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { Config } from '../config/config.js';
import type { ToolResult } from '../tools/tools.js';
import type { AgentEvent } from '../agent/types.js';
vi.mock('./remote-subagent-protocol.js');
const mockDefinition: RemoteAgentDefinition = {
name: 'test-agent',
kind: 'remote',
agentCardUrl: 'http://test-agent/card',
displayName: 'Test Agent',
description: 'A test agent',
inputConfig: { inputSchema: { type: 'object' } },
};
const mockMessageBus = createMockMessageBus();
interface MockSessionSetupOptions {
result?: ToolResult;
error?: Error;
progress?: SubagentProgress;
sessionState?: { contextId?: string; taskId?: string };
}
function setupMockSession(options: MockSessionSetupOptions = {}) {
const {
result = {
llmContent: [{ text: 'done' }],
returnDisplay: {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.COMPLETED,
result: 'done',
recentActivity: [],
} satisfies SubagentProgress,
},
error,
progress,
sessionState = {},
} = options;
const subscriberCallbacks: Array<(event: AgentEvent) => void> = [];
const mockSession = {
send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }),
getResult: error
? vi.fn().mockRejectedValue(error)
: vi.fn().mockResolvedValue(result),
getLatestProgress: vi.fn().mockReturnValue(progress),
getSessionState: vi.fn().mockReturnValue(sessionState),
subscribe: vi.fn((cb: (event: AgentEvent) => void) => {
subscriberCallbacks.push(cb);
return vi.fn(); // unsubscribe
}),
abort: vi.fn(),
};
vi.mocked(RemoteSubagentSession).mockImplementation(
() => mockSession as unknown as RemoteSubagentSession,
);
return {
mockSession,
subscriberCallbacks,
/** Fire a message event through all subscribed callbacks. */
emitEvent(event: AgentEvent) {
for (const cb of subscriberCallbacks) {
cb(event);
}
},
};
}
describe('RemoteSessionInvocation', () => {
let mockContext: AgentLoopContext;
beforeEach(() => {
vi.clearAllMocks();
const mockConfig = {
getA2AClientManager: vi.fn().mockReturnValue({}),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
mockContext = { config: mockConfig } as unknown as AgentLoopContext;
// Clear the static sessionState map between tests
(
RemoteSessionInvocation as unknown as {
sessionState?: Map<string, unknown>;
}
).sessionState?.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Constructor Validation
// ---------------------------------------------------------------------------
describe('Constructor Validation', () => {
it('accepts valid input with string query', () => {
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hello' },
mockMessageBus,
);
}).not.toThrow();
});
it('accepts missing query (defaults to "Get Started!")', () => {
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
mockContext,
{},
mockMessageBus,
);
}).not.toThrow();
});
it('throws if query is not a string', () => {
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 123 },
mockMessageBus,
);
}).toThrow("requires a string 'query' input");
});
it('throws if A2AClientManager is not available', () => {
const noA2AConfig = {
getA2AClientManager: vi.fn().mockReturnValue(undefined),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
const noA2AContext = {
config: noA2AConfig,
} as unknown as AgentLoopContext;
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
noA2AContext,
{ query: 'hi' },
mockMessageBus,
);
}).toThrow('A2AClientManager is not available');
});
});
// ---------------------------------------------------------------------------
// Execution Logic
// ---------------------------------------------------------------------------
describe('Execution Logic', () => {
it('should create session and return result', async () => {
const completedProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.COMPLETED,
result: 'Agent output',
recentActivity: [],
};
const expectedResult: ToolResult = {
llmContent: [{ text: 'Agent output' }],
returnDisplay: completedProgress,
};
setupMockSession({
result: expectedResult,
progress: completedProgress,
});
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'do stuff' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
expect(RemoteSubagentSession).toHaveBeenCalledOnce();
expect(result).toBe(expectedResult);
});
it('should pass initial state from static map to session', async () => {
const priorState = { contextId: 'ctx-42', taskId: 'task-42' };
// Seed the static map before constructing the invocation
(
RemoteSessionInvocation as unknown as {
sessionState: Map<string, unknown>;
}
).sessionState.set('test-agent::http://test-agent/card', priorState);
setupMockSession();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
});
// Verify the session constructor received the prior state
expect(RemoteSubagentSession).toHaveBeenCalledWith(
mockDefinition,
mockContext,
mockMessageBus,
priorState,
);
});
it('should persist session state in finally block', async () => {
const newState = { contextId: 'ctx-new', taskId: 'task-new' };
setupMockSession({ sessionState: newState });
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
});
// Verify the state was persisted in the static map
const storedState = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState.get('test-agent::http://test-agent/card');
expect(storedState).toEqual(newState);
});
it('should persist session state across invocations', async () => {
// First invocation returns state
const firstState = { contextId: 'ctx-1', taskId: 'task-1' };
setupMockSession({ sessionState: firstState });
const invocation1 = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'first' },
mockMessageBus,
);
await invocation1.execute({
abortSignal: new AbortController().signal,
});
// Second invocation — the mock constructor should receive firstState
const secondState = { contextId: 'ctx-2', taskId: 'task-2' };
setupMockSession({ sessionState: secondState });
const invocation2 = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'second' },
mockMessageBus,
);
await invocation2.execute({
abortSignal: new AbortController().signal,
});
// The second invocation should have received the first's state
const secondCallArgs = vi.mocked(RemoteSubagentSession).mock.calls[1];
expect(secondCallArgs[3]).toEqual(firstState);
});
it('should subscribe for progress updates', async () => {
const completedProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.RUNNING,
result: 'partial',
recentActivity: [],
};
const { mockSession, emitEvent } = setupMockSession({
progress: completedProgress,
});
const updateOutput = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
// Override getResult to emit a message event mid-execution
mockSession.getResult.mockImplementation(async () => {
emitEvent({
type: 'message',
id: 'e1',
timestamp: new Date().toISOString(),
streamId: 's1',
role: 'agent',
content: [{ type: 'text', text: 'hello' }],
});
return {
llmContent: [{ text: 'done' }],
returnDisplay: completedProgress,
};
});
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
// subscribe should have been called (at least once for progress, possibly for parent)
expect(mockSession.subscribe).toHaveBeenCalled();
// updateOutput should have been called with the progress from getLatestProgress
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
}),
);
});
it('should handle abort gracefully', async () => {
const controller = new AbortController();
const partialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.RUNNING,
result: '',
recentActivity: [
{
id: 'a1',
type: 'thought',
content: 'Thinking...',
status: SubagentState.RUNNING,
},
],
};
const { mockSession } = setupMockSession({ progress: partialProgress });
// When getResult resolves, the signal will already be aborted
mockSession.getResult.mockImplementation(async () => {
controller.abort();
return {
llmContent: [{ text: '' }],
returnDisplay: '',
};
});
const updateOutput = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
expect(result.returnDisplay).toMatchObject({ state: 'cancelled' });
expect(
(result.returnDisplay as SubagentProgress).recentActivity[0].status,
).toBe(SubagentState.CANCELLED);
expect(result.llmContent).toEqual([
{ text: 'Operation cancelled by user' },
]);
});
});
// ---------------------------------------------------------------------------
// Error Handling
// ---------------------------------------------------------------------------
describe('Error Handling', () => {
it('should handle execution errors gracefully', async () => {
setupMockSession({ error: new Error('Network failure') });
const updateOutput = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
'Network failure',
);
// updateOutput should be called with error progress
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({ state: 'error' }),
);
});
it('should include partial output in error display', async () => {
const partialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.RUNNING,
result: 'Partial work so far',
recentActivity: [
{
id: 'a1',
type: 'thought',
content: 'Thinking...',
status: SubagentState.RUNNING,
},
],
};
setupMockSession({
error: new Error('mid-stream error'),
progress: partialProgress,
});
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const display = result.returnDisplay as SubagentProgress;
// Should contain both the partial output and the error
expect(display.result).toContain('Partial work so far');
expect(display.result).toContain('mid-stream error');
// Should preserve and update partial activity status to ERROR
expect(display.recentActivity).toHaveLength(1);
expect(display.recentActivity[0].content).toBe('Thinking...');
expect(display.recentActivity[0].status).toBe(SubagentState.ERROR);
});
it('should clean up listeners in finally', async () => {
const { mockSession } = setupMockSession();
const controller = new AbortController();
const removeEventListenerSpy = vi.spyOn(
controller.signal,
'removeEventListener',
);
const onAgentEvent = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
{ onAgentEvent },
);
await invocation.execute({
abortSignal: controller.signal,
});
// removeEventListener should have been called for the abort listener
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'abort',
expect.any(Function),
);
// All unsubscribe functions returned by subscribe during execute should be called
const postExecuteUnsubscribes = mockSession.subscribe.mock.results.map(
(r) => r.value,
);
for (const unsub of postExecuteUnsubscribes) {
expect(unsub).toHaveBeenCalled();
}
});
});
// ---------------------------------------------------------------------------
// SessionState Management
// ---------------------------------------------------------------------------
describe('SessionState Management', () => {
it('should use composite name::url as session state key', async () => {
const secondDefinition: RemoteAgentDefinition = {
...mockDefinition,
name: 'other-agent',
displayName: 'Other Agent',
agentCardUrl: 'http://other-agent/card',
};
// First agent
setupMockSession({
sessionState: { contextId: 'ctx-a' },
});
const inv1 = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await inv1.execute({ abortSignal: new AbortController().signal });
// Second agent
setupMockSession({
sessionState: { contextId: 'ctx-b' },
});
const inv2 = new RemoteSessionInvocation(
secondDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await inv2.execute({ abortSignal: new AbortController().signal });
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
// Each agent should have its own entry keyed by name::url
expect(stateMap.get('test-agent::http://test-agent/card')).toEqual({
contextId: 'ctx-a',
});
expect(stateMap.get('other-agent::http://other-agent/card')).toEqual({
contextId: 'ctx-b',
});
});
it('should isolate same-name agents with different URLs', async () => {
const defA: RemoteAgentDefinition = {
...mockDefinition,
agentCardUrl: 'http://host-a/card',
};
const defB: RemoteAgentDefinition = {
...mockDefinition,
agentCardUrl: 'http://host-b/card',
};
// Agent A
setupMockSession({ sessionState: { contextId: 'ctx-a' } });
const invA = new RemoteSessionInvocation(
defA,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invA.execute({ abortSignal: new AbortController().signal });
// Agent B (same name, different URL)
setupMockSession({ sessionState: { contextId: 'ctx-b' } });
const invB = new RemoteSessionInvocation(
defB,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invB.execute({ abortSignal: new AbortController().signal });
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
expect(stateMap.get('test-agent::http://host-a/card')).toEqual({
contextId: 'ctx-a',
});
expect(stateMap.get('test-agent::http://host-b/card')).toEqual({
contextId: 'ctx-b',
});
});
it('should fall back to name-only key when URL is unavailable', async () => {
const noUrlDef: RemoteAgentDefinition = {
...mockDefinition,
agentCardUrl: undefined,
};
setupMockSession({ sessionState: { contextId: 'ctx-no-url' } });
const inv = new RemoteSessionInvocation(
noUrlDef,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await inv.execute({ abortSignal: new AbortController().signal });
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
expect(stateMap.get('test-agent')).toEqual({ contextId: 'ctx-no-url' });
});
it('should persist state even on error', async () => {
const stateOnError = { contextId: 'ctx-err', taskId: 'task-err' };
setupMockSession({
error: new Error('boom'),
sessionState: stateOnError,
});
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
});
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
expect(stateMap.get('test-agent::http://test-agent/card')).toEqual(
stateOnError,
);
});
});
});
@@ -0,0 +1,281 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseToolInvocation,
type ToolConfirmationOutcome,
type ToolResult,
type ToolCallConfirmationDetails,
type ExecuteOptions,
} from '../tools/tools.js';
import {
DEFAULT_QUERY_STRING,
type RemoteAgentInputs,
type RemoteAgentDefinition,
type AgentInputs,
type SubagentProgress,
type SubagentActivityItem,
SubagentState,
getRemoteAgentTargetUrl,
} from './types.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { A2AAgentError } from './a2a-errors.js';
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
import type { AgentEvent } from '../agent/types.js';
/** Optional configuration for remote agent invocations. */
export interface SubagentInvocationOptions {
toolName?: string;
toolDisplayName?: string;
onAgentEvent?: (event: AgentEvent) => void;
}
/**
* Session-based remote agent invocation.
*
* This implementation delegates execution to {@link RemoteSubagentSession},
* which wraps the A2A client streaming behind the AgentProtocol interface.
*
* Cross-invocation A2A session state (contextId/taskId) is persisted via a
* static map keyed by a composite of agent name and target URL. This ensures
* agents with the same name but different endpoints maintain independent state.
*/
export class RemoteSessionInvocation extends BaseToolInvocation<
RemoteAgentInputs,
ToolResult
> {
// Persist A2A conversation state across ephemeral invocation instances.
// Keyed by composite of name + target URL so agents with the same name
// but different endpoints don't share state.
private static readonly sessionState = new Map<
string,
{ contextId?: string; taskId?: string }
>();
/**
* Builds a composite key for the sessionState map.
* Format: `name::targetUrl` (or just `name` if no URL can be derived).
*/
private static sessionKey(definition: RemoteAgentDefinition): string {
const url = getRemoteAgentTargetUrl(definition);
return url ? `${definition.name}::${url}` : definition.name;
}
private readonly _onAgentEvent?: (event: AgentEvent) => void;
constructor(
private readonly definition: RemoteAgentDefinition,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
options?: SubagentInvocationOptions,
) {
const query = params['query'] ?? DEFAULT_QUERY_STRING;
if (typeof query !== 'string') {
throw new Error(
`Remote agent '${definition.name}' requires a string 'query' input.`,
);
}
// Safe to pass strict object to super
super(
{ query },
messageBus,
options?.toolName ?? definition.name,
options?.toolDisplayName ?? definition.displayName,
);
this._onAgentEvent = options?.onAgentEvent;
// Validate that A2AClientManager is available at construction time
if (!this.context.config.getA2AClientManager()) {
throw new Error(
`Failed to initialize RemoteSessionInvocation for '${definition.name}': A2AClientManager is not available.`,
);
}
}
getDescription(): string {
return `Calling remote agent ${this.definition.displayName ?? this.definition.name}`;
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
return {
type: 'info',
title: `Call Remote Agent: ${this.definition.displayName ?? this.definition.name}`,
prompt: `Calling remote agent: "${this.params.query}"`,
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// Policy updates are now handled centrally by the scheduler
},
};
}
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: _signal, updateOutput } = options;
const agentName = this.definition.displayName ?? this.definition.name;
const emptyActivity: SubagentActivityItem[] = [];
// Seed session with prior A2A conversation state
const stateKey = RemoteSessionInvocation.sessionKey(this.definition);
const priorState = RemoteSessionInvocation.sessionState.get(stateKey);
const session = new RemoteSubagentSession(
this.definition,
this.context,
this.messageBus,
priorState,
);
// Wire external abort signal to session abort
const abortListener = () => void session.abort();
_signal?.addEventListener('abort', abortListener, { once: true });
// Subscribe for parent session observability
let unsubscribeParent: (() => void) | undefined;
if (this._onAgentEvent) {
unsubscribeParent = session.subscribe(this._onAgentEvent);
}
// Subscribe to message events for live SubagentProgress updates
const unsubscribeProgress = session.subscribe((event: AgentEvent) => {
if (event.type === 'message' && updateOutput) {
const currentProgress = session.getLatestProgress();
if (currentProgress) updateOutput(currentProgress);
}
});
try {
if (updateOutput) {
updateOutput({
isSubagentProgress: true,
agentName,
state: SubagentState.RUNNING,
recentActivity: [
{
id: 'pending',
type: 'thought',
content: 'Working...',
status: SubagentState.RUNNING,
},
],
});
}
await session.send({
message: { content: [{ type: 'text', text: this.params.query }] },
});
const result = await session.getResult();
// The protocol resolves aborts with an empty result rather than
// rejecting. Detect this and surface proper error state.
if (_signal?.aborted) {
const partialProgress = session.getLatestProgress();
const recentActivity = this.stopRunningActivities(
partialProgress?.recentActivity ?? emptyActivity,
SubagentState.CANCELLED,
);
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: SubagentState.CANCELLED,
result:
typeof partialProgress?.result === 'string'
? partialProgress.result
: '',
recentActivity,
};
if (updateOutput) updateOutput(errorProgress);
return {
llmContent: [{ text: 'Operation cancelled by user' }],
returnDisplay: errorProgress,
};
}
// Emit final completed progress
if (updateOutput) {
const finalProgress = session.getLatestProgress();
if (finalProgress) updateOutput(finalProgress);
}
return result;
} catch (error: unknown) {
const partialProgress = session.getLatestProgress();
const partialOutput =
typeof partialProgress?.result === 'string'
? partialProgress.result
: '';
const errorMessage = this.formatExecutionError(error);
const fullDisplay = partialOutput
? `${partialOutput}\n\n${errorMessage}`
: errorMessage;
const isAbort =
(error instanceof Error && error.name === 'AbortError') ||
errorMessage.includes('Aborted');
const status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
const recentActivity = this.stopRunningActivities(
partialProgress?.recentActivity ?? emptyActivity,
status,
);
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: status,
result: fullDisplay,
recentActivity,
};
if (updateOutput) {
updateOutput(errorProgress);
}
return {
llmContent: [{ text: fullDisplay }],
returnDisplay: errorProgress,
};
} finally {
// Persist A2A state for next invocation — even on abort/error
RemoteSessionInvocation.sessionState.set(
stateKey,
session.getSessionState(),
);
_signal?.removeEventListener('abort', abortListener);
unsubscribeProgress();
unsubscribeParent?.();
}
}
private stopRunningActivities(
activity: SubagentActivityItem[],
status: SubagentState,
): SubagentActivityItem[] {
const result: SubagentActivityItem[] = [];
for (const item of activity) {
result.push(
item.status === SubagentState.RUNNING ? { ...item, status } : item,
);
}
return result;
}
/**
* Formats an execution error into a user-friendly message.
* Recognizes typed A2AAgentError subclasses and falls back to
* a generic message for unknown errors.
*/
private formatExecutionError(error: unknown): string {
if (error instanceof A2AAgentError) {
return error.userMessage;
}
return `Error calling remote agent: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
@@ -82,8 +82,21 @@ class RemoteSubagentProtocol implements AgentProtocol {
private readonly context: AgentLoopContext,
// Required for API parity across protocol constructors (local, remote, legacy)
_messageBus: MessageBus,
initialState?: { contextId?: string; taskId?: string },
) {
this._agentName = definition.displayName ?? definition.name;
if (initialState) {
this.contextId = initialState.contextId;
this.taskId = initialState.taskId;
}
}
/**
* Returns the current A2A conversation state.
* Used by the invocation layer to persist state across invocations.
*/
getSessionState(): { contextId?: string; taskId?: string } {
return { contextId: this.contextId, taskId: this.taskId };
}
// ---------------------------------------------------------------------------
@@ -394,11 +407,13 @@ export class RemoteSubagentSession extends AgentSession {
definition: RemoteAgentDefinition,
context: AgentLoopContext,
messageBus: MessageBus,
initialState?: { contextId?: string; taskId?: string },
) {
const protocol = new RemoteSubagentProtocol(
definition,
context,
messageBus,
initialState,
);
super(protocol);
this._remoteProtocol = protocol;
@@ -420,6 +435,14 @@ export class RemoteSubagentSession extends AgentSession {
return this._remoteProtocol.getLatestProgress();
}
/**
* Returns the current A2A conversation state (contextId/taskId).
* Used by the invocation layer to persist state across invocations.
*/
getSessionState(): { contextId?: string; taskId?: string } {
return this._remoteProtocol.getSessionState();
}
/**
* Convenience: start execution with a query string.
* Equivalent to send({message: {content: [{type:'text', text: query}]}}).
@@ -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) {
+4
View File
@@ -5,6 +5,8 @@
*/
import type { Content, PartListUnion } from '@google/genai';
import type { MessageRecord } from '../services/chatRecordingTypes.js';
/**
* The return type for a command action that results in scheduling a tool call.
*/
@@ -37,6 +39,8 @@ export interface LoadHistoryActionReturn<HistoryType = unknown> {
type: 'load_history';
history: HistoryType;
clientHistory: readonly Content[]; // The history for the generative client
messages?: MessageRecord[];
version?: '2.0';
}
/**
-1
View File
@@ -2019,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);
+11 -1
View File
@@ -237,6 +237,7 @@ export interface GemmaModelRouterSettings {
export interface ADKSettings {
agentSessionNoninteractiveEnabled?: boolean;
agentSessionInteractiveEnabled?: boolean;
agentSessionSubagentEnabled?: boolean;
}
export interface ExtensionSetting {
@@ -688,6 +689,7 @@ export interface ConfigParameters {
enableShellOutputEfficiency?: boolean;
shellToolInactivityTimeout?: number;
fakeResponses?: string;
fakeResponsesNonStrict?: string;
recordResponses?: string;
ptyInfo?: string;
disableYoloMode?: boolean;
@@ -913,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;
@@ -1299,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;
@@ -1359,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,
@@ -1833,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);
@@ -2574,6 +2580,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.contextManagement.enabled;
}
isAgentSessionSubagentEnabled(): boolean {
return this.agentSessionSubagentEnabled;
}
getMemoryBoundaryMarkers(): readonly string[] {
return this.memoryBoundaryMarkers;
}
@@ -71,6 +71,24 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-flash-preview',
},
},
'gemini-3.1-pro-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview',
},
},
'gemini-3.1-pro-preview-customtools': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview-customtools',
},
},
'gemini-3.1-flash-lite-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-flash-lite-preview',
},
},
'gemini-2.5-pro': {
extends: 'chat-base-2.5',
modelConfig: {
@@ -449,7 +467,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
auto: {
default: 'gemini-3-pro-preview',
contexts: [
{ condition: { releaseChannel: 'stable' }, target: 'gemini-2.5-pro' },
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
{
condition: { useGemini3_1: true, useCustomTools: true },
@@ -541,10 +558,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
condition: { hasAccessToPreview: false },
target: 'gemini-2.5-pro',
},
{
condition: { releaseChannel: 'stable', requestedModels: ['auto'] },
target: 'gemini-2.5-pro',
},
{
condition: { requestedModels: ['gemini-2.5-pro', 'auto-gemini-2.5'] },
target: 'gemini-2.5-pro',
+53
View File
@@ -34,6 +34,7 @@ import {
isProModel,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
getAutoModelDescription,
} from './models.js';
import type { Config } from './config.js';
import { ModelConfigService } from '../services/modelConfigService.js';
@@ -672,3 +673,55 @@ describe('isActiveModel', () => {
).toBe(false);
});
});
describe('Gemini 3.1 Config Resolution', () => {
it('PREVIEW_GEMINI_3_1_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
});
describe('getAutoModelDescription', () => {
it('should return Gemini 2.5 description when hasAccessToPreview is false', () => {
const desc = getAutoModelDescription(false, false);
expect(desc).toContain('gemini-2.5-pro');
expect(desc).toContain('gemini-2.5-flash');
});
it('should return Gemini 3.0 description when hasAccessToPreview is true', () => {
const desc = getAutoModelDescription(true, false);
expect(desc).toContain('gemini-3-pro');
expect(desc).toContain('gemini-3-flash');
});
it('should return Gemini 3.1 description when hasAccessToPreview and useGemini3_1 are true', () => {
const desc = getAutoModelDescription(true, true);
expect(desc).toContain('gemini-3.1-pro');
expect(desc).toContain('gemini-3-flash');
});
});
+4 -10
View File
@@ -49,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';
@@ -97,16 +96,15 @@ 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}`;
}
@@ -126,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)
@@ -135,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)) {
@@ -164,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;
}
@@ -196,7 +196,7 @@ describe('ChatCompressionService', () => {
} as unknown as Config;
vi.mocked(getInitialChatHistory).mockImplementation(
async (_config, extraHistory) => extraHistory || [],
async (_config, extraHistory) => (extraHistory ? [...extraHistory] : []),
);
});
@@ -442,7 +442,9 @@ export class ChatCompressionService {
const fullNewHistory = await getInitialChatHistory(config, extraHistory);
const newTokenCount = await calculateRequestTokenCount(
fullNewHistory.flatMap((c) => c.parts || []),
fullNewHistory.flatMap(
(c) => ('content' in c ? c.content.parts : c.parts) || [],
),
config.getContentGenerator(),
model,
);
@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { randomUUID } from 'node:crypto';
import { testTruncateProfile } from './testing/testProfile.js';
import {
createSyntheticHistory,
@@ -32,20 +33,35 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
// 2. Add System Prompt (Episode 0 - Protected)
chatHistory.set([
{ role: 'user', parts: [{ text: 'System prompt' }] },
{ role: 'model', parts: [{ text: 'Understood.' }] },
{
id: 'h1',
content: { role: 'user', parts: [{ text: 'System prompt' }] },
},
{
id: 'h2',
content: { role: 'model', parts: [{ text: 'Understood.' }] },
},
]);
// 3. Add massive history that blows past the 150k maxTokens limit
// 20 turns * ~20,000 tokens/turn (10k user + 10k model) = ~400,000 tokens
const massiveHistory = createSyntheticHistory(20, 10000);
const massiveHistory = createSyntheticHistory(20, 10000).map((c) => ({
id: randomUUID(),
content: c,
}));
chatHistory.set([...chatHistory.get(), ...massiveHistory]);
// 4. Add the Latest Turn (Protected)
chatHistory.set([
...chatHistory.get(),
{ role: 'user', parts: [{ text: 'Final question.' }] },
{ role: 'model', parts: [{ text: 'Final answer.' }] },
{
id: 'h-last-user',
content: { role: 'user', parts: [{ text: 'Final question.' }] },
},
{
id: 'h-last-model',
content: { role: 'model', parts: [{ text: 'Final answer.' }] },
},
]);
const rawHistoryLength = chatHistory.get().length;
@@ -59,21 +75,22 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
expect(projection.length).toBeLessThan(rawHistoryLength);
// Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation
expect(projection[0].role).toBe('user');
expect(projection[0].content.role).toBe('user');
const projectionString = JSON.stringify(projection);
expect(projectionString).toContain('User turn 17');
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
const contentNodes = projection.filter(
(p) =>
p.parts && p.parts.some((part) => part.text && part.text !== 'Yield'),
p.content.parts &&
p.content.parts.some((part) => part.text && part.text !== 'Yield'),
);
// Verify the latest turn is perfectly preserved at the back
// Note: The HistoryHardener appends a "Please continue." user turn if we end on model,
// so we look at the turns before the sentinel.
const lastSentinel = contentNodes[contentNodes.length - 1];
const lastModel = contentNodes[contentNodes.length - 2];
const lastUser = contentNodes[contentNodes.length - 3];
const lastSentinel = contentNodes[contentNodes.length - 1].content;
const lastModel = contentNodes[contentNodes.length - 2].content;
const lastUser = contentNodes[contentNodes.length - 3].content;
expect(lastSentinel.role).toBe('user');
expect(lastSentinel.parts![0].text).toBe('Please continue.');
@@ -47,7 +47,9 @@ describe('ContextManager - Hot Start Calibration', () => {
const emitGroundTruthSpy = vi.spyOn(env.eventBus, 'emitTokenGroundTruth');
// Add a node to make the buffer non-empty
chatHistory.set([{ role: 'user', parts: [{ text: 'Hello' }] }]);
chatHistory.set([
{ id: 'h1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
]);
// First render should trigger calibration
await contextManager.renderHistory();
@@ -81,7 +83,9 @@ describe('ContextManager - Hot Start Calibration', () => {
);
// Add a node
chatHistory.set([{ role: 'user', parts: [{ text: 'Hello' }] }]);
chatHistory.set([
{ id: 'h1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
]);
// Render should succeed without throwing
const result = await contextManager.renderHistory();
+86 -36
View File
@@ -5,8 +5,11 @@
*/
import type { Content } from '@google/genai';
import type { AgentChatHistory } from '../core/agentChatHistory.js';
import { isToolExecution, type ConcreteNode } from './graph/types.js';
import type {
AgentChatHistory,
HistoryTurn,
} from '../core/agentChatHistory.js';
import type { ConcreteNode } from './graph/types.js';
import type { ContextEventBus } from './eventBus.js';
import type { ContextTracer } from './tracer.js';
import type { ContextEnvironment } from './pipeline/environment.js';
@@ -38,9 +41,11 @@ export class ContextManager {
private lastRenderCache?: {
nodesHash: string;
result: {
history: Content[];
history: HistoryTurn[];
apiHistory: Content[];
didApplyManagement: boolean;
baseUnits: number;
processedNodes: readonly ConcreteNode[];
};
};
@@ -75,6 +80,21 @@ export class ContextManager {
this.evaluateTriggers(event.newNodes);
});
this.eventBus.onProcessorResult((event) => {
// Defensive: Verify all targets are still present in the buffer.
// If a synchronous render or a previous async task already removed them,
// this result is stale and should be dropped.
const currentIds = new Set(this.buffer.nodes.map((n) => n.id));
const allTargetsPresent = event.targets.every((t) =>
currentIds.has(t.id),
);
if (!allTargetsPresent) {
debugLogger.log(
`[ContextManager] Dropping stale processor result from ${event.processorId}. One or more targets were already removed.`,
);
return;
}
this.buffer = this.buffer.applyProcessorResult(
event.processorId,
event.targets,
@@ -127,11 +147,11 @@ export class ContextManager {
const agedOutNodes = new Set<string>();
let rollingTokens = 0;
// Identify active tool calls that must NEVER be truncated
// Identify nodes that must NEVER be truncated
const protectedIds = this.getProtectedNodeIds(this.buffer.nodes);
if (protectedIds.size > 0) {
debugLogger.log(
`[ContextManager] Pinning ${protectedIds.size} active tool call nodes to prevent truncation.`,
`[ContextManager] Pinning ${protectedIds.size} nodes (recent_turn or external_active_task) to prevent truncation.`,
);
}
@@ -215,24 +235,7 @@ export class ContextManager {
}
}
// 2. Identify active tool calls that must NEVER be truncated
const calls = nodes.filter((n) => isToolExecution(n) && n.role === 'model');
const responses = new Set(
nodes
.filter((n) => isToolExecution(n) && n.role === 'user')
.map((n) => n.payload.functionResponse?.id)
.filter((id): id is string => !!id),
);
for (const call of calls) {
const id = call.payload.functionCall?.id;
// If we have a call but no response in the current graph, it's 'in flight'
if (id && !responses.has(id)) {
protectionMap.set(call.id, 'in_flight_tool_call');
}
}
// 3. Any externally requested protections
// 2. Any externally requested protections
for (const id of extraProtectedIds) {
protectionMap.set(id, 'external_active_task');
}
@@ -278,13 +281,15 @@ export class ContextManager {
* This is the primary method called by the agent framework before sending a request.
*/
async renderHistory(
pendingRequest?: Content,
pendingRequest?: HistoryTurn,
activeTaskIds: Set<string> = new Set(),
abortSignal?: AbortSignal,
): Promise<{
history: Content[];
history: HistoryTurn[];
apiHistory: Content[];
didApplyManagement: boolean;
baseUnits: number;
processedNodes: readonly ConcreteNode[];
}> {
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
@@ -302,6 +307,7 @@ export class ContextManager {
const hotStartPromise = (async () => {
if (!this.hasPerformedHotStart) {
this.hasPerformedHotStart = true;
if (this.buffer.nodes.length > 0) {
const nodesForHotStart = [...this.buffer.nodes, ...previewNodes];
await this.performHotStartCalibration(nodesForHotStart, abortSignal);
@@ -345,11 +351,7 @@ export class ContextManager {
const protectionReasons = this.getProtectedNodeIds(nodes, activeTaskIds);
// Apply final GC Backstop pressure barrier synchronously before mapping
const {
history: renderedHistory,
didApplyManagement,
baseUnits,
} = await render(
const renderResult = await render(
nodes,
this.orchestrator,
this.sidecar,
@@ -361,21 +363,68 @@ export class ContextManager {
previewNodeIds,
);
const {
history: renderedHistory,
didApplyManagement,
baseUnits,
processedNodes,
} = renderResult;
if (didApplyManagement) {
// Commit the GC backstop results back to the master buffer.
// We filter out preview nodes because they are ephemeral and will be
// added to history naturally by the client after the turn completes.
this.buffer = this.buffer.applyProcessorResult(
'sync_backstop',
this.buffer.nodes,
processedNodes.filter((n) => !previewNodeIds.has(n.id)),
);
}
// Structural validation in debug mode
checkContextInvariants(this.buffer.nodes, 'RenderHistory');
this.tracer.logEvent('ContextManager', 'Finished rendering');
const combinedHistory = header
? [header, ...renderedHistory]
// We must temporarily append the pendingRequest (if any) before hardening.
// Otherwise, the hardener will see dangling functionCalls and inject sentinels
// even though the pendingRequest provides the required functionResponses.
const fullHistoryToHarden = pendingRequest
? [...renderedHistory, pendingRequest]
: renderedHistory;
const hardenedHistory = hardenHistory(fullHistoryToHarden, {
sentinels: this.sidecar.sentinels,
});
if (pendingRequest) {
const last = hardenedHistory[hardenedHistory.length - 1];
if (last && last.content.parts) {
const numPartsToRemove = pendingRequest.content.parts?.length || 0;
if (
numPartsToRemove > 0 &&
last.content.parts.length > numPartsToRemove
) {
last.content.parts.splice(-numPartsToRemove);
} else {
hardenedHistory.pop();
}
} else {
hardenedHistory.pop();
}
}
const apiHistory = hardenedHistory.map((h) => h.content);
if (header) {
apiHistory.unshift(header);
}
const result = {
history: hardenHistory(combinedHistory, {
sentinels: this.sidecar.sentinels,
}),
history: hardenedHistory,
apiHistory,
didApplyManagement,
baseUnits,
processedNodes,
};
// Update cache
@@ -394,10 +443,11 @@ export class ContextManager {
);
const contents = this.env.graphMapper.fromGraph(nodes);
const rawContents = contents.map((h) => h.content);
const header = this.headerProvider
? await this.headerProvider()
: undefined;
const combinedHistory = header ? [header, ...contents] : contents;
const combinedHistory = header ? [header, ...rawContents] : rawContents;
const baseUnits =
this.advancedTokenCalculator.getRawBaseUnits(nodes) +
@@ -0,0 +1,202 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { fromGraph } from './fromGraph.js';
import { NodeType, type ConcreteNode } from './types.js';
import { NodeIdService } from './nodeIdService.js';
describe('fromGraph', () => {
it('should reconstruct an empty history from empty nodes', () => {
expect(fromGraph([])).toEqual([]);
});
it('should reconstruct a single turn from a single node', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
];
const history = fromGraph(nodes);
expect(history).toEqual([
{
id: 'durable_1',
content: {
role: 'user',
parts: [{ text: 'hello' }],
},
},
]);
});
it('should coalesce adjacent nodes with the same turnId into a single turn', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
{
id: 'node_2',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'world' },
timestamp: 101,
},
];
const history = fromGraph(nodes);
expect(history).toEqual([
{
id: 'durable_1',
content: {
role: 'user',
parts: [{ text: 'hello' }, { text: 'world' }],
},
},
]);
});
it('should split turns when the role changes', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
{
id: 'node_2',
turnId: 'turn_durable_2',
role: 'model',
type: NodeType.AGENT_THOUGHT,
payload: { text: 'hi' },
timestamp: 101,
},
];
const history = fromGraph(nodes);
expect(history).toEqual([
{
id: 'durable_1',
content: {
role: 'user',
parts: [{ text: 'hello' }],
},
},
{
id: 'durable_2',
content: {
role: 'model',
parts: [{ text: 'hi' }],
},
},
]);
});
it('should split turns when the turnId changes, even if role is the same', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_durable_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
{
id: 'node_2',
turnId: 'turn_durable_2',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'world' },
timestamp: 101,
},
];
const history = fromGraph(nodes);
expect(history).toEqual([
{
id: 'durable_1',
content: {
role: 'user',
parts: [{ text: 'hello' }],
},
},
{
id: 'durable_2',
content: {
role: 'user',
parts: [{ text: 'world' }],
},
},
]);
});
it('should correctly strip the turn_ prefix from turnId', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_my_stable_id_123',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'hello' },
timestamp: 100,
},
];
const history = fromGraph(nodes);
expect(history[0].id).toBe('my_stable_id_123');
});
it('should handle orphan nodes gracefully', () => {
const nodes: ConcreteNode[] = [
{
id: 'node_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload: { text: 'orphan part' },
timestamp: 100,
} as unknown as ConcreteNode,
];
const history = fromGraph(nodes);
expect(history[0].id).toBe('orphan');
expect(history[0].content.parts).toEqual([{ text: 'orphan part' }]);
});
it('should register identities with the NodeIdService if provided', () => {
const idService = new NodeIdService();
const payload = { text: 'hello' };
const nodes: ConcreteNode[] = [
{
id: 'node_1',
turnId: 'turn_1',
role: 'user',
type: NodeType.USER_PROMPT,
payload,
timestamp: 100,
},
];
fromGraph(nodes, idService);
// The payload object reference should map to the node ID
expect(idService.get(payload)).toBe('node_1');
});
});
+29 -18
View File
@@ -7,22 +7,34 @@
import type { Content } from '@google/genai';
import type { ConcreteNode } from './types.js';
import { debugLogger } from '../../utils/debugLogger.js';
import type { NodeIdService } from './nodeIdService.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
/**
* Reconstructs a valid Gemini Chat History from a list of Concrete Nodes.
* Reconstructs a list of HistoryTurns from a list of Concrete Nodes.
* This process is "role-alternation-aware" and uses turnId to
* preserve original turn boundaries even if multiple turns have the same role.
* preserve original turn boundaries and IDs.
*/
export function fromGraph(nodes: readonly ConcreteNode[]): Content[] {
export function fromGraph(
nodes: readonly ConcreteNode[],
idService?: NodeIdService,
): HistoryTurn[] {
debugLogger.log(
`[fromGraph] Reconstructing history from ${nodes.length} nodes`,
);
const history: Content[] = [];
let currentTurn: (Content & { _turnId?: string }) | null = null;
const history: HistoryTurn[] = [];
let currentTurn: { id: string; content: Content } | null = null;
for (const node of nodes) {
const turnId = node.turnId;
const turnId = node.turnId || 'orphan';
const durableId = turnId.startsWith('turn_') ? turnId.slice(5) : turnId;
// Register the payload in the identity service to ensure stability
// even if the turn content changes (e.g. after GC backstop).
if (idService) {
idService.set(node.payload, node.id);
}
// We start a new turn if:
// 1. We don't have a current turn.
@@ -30,26 +42,25 @@ export function fromGraph(nodes: readonly ConcreteNode[]): Content[] {
// 3. The turnId changes (Preserving distinct turns of the same role).
if (
!currentTurn ||
currentTurn.role !== node.role ||
currentTurn._turnId !== turnId
currentTurn.content.role !== node.role ||
currentTurn.id !== durableId
) {
currentTurn = {
role: node.role,
parts: [node.payload],
_turnId: turnId,
id: durableId,
content: {
role: node.role,
parts: [node.payload],
},
};
history.push(currentTurn);
} else {
currentTurn.parts = [...(currentTurn.parts || []), node.payload];
currentTurn.content.parts = [
...(currentTurn.content.parts || []),
node.payload,
];
}
}
// Final cleanup: remove our internal tracking field
for (const turn of history) {
const t = turn as Content & { _turnId?: string };
delete t._turnId;
}
debugLogger.log(`[fromGraph] Reconstructed ${history.length} turns`);
return history;
}
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ContextGraphMapper } from './mapper.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
import { hardenHistory } from '../../utils/historyHardening.js';
describe('ContextGraphMapper (Round-Trip Fidelity)', () => {
it('should flawlessly round-trip a complex history containing parallel tool calls and responses', () => {
// 1. Define a complex, worst-case scenario history
const originalHistory: HistoryTurn[] = [
{
id: 'system_prompt_id',
content: {
role: 'user',
parts: [{ text: '<session_context>\nSystem Prompt here' }],
},
},
{
id: 'user_turn_1',
content: {
role: 'user',
parts: [{ text: 'Please read file A and file B at the same time.' }],
},
},
{
id: 'model_turn_1',
content: {
role: 'model',
parts: [
{ text: 'I will read both files concurrently.' },
{
functionCall: {
id: 'call_A',
name: 'read_file',
args: { path: 'A.txt' },
},
thoughtSignature: 'synthetic_sig_xyz',
},
{
functionCall: {
id: 'call_B',
name: 'read_file',
args: { path: 'B.txt' },
},
},
],
},
},
// Note: GeminiChat records these as separate sequential user turns initially
{
id: 'tool_resp_B_id',
content: {
role: 'user',
parts: [
{
functionResponse: {
id: 'call_B',
name: 'read_file',
response: { content: 'File B' },
},
},
],
},
},
{
id: 'tool_resp_A_id',
content: {
role: 'user',
parts: [
{
functionResponse: {
id: 'call_A',
name: 'read_file',
response: { content: 'File A' },
},
},
],
},
},
];
// 2. We harden the original history first. The core agent loop feeds the hardener the pure history.
// We want our round-tripped history to match what the hardener WOULD have produced natively.
const hardenedOriginal = hardenHistory(originalHistory);
// 3. Translate History -> Graph
const mapper = new ContextGraphMapper();
// Simulate the HistoryObserver capturing the push
const nodes = mapper.applyEvent({
type: 'SYNC_FULL',
payload: originalHistory,
});
// 4. Translate Graph -> History
const reconstructedHistory = mapper.fromGraph(nodes);
// 5. Harden the reconstructed history (as the ContextManager does before sending to API)
const hardenedReconstructed = hardenHistory(reconstructedHistory);
// 6. Assert Absolute Equality
// The round-trip through the Context Graph and Hardener must exactly equal
// the original history put through the Hardener.
expect(hardenedReconstructed).toEqual(hardenedOriginal);
});
});
+10 -6
View File
@@ -5,23 +5,27 @@
*/
import type { ConcreteNode } from './types.js';
import { ContextGraphBuilder } from './toGraph.js';
import type { Content } from '@google/genai';
import type { HistoryEvent } from '../../core/agentChatHistory.js';
import type { HistoryEvent, HistoryTurn } from '../../core/agentChatHistory.js';
import { fromGraph } from './fromGraph.js';
import { NodeIdService } from './nodeIdService.js';
export class ContextGraphMapper {
private readonly nodeIdentityMap = new WeakMap<object, string>();
private readonly idService = new NodeIdService();
private readonly builder: ContextGraphBuilder;
constructor() {
this.builder = new ContextGraphBuilder(this.nodeIdentityMap);
this.builder = new ContextGraphBuilder(this.idService);
}
applyEvent(event: HistoryEvent): ConcreteNode[] {
return this.builder.processHistory(event.payload);
}
fromGraph(nodes: readonly ConcreteNode[]): Content[] {
return fromGraph(nodes);
fromGraph(nodes: readonly ConcreteNode[]): HistoryTurn[] {
return fromGraph(nodes, this.idService);
}
getIdService(): NodeIdService {
return this.idService;
}
}
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Provides a durable mapping between history object references and their
* corresponding graph node IDs. This ensures that context management logic
* can track the identity of turns even after they are transformed (e.g. scrubbed
* or hardened) without polluting the raw JSON sent to the Gemini API.
*/
export class NodeIdService {
constructor(private readonly map: WeakMap<object, string> = new WeakMap()) {}
get(obj: object): string | undefined {
return this.map.get(obj);
}
set(obj: object, id: string): void {
this.map.set(obj, id);
}
}
+22 -5
View File
@@ -12,9 +12,10 @@ import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import { performCalibration } from '../utils/tokenCalibration.js';
import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
/**
* Maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
* Maps the Episodic Context Graph back into a list of HistoryTurns for transmission.
* It applies synchronous context management (GC backstop) if the budget is exceeded.
*/
export async function render(
@@ -28,9 +29,10 @@ export async function render(
header?: Content,
previewNodeIds: ReadonlySet<string> = new Set(),
): Promise<{
history: Content[];
history: HistoryTurn[];
didApplyManagement: boolean;
baseUnits: number;
processedNodes: readonly ConcreteNode[];
}> {
let headerTokens = 0;
let headerBaseUnits = 0;
@@ -52,7 +54,12 @@ export async function render(
const baseUnits =
advancedTokenCalculator.getRawBaseUnits(nodes) + headerBaseUnits;
return { history: contents, didApplyManagement: false, baseUnits };
return {
history: contents,
didApplyManagement: false,
baseUnits,
processedNodes: nodes,
};
}
const maxTokens = sidecar.config.budget.maxTokens;
@@ -92,11 +99,16 @@ export async function render(
tracer.logEvent('Render', 'Render Context for LLM', {
renderedContext: contents,
});
performCalibration(env, visibleNodes, contents);
performCalibration(
env,
visibleNodes,
contents.map((h) => h.content),
);
return {
history: contents,
didApplyManagement: false,
baseUnits: graphBaseUnits + headerBaseUnits,
processedNodes: nodes,
};
}
const targetDelta = currentTokens - sidecar.config.budget.retainedTokens;
@@ -145,11 +157,16 @@ export async function render(
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
renderedContextSanitized: contents,
});
performCalibration(env, visibleNodes, contents);
performCalibration(
env,
visibleNodes,
contents.map((h) => h.content),
);
return {
history: contents,
didApplyManagement: true,
baseUnits:
advancedTokenCalculator.getRawBaseUnits(visibleNodes) + headerBaseUnits,
processedNodes,
};
}
+102 -13
View File
@@ -4,29 +4,42 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { ContextGraphBuilder } from './toGraph.js';
import type { Content } from '@google/genai';
import type { BaseConcreteNode } from './types.js';
import { NodeIdService } from './nodeIdService.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
describe('ContextGraphBuilder', () => {
describe('toGraph', () => {
it('should skip legacy <session_context> headers even if they appear later in the history', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Message 1' }] },
{ role: 'model', parts: [{ text: 'Reply 1' }] },
const history: HistoryTurn[] = [
{
role: 'user',
parts: [
{
text: '<session_context>\nThis is the Gemini CLI\nSome context...',
},
],
id: '1',
content: { role: 'user', parts: [{ text: 'Message 1' }] },
},
{
id: '2',
content: { role: 'model', parts: [{ text: 'Reply 1' }] },
},
{
id: '3',
content: {
role: 'user',
parts: [
{
text: '<session_context>\nThis is the Gemini CLI\nSome context...',
},
],
},
},
{
id: '4',
content: { role: 'user', parts: [{ text: 'Message 2' }] },
},
{ role: 'user', parts: [{ text: 'Message 2' }] },
];
const builder = new ContextGraphBuilder();
const builder = new ContextGraphBuilder(new NodeIdService());
const nodes = builder.processHistory(history);
// We expect the first two messages and the last one to be present
@@ -36,5 +49,81 @@ describe('ContextGraphBuilder', () => {
expect((nodes[1] as BaseConcreteNode).payload.text).toBe('Reply 1');
expect((nodes[2] as BaseConcreteNode).payload.text).toBe('Message 2');
});
it('should generate completely deterministic graph structure and UUIDs across JSON serialization cycles', () => {
vi.spyOn(Date, 'now').mockReturnValue(0);
const complexHistory: HistoryTurn[] = [
{
id: 'turn-1',
content: {
role: 'user',
parts: [{ text: 'Step 1: complex analysis' }],
},
},
{
id: 'turn-2',
content: {
role: 'model',
parts: [
{ text: 'Thinking about the tool to use.' },
{
functionCall: {
name: 'fetch_data',
args: { query: 'test data' },
},
},
],
},
},
{
id: 'turn-3',
content: {
role: 'user',
parts: [
{
functionResponse: {
name: 'fetch_data',
response: { status: 'success', data: [1, 2, 3] },
},
},
],
},
},
{
id: 'turn-4',
content: { role: 'model', parts: [{ text: 'Analysis complete.' }] },
},
];
// 1. Initial Graph Generation
const builder1 = new ContextGraphBuilder(new NodeIdService());
const nodes1 = builder1.processHistory(complexHistory);
// 2. Serialize and Deserialize (Simulating saving and loading from disk)
const serializedHistory = JSON.stringify(complexHistory);
const parsedHistory = JSON.parse(serializedHistory) as HistoryTurn[];
// 3. Second Graph Generation from parsed JSON
const builder2 = new ContextGraphBuilder(new NodeIdService());
const nodes2 = builder2.processHistory(parsedHistory);
// Assertion: The arrays must be completely identical, including all generated UUIDs
expect(nodes1).toEqual(nodes2);
// Sanity check to ensure IDs are actually populated and consistent
expect(nodes1.length).toBeGreaterThan(0);
nodes1.forEach((node, index) => {
expect(node.id).toBeDefined();
expect(node.id).toBe(nodes2[index].id);
expect(node.timestamp).toBe(0);
if ('turnId' in node) {
expect(node.turnId).toBeDefined();
expect(node.turnId).toBe((nodes2[index] as BaseConcreteNode).turnId);
}
});
vi.restoreAllMocks();
});
});
});
+80 -42
View File
@@ -4,14 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, Part } from '@google/genai';
import type { Part } from '@google/genai';
import { type ConcreteNode, NodeType } from './types.js';
import { randomUUID, createHash } from 'node:crypto';
import { createHash } from 'node:crypto';
import { debugLogger } from '../../utils/debugLogger.js';
interface PartWithSynthId extends Part {
_synthId?: string;
}
import type { NodeIdService } from './nodeIdService.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
import { isSnapshotState } from '../utils/snapshotGenerator.js';
// Global WeakMap to cache hashes for Part objects.
// This optimizes getStableId by avoiding redundant stringify/hash operations
@@ -62,34 +61,53 @@ function isFunctionResponsePart(
);
}
function isExecutableCodePart(
part: Part,
): part is Part & { executableCode: { code: string; language: string } } {
return (
typeof part.executableCode === 'object' &&
part.executableCode !== null &&
typeof part.executableCode.code === 'string' &&
typeof part.executableCode.language === 'string'
);
}
function isCodeExecutionResultPart(
part: Part,
): part is Part & { codeExecutionResult: { outcome: string; output: string } } {
return (
typeof part.codeExecutionResult === 'object' &&
part.codeExecutionResult !== null &&
typeof part.codeExecutionResult.output === 'string' &&
typeof part.codeExecutionResult.outcome === 'string'
);
}
/**
* Generates a stable ID for an object reference using a WeakMap.
* Generates a stable ID for an object reference using a NodeIdService.
* Falls back to content-based hashing for Part-like objects to ensure
* stability across object re-creations (e.g. during history mapping).
*/
export function getStableId(
obj: object,
nodeIdentityMap: WeakMap<object, string>,
idService: NodeIdService,
turnSalt: string = '',
partIdx: number = 0,
): string {
let id = nodeIdentityMap.get(obj);
let id = idService.get(obj);
if (id) return id;
const cachedHash = PART_HASH_CACHE.get(obj);
if (cachedHash) {
id = `${cachedHash}_${turnSalt}_${partIdx}`;
nodeIdentityMap.set(obj, id);
idService.set(obj, id);
return id;
}
const part = obj as PartWithSynthId;
const part = obj as Part;
let contentHash: string | undefined;
// If the object already has a synthetic ID property, use it.
if (typeof part._synthId === 'string') {
id = part._synthId;
} else if (isTextPart(part)) {
if (isTextPart(part)) {
contentHash = createHash('sha256').update(part.text).digest('hex');
id = `text_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isInlineDataPart(part)) {
@@ -116,6 +134,20 @@ export function getStableId(
)
.digest('hex');
id = `resp_h_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isExecutableCodePart(part)) {
contentHash = createHash('sha256')
.update(
`exec:${part.executableCode.language}:${part.executableCode.code}`,
)
.digest('hex');
id = `exec_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isCodeExecutionResultPart(part)) {
contentHash = createHash('sha256')
.update(
`result:${part.codeExecutionResult.outcome}:${part.codeExecutionResult.output}`,
)
.digest('hex');
id = `result_${contentHash}_${turnSalt}_${partIdx}`;
}
if (contentHash) {
@@ -123,10 +155,14 @@ export function getStableId(
}
if (!id) {
id = randomUUID();
if (turnSalt && partIdx === -1) {
id = `turn_${turnSalt}`;
} else {
id = `${turnSalt}_f_${partIdx}`;
}
}
nodeIdentityMap.set(obj, id);
idService.set(obj, id);
return id;
}
@@ -135,18 +171,14 @@ export function getStableId(
* Every Part in history is mapped to exactly one ConcreteNode.
*/
export class ContextGraphBuilder {
constructor(
private readonly nodeIdentityMap: WeakMap<object, string> = new WeakMap(),
) {}
constructor(private readonly idService: NodeIdService) {}
processHistory(history: readonly Content[]): ConcreteNode[] {
processHistory(history: readonly HistoryTurn[]): ConcreteNode[] {
const nodes: ConcreteNode[] = [];
// Tracks occurrences of identical turn content to ensure unique stable IDs
const seenHashes = new Map<string, number>();
for (let turnIdx = 0; turnIdx < history.length; turnIdx++) {
const msg = history[turnIdx];
const turn = history[turnIdx];
const msg = turn.content;
if (!msg.parts) continue;
// Defensive: Skip legacy environment header regardless of where it appears.
@@ -164,15 +196,8 @@ export class ContextGraphBuilder {
}
}
// Generate a stable salt for this turn based on its role and content
const turnContent = JSON.stringify(msg.parts);
const h = createHash('md5')
.update(`${msg.role}:${turnContent}`)
.digest('hex');
const occurrence = (seenHashes.get(h) || 0) + 1;
seenHashes.set(h, occurrence);
const turnSalt = `${h}_${occurrence}`;
const turnId = getStableId(msg, this.nodeIdentityMap, turnSalt, -1);
const turnSalt = turn.id;
const turnId = `turn_${turnSalt}`;
if (msg.role === 'user') {
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
@@ -180,34 +205,46 @@ export class ContextGraphBuilder {
const apiId =
isFunctionResponsePart(part) &&
typeof part.functionResponse.id === 'string'
? `resp_${part.functionResponse.id}_${turnSalt}_${partIdx}`
? part.functionResponse.id
: isFunctionCallPart(part) &&
typeof part.functionCall.id === 'string'
? `call_${part.functionCall.id}_${turnSalt}_${partIdx}`
? part.functionCall.id
: undefined;
const id =
apiId || getStableId(part, this.nodeIdentityMap, turnSalt, partIdx);
const isSnapshot = isTextPart(part) && isSnapshotState(part.text);
// Use stable API ID if available, otherwise anchor to the turn and index.
const id = apiId
? `${apiId}_${turnSalt}_${partIdx}`
: `${turnSalt}_${partIdx}`;
const node: ConcreteNode = {
id,
timestamp: Date.now(),
type: isFunctionResponsePart(part)
? NodeType.TOOL_EXECUTION
: NodeType.USER_PROMPT,
: isSnapshot
? NodeType.SNAPSHOT
: NodeType.USER_PROMPT,
role: 'user',
payload: part,
turnId,
};
nodes.push(node);
this.idService.set(part, id);
}
} else if (msg.role === 'model') {
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
const part = msg.parts[partIdx];
const apiId =
isFunctionCallPart(part) && typeof part.functionCall.id === 'string'
? `call_${part.functionCall.id}_${turnSalt}_${partIdx}`
? part.functionCall.id
: undefined;
const id =
apiId || getStableId(part, this.nodeIdentityMap, turnSalt, partIdx);
const id = apiId
? `${apiId}_${turnSalt}_${partIdx}`
: `${turnSalt}_${partIdx}`;
const node: ConcreteNode = {
id,
timestamp: Date.now(),
@@ -219,6 +256,7 @@ export class ContextGraphBuilder {
turnId,
};
nodes.push(node);
this.idService.set(part, id);
}
}
}
+17
View File
@@ -24,6 +24,7 @@ import { StateSnapshotAsyncProcessorOptionsSchema } from './processors/stateSnap
import { RollingSummaryProcessorOptionsSchema } from './processors/rollingSummaryProcessor.js';
import { getEnvironmentContext } from '../utils/environmentContext.js';
import { AdaptiveTokenCalculator } from './utils/adaptiveTokenCalculator.js';
import { estimateContextBreakdown } from '../core/loggingContentGenerator.js';
import { NodeBehaviorRegistry } from './graph/behaviorRegistry.js';
import { registerBuiltInBehaviors } from './graph/builtinBehaviors.js';
@@ -92,10 +93,26 @@ export async function initializeContextManager(
const behaviorRegistry = new NodeBehaviorRegistry();
registerBuiltInBehaviors(behaviorRegistry);
const getOverheadTokens = () => {
const breakdown = estimateContextBreakdown([], {
systemInstruction: {
role: 'system',
parts: [{ text: chat.getSystemInstruction() }],
},
tools: chat.getTools(),
});
return (
breakdown.system_instructions +
breakdown.tool_definitions +
breakdown.mcp_servers
);
};
const calculator = new AdaptiveTokenCalculator(
charsPerToken,
behaviorRegistry,
eventBus,
getOverheadTokens,
);
const env = new ContextEnvironmentImpl(
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type { ProcessArgs, ContextProcessor } from '../pipeline.js';
import * as fs from 'node:fs/promises';
@@ -62,7 +62,8 @@ export function createBlobDegradationProcessor(
if (payload.inlineData?.data && payload.inlineData?.mimeType) {
await ensureDir();
const ext = payload.inlineData.mimeType.split('/')[1] || 'bin';
const fileName = `blob_${Date.now()}_${randomUUID()}.${ext}`;
// Use a stable filename based on the node ID
const fileName = `blob_${deriveStableId([node.id])}.${ext}`;
const filePath = path.join(blobOutputsDir, fileName);
const buffer = Buffer.from(payload.inlineData.data, 'base64');
@@ -92,7 +93,7 @@ export function createBlobDegradationProcessor(
if (newText && tokensSaved > 0) {
returnedNodes.push({
...node,
id: randomUUID(),
id: deriveStableId([node.id, 'degraded']),
payload: { text: newText },
replacesId: node.id,
turnId: node.turnId,
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import { type ConcreteNode, NodeType } from '../graph/types.js';
@@ -99,9 +99,10 @@ export function createNodeDistillationProcessor(
if (newTokens < oldTokens) {
const distilledPayload = updatePart(payload, { text: summary });
const newId = deriveStableId([node.id, 'distilled']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: distilledPayload,
replacesId: node.id,
timestamp: node.timestamp,
@@ -158,9 +159,10 @@ export function createNodeDistillationProcessor(
functionResponse: newFR,
});
const newId = deriveStableId([node.id, 'distilled']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: distilledPayload,
replacesId: node.id,
timestamp: node.timestamp,
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
@@ -79,9 +79,10 @@ export function createNodeTruncationProcessor(
if (text) {
const squashResult = tryApplySquash(text, limitChars);
if (squashResult) {
const newId = deriveStableId([node.id, 'truncated']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: { ...payload, text: squashResult.text },
replacesId: node.id,
turnId: node.turnId,
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type {
ContextProcessor,
@@ -112,7 +112,8 @@ export function createRollingSummaryProcessor(
try {
// Synthesize the rolling summary synchronously
const snapshotText = await generateRollingSummary(nodesToSummarize);
const newId = randomUUID();
const consumedIds = nodesToSummarize.map((n) => n.id);
const newId = deriveStableId(consumedIds);
const summaryNode: RollingSummary = {
id: newId,
@@ -121,10 +122,9 @@ export function createRollingSummaryProcessor(
timestamp: nodesToSummarize[nodesToSummarize.length - 1].timestamp,
role: 'user',
payload: { text: snapshotText },
abstractsIds: nodesToSummarize.map((n) => n.id),
abstractsIds: consumedIds,
};
const consumedIds = nodesToSummarize.map((n) => n.id);
const returnedNodes = targets.filter(
(t) => !consumedIds.includes(t.id),
);
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type {
ContextProcessor,
@@ -90,8 +90,11 @@ export function createStateSnapshotProcessor(
const isValid = consumedIds.every((id) => targetIds.has(id));
if (isValid) {
debugLogger.log(
`[StateSnapshotProcessor] Successfully spliced PROPOSED_SNAPSHOT from Inbox into Graph. Consumed ${consumedIds.length} nodes.`,
);
// If valid, apply it!
const newId = randomUUID();
const newId = deriveStableId(consumedIds);
const snapshotNode: Snapshot = {
id: newId,
@@ -120,6 +123,10 @@ export function createStateSnapshotProcessor(
inbox.consume(proposed.id);
return returnedNodes;
} else {
debugLogger.log(
`[StateSnapshotProcessor] Rejected PROPOSED_SNAPSHOT from Inbox because one or more target IDs were missing from the current graph window.`,
);
}
}
}
@@ -179,11 +186,11 @@ export function createStateSnapshotProcessor(
maxStateTokens: options.maxStateTokens,
},
);
const newId = randomUUID();
const consumedIds = nodesToSummarize.map((n) => n.id);
if (baselineIdToConsume && !consumedIds.includes(baselineIdToConsume)) {
consumedIds.push(baselineIdToConsume);
}
const newId = deriveStableId(consumedIds);
const snapshotNode: Snapshot = {
id: newId,
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import type { JSONSchemaType } from 'ajv';
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import * as fs from 'node:fs/promises';
@@ -120,7 +120,7 @@ export function createToolMaskingProcessor(
directoryCreated = true;
}
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${randomUUID()}.txt`;
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${deriveStableId([content])}.txt`;
const filePath = path.join(toolOutputsDir, fileName);
await fs.writeFile(filePath, content);
@@ -214,9 +214,10 @@ export function createToolMaskingProcessor(
functionCall: newFC,
});
const newId = deriveStableId([node.id, 'masked']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: maskedPart,
replacesId: node.id,
turnId: node.turnId,
@@ -242,9 +243,10 @@ export function createToolMaskingProcessor(
functionResponse: newFR,
});
const newId = deriveStableId([node.id, 'masked']);
returnedNodes.push({
...node,
id: randomUUID(),
id: newId,
payload: maskedPart,
replacesId: node.id,
turnId: node.turnId,
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ import { SimulationHarness } from './simulationHarness.js';
import { createMockLlmClient } from '../testing/contextTestUtils.js';
import type { ContextProfile } from '../config/profiles.js';
import { generalistProfile } from '../config/profiles.js';
import type { Content } from '@google/genai';
import { type HistoryTurn } from '../../core/agentChatHistory.js';
describe('Context Manager Hysteresis Tests', () => {
const mockLlmClient = createMockLlmClient(['<SNAPSHOT>']);
@@ -18,6 +18,7 @@ describe('Context Manager Hysteresis Tests', () => {
...generalistProfile,
name: 'Hysteresis Stress Test',
config: {
...generalistProfile.config,
budget: {
maxTokens: 5000,
retainedTokens: 1000,
@@ -26,9 +27,13 @@ describe('Context Manager Hysteresis Tests', () => {
},
});
const getProjectionTokens = (proj: Content[], harness: SimulationHarness) =>
const getProjectionTokens = (
proj: HistoryTurn[],
harness: SimulationHarness,
) =>
proj.reduce(
(sum, c) => sum + harness.env.tokenCalculator.calculateContentTokens(c),
(sum, c) =>
sum + harness.env.tokenCalculator.calculateContentTokens(c.content),
0,
);
@@ -57,7 +62,7 @@ describe('Context Manager Hysteresis Tests', () => {
// No snapshot because maxTokens (5000) not exceeded, and deficit < threshold.
expect(
state.finalProjection.some((c) =>
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
c.content.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
),
).toBe(false);
@@ -79,7 +84,7 @@ describe('Context Manager Hysteresis Tests', () => {
state = await harness.getGoldenState();
expect(
state.finalProjection.some((c) =>
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
c.content.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
),
).toBe(true);
});
@@ -108,7 +113,7 @@ describe('Context Manager Hysteresis Tests', () => {
let state = await harness.getGoldenState();
expect(
state.finalProjection.some((c) =>
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
c.content.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
),
).toBe(true);
@@ -17,6 +17,7 @@ expect.addSnapshotSerializer({
(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(
val,
) ||
/^[0-9a-f]{32}$/i.test(val) ||
/[\\/]tmp[\\/]sim/.test(val)),
print: (val) => {
if (typeof val !== 'string') return `"${val}"`;
@@ -25,6 +26,7 @@ expect.addSnapshotSerializer({
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
'<UUID>',
)
.replace(/\b[0-9a-f]{32}\b/gi, '<UUID>')
.replace(/[\\/]tmp[\\/]sim[^\s"'\]]*/g, '<MOCKED_DIR>');
// Also scrub timestamps in filenames like blob_1234567890_...
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { ContextManager } from '../contextManager.js';
import { AgentChatHistory } from '../../core/agentChatHistory.js';
import type { Content } from '@google/genai';
@@ -98,7 +99,8 @@ export class SimulationHarness {
async simulateTurn(messages: Content[]) {
// 1. Append the new messages
const currentHistory = this.chatHistory.get();
this.chatHistory.set([...currentHistory, ...messages]);
const turns = messages.map((m) => ({ id: randomUUID(), content: m }));
this.chatHistory.set([...currentHistory, ...turns]);
// 2. Measure tokens immediately after append
const tokensBefore = this.env.tokenCalculator.calculateConcreteListTokens(
@@ -122,4 +122,69 @@ describe('AdaptiveTokenCalculator', () => {
expect(calculator.getLearnedWeight()).toBe(1.0);
});
it('should subtract overhead tokens from actual tokens when determining target weight', () => {
const eventBus = new ContextEventBus();
const getOverheadTokens = () => 40;
const calculator = new AdaptiveTokenCalculator(
charsPerToken,
registry,
eventBus,
getOverheadTokens,
);
// Initial state: weight = 1.0
// Simulate an event where the API reported 100 tokens, and our base units were 100
// But overhead is 40.
// actualGraphTokens = 100 - 40 = 60
// rawTargetWeight = 60 / 100 = 0.6
// targetWeight = Math.max(0.5, 0.6) = 0.6
// newWeight = 1.0 * 0.8 + 0.6 * 0.2 = 0.8 + 0.12 = 0.92
eventBus.emitTokenGroundTruth({
actualTokens: 100,
promptBaseUnits: 100,
});
expect(calculator.getLearnedWeight()).toBeCloseTo(0.92, 5);
});
it('should enforce the maxStep limit to prevent violent oscillation from massive outliers', () => {
const eventBus = new ContextEventBus();
const maxStep = 0.05; // Tight limit
const calculator = new AdaptiveTokenCalculator(
charsPerToken,
registry,
eventBus,
undefined,
{ maxStep },
);
// Initial state: weight = 1.0
// Simulate a massive outlier where the API reports 10,000 tokens for 100 base units.
// rawTargetWeight = 100
// targetWeight = Math.min(100, 1.0 * 2.0) = 2.0
// emaWeight = 1.0 * 0.8 + 2.0 * 0.2 = 1.2
// BUT maxStep is 0.05, so the actual step is clamped.
// finalWeight = 1.0 + 0.05 = 1.05
eventBus.emitTokenGroundTruth({
actualTokens: 10000,
promptBaseUnits: 100,
});
expect(calculator.getLearnedWeight()).toBeCloseTo(1.05, 5);
// Simulate a massive under-estimation
// rawTargetWeight = 0
// targetWeight = Math.max(0, 1.05 * 0.5) = 0.525
// emaWeight = 1.05 * 0.8 + 0.525 * 0.2 = 0.84 + 0.105 = 0.945
// BUT maxStep is 0.05, so step is clamped: 1.05 - 0.05 = 1.0
eventBus.emitTokenGroundTruth({
actualTokens: 0,
promptBaseUnits: 100,
});
expect(calculator.getLearnedWeight()).toBeCloseTo(1.0, 5);
});
});
@@ -14,6 +14,13 @@ import type { NodeBehaviorRegistry } from '../graph/behaviorRegistry.js';
import type { ContextEventBus, TokenGroundTruthEvent } from '../eventBus.js';
import { debugLogger } from '../../utils/debugLogger.js';
export interface AdaptiveLearningConfig {
/** The momentum factor for the Exponential Moving Average (EMA). Defaults to 0.2. */
learningRate?: number;
/** The absolute maximum change allowed to the weight in a single turn. Defaults to 0.15. */
maxStep?: number;
}
/**
* An Adaptive Token Calculator that dynamically learns the true token cost of the user's
* conversation by applying an Exponential Moving Average (EMA) gradient descent to
@@ -26,12 +33,18 @@ import { debugLogger } from '../../utils/debugLogger.js';
export class AdaptiveTokenCalculator implements AdvancedTokenCalculator {
private learnedWeight = 1.0;
private readonly baseCalculator: StaticTokenCalculator;
private readonly learningRate: number;
private readonly maxStep: number;
constructor(
charsPerToken: number,
registry: NodeBehaviorRegistry,
eventBus: ContextEventBus,
private readonly getOverheadTokens?: () => number,
config?: AdaptiveLearningConfig,
) {
this.learningRate = config?.learningRate ?? 0.2;
this.maxStep = config?.maxStep ?? 0.15;
this.baseCalculator = new StaticTokenCalculator(charsPerToken, registry);
eventBus.onTokenGroundTruth((event: TokenGroundTruthEvent) => {
this.handleGroundTruth(event.actualTokens, event.promptBaseUnits);
@@ -41,21 +54,44 @@ export class AdaptiveTokenCalculator implements AdvancedTokenCalculator {
private handleGroundTruth(actualTokens: number, promptBaseUnits: number) {
if (promptBaseUnits <= 0) return;
const overheadTokens = this.getOverheadTokens
? this.getOverheadTokens()
: 0;
// The Gemini API token count includes the static overhead (system instruction + tools)
// and the dynamic chat history (which we measure as promptBaseUnits).
// We subtract the overhead so the adaptive calculator is comparing "apples to apples"
// when learning the weight multiplier for the graph nodes.
const actualGraphTokens = Math.max(0, actualTokens - overheadTokens);
// Determine what ratio we should have used
const targetWeight = actualTokens / promptBaseUnits;
const rawTargetWeight = actualGraphTokens / promptBaseUnits;
const oldWeight = this.learnedWeight;
// Apply Momentum (Learning Rate)
const learningRate = 0.2;
const newWeight =
oldWeight * (1 - learningRate) + targetWeight * learningRate;
// Dampen extreme outliers *before* applying the EMA by capping the target weight
// to a reasonable multiple of the current weight. This prevents a single massive
// anomaly from destroying the running average.
const targetWeight = Math.max(
oldWeight * 0.5,
Math.min(rawTargetWeight, oldWeight * 2.0),
);
// Clamp to reasonable safety bounds to prevent rogue metadata poisoning the system
// Apply Momentum (Learning Rate)
let newWeight =
oldWeight * (1 - this.learningRate) + targetWeight * this.learningRate;
// Hard limit the maximum step size per turn to prevent violent oscillation
if (newWeight > oldWeight + this.maxStep)
newWeight = oldWeight + this.maxStep;
if (newWeight < oldWeight - this.maxStep)
newWeight = oldWeight - this.maxStep;
// Clamp to reasonable absolute safety bounds
this.learnedWeight = Math.max(0.5, Math.min(newWeight, 2.0));
debugLogger.log(
`[AdaptiveTokenCalculator] Learned weight updated to ${this.learnedWeight.toFixed(3)} ` +
`(API Tokens: ${actualTokens}, Base Units: ${promptBaseUnits}, Target Ratio: ${targetWeight.toFixed(3)})`,
`(API Tokens: ${actualTokens}, Overhead: ${overheadTokens}, Graph Tokens: ${actualGraphTokens}, Base Units: ${promptBaseUnits}, Target Ratio: ${targetWeight.toFixed(3)})`,
);
}
@@ -5,12 +5,132 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SnapshotGenerator, type SnapshotState } from './snapshotGenerator.js';
import {
SnapshotGenerator,
type SnapshotState,
SnapshotStateHelper,
} from './snapshotGenerator.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import { NodeType, type ConcreteNode } from '../graph/types.js';
import type { Mock } from 'vitest';
describe('SnapshotStateHelper', () => {
describe('exportState', () => {
it('should flatten nested abstractsIds to pristine IDs', () => {
// Setup a graph with nested snapshots
// S3 abstracts [S2, N5]
// S2 abstracts [S1, N3, N4]
// S1 abstracts [N1, N2]
const nodes: ConcreteNode[] = [
{
id: 'N1',
type: NodeType.USER_PROMPT,
timestamp: 10,
role: 'user',
payload: { text: '1' },
turnId: 'T1',
},
{
id: 'N2',
type: NodeType.AGENT_THOUGHT,
timestamp: 20,
role: 'model',
payload: { text: '2' },
turnId: 'T1',
},
{
id: 'S1',
type: NodeType.SNAPSHOT,
timestamp: 30,
role: 'user',
payload: { text: 'State 1' },
turnId: 'S1',
abstractsIds: ['N1', 'N2'],
},
{
id: 'N3',
type: NodeType.USER_PROMPT,
timestamp: 40,
role: 'user',
payload: { text: '3' },
turnId: 'T2',
},
{
id: 'N4',
type: NodeType.AGENT_THOUGHT,
timestamp: 50,
role: 'model',
payload: { text: '4' },
turnId: 'T2',
},
{
id: 'S2',
type: NodeType.SNAPSHOT,
timestamp: 60,
role: 'user',
payload: { text: 'State 2' },
turnId: 'S2',
abstractsIds: ['S1', 'N3', 'N4'],
},
{
id: 'N5',
type: NodeType.USER_PROMPT,
timestamp: 70,
role: 'user',
payload: { text: '5' },
turnId: 'T3',
},
{
id: 'S3',
type: NodeType.SNAPSHOT,
timestamp: 80,
role: 'user',
payload: { text: 'State 3' },
turnId: 'S3',
abstractsIds: ['S2', 'N5'],
},
];
const state = SnapshotStateHelper.exportState(nodes);
expect(state.snapshot).toBeDefined();
expect(state.snapshot?.text).toBe('State 3');
// Should be flattened to only the "pristine" (non-snapshot) IDs
const consumedIds = state.snapshot?.consumedIds;
expect(consumedIds).toContain('N1');
expect(consumedIds).toContain('N2');
expect(consumedIds).toContain('N3');
expect(consumedIds).toContain('N4');
expect(consumedIds).toContain('N5');
// Should NOT contain the intermediate snapshot IDs
expect(consumedIds).not.toContain('S1');
expect(consumedIds).not.toContain('S2');
expect(consumedIds?.length).toBe(5);
});
it('should return empty state if no snapshot baseline is found', () => {
const nodes: ConcreteNode[] = [
{
id: 'N1',
type: NodeType.USER_PROMPT,
timestamp: 10,
role: 'user',
payload: { text: '1' },
turnId: 'T1',
},
];
const state = SnapshotStateHelper.exportState(nodes);
expect(state).toEqual({});
});
});
});
describe('SnapshotGenerator', () => {
let mockEnv: ContextEnvironment;
let mockGenerateJson: Mock;
@@ -48,10 +48,39 @@ export interface SnapshotState {
recent_arc: string[];
}
import { debugLogger } from '../../utils/debugLogger.js';
export function isSnapshotState(text: string): boolean {
const trimmed = text.trim();
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
return false;
}
try {
const parsed: unknown = JSON.parse(trimmed);
if (!isRecord(parsed)) return false;
const isSnap =
Array.isArray(parsed['active_tasks']) &&
Array.isArray(parsed['discovered_facts']) &&
Array.isArray(parsed['constraints_and_preferences']) &&
Array.isArray(parsed['recent_arc']);
if (!isSnap) {
debugLogger.log(
'[isSnapshotState] FAILED FOR JSON:',
JSON.stringify(parsed),
);
}
return isSnap;
} catch {
debugLogger.log('[isSnapshotState] PARSE FAILED FOR:', trimmed);
return false;
}
}
export interface BaselineSnapshotInfo {
text: string;
abstractsIds: string[];
id: string;
timestamp: number;
}
/**
@@ -61,6 +90,20 @@ export interface BaselineSnapshotInfo {
export function findLatestSnapshotBaseline(
targets: readonly ConcreteNode[],
): BaselineSnapshotInfo | undefined {
debugLogger.log(
'[findLatestSnapshotBaseline] Targets:',
targets.map((t) => ({
id: t.id,
type: t.type,
text:
t.payload &&
typeof t.payload === 'object' &&
'text' in t.payload &&
typeof t.payload.text === 'string'
? t.payload.text.substring(0, 20)
: '',
})),
);
const lastSnapshotNode = [...targets]
.reverse()
.find((n) => n.type === NodeType.SNAPSHOT && n.payload.text);
@@ -72,8 +115,10 @@ export function findLatestSnapshotBaseline(
? [...lastSnapshotNode.abstractsIds]
: [],
id: lastSnapshotNode.id,
timestamp: lastSnapshotNode.timestamp,
};
}
return undefined;
}
@@ -326,3 +371,61 @@ ${formatNodesForLlm(nodes)}`;
return JSON.stringify(newState);
}
}
/**
* Shared logic for working with Snapshot node state.
*/
export class SnapshotStateHelper {
/**
* Flatten nested abstract IDs to only the "pristine" (non-snapshot) IDs.
*/
static flattenAbstracts(
nodes: ConcreteNode[],
abstractsIds: readonly string[],
): string[] {
const pristineIds: string[] = [];
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const walk = (ids: readonly string[]) => {
for (const id of ids) {
const node = nodeMap.get(id);
if (!node) {
// Fallback: if node not in map, treat as pristine ID
pristineIds.push(id);
continue;
}
if (node.type === NodeType.SNAPSHOT && node.abstractsIds) {
walk(node.abstractsIds);
} else {
pristineIds.push(id);
}
}
};
walk(abstractsIds);
return Array.from(new Set(pristineIds)); // Dedupe
}
/**
* Helper to extract state from the most recent snapshot in a list of nodes.
*/
static exportState(nodes: ConcreteNode[]): {
snapshot?: { text: string; consumedIds: string[] };
} {
const baseline = findLatestSnapshotBaseline(nodes);
if (!baseline) return {};
const node = nodes.find((n) => n.id === baseline.id);
if (!node || node.type !== NodeType.SNAPSHOT) return {};
const consumedIds = this.flattenAbstracts(nodes, node.abstractsIds || []);
return {
snapshot: {
text: baseline.text,
consumedIds,
},
};
}
}
+45 -16
View File
@@ -6,21 +6,35 @@
import type { Content } from '@google/genai';
/**
* A durable wrapper for Gemini Content that carries a stable ID.
* This ID is preserved across all transformations and is used as the anchor
* for context graph node identity.
*/
export interface HistoryTurn {
readonly id: string;
readonly content: Content;
}
export type HistoryEventType = 'PUSH' | 'SYNC_FULL' | 'CLEAR' | 'SILENT_SYNC';
export interface HistoryEvent {
type: HistoryEventType;
payload: readonly Content[];
payload: readonly HistoryTurn[];
}
export type HistoryListener = (event: HistoryEvent) => void;
/**
* The 'Strong Owner' of chat history turns.
* It ensures that every turn in the session is associated with a durable ID.
*/
export class AgentChatHistory {
private history: Content[];
private history: HistoryTurn[] = [];
private listeners: Set<HistoryListener> = new Set();
constructor(initialHistory: Content[] = []) {
this.history = [...initialHistory];
constructor(initialTurns: HistoryTurn[] = []) {
this.history = [...initialTurns];
}
subscribe(listener: HistoryListener): () => void {
@@ -30,20 +44,27 @@ export class AgentChatHistory {
return () => this.listeners.delete(listener);
}
private notify(type: HistoryEventType, payload: readonly Content[]) {
private notify(type: HistoryEventType, payload: readonly HistoryTurn[]) {
const event: HistoryEvent = { type, payload };
for (const listener of this.listeners) {
listener(event);
}
}
push(content: Content) {
this.history.push(content);
this.notify('PUSH', [content]);
/**
* Adds a new turn to the history.
* Every turn must have a durable ID, usually provided by the ChatRecordingService.
*/
push(turn: HistoryTurn) {
this.history.push(turn);
this.notify('PUSH', [turn]);
}
set(history: readonly Content[], options: { silent?: boolean } = {}) {
this.history = [...history];
/**
* Overwrites the entire history with a new list of turns.
*/
set(turns: readonly HistoryTurn[], options: { silent?: boolean } = {}) {
this.history = [...turns];
this.notify(options.silent ? 'SILENT_SYNC' : 'SYNC_FULL', this.history);
}
@@ -52,20 +73,28 @@ export class AgentChatHistory {
this.notify('CLEAR', []);
}
get(): readonly Content[] {
get(): readonly HistoryTurn[] {
return this.history;
}
map(callback: (value: Content, index: number, array: Content[]) => Content) {
this.history = this.history.map(callback);
this.notify('SYNC_FULL', this.history);
/**
* Returns a copy of the raw Gemini Content[] for API consumption.
*/
getContents(): Content[] {
return this.history.map((h) => h.content);
}
map<U>(
callback: (value: HistoryTurn, index: number, array: HistoryTurn[]) => U,
): U[] {
return this.history.map(callback);
}
flatMap<U>(
callback: (
value: Content,
value: HistoryTurn,
index: number,
array: Content[],
array: HistoryTurn[],
) => U | readonly U[],
): U[] {
return this.history.flatMap(callback);
+22 -8
View File
@@ -40,12 +40,14 @@ import { tokenLimit } from './tokenLimits.js';
import type {
ChatRecordingService,
ResumedSessionData,
MessageRecord,
} from '../services/chatRecordingService.js';
import type { ContentGenerator } from './contentGenerator.js';
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 +69,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 +296,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,10 +338,11 @@ export class GeminiClient {
}
async resumeChat(
history: Content[],
history: ReadonlyArray<Content | HistoryTurn>,
resumedSessionData?: ResumedSessionData,
messages?: MessageRecord[],
): Promise<void> {
this.chat = await this.startChat(history, resumedSessionData);
this.chat = await this.startChat(history, resumedSessionData, messages);
this.updateTelemetryTokenCount();
}
@@ -376,8 +380,9 @@ export class GeminiClient {
}
async startChat(
extraHistory?: Content[],
extraHistory?: ReadonlyArray<Content | HistoryTurn>,
resumedSessionData?: ResumedSessionData,
messages?: MessageRecord[],
): Promise<GeminiChat> {
this.forceFullIdeContext = true;
this.hasFailedCompressionAttempt = false;
@@ -398,7 +403,7 @@ export class GeminiClient {
this.config,
systemInstruction,
tools,
history,
[...history],
resumedSessionData,
async (modelId: string) => {
this.lastUsedModelId = modelId;
@@ -407,8 +412,9 @@ export class GeminiClient {
toolRegistry.getFunctionDeclarations(modelId);
return [{ functionDeclarations: toolDeclarations }];
},
messages,
);
await chat.initialize(resumedSessionData, 'main');
await chat.initialize(resumedSessionData, 'main', messages);
this.contextManager = await initializeContextManager(
this.config,
chat,
@@ -419,7 +425,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 +647,15 @@ export class GeminiClient {
if (this.config.getContextManagementConfig().enabled) {
if (this.contextManager) {
const pendingRequest = createUserContent(request);
const rawPendingRequest = createUserContent(request);
const pendingRequest = {
id:
this.getChatRecordingService()?.recordSyntheticMessage(
'user',
rawPendingRequest.parts || [],
) || randomUUID(),
content: rawPendingRequest,
};
const {
history: newHistory,
didApplyManagement,
@@ -199,6 +199,13 @@ export async function createContentGenerator(
sessionId?: string,
): Promise<ContentGenerator> {
const generator = await (async () => {
if (gcConfig.fakeResponsesNonStrict) {
const fakeGenerator = await FakeContentGenerator.fromFile(
gcConfig.fakeResponsesNonStrict,
{ nonStrict: true },
);
return new LoggingContentGenerator(fakeGenerator, gcConfig);
}
if (gcConfig.fakeResponses) {
const fakeGenerator = await FakeContentGenerator.fromFile(
gcConfig.fakeResponses,
+38 -3
View File
@@ -36,6 +36,18 @@ export type FakeResponse =
response: EmbedContentResponse;
};
/**
* Options for the FakeContentGenerator.
*/
export interface FakeContentGeneratorOptions {
/**
* If true, the generator will find the first available response that matches
* the requested method, rather than strictly following the input order.
* Useful for non-deterministic background tasks.
*/
nonStrict?: boolean;
}
// A ContentGenerator that responds with canned responses.
//
// Typically these would come from a file, provided by the `--fake-responses`
@@ -46,22 +58,45 @@ export class FakeContentGenerator implements ContentGenerator {
userTierName?: string;
paidTier?: GeminiUserTier;
constructor(private readonly responses: FakeResponse[]) {}
private readonly responses: FakeResponse[];
static async fromFile(filePath: string): Promise<FakeContentGenerator> {
constructor(
responses: FakeResponse[],
private readonly options: FakeContentGeneratorOptions = {},
) {
this.responses = structuredClone(responses);
}
static async fromFile(
filePath: string,
options: FakeContentGeneratorOptions = {},
): Promise<FakeContentGenerator> {
const fileContent = await promises.readFile(filePath, 'utf-8');
const responses = fileContent
.split('\n')
.filter((line) => line.trim() !== '')
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
.map((line) => JSON.parse(line) as FakeResponse);
return new FakeContentGenerator(responses);
return new FakeContentGenerator(responses, options);
}
private getNextResponse<
M extends FakeResponse['method'],
R = Extract<FakeResponse, { method: M }>['response'],
>(method: M, request: unknown): R {
if (this.options.nonStrict) {
const index = this.responses.findIndex((r) => r.method === method);
if (index === -1) {
throw new Error(
`No more mock responses for ${method}, got request:\n` +
safeJsonStringify(request),
);
}
const response = this.responses.splice(index, 1)[0];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return response.response as R;
}
const response = this.responses[this.callCounter++];
if (!response) {
throw new Error(
+128 -54
View File
@@ -19,6 +19,7 @@ import {
SYNTHETIC_THOUGHT_SIGNATURE,
type StreamEvent,
stripToolCallIdPrefixes,
type HistoryTurn,
} from './geminiChat.js';
import {
type CompletedToolCall,
@@ -40,6 +41,7 @@ import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils
import type { HookSystem } from '../hooks/hookSystem.js';
import { LlmRole } from '../telemetry/types.js';
import { BINARY_INJECTION_KEY } from '../utils/generateContentResponseUtilities.js';
import type { ResumedSessionData } from '../services/chatRecordingTypes.js';
// Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>();
@@ -234,9 +236,9 @@ describe('GeminiChat', () => {
describe('constructor', () => {
it('should initialize lastPromptTokenCount based on history size', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
{ id: '2', content: { role: 'model', parts: [{ text: 'Hi there' }] } },
];
const chatWithHistory = new GeminiChat(mockConfig, '', [], history);
// 'Hello': 5 chars * 0.25 = 1.25
@@ -249,12 +251,66 @@ describe('GeminiChat', () => {
const chatEmpty = new GeminiChat(mockConfig);
expect(chatEmpty.getLastPromptTokenCount()).toBe(0);
});
it('should prioritize in-memory history over resumedSessionData', () => {
// This test simulates a "hot restart" after a context management operation
// like compression, where the in-memory history is shorter and more up-to-date
// than the session data that might be on disk.
// 1. A stale, longer history from a persisted session record
const resumedSessionData = {
conversation: {
messages: [
{
id: 'a',
type: 'user',
content: [{ text: 'turn 1' }],
create_time: new Date(),
},
{
id: 'b',
type: 'gemini',
content: [{ text: 'turn 2' }],
create_time: new Date(),
},
{
id: 'c',
type: 'user',
content: [{ text: 'turn 3' }],
create_time: new Date(),
},
],
},
} as unknown as ResumedSessionData;
// 2. A fresh, compressed in-memory history
const compressedHistory: HistoryTurn[] = [
{
id: 'summary-1',
content: { role: 'user', parts: [{ text: 'summary of turns 1-3' }] },
},
];
// 3. Instantiate the chat, providing both.
const chat = new GeminiChat(
mockConfig,
'',
[],
compressedHistory, // This should be prioritized
resumedSessionData, // This should be ignored
);
// 4. Assert that the shorter, in-memory history was used.
const finalHistory = chat.getHistoryTurns();
expect(finalHistory).toHaveLength(1);
expect(finalHistory[0].id).toBe('summary-1');
});
});
describe('setHistory', () => {
it('should recalculate lastPromptTokenCount when history is updated', () => {
const initialHistory: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
const initialHistory: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
];
const chatWithHistory = new GeminiChat(
mockConfig,
@@ -264,14 +320,17 @@ describe('GeminiChat', () => {
);
const initialCount = chatWithHistory.getLastPromptTokenCount();
const newHistory: Content[] = [
const newHistory: HistoryTurn[] = [
{
role: 'user',
parts: [
{
text: 'This is a much longer history item that should result in more tokens than just hello.',
},
],
id: '2',
content: {
role: 'user',
parts: [
{
text: 'This is a much longer history item that should result in more tokens than just hello.',
},
],
},
},
];
chatWithHistory.setHistory(newHistory);
@@ -331,9 +390,9 @@ describe('GeminiChat', () => {
).resolves.not.toThrow();
// 3. Verify history was recorded correctly
const history = chat.getHistory();
const history = chat.getHistoryTurns();
expect(history.length).toBe(2); // user turn + model turn
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn?.parts?.length).toBe(1); // The empty part is discarded
expect(modelTurn?.parts![0].functionCall).toBeDefined();
});
@@ -433,9 +492,9 @@ describe('GeminiChat', () => {
).resolves.not.toThrow();
// 3. Verify history was recorded correctly with only the valid part.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
expect(history.length).toBe(2); // user turn + model turn
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn?.parts?.length).toBe(1);
expect(modelTurn?.parts![0].text).toBe('Initial valid content...');
});
@@ -478,9 +537,9 @@ describe('GeminiChat', () => {
}
// 3. Assert: Check that the final history was correctly consolidated.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
expect(history.length).toBe(2);
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn?.parts?.length).toBe(1);
expect(modelTurn?.parts![0].text).toBe('Hello World!');
});
@@ -538,12 +597,12 @@ describe('GeminiChat', () => {
}
// 3. Assert: Check that the final history was correctly consolidated.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
// The history should contain the user's turn and ONE consolidated model turn.
expect(history.length).toBe(2);
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn.role).toBe('model');
// The model turn should have 3 distinct parts: the merged text, the function call, and the final text.
@@ -599,10 +658,10 @@ describe('GeminiChat', () => {
}
// 3. Assert: Check that the final history contains both function calls.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
expect(history.length).toBe(2);
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn.role).toBe('model');
expect(modelTurn.parts?.length).toBe(2);
expect(modelTurn.parts![0].functionCall?.name).toBe('tool_A');
@@ -647,8 +706,8 @@ describe('GeminiChat', () => {
// Consume the stream to trigger history recording
}
const history = chat.getHistory();
const modelTurn = history[1];
const history = chat.getHistoryTurns();
const modelTurn = history[1].content;
expect(modelTurn.parts?.length).toBe(2);
expect(modelTurn.parts![0].functionCall?.name).toBe('tool_X');
expect(modelTurn.parts![0].functionCall?.args).toEqual({ id: 1 });
@@ -694,12 +753,12 @@ describe('GeminiChat', () => {
}
// 3. Assert: Check the final state of the history.
const history = chat.getHistory();
const history = chat.getHistoryTurns();
// The history should contain two turns: the user's message and the model's response.
expect(history.length).toBe(2);
const modelTurn = history[1];
const modelTurn = history[1].content;
expect(modelTurn.role).toBe('model');
// CRUCIAL ASSERTION:
@@ -713,21 +772,27 @@ describe('GeminiChat', () => {
it('should throw an error when a tool call is followed by an empty stream response', async () => {
// 1. Setup: A history where the model has just made a function call.
const initialHistory: Content[] = [
const initialHistory: HistoryTurn[] = [
{
role: 'user',
parts: [{ text: 'Find a good Italian restaurant for me.' }],
id: '1',
content: {
role: 'user',
parts: [{ text: 'Find a good Italian restaurant for me.' }],
},
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'find_restaurant',
args: { cuisine: 'Italian' },
id: '2',
content: {
role: 'model',
parts: [
{
functionCall: {
name: 'find_restaurant',
args: { cuisine: 'Italian' },
},
},
},
],
],
},
},
];
chat.setHistory(initialHistory);
@@ -1251,31 +1316,40 @@ describe('GeminiChat', () => {
describe('addHistory', () => {
it('should add a new content item to the history', () => {
const newContent: Content = {
role: 'user',
parts: [{ text: 'A new message' }],
const newTurn: HistoryTurn = {
id: '1',
content: {
role: 'user',
parts: [{ text: 'A new message' }],
},
};
chat.addHistory(newContent);
const history = chat.getHistory();
chat.addHistory(newTurn);
const history = chat.getHistoryTurns();
expect(history.length).toBe(1);
expect(history[0]).toEqual(newContent);
expect(history[0]).toEqual(newTurn);
});
it('should add multiple items correctly', () => {
const content1: Content = {
role: 'user',
parts: [{ text: 'Message 1' }],
const turn1: HistoryTurn = {
id: '1',
content: {
role: 'user',
parts: [{ text: 'Message 1' }],
},
};
const content2: Content = {
role: 'model',
parts: [{ text: 'Message 2' }],
const turn2: HistoryTurn = {
id: '2',
content: {
role: 'model',
parts: [{ text: 'Message 2' }],
},
};
chat.addHistory(content1);
chat.addHistory(content2);
const history = chat.getHistory();
chat.addHistory(turn1);
chat.addHistory(turn2);
const history = chat.getHistoryTurns();
expect(history.length).toBe(2);
expect(history[0]).toEqual(content1);
expect(history[1]).toEqual(content2);
expect(history[0]).toEqual(turn1);
expect(history[1]).toEqual(turn2);
});
});
+209 -82
View File
@@ -19,7 +19,10 @@ import {
type GenerateContentParameters,
type FunctionCall,
} from '@google/genai';
import { AgentChatHistory } from './agentChatHistory.js';
export { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
import { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
import { randomUUID } from 'node:crypto';
import { toParts } from '../code_assist/converter.js';
import {
retryWithBackoff,
@@ -39,6 +42,8 @@ import {
import {
ChatRecordingService,
type ResumedSessionData,
type ConversationRecord,
type MessageRecord,
} from '../services/chatRecordingService.js';
import {
ContentRetryEvent,
@@ -159,8 +164,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 +181,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++;
@@ -266,21 +274,52 @@ export class GeminiChat {
private readonly chatRecordingService: ChatRecordingService;
private lastPromptTokenCount: number;
private callCounter = 0;
private initialMessages?: MessageRecord[];
agentHistory: AgentChatHistory;
constructor(
readonly context: AgentLoopContext,
private systemInstruction: string = '',
private tools: Tool[] = [],
history: Content[] = [],
history: Array<Content | HistoryTurn> = [],
resumedSessionData?: ResumedSessionData,
private readonly onModelChanged?: (modelId: string) => Promise<Tool[]>,
messages?: MessageRecord[],
) {
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.initialMessages = messages;
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 || []),
);
}
@@ -291,14 +330,42 @@ export class GeminiChat {
async initialize(
resumedSessionData?: ResumedSessionData,
kind: 'main' | 'subagent' = 'main',
messages?: MessageRecord[],
) {
const messagesToUse = messages ?? this.initialMessages;
await this.chatRecordingService.initialize(resumedSessionData, kind);
if (messagesToUse) {
this.chatRecordingService.resetMessages(messagesToUse);
}
// 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;
}
getConversation(): ConversationRecord | null {
return this.chatRecordingService.getConversation();
}
getChatRecordingService(): ChatRecordingService {
return this.chatRecordingService;
}
async getSubagentTrajectories(): Promise<Record<string, ConversationRecord>> {
return this.chatRecordingService.getSubagentTrajectories();
}
/**
* Sends a message to the model and returns the response in chunks.
*
@@ -362,41 +429,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 +513,7 @@ export class GeminiChat {
isConnectionPhase = true;
const stream = await this.makeApiCallAndProcessStream(
currentConfigKey,
requestContents,
requestHistory,
prompt_id,
signal,
role,
@@ -542,45 +635,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,
@@ -679,7 +771,7 @@ export class GeminiChat {
}
throw new AgentExecutionBlockedError(
beforeModelResult.reason || 'Model call blocked by hook',
beforeModelResult.reason || 'Agent execution blocked by hook',
syntheticResponse,
);
}
@@ -829,14 +921,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 +948,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 +996,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 +1056,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 +1286,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,20 +1339,16 @@ export class GeminiChat {
}
}
this.agentHistory.push({ role: 'model', parts: consolidatedParts });
this.agentHistory.push({
id,
content: { role: 'model', parts: consolidatedParts },
});
}
getLastPromptTokenCount(): number {
return this.lastPromptTokenCount;
}
/**
* Gets the chat recording service instance.
*/
getChatRecordingService(): ChatRecordingService {
return this.chatRecordingService;
}
/**
* Records completed tool calls with full metadata.
* This is called by external components when tool calls complete, before sending responses to Gemini.
+19 -10
View File
@@ -437,7 +437,11 @@ describe('Logger', () => {
},
])('should save a checkpoint', async ({ tag, encodedTag }) => {
await logger.saveCheckpoint(
{ history: conversation, authType: AuthType.LOGIN_WITH_GOOGLE },
{
history: conversation,
messages: [],
authType: AuthType.LOGIN_WITH_GOOGLE,
},
tag,
);
const taggedFilePath = path.join(
@@ -447,6 +451,7 @@ describe('Logger', () => {
const fileContent = await fs.readFile(taggedFilePath, 'utf-8');
expect(JSON.parse(fileContent)).toEqual({
history: conversation,
messages: [],
authType: AuthType.LOGIN_WITH_GOOGLE,
});
});
@@ -462,7 +467,10 @@ describe('Logger', () => {
.mockImplementation(() => {});
await expect(
uninitializedLogger.saveCheckpoint({ history: conversation }, 'tag'),
uninitializedLogger.saveCheckpoint(
{ history: conversation, messages: [] },
'tag',
),
).resolves.not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.',
@@ -507,6 +515,7 @@ describe('Logger', () => {
...conversation,
{ role: 'user', parts: [{ text: 'hello' }] },
],
messages: [],
authType: AuthType.USE_GEMINI,
};
const taggedFilePath = path.join(
@@ -534,18 +543,18 @@ describe('Logger', () => {
await fs.writeFile(taggedFilePath, JSON.stringify(conversation, null, 2));
const loaded = await logger.loadCheckpoint(tag);
expect(loaded).toEqual({ history: conversation });
expect(loaded).toEqual({ history: conversation, messages: [] });
});
it('should return an empty history if a tagged checkpoint file does not exist', async () => {
it('should return an empty message list if a tagged checkpoint file does not exist', async () => {
const loaded = await logger.loadCheckpoint('nonexistent-tag');
expect(loaded).toEqual({ history: [] });
expect(loaded).toEqual({ messages: [] });
});
it('should return an empty history if the checkpoint file does not exist', async () => {
it('should return an empty message list if the checkpoint file does not exist', async () => {
await fs.unlink(TEST_CHECKPOINT_FILE_PATH); // Ensure it's gone
const loaded = await logger.loadCheckpoint('missing');
expect(loaded).toEqual({ history: [] });
expect(loaded).toEqual({ messages: [] });
});
it('should return an empty history if the file contains invalid JSON', async () => {
@@ -560,14 +569,14 @@ describe('Logger', () => {
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const loadedCheckpoint = await logger.loadCheckpoint(tag);
expect(loadedCheckpoint).toEqual({ history: [] });
expect(loadedCheckpoint).toEqual({ messages: [] });
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to read or parse checkpoint file'),
expect.any(Error),
);
});
it('should return an empty history if logger is not initialized', async () => {
it('should return an empty message list if logger is not initialized', async () => {
const uninitializedLogger = new Logger(
testSessionId,
new Storage(process.cwd()),
@@ -577,7 +586,7 @@ describe('Logger', () => {
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const loadedCheckpoint = await uninitializedLogger.loadCheckpoint('tag');
expect(loadedCheckpoint).toEqual({ history: [] });
expect(loadedCheckpoint).toEqual({ messages: [] });
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
);
+44 -12
View File
@@ -11,6 +11,12 @@ import type { AuthType } from './contentGenerator.js';
import type { Storage } from '../config/storage.js';
import { debugLogger } from '../utils/debugLogger.js';
import { coreEvents } from '../utils/events.js';
import {
type ConversationRecord,
type MessageRecord,
} from '../services/chatRecordingService.js';
import { reconstructHistory } from '../utils/history-reconstruction.js';
const LOG_FILE_NAME = 'logs.json';
@@ -27,8 +33,22 @@ export interface LogEntry {
}
export interface Checkpoint {
history: readonly Content[];
/**
* The rich message records which are the source of truth for the session.
*/
messages: MessageRecord[];
/**
* The version of the checkpoint format.
* Version 2.0 uses messages as the source of truth and reconstructs history.
*/
version?: '2.0';
/**
* The standard history array used for model requests.
* Only included in legacy checkpoints (pre-2.0).
*/
history?: readonly Content[];
authType?: AuthType;
trajectories?: Record<string, ConversationRecord>;
}
// This regex matches any character that is NOT a letter (a-z, A-Z),
@@ -347,7 +367,7 @@ export class Logger {
debugLogger.error(
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
);
return { history: [] };
return { messages: [] };
}
const path = await this._getCheckpointPath(tag);
@@ -359,34 +379,46 @@ export class Logger {
// Handle legacy format (just an array of Content)
if (Array.isArray(parsedContent)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return { history: parsedContent as Content[] };
return { history: parsedContent as Content[], messages: [] };
}
if (
typeof parsedContent === 'object' &&
parsedContent !== null &&
'history' in parsedContent
) {
if (typeof parsedContent === 'object' && parsedContent !== null) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return parsedContent as Checkpoint;
const checkpoint = parsedContent as Checkpoint;
// Version 2.0: Reconstruct history from messages
if (checkpoint.version === '2.0' && checkpoint.messages) {
return {
...checkpoint,
history: reconstructHistory(checkpoint.messages),
};
}
// Legacy Object format (pre-2.0, had history but maybe not messages)
if (checkpoint.history) {
return {
...checkpoint,
messages: checkpoint.messages ?? [],
};
}
}
debugLogger.warn(
`Checkpoint file at ${path} has an unknown format. Returning empty checkpoint.`,
);
return { history: [] };
return { messages: [] };
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
// This is okay, it just means the checkpoint doesn't exist in either format.
return { history: [] };
return { messages: [] };
}
debugLogger.error(
`Failed to read or parse checkpoint file ${path}:`,
error,
);
return { history: [] };
return { messages: [] };
}
}
+3 -3
View File
@@ -365,7 +365,7 @@ describe('HookRunner', () => {
);
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.stringMatching(/bash|pwsh|powershell/),
expect.arrayContaining([
expect.stringMatching(/['"]?\/test\/project['"]?\/hooks\/test\.sh/),
]),
@@ -408,7 +408,7 @@ describe('HookRunner', () => {
);
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.stringMatching(/bash|pwsh|powershell/),
expect.arrayContaining([
expect.stringMatching(
/ls ['"]\/test\/project\/plans with spaces['"]/,
@@ -447,7 +447,7 @@ describe('HookRunner', () => {
// If secure, spawn will be called with the shell executable and escaped command
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.stringMatching(/bash|pwsh|powershell/),
expect.arrayContaining([
expect.stringMatching(/ls (['"]).*echo.*pwned.*\1/),
]),
+2
View File
@@ -36,6 +36,7 @@ export * from './commands/types.js';
export * from './core/baseLlmClient.js';
export * from './core/client.js';
export * from './core/contentGenerator.js';
export * from './core/fakeContentGenerator.js';
export * from './core/loggingContentGenerator.js';
export * from './core/geminiChat.js';
export * from './core/logger.js';
@@ -128,6 +129,7 @@ export * from './utils/channel.js';
export * from './utils/constants.js';
export * from './utils/sessionUtils.js';
export * from './utils/cache.js';
export * from './utils/history-reconstruction.js';
export * from './utils/markdownUtils.js';
// Export services
+140
View File
@@ -24,6 +24,7 @@ import {
import { Storage } from '../config/storage.js';
import * as tomlLoader from './toml-loader.js';
import { coreEvents } from '../utils/events.js';
import { MCPServerConfig } from '../config/config.js';
vi.unmock('../config/storage.js');
@@ -279,6 +280,145 @@ describe('createPolicyEngineConfig', () => {
expect(untrustedRule).toBeUndefined();
});
it('should NOT automatically allow configured MCP servers in non-interactive mode by default', async () => {
const config = await createPolicyEngineConfig(
{
mcpServers: {
'server-1': new MCPServerConfig('node', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
false, // non-interactive
);
const rule = config.rules?.find(
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeUndefined();
});
it('should automatically allow configured MCP servers in non-interactive mode if opted-in', async () => {
const config = await createPolicyEngineConfig(
{
mcp: { autoAllowInHeadless: true },
mcpServers: {
'server-1': new MCPServerConfig('node', []),
'server-2': new MCPServerConfig('python', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
false, // non-interactive
);
const rule1 = config.rules?.find(
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
);
const rule2 = config.rules?.find(
(r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
);
expect(rule1).toBeDefined();
expect(rule1?.source).toBe('Settings (Headless MCP Auto-Allow)');
expect(rule2).toBeDefined();
expect(rule2?.source).toBe('Settings (Headless MCP Auto-Allow)');
});
it('should NOT automatically allow configured MCP servers in interactive mode even if opted-in', async () => {
const config = await createPolicyEngineConfig(
{
mcp: { autoAllowInHeadless: true },
mcpServers: {
'server-1': new MCPServerConfig('node', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
true, // interactive
);
const rule = config.rules?.find(
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeUndefined();
});
it('should NOT duplicate allow rules if an MCP server is already explicitly allowed, wildcard allowed, or trusted', async () => {
const config = await createPolicyEngineConfig(
{
mcp: {
autoAllowInHeadless: true,
allowed: ['server-1', '*'],
},
mcpServers: {
'server-1': new MCPServerConfig('node', []),
'server-2': new MCPServerConfig('node', []),
'server-3': { trust: true },
'server-4': new MCPServerConfig('node', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
false, // non-interactive
);
// server-1: already in mcp.allowed
const rules1 = config.rules?.filter(
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
);
expect(rules1).toHaveLength(1);
expect(rules1?.[0].source).toBe('Settings (MCP Allowed)');
// server-2: covered by '*' in mcp.allowed
// Note: the logic adds a rule for '*' which will match server-2 at runtime,
// but the loop in headless auto-allow should skip adding a specific rule for server-2.
const rules2 = config.rules?.filter(
(r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
);
expect(rules2).toHaveLength(0);
// server-3: already trusted
const rules3 = config.rules?.filter(
(r) => r.mcpName === 'server-3' && r.decision === PolicyDecision.ALLOW,
);
expect(rules3).toHaveLength(1);
expect(rules3?.[0].source).toBe('Settings (MCP Trusted)');
// server-4: NOT explicitly allowed or trusted, but SHOULD NOT be added because '*' exists in mcp.allowed
const rules4 = config.rules?.filter(
(r) => r.mcpName === 'server-4' && r.decision === PolicyDecision.ALLOW,
);
expect(rules4).toHaveLength(0);
// Verify the wildcard rule exists
const wildcardRule = config.rules?.find(
(r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
);
expect(wildcardRule).toBeDefined();
expect(wildcardRule?.toolName).toBe('mcp_*');
});
it('should use correct tool name pattern for wildcard server in headless auto-allow', async () => {
const config = await createPolicyEngineConfig(
{
mcp: { autoAllowInHeadless: true },
mcpServers: {
'*': new MCPServerConfig('node', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
false, // non-interactive
);
const rule = config.rules?.find(
(r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeDefined();
expect(rule?.toolName).toBe('mcp_*');
});
it('should handle multiple MCP server configurations together', async () => {
const config = await createPolicyEngineConfig(
{
+32
View File
@@ -600,6 +600,38 @@ export async function createPolicyEngineConfig(
}
}
// In non-interactive mode, automatically allow all configured MCP servers if opted-in.
// This ensures that tools provided by these servers are available without
// requiring explicit entries in settings.mcp.allowed.
if (
!interactive &&
settings.mcp?.autoAllowInHeadless &&
settings.mcpServers
) {
for (const serverName of Object.keys(settings.mcpServers)) {
// Avoid duplicates if already explicitly allowed, allowed via wildcard, or trusted.
if (
settings.mcp?.allowed?.includes(serverName) ||
settings.mcp?.allowed?.includes('*') ||
settings.mcpServers[serverName].trust
) {
continue;
}
rules.push({
toolName:
serverName === '*'
? `${MCP_TOOL_PREFIX}*`
: `${MCP_TOOL_PREFIX}${serverName}_*`,
mcpName: serverName,
decision: PolicyDecision.ALLOW,
priority: ALLOWED_MCP_SERVER_PRIORITY,
source: 'Settings (Headless MCP Auto-Allow)',
modes: nonPlanModes,
});
}
}
return {
rules,
checkers,
@@ -0,0 +1,181 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { stableStringify } from './stable-stringify.js';
describe('stableStringify', () => {
it('should stringify basic primitives', () => {
expect(stableStringify(null)).toBe('null');
expect(stableStringify(true)).toBe('true');
expect(stableStringify(false)).toBe('false');
expect(stableStringify(123)).toBe('123');
expect(stableStringify('hello')).toBe('"hello"');
});
it('should sort object keys alphabetically', () => {
const obj1 = { b: 2, a: 1, c: 3 };
const obj2 = { c: 3, b: 2, a: 1 };
// Note: Top-level properties are wrapped in \0
const expected = '{\0"a":1\0,\0"b":2\0,\0"c":3\0}';
expect(stableStringify(obj1)).toBe(expected);
expect(stableStringify(obj2)).toBe(expected);
});
it('should handle nested objects (only top-level gets \0)', () => {
const obj = { b: { d: 4, c: 3 }, a: 1 };
const expected = '{\0"a":1\0,\0"b":{"c":3,"d":4}\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle arrays', () => {
const arr = [3, 1, 2];
// Top-level arrays don't get \0 because they don't have "keys" in the same way objects do in this implementation
expect(stableStringify(arr)).toBe('[3,1,2]');
});
it('should handle nested arrays and objects', () => {
const obj = {
b: [{ y: 2, x: 1 }, 3],
a: 1,
};
const expected = '{\0"a":1\0,\0"b":[{"x":1,"y":2},3]\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle circular references by replacing them with "[Circular]"', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = { a: 1 };
obj.self = obj;
const expected = '{\0"a":1\0,\0"self":"[Circular]"\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle deep circular references', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = { a: { b: {} } };
obj.a.b.parent = obj.a;
obj.root = obj;
// ancestors: {obj}
// "a": stringify({b: ...}, {obj}, false)
// ancestors: {obj, obj.a}
// "b": stringify({parent: ...}, {obj, obj.a}, false)
// ancestors: {obj, obj.a, obj.a.b}
// "parent": ancestors.has(obj.a) -> "[Circular]"
const expected =
'{\0"a":{"b":{"parent":"[Circular]"}}\0,\0"root":"[Circular]"\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should correctly handle multiple references to the same non-circular object', () => {
const shared = { x: 1 };
const obj = { a: shared, b: shared };
// This is NOT circular, so it should be stringified twice
const expected = '{\0"a":{"x":1}\0,\0"b":{"x":1}\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should respect toJSON methods', () => {
const obj = {
a: 1,
toJSON: () => ({ b: 2 }),
};
// stableStringify calls toJSON, then stringifies the result.
// If it's top-level, it should still have \0 for the resulting object's keys.
const expected = '{\0"b":2\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle toJSON that returns a primitive', () => {
const obj = {
toJSON: () => 'json-val',
};
expect(stableStringify(obj)).toBe('"json-val"');
});
it('should handle toJSON that throws by treating it as a regular object', () => {
const obj = {
a: 1,
toJSON: () => {
throw new Error('fail');
},
};
// It should skip toJSON and proceed to stringify the object
// Wait, if it treats it as a regular object, it will try to stringify 'toJSON' property?
// But 'toJSON' is a function, so it should be omitted in objects.
const expected = '{\0"a":1\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should omit undefined and functions in objects', () => {
const obj = {
a: 1,
b: undefined,
c: () => {},
d: 2,
};
const expected = '{\0"a":1\0,\0"d":2\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should convert undefined and functions to null in arrays', () => {
const arr = [1, undefined, () => {}, 2];
expect(stableStringify(arr)).toBe('[1,null,null,2]');
});
it('should handle Symbols in arrays (should ideally be null like undefined)', () => {
const arr = [1, Symbol('foo'), 2];
// If it behaves like JSON.stringify, it should be [1,null,2]
// Let's see what it actually does.
expect(stableStringify(arr)).toBe('[1,null,2]');
});
it('should handle top-level undefined and functions', () => {
expect(stableStringify(undefined)).toBe('null');
expect(stableStringify(() => {})).toBe('null');
});
it('should handle empty objects and arrays', () => {
expect(stableStringify({})).toBe('{}');
expect(stableStringify([])).toBe('[]');
});
it('should handle special characters in keys (they should be escaped by JSON.stringify)', () => {
const obj = { 'key\0with\0null': 1 };
// JSON.stringify handles escaping \0 to \u0000
// So it should be {\0"key\u0000with\u0000null":1\0}
const expected = '{\0"key\\u0000with\\u0000null":1\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle repeated non-circular objects at different levels', () => {
const shared = { x: 1 };
const obj = {
a: shared,
b: {
c: shared,
},
};
const expected = '{\0"a":{"x":1}\0,\0"b":{"c":{"x":1}}\0}';
expect(stableStringify(obj)).toBe(expected);
});
it('should handle Symbols (return "null" consistently with undefined)', () => {
// JSON.stringify(Symbol('foo')) is undefined, but stableStringify returns 'null' for consistency and type safety
expect(stableStringify(Symbol('foo'))).toBe('null');
});
it('should omit Symbols in objects', () => {
const obj = { a: 1, b: Symbol('foo') };
expect(stableStringify(obj)).toBe('{\0"a":1\0}');
});
it('should handle BigInt (JSON.stringify throws, so stableStringify will throw)', () => {
expect(() => stableStringify(BigInt(123))).toThrow();
});
});
+14 -6
View File
@@ -63,8 +63,8 @@ export function stableStringify(obj: unknown): string {
isTopLevel = false,
): string => {
// Handle primitives and null
if (currentObj === undefined) {
return 'null'; // undefined in arrays becomes null in JSON
if (currentObj === undefined || typeof currentObj === 'symbol') {
return 'null'; // undefined and symbols in arrays become null in JSON
}
if (currentObj === null) {
return 'null';
@@ -104,8 +104,12 @@ export function stableStringify(obj: unknown): string {
if (Array.isArray(currentObj)) {
const items = currentObj.map((item) => {
// undefined and functions in arrays become null
if (item === undefined || typeof item === 'function') {
// undefined, functions and symbols in arrays become null
if (
item === undefined ||
typeof item === 'function' ||
typeof item === 'symbol'
) {
return 'null';
}
return stringify(item, ancestors, false);
@@ -120,8 +124,12 @@ export function stableStringify(obj: unknown): string {
for (const key of sortedKeys) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const value = (currentObj as Record<string, unknown>)[key];
// Skip undefined and function values in objects (per JSON spec)
if (value !== undefined && typeof value !== 'function') {
// Skip undefined, function and symbol values in objects (per JSON spec)
if (
value !== undefined &&
typeof value !== 'function' &&
typeof value !== 'symbol'
) {
let pairStr =
JSON.stringify(key) + ':' + stringify(value, ancestors, false);
+1
View File
@@ -333,6 +333,7 @@ export interface PolicySettings {
mcp?: {
excluded?: string[];
allowed?: string[];
autoAllowInHeadless?: boolean;
};
tools?: {
core?: string[];
@@ -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,177 @@ 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');
});
});
describe('getSubagentTrajectories', () => {
it('should recursively collect subagent trajectories', async () => {
await chatRecordingService.initialize();
// Setup a main conversation with a subagent call
const subagentId = 'sub-1';
chatRecordingService.recordToolCalls('gemini-pro', [
{
id: 'call-1',
name: 'invoke_agent',
args: { agent_name: 'test-agent', prompt: 'test' },
status: CoreToolCallStatus.Success,
timestamp: new Date().toISOString(),
agentId: subagentId,
},
]);
// Mock the subagent session file
const tempDir = mockConfig.storage.getProjectTempDir();
const chatsDir = path.join(tempDir, 'chats');
const subagentDir = path.join(chatsDir, 'test-session-id');
const subagentFile = path.join(subagentDir, `${subagentId}.jsonl`);
await fs.promises.mkdir(subagentDir, { recursive: true });
// Subagent conversation has another subagent call
const subSubagentId = 'sub-2';
const subagentConversation: ConversationRecord = {
sessionId: subagentId,
projectHash: 'mocked-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
kind: 'subagent',
messages: [
{
id: 'msg-1',
type: 'gemini',
timestamp: new Date().toISOString(),
content: [],
toolCalls: [
{
id: 'call-2',
name: 'invoke_agent',
args: { agent_name: 'inner-agent', prompt: 'inner' },
status: CoreToolCallStatus.Success,
timestamp: new Date().toISOString(),
agentId: subSubagentId,
},
],
},
],
};
await fs.promises.writeFile(
subagentFile,
JSON.stringify(subagentConversation) + '\n',
);
// Mock the sub-subagent session file
const subSubagentDir = path.join(chatsDir, subagentId);
const subSubagentFile = path.join(
subSubagentDir,
`${subSubagentId}.jsonl`,
);
await fs.promises.mkdir(subSubagentDir, { recursive: true });
const subSubagentConversation: ConversationRecord = {
sessionId: subSubagentId,
projectHash: 'mocked-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
kind: 'subagent',
messages: [
{
id: 'msg-2',
type: 'gemini',
timestamp: new Date().toISOString(),
content: [{ text: 'done' }],
},
],
};
await fs.promises.writeFile(
subSubagentFile,
JSON.stringify(subSubagentConversation) + '\n',
);
const trajectories = await chatRecordingService.getSubagentTrajectories();
expect(trajectories).toHaveProperty(subagentId);
expect(trajectories).toHaveProperty(subSubagentId);
expect(trajectories[subagentId].sessionId).toBe(subagentId);
expect(trajectories[subSubagentId].sessionId).toBe(subSubagentId);
});
it('should return empty object if no subagents are called', async () => {
await chatRecordingService.initialize();
chatRecordingService.recordMessage({
type: 'user',
content: 'hello',
model: 'gemini-pro',
});
const trajectories = await chatRecordingService.getSubagentTrajectories();
expect(trajectories).toEqual({});
});
});
});

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