mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 17:51:04 -07:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8a1a1e527 | |||
| ccee31d133 | |||
| 91c592f0c0 | |||
| 395fe8ffbc | |||
| 413416d587 | |||
| 1a024f30a3 | |||
| 5650fa90d7 | |||
| 85566a73f6 | |||
| 7478859502 | |||
| f09d45d133 | |||
| 792654c88b | |||
| 6973b963ae | |||
| dc47aaa2d9 | |||
| 7ff60809ef | |||
| 055e0f6452 | |||
| 9d01958cdb | |||
| 84423e6ea1 | |||
| 5611ff40e7 | |||
| 77e65c0db5 | |||
| b36788eb2a | |||
| d32c9b77df |
@@ -0,0 +1,330 @@
|
||||
---
|
||||
name: agent-tui
|
||||
description: >
|
||||
Main Agents: Do NOT use this skill directly. If you need to test the TUI, invoke the `tui_tester` subagent.
|
||||
Drive terminal UI (TUI) applications programmatically for testing, automation, and inspection.
|
||||
Use when: automating CLI/TUI interactions, regression testing terminal apps, or verifying interactive behavior.
|
||||
Also use when: user asks "what is agent-tui", "what does agent-tui do", "demo agent-tui", "show me agent-tui", "how does agent-tui work", or wants to see it in action.
|
||||
---
|
||||
|
||||
## 🚨 CRITICAL: macOS Daemon Workaround & Gemini CLI Usage 🚨
|
||||
|
||||
When using `agent-tui` in this macOS environment, the default background daemonization process crashes, causing `Connection refused (os error 61)` errors.
|
||||
|
||||
**You MUST start the daemon manually shielded from TTY hangups before running any `agent-tui` commands.** Using `nohup` is insufficient; you must use `tmux` to provide a fully isolated pseudo-terminal.
|
||||
|
||||
To support parallel runs, **only restart the daemon if it is not currently running:**
|
||||
|
||||
```bash
|
||||
# Check if daemon is alive, start it in tmux if it is not
|
||||
if ! agent-tui sessions >/dev/null 2>&1; then
|
||||
tmux kill-session -t agent-tui 2>/dev/null || true
|
||||
agent-tui daemon stop 2>/dev/null || true
|
||||
rm -f /tmp/agent-tui*
|
||||
tmux new-session -d -s agent-tui 'agent-tui daemon start --foreground > /tmp/agent-tui-daemon.log 2>&1'
|
||||
sleep 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Session ID vs PID (Crucial for Reconnection)
|
||||
|
||||
When `agent-tui run` returns JSON, it includes both a `session_id` and a `pid`. The `pid` is purely informational (the OS process ID of the child command). You **do not** use the `pid` to reconnect or issue commands. You must always use the `session_id` (e.g., `--session <id>`).
|
||||
|
||||
If the daemon crashes (`os error 61`), the pseudo-terminal is destroyed. Even if the child `pid` survives as an orphan, you cannot reconnect to it. You must restart the daemon using the workaround above and start a completely new session.
|
||||
|
||||
### Testing the Gemini CLI
|
||||
|
||||
When testing the Gemini CLI with `agent-tui`, there are several strict requirements to ensure deterministic and accurate behavior:
|
||||
|
||||
1. **Build Before Running**: `agent-tui` runs the built JS files, not TypeScript. You **MUST** run `npm run build` or `npm run build:all` after making code changes and before launching the CLI with `agent-tui`.
|
||||
2. **Bypass Trust Modals**: Always pass `GEMINI_CLI_TRUST_WORKSPACE=true` in the environment. If you don't, any new project-level agents or extensions will trigger a full-screen "Acknowledge and Enable" modal. This modal steals focus, swallows automation keystrokes, and causes `agent-tui wait` commands to time out.
|
||||
3. **Isolated Environments**: If you need to test without real user credentials or existing agents interfering, isolate the global settings using `GEMINI_CLI_HOME=<some-test-dir>`.
|
||||
4. **Testing State Deltas (e.g., Reloads)**: If you are testing features that report deltas (e.g., `/agents reload` outputting "1 new local subagent"), you **MUST**:
|
||||
- Start the CLI *first* so it establishes its baseline registry.
|
||||
- Use a separate shell command (outside of `agent-tui`) to write the new agent `.md`/`.toml` file.
|
||||
- Use `agent-tui type` and `press` to trigger the `/agents reload` command inside the running session.
|
||||
- (If you add the files before starting the CLI, they become part of the baseline and won't trigger the delta logic).
|
||||
|
||||
```bash
|
||||
# Example: Standard isolated run (sandboxed config + bypass trust modals)
|
||||
env GEMINI_CLI_TRUST_WORKSPACE=true GEMINI_CLI_HOME=test-gemini-home agent-tui run -d "$(pwd)" node packages/cli/dist/index.js
|
||||
```
|
||||
|
||||
# Terminal Automation Mastery
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Supported OS**: macOS or Linux (Windows not supported yet).
|
||||
- **Verify install**:
|
||||
|
||||
```bash
|
||||
agent-tui --version
|
||||
```
|
||||
|
||||
If not installed, use one of:
|
||||
|
||||
```bash
|
||||
# Recommended: one-line install (macOS/Linux)
|
||||
curl -fsSL https://raw.githubusercontent.com/pproenca/agent-tui/master/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# Package manager
|
||||
npm i -g agent-tui
|
||||
pnpm add -g agent-tui
|
||||
bun add -g agent-tui
|
||||
```
|
||||
|
||||
```bash
|
||||
# Build from source
|
||||
cargo install --git https://github.com/pproenca/agent-tui.git --path cli/crates/agent-tui
|
||||
```
|
||||
|
||||
If you used the install script, ensure `~/.local/bin` is on your PATH.
|
||||
|
||||
## Philosophy: Why Terminal Automation Is Different
|
||||
|
||||
Terminal UIs are **stateless from the observer's perspective**. Unlike web browsers with a persistent DOM, terminal automation works with a constantly-refreshed character grid. This fundamental difference shapes everything:
|
||||
|
||||
| Web Automation | Terminal Automation |
|
||||
|----------------|---------------------|
|
||||
| DOM persists across interactions | Screen buffer is redrawn constantly |
|
||||
| Selectors are stable | Text positions may shift |
|
||||
| Query once, act many times | Must re-verify before EVERY action |
|
||||
| Network events signal completion | Must detect visual stability |
|
||||
|
||||
**The Core Insight**: agent-tui gives you vision without memory. Each screenshot is a fresh observation. Previous state means nothing after the UI changes. This isn't a limitation—it's the nature of terminal interaction.
|
||||
|
||||
## Mental Model: The Feedback Loop
|
||||
|
||||
Think of terminal automation as a **closed-loop control system**:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
OBSERVE ──► DECIDE ──► ACT ──► WAIT ──► VERIFY ───┘
|
||||
│ │
|
||||
│ │
|
||||
└─────── NEVER skip ◄────────────────────┘
|
||||
```
|
||||
|
||||
**Each phase is mandatory.** Skipping verification is the #1 cause of flaky automation.
|
||||
|
||||
### The "Fresh Eyes" Principle
|
||||
|
||||
Every time you need to interact with the UI:
|
||||
|
||||
1. **Take a fresh screenshot** — your previous one is now stale
|
||||
2. **Locate your target visually** — text positions may have changed
|
||||
3. **Verify the state** — the UI may have changed unexpectedly
|
||||
4. **Act only when stable** — animations and loading states cause failures
|
||||
|
||||
This feels slower, but it's the only reliable approach. Optimistic reuse of stale state causes intermittent failures that are painful to debug.
|
||||
|
||||
## Critical Rules (Non-Negotiable)
|
||||
|
||||
> **RULE 1: Atomic Execution (No Pipelining)**
|
||||
> You are FORBIDDEN from chaining commands with `&&` (e.g., `type "x" && press Enter && wait`). Modals or UI updates can intercept your keystrokes. You MUST execute one atomic action, wait, screenshot, and verify before taking the next action in a new turn.
|
||||
|
||||
> **RULE 2: Re-snapshot after EVERY action**
|
||||
> The UI state is invalidated by any change. Always take a fresh screenshot before acting again.
|
||||
|
||||
> **RULE 3: Never act on unstable UI**
|
||||
> If the UI is animating, loading, or transitioning, `wait --stable` first. Acting during transitions because race conditions.
|
||||
|
||||
> **RULE 4: Verify before claiming success**
|
||||
> Use `wait "expected text" --assert` to confirm outcomes. Don't assume an action worked—prove it.
|
||||
|
||||
> **RULE 5: Error Recovery**
|
||||
> If a `wait` command times out, DO NOT blindly restart or kill the session. Execute `screenshot` to visually diagnose what unexpected UI element (modal, error dialog, lost focus) intercepted the flow.
|
||||
|
||||
> **RULE 6: Clean up sessions**
|
||||
> Always end with `agent-tui kill`. Orphaned sessions consume resources and can interfere with future runs.
|
||||
|
||||
## Decision Framework
|
||||
|
||||
### Which Screenshot Mode?
|
||||
|
||||
Use `screenshot --format json` when parsing automation output, or plain `screenshot` for human readable text.
|
||||
|
||||
### How to Wait?
|
||||
|
||||
```
|
||||
What are you waiting for?
|
||||
│
|
||||
├─► Specific text to appear
|
||||
│ └─► `wait "text" --assert` (fails if not found)
|
||||
│
|
||||
├─► Specific text to disappear
|
||||
│ └─► `wait "text" --gone --assert`
|
||||
│
|
||||
├─► UI to stop changing (animations, loading)
|
||||
│ └─► `wait --stable`
|
||||
│
|
||||
└─► Multiple conditions
|
||||
└─► Chain waits sequentially
|
||||
```
|
||||
|
||||
### How to Act?
|
||||
|
||||
```
|
||||
What do you need to do?
|
||||
│
|
||||
├─► Type text into the terminal
|
||||
│ └─► `type "text"`
|
||||
│
|
||||
├─► Send keyboard shortcuts/navigation
|
||||
│ └─► `press Ctrl+C` or `press ArrowDown Enter`
|
||||
```
|
||||
|
||||
## Core Workflow
|
||||
|
||||
The canonical automation loop:
|
||||
|
||||
```bash
|
||||
# 1. START: Launch the TUI app
|
||||
agent-tui run <command> [-- args...]
|
||||
|
||||
# 2. OBSERVE: Get current UI state
|
||||
agent-tui screenshot --format json
|
||||
|
||||
# 3. DECIDE: Based on text, determine next action
|
||||
# (This happens in your head/code)
|
||||
|
||||
# 4. ACT: Execute the action
|
||||
agent-tui type "text"
|
||||
agent-tui press Enter
|
||||
|
||||
# 5. WAIT: Synchronize with UI changes
|
||||
agent-tui wait "Expected" --assert # or wait --stable
|
||||
|
||||
# 6. VERIFY: Confirm the outcome (often combined with step 5)
|
||||
# If verification fails, handle the error
|
||||
|
||||
# 7. REPEAT: Go back to step 2 until done
|
||||
|
||||
# 8. CLEANUP: Always clean up
|
||||
agent-tui kill
|
||||
```
|
||||
|
||||
## Anti-Patterns (What NOT to Do)
|
||||
|
||||
### ❌ Acting During Animation/Loading
|
||||
|
||||
```bash
|
||||
# WRONG: Acting immediately on dynamic UI
|
||||
agent-tui run my-app
|
||||
agent-tui screenshot --format json # UI might still be loading!
|
||||
agent-tui type "value" # ❌ Might miss the input field
|
||||
|
||||
# RIGHT: Wait for stability first
|
||||
agent-tui run my-app
|
||||
agent-tui wait --stable # Let UI settle
|
||||
agent-tui screenshot --format json # Now it's reliable
|
||||
agent-tui type "value"
|
||||
```
|
||||
|
||||
### ❌ Assuming Success Without Verification
|
||||
|
||||
```bash
|
||||
# WRONG: Assuming the type worked
|
||||
agent-tui type "value"
|
||||
agent-tui press Enter
|
||||
# ...proceed as if success... # ❌ What if it failed silently?
|
||||
|
||||
# RIGHT: Verify the outcome
|
||||
agent-tui type "value"
|
||||
agent-tui press Enter
|
||||
agent-tui wait "Success" --assert # ✓ Proves the action worked
|
||||
```
|
||||
|
||||
### ❌ Skipping Cleanup
|
||||
|
||||
```bash
|
||||
# WRONG: Forgetting to kill the session
|
||||
agent-tui run my-app
|
||||
# ...do stuff...
|
||||
# script ends # ❌ Session left running!
|
||||
|
||||
# RIGHT: Always clean up
|
||||
agent-tui run my-app
|
||||
# ...do stuff...
|
||||
agent-tui kill # ✓ Clean exit
|
||||
```
|
||||
|
||||
## Before You Start: Clarify Requirements
|
||||
|
||||
Before automating any TUI, gather this information:
|
||||
|
||||
1. **Command**: What exactly to run? (`my-app --flag` or `npm start`?)
|
||||
2. **Success criteria**: What text/state indicates success?
|
||||
3. **Input sequence**: What keystrokes/data to enter, in what order?
|
||||
4. **Safety**: Is it safe to submit forms, delete data, etc.?
|
||||
5. **Auth**: Does it need login? Test credentials?
|
||||
6. **Live preview**: Does the user want to watch? (`agent-tui live start --open`)
|
||||
|
||||
If any of these are unclear, ask before running.
|
||||
|
||||
## Demo Mode: Showing What agent-tui Can Do
|
||||
|
||||
When a user asks what agent-tui is, wants a demo, or asks "show me how it works":
|
||||
|
||||
1. **Don't explain—demonstrate.** Actions speak louder than words.
|
||||
2. **Use the live preview** so they can watch in real-time.
|
||||
3. **Run `top`**—it's universal and shows dynamic real-time updates.
|
||||
|
||||
**Quick demo trigger phrases:**
|
||||
- "What is agent-tui?" / "What does agent-tui do?"
|
||||
- "Demo agent-tui" / "Show me agent-tui"
|
||||
- "How does agent-tui work?" / "See it in action"
|
||||
|
||||
## Failure Recovery
|
||||
|
||||
| Symptom | Diagnosis | Solution |
|
||||
|---------|-----------|----------|
|
||||
| "Text not found" | Stale view or text moved | Re-snapshot, locate text again |
|
||||
| Wait times out | UI didn't reach expected state | Check screenshot, verify expectations |
|
||||
| "Daemon not running" | Daemon crashed or not started | `agent-tui daemon start` |
|
||||
| Unexpected layout | Wrong terminal size | `agent-tui resize --cols 120 --rows 40` |
|
||||
| Session unresponsive | App crashed or hung | `agent-tui kill`, then re-run |
|
||||
| Repeated failures | Something fundamentally wrong | Stop after 3-5 attempts, ask user |
|
||||
|
||||
## Self-Discovery: Use --help
|
||||
|
||||
You don't need to memorize every flag. The CLI is self-documenting:
|
||||
|
||||
```bash
|
||||
agent-tui --help # List all commands
|
||||
agent-tui run --help # Options for 'run'
|
||||
agent-tui screenshot --help # Options for 'screenshot'
|
||||
agent-tui wait --help # Options for 'wait'
|
||||
```
|
||||
|
||||
**When in doubt, ask the CLI.** This skill teaches *when* and *why* to use commands. For exact flags and syntax, `--help` is authoritative.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Start app
|
||||
agent-tui run <cmd> [-- args] # Launch TUI under control
|
||||
|
||||
# Observe
|
||||
agent-tui screenshot # Plain text view
|
||||
agent-tui screenshot --format json # Machine-readable output
|
||||
|
||||
# Act
|
||||
agent-tui press Enter # Press key(s)
|
||||
agent-tui press Ctrl+C # Keyboard shortcuts
|
||||
agent-tui type "text" # Type text
|
||||
|
||||
# Wait/Verify
|
||||
agent-tui wait "text" --assert # Wait for text, fail if not found
|
||||
agent-tui wait "text" --gone --assert # Wait for text to disappear
|
||||
agent-tui wait --stable # Wait for UI to stop changing
|
||||
|
||||
# Manage
|
||||
agent-tui sessions # List active sessions
|
||||
agent-tui live start --open # Start live preview
|
||||
agent-tui kill # End current session
|
||||
```
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: tui-tester
|
||||
description: Expert guidance for testing Gemini CLI behavior and visual output using terminal automation.
|
||||
---
|
||||
|
||||
# TUI Tester Skill
|
||||
|
||||
This skill provides the operational manual for verifying Gemini CLI behavioral changes and visual output using terminal automation.
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
- **Verify Behavior**: Confirm that code changes result in the expected terminal interactions.
|
||||
- **Visual Validation**: Ensure the TUI renders correctly across different terminal sizes and states.
|
||||
- **Regression Testing**: Use automation to prevent breaking existing interactive workflows.
|
||||
|
||||
## Critical Protocol
|
||||
|
||||
When performing TUI testing, you must adhere to these strict rules:
|
||||
|
||||
### 1. Initialization
|
||||
**YOUR ABSOLUTE FIRST ACTION MUST BE:**
|
||||
Activate the `agent-tui` skill. This provides the underlying tools needed for terminal automation.
|
||||
|
||||
### 2. Environment Setup (macOS / Parallel Safe)
|
||||
Ensure the global daemon is running and the live preview is open:
|
||||
```bash
|
||||
if ! agent-tui sessions >/dev/null 2>&1; then
|
||||
tmux kill-session -t agent-tui 2>/dev/null || true
|
||||
agent-tui daemon stop 2>/dev/null || true
|
||||
rm -f /tmp/agent-tui*
|
||||
tmux new-session -d -s agent-tui 'agent-tui daemon start --foreground > /tmp/agent-tui-daemon.log 2>&1'
|
||||
sleep 1
|
||||
fi
|
||||
agent-tui live start --open
|
||||
```
|
||||
|
||||
### 3. Session Management
|
||||
- **Session IDs**: Always use the `session_id` returned by `agent-tui run` for subsequent interactions.
|
||||
- **Atomic Execution**: Execute exactly one command per turn. Do not pipeline actions.
|
||||
- **The Loop**: Action -> Wait -> Screenshot -> Verify -> Next Action.
|
||||
|
||||
### 4. Gemini CLI Specifics
|
||||
- **Build First**: Always run `npm run build` or `npm run build:all` before testing local changes.
|
||||
- **Bypass Trust**: Set `GEMINI_CLI_TRUST_WORKSPACE=true` to avoid focus-stealing modals.
|
||||
- **Isolate Config**: Use `GEMINI_CLI_HOME` to prevent interference with your personal settings.
|
||||
|
||||
## Workflow Example
|
||||
|
||||
```bash
|
||||
# Start the CLI
|
||||
env GEMINI_CLI_TRUST_WORKSPACE=true agent-tui run node packages/cli/dist/index.js
|
||||
|
||||
# Wait for the prompt
|
||||
agent-tui wait "│" --assert
|
||||
|
||||
# Send a command
|
||||
agent-tui type "/help"
|
||||
agent-tui press Enter
|
||||
|
||||
# Verify output
|
||||
agent-tui wait "Available Commands" --assert
|
||||
```
|
||||
|
||||
## Error Recovery
|
||||
If a wait times out, take a fresh screenshot to diagnose the state. If you see `os error 61`, restart the daemon using the tmux method.
|
||||
@@ -550,6 +550,24 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"gemini-3.1-pro-preview": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.1-pro-preview"
|
||||
}
|
||||
},
|
||||
"gemini-3.1-pro-preview-customtools": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.1-pro-preview-customtools"
|
||||
}
|
||||
},
|
||||
"gemini-3.1-flash-lite-preview": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.1-flash-lite-preview"
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"extends": "chat-base-2.5",
|
||||
"modelConfig": {
|
||||
@@ -1019,12 +1037,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"auto": {
|
||||
"default": "gemini-3-pro-preview",
|
||||
"contexts": [
|
||||
{
|
||||
"condition": {
|
||||
"releaseChannel": "stable"
|
||||
},
|
||||
"target": "gemini-2.5-pro"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"hasAccessToPreview": false
|
||||
@@ -1168,13 +1180,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"target": "gemini-2.5-pro"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"releaseChannel": "stable",
|
||||
"requestedModels": ["auto"]
|
||||
},
|
||||
"target": "gemini-2.5-pro"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"requestedModels": ["gemini-2.5-pro", "auto-gemini-2.5"]
|
||||
@@ -1836,6 +1841,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.adk.agentSessionSubagentEnabled`** (boolean):
|
||||
- **Description:** Route subagent invocations through the AgentSession
|
||||
protocol instead of legacy executors.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents.
|
||||
- **Default:** `true`
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { FinishReason, GenerateContentResponse } from '@google/genai';
|
||||
import type { FakeResponse, HistoryTurn } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Context Management Fidelity E2E', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should reproduce the exact context working buffer on resume', async () => {
|
||||
// Mock responses to trigger GC (summarization)
|
||||
const snapshotResponse: FakeResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: JSON.stringify({
|
||||
new_facts: ['GC Triggered.'],
|
||||
new_constraints: [],
|
||||
new_tasks: [],
|
||||
resolved_task_ids: [],
|
||||
obsolete_fact_indices: [],
|
||||
obsolete_constraint_indices: [],
|
||||
chronological_summary: 'Snapshot created.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: FinishReason.STOP,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse,
|
||||
};
|
||||
|
||||
const countTokensResponse: FakeResponse = {
|
||||
method: 'countTokens',
|
||||
response: { totalTokens: 50000 },
|
||||
};
|
||||
|
||||
const streamResponse = (text: string): FakeResponse => ({
|
||||
method: 'generateContentStream',
|
||||
response: [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text }], role: 'model' },
|
||||
finishReason: FinishReason.STOP,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[],
|
||||
});
|
||||
|
||||
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
|
||||
const filePath = path.join(rig.testDir!, fileName);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
mocks.map((m) => JSON.stringify(m)).join('\n'),
|
||||
);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
await rig.setup('context-fidelity', {
|
||||
settings: {
|
||||
experimental: {
|
||||
stressTestProfile: true, // Lowers thresholds to trigger GC easily
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const massivePayload = 'X'.repeat(50000);
|
||||
const traceDir = path.join(rig.testDir!, 'traces');
|
||||
fs.mkdirSync(traceDir, { recursive: true });
|
||||
const traceLog = path.join(traceDir, 'trace.log');
|
||||
|
||||
const commonEnv = {
|
||||
GEMINI_API_KEY: 'mock-key',
|
||||
GEMINI_CONTEXT_TRACE_DIR: traceDir,
|
||||
GEMINI_CONTEXT_TRACE_ENABLED: 'true',
|
||||
GEMINI_DEBUG_LOG_FILE: path.join(rig.testDir!, 'debug.log'),
|
||||
};
|
||||
|
||||
const runMocks: FakeResponse[] = [
|
||||
streamResponse('Ack 1'),
|
||||
streamResponse('Ack 2'),
|
||||
streamResponse('Ack 3'),
|
||||
streamResponse('Ack 4'),
|
||||
streamResponse('Ack 5'),
|
||||
];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
runMocks.push(snapshotResponse);
|
||||
runMocks.push(countTokensResponse);
|
||||
}
|
||||
|
||||
// Turn 1: Initial massive payload to put pressure
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp1.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 1: ' + massivePayload,
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
// Turn 2: Another turn, resuming Turn 1
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp2.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 2: ' + massivePayload,
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
// Turn 3: Third turn to force GC, resuming Turn 2
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp3.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 3: ' + massivePayload,
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
// Extract the rendered context asset from the log
|
||||
const getRenderedContext = (logContent: string): HistoryTurn[] | null => {
|
||||
const lines = logContent.split('\n');
|
||||
const renderLines = lines.filter(
|
||||
(l) =>
|
||||
l.includes('[Render] Render Sanitized Context for LLM') ||
|
||||
l.includes('[Render] Render Context for LLM'),
|
||||
);
|
||||
if (renderLines.length === 0) return null;
|
||||
|
||||
const lastRender = renderLines[renderLines.length - 1];
|
||||
const detailsMatch = lastRender.match(/\| Details: (.*)$/);
|
||||
if (!detailsMatch) return null;
|
||||
|
||||
const details = JSON.parse(detailsMatch[1]);
|
||||
const assetInfo =
|
||||
details.renderedContextSanitized || details.renderedContext;
|
||||
if (assetInfo && assetInfo.$asset) {
|
||||
const assetPath = path.join(traceDir, 'assets', assetInfo.$asset);
|
||||
return JSON.parse(fs.readFileSync(assetPath, 'utf-8'));
|
||||
}
|
||||
return assetInfo;
|
||||
};
|
||||
|
||||
const log1 = fs.readFileSync(traceLog, 'utf-8');
|
||||
const contextBeforeExit = getRenderedContext(log1);
|
||||
expect(contextBeforeExit).toBeDefined();
|
||||
console.log(
|
||||
'Context Before Exit (First 2 turns):',
|
||||
JSON.stringify(contextBeforeExit!.slice(0, 2), null, 2),
|
||||
);
|
||||
|
||||
// Turn 4: Resume and run a small command
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp4.json', runMocks),
|
||||
'continue',
|
||||
],
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
const log2 = fs.readFileSync(traceLog, 'utf-8');
|
||||
const contextAfterResume = getRenderedContext(log2);
|
||||
expect(contextAfterResume).toBeDefined();
|
||||
console.log(
|
||||
'Context After Resume (First 2 turns):',
|
||||
JSON.stringify(contextAfterResume!.slice(0, 2), null, 2),
|
||||
);
|
||||
|
||||
expect(contextAfterResume!.length).toBeGreaterThanOrEqual(
|
||||
contextBeforeExit!.length,
|
||||
);
|
||||
|
||||
for (let i = 0; i < contextBeforeExit!.length; i++) {
|
||||
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
|
||||
expect(contextAfterResume![i].content).toEqual(
|
||||
contextBeforeExit![i].content,
|
||||
);
|
||||
}
|
||||
|
||||
// Most importantly, synthetic IDs (like summaries) must be stable.
|
||||
const syntheticTurns = contextBeforeExit!.filter(
|
||||
(t: HistoryTurn) => t.id && t.id.length === 32,
|
||||
); // deriveStableId produces 32-char hex
|
||||
expect(syntheticTurns.length).toBeGreaterThan(0);
|
||||
|
||||
const syntheticTurnsAfter = contextAfterResume!.filter(
|
||||
(t: HistoryTurn) => t.id && t.id.length === 32,
|
||||
);
|
||||
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
|
||||
syntheticTurns.length,
|
||||
);
|
||||
|
||||
// Check if the first synthetic turn is identical
|
||||
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
|
||||
expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { FinishReason, GenerateContentResponse } from '@google/genai';
|
||||
import type { FakeResponse } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Context Management Resume E2E', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should preserve and utilize GC snapshot boundaries when resuming a session', async () => {
|
||||
const snapshotResponse: FakeResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: JSON.stringify({
|
||||
new_facts: ['GC Triggered.'],
|
||||
new_constraints: [],
|
||||
new_tasks: [],
|
||||
resolved_task_ids: [],
|
||||
obsolete_fact_indices: [],
|
||||
obsolete_constraint_indices: [],
|
||||
chronological_summary: 'Snapshot created.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: FinishReason.STOP,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse,
|
||||
};
|
||||
|
||||
const countTokensResponse: FakeResponse = {
|
||||
method: 'countTokens',
|
||||
response: { totalTokens: 50000 },
|
||||
};
|
||||
|
||||
const streamResponse = (text: string): FakeResponse => ({
|
||||
method: 'generateContentStream',
|
||||
response: [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text }], role: 'model' },
|
||||
finishReason: FinishReason.STOP,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[],
|
||||
});
|
||||
|
||||
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
|
||||
const filePath = path.join(rig.testDir!, fileName);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
mocks.map((m) => JSON.stringify(m)).join('\n'),
|
||||
);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
await rig.setup('resume-gc-snapshot', {
|
||||
settings: {
|
||||
experimental: {
|
||||
stressTestProfile: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const massivePayload = 'X'.repeat(40000);
|
||||
const logFile = path.join(rig.testDir!, 'debug.log');
|
||||
const traceDir = path.join(rig.testDir!, 'traces');
|
||||
fs.mkdirSync(traceDir, { recursive: true });
|
||||
const traceLog = path.join(traceDir, 'trace.log');
|
||||
|
||||
const commonEnv = {
|
||||
GEMINI_API_KEY: 'mock-key',
|
||||
GEMINI_DEBUG_LOG_FILE: logFile,
|
||||
GEMINI_CONTEXT_TRACE_DIR: traceDir,
|
||||
};
|
||||
|
||||
// Provide a massive pool of responses to prevent exhaustion
|
||||
const runMocks: FakeResponse[] = [streamResponse('Acknowledged block.')];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
runMocks.push(snapshotResponse);
|
||||
runMocks.push(countTokensResponse);
|
||||
}
|
||||
|
||||
// Use stdin for the massive payload to avoid ENAMETOOLONG on Windows
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp1.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 1: ' + massivePayload,
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp2.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 2: ' + massivePayload,
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
const result3 = await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp3.json', runMocks),
|
||||
'continue',
|
||||
],
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
expect(result3).toContain('Acknowledged block');
|
||||
|
||||
const traces = fs.readFileSync(traceLog, 'utf-8');
|
||||
expect(traces).toContain('Hitting Synchronous Pressure Barrier');
|
||||
expect(traces).toContain('GC Triggered.');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface AgentSettings {
|
||||
kind: CoderAgentEvent.StateAgentSettingsEvent;
|
||||
workspacePath: string;
|
||||
autoExecute?: boolean;
|
||||
isTrusted?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolCallConfirmation {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
ToolConfirmationOutcome,
|
||||
getChannelFromVersion,
|
||||
getAutoModelDescription,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
@@ -272,8 +271,6 @@ export function buildAvailableModels(
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
const releaseChannel = getChannelFromVersion(config.clientVersion);
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
@@ -284,7 +281,6 @@ export function buildAvailableModels(
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
releaseChannel,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -298,7 +294,10 @@ export function buildAvailableModels(
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_AUTO,
|
||||
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
|
||||
description: getAutoModelDescription(releaseChannel, useGemini31),
|
||||
description: getAutoModelDescription(
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface CliArgs {
|
||||
useWriteTodos: boolean | undefined;
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
fakeResponsesNonStrict?: string | undefined;
|
||||
recordResponses: string | undefined;
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
@@ -474,6 +475,12 @@ export async function parseArguments(
|
||||
description: 'Path to a file with fake model responses for testing.',
|
||||
hidden: true,
|
||||
})
|
||||
.option('fake-responses-non-strict', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Path to a file with fake model responses for testing (non-strict mode).',
|
||||
hidden: true,
|
||||
})
|
||||
.option('record-responses', {
|
||||
type: 'string',
|
||||
description: 'Path to a file to record model responses for testing.',
|
||||
@@ -1074,6 +1081,7 @@ export async function loadCliConfig(
|
||||
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
|
||||
adk: settings.experimental?.adk,
|
||||
fakeResponses: argv.fakeResponses,
|
||||
fakeResponsesNonStrict: argv.fakeResponsesNonStrict,
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
billing: settings.billing,
|
||||
|
||||
@@ -571,6 +571,18 @@ describe('SettingsSchema', () => {
|
||||
expect(agentSessionNoninteractiveEnabled.description).toBe(
|
||||
'Enable non-interactive agent sessions.',
|
||||
);
|
||||
|
||||
const agentSessionSubagentEnabled =
|
||||
adk.properties.agentSessionSubagentEnabled;
|
||||
expect(agentSessionSubagentEnabled).toBeDefined();
|
||||
expect(agentSessionSubagentEnabled.type).toBe('boolean');
|
||||
expect(agentSessionSubagentEnabled.category).toBe('Experimental');
|
||||
expect(agentSessionSubagentEnabled.default).toBe(false);
|
||||
expect(agentSessionSubagentEnabled.requiresRestart).toBe(true);
|
||||
expect(agentSessionSubagentEnabled.showInDialog).toBe(false);
|
||||
expect(agentSessionSubagentEnabled.description).toBe(
|
||||
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2194,6 +2194,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable the agent session implementation for the interactive CLI.',
|
||||
showInDialog: false,
|
||||
},
|
||||
agentSessionSubagentEnabled: {
|
||||
type: 'boolean',
|
||||
label: 'Agent Session Subagent Enabled',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
enableAgents: {
|
||||
|
||||
@@ -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}';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -92,6 +92,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
|
||||
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import type { VimMode } from '../contexts/VimModeContext.js';
|
||||
|
||||
const SCROLLBAR_GUTTER_WIDTH = 1;
|
||||
|
||||
@@ -126,6 +127,8 @@ export interface InputPromptProps {
|
||||
onEscapePromptChange?: (showPrompt: boolean) => void;
|
||||
onSuggestionsVisibilityChange?: (visible: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
vimEnabled?: boolean;
|
||||
vimMode?: VimMode;
|
||||
isEmbeddedShellFocused?: boolean;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
streamingState: StreamingState;
|
||||
@@ -214,6 +217,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onEscapePromptChange,
|
||||
onSuggestionsVisibilityChange,
|
||||
vimHandleInput,
|
||||
vimEnabled,
|
||||
vimMode,
|
||||
isEmbeddedShellFocused,
|
||||
setQueueErrorMessage,
|
||||
streamingState,
|
||||
@@ -859,7 +864,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (shortcutsHelpVisible) {
|
||||
if (key.sequence === '?' && key.insertable) {
|
||||
if (
|
||||
key.sequence === '?' &&
|
||||
key.insertable &&
|
||||
(!vimEnabled || vimMode === 'INSERT')
|
||||
) {
|
||||
setShortcutsHelpVisible(false);
|
||||
buffer.handleInput(key);
|
||||
return true;
|
||||
@@ -879,7 +888,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
key.sequence === '?' &&
|
||||
key.insertable &&
|
||||
!shortcutsHelpVisible &&
|
||||
buffer.text.length === 0
|
||||
buffer.text.length === 0 &&
|
||||
(!vimEnabled || vimMode === 'INSERT')
|
||||
) {
|
||||
setShortcutsHelpVisible(true);
|
||||
return true;
|
||||
@@ -1374,6 +1384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
resetCompletionState,
|
||||
resetEscapeState,
|
||||
vimHandleInput,
|
||||
vimEnabled,
|
||||
vimMode,
|
||||
reverseSearchActive,
|
||||
textBeforeReverseSearch,
|
||||
cursorPosition,
|
||||
|
||||
@@ -34,6 +34,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getAutoModelDescription: (
|
||||
hasAccessToPreview: boolean,
|
||||
useGemini3_1?: boolean,
|
||||
) =>
|
||||
`Auto Model Description (preview: ${hasAccessToPreview}, 3.1: ${useGemini3_1})`,
|
||||
getDisplayString: (val: string) => mockGetDisplayString(val),
|
||||
logModelSlashCommand: (config: Config, event: ModelSlashCommandEvent) =>
|
||||
mockLogModelSlashCommand(config, event),
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
AuthType,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
isProModel,
|
||||
getChannelFromVersion,
|
||||
getAutoModelDescription,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
@@ -66,7 +65,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || GEMINI_MODEL_ALIAS_AUTO;
|
||||
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel() ?? false;
|
||||
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
@@ -122,12 +121,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const releaseChannel = useMemo(
|
||||
() => getChannelFromVersion(config?.clientVersion ?? ''),
|
||||
[config?.clientVersion],
|
||||
);
|
||||
|
||||
const mainOptions = useMemo(() => {
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
@@ -142,7 +135,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
});
|
||||
|
||||
const list = allOptions
|
||||
@@ -170,7 +162,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_AUTO,
|
||||
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
|
||||
description: getAutoModelDescription(releaseChannel, useGemini31),
|
||||
description: getAutoModelDescription(
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
),
|
||||
key: GEMINI_MODEL_ALIAS_AUTO,
|
||||
},
|
||||
{
|
||||
@@ -192,7 +187,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
@@ -209,7 +203,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
});
|
||||
|
||||
return allOptions
|
||||
@@ -302,7 +295,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
config,
|
||||
]);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { type SessionMetrics } from '../contexts/SessionContext.js';
|
||||
import {
|
||||
ToolCallDecision,
|
||||
getShellConfiguration,
|
||||
isWindows,
|
||||
type WorktreeSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -22,7 +21,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getShellConfiguration: vi.fn(),
|
||||
isWindows: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -45,7 +43,6 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
|
||||
const isWindowsMock = vi.mocked(isWindows);
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
@@ -104,11 +101,6 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getShellConfigurationMock.mockReturnValue({
|
||||
executable: 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
shell: 'bash',
|
||||
});
|
||||
isWindowsMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
@@ -173,11 +165,6 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
|
||||
it('renders a standard UUID-formatted session ID in the footer (powershell) on Windows', async () => {
|
||||
isWindowsMock.mockReturnValue(true);
|
||||
getShellConfigurationMock.mockReturnValue({
|
||||
executable: 'powershell.exe',
|
||||
argsPrefix: ['-NoProfile', '-Command'],
|
||||
shell: 'powershell',
|
||||
});
|
||||
|
||||
const uuidSessionId = '1234-abcd-5678-efgh';
|
||||
const { lastFrame, unmount } = await renderWithMockedStats(
|
||||
@@ -192,11 +179,7 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
});
|
||||
|
||||
it('sanitizes a malicious session ID in the footer (powershell)', async () => {
|
||||
getShellConfigurationMock.mockReturnValue({
|
||||
executable: 'powershell.exe',
|
||||
argsPrefix: ['-NoProfile', '-Command'],
|
||||
shell: 'powershell',
|
||||
});
|
||||
isWindowsMock.mockReturnValue(true);
|
||||
|
||||
const maliciousSessionId = "'; rm -rf / #";
|
||||
const { lastFrame, unmount } = await renderWithMockedStats(
|
||||
|
||||
@@ -10,8 +10,8 @@ import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import {
|
||||
escapeShellArg,
|
||||
getShellConfiguration,
|
||||
isWindows,
|
||||
type ShellType,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
@@ -23,7 +23,7 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const config = useConfig();
|
||||
const { shell } = getShellConfiguration();
|
||||
const shell: ShellType = isWindows() ? 'powershell' : 'bash';
|
||||
|
||||
const worktreeSettings = config.getWorktreeSettings();
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,22 +12,28 @@ import {
|
||||
convertSessionToClientHistory,
|
||||
uiTelemetryService,
|
||||
loadConversationRecord,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryTurn,
|
||||
Config,
|
||||
ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
convertSessionToHistoryFormats,
|
||||
type SessionInfo,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
export { convertSessionToHistoryFormats };
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
export const useSessionBrowser = (
|
||||
config: Config,
|
||||
onLoadHistory: (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
clientHistory: Array<
|
||||
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
|
||||
>,
|
||||
resumedSessionData: ResumedSessionData,
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ResumedSessionData,
|
||||
ConversationRecord,
|
||||
MessageRecord,
|
||||
HistoryTurn,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
@@ -527,10 +528,12 @@ describe('useSessionResume', () => {
|
||||
|
||||
// Should only have the non-slash-command message
|
||||
expect(clientHistory).toHaveLength(1);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Regular message' }],
|
||||
});
|
||||
expect(clientHistory.map((h: HistoryTurn) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Regular message' }],
|
||||
},
|
||||
]);
|
||||
|
||||
// But UI history should have both
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
coreEvents,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import type {
|
||||
HistoryTurn,
|
||||
Config,
|
||||
ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
interface UseSessionResumeParams {
|
||||
config: Config;
|
||||
@@ -54,7 +57,9 @@ export function useSessionResume({
|
||||
const loadHistoryForResume = useCallback(
|
||||
async (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
clientHistory: Array<
|
||||
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
|
||||
>,
|
||||
resumedData: ResumedSessionData,
|
||||
) => {
|
||||
// Wait for the client.
|
||||
|
||||
@@ -2258,6 +2258,80 @@ describe('useVim hook', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle unmapped keys in Normal mode', () => {
|
||||
type UnmappedKeyCase = {
|
||||
char: string;
|
||||
insertable: boolean;
|
||||
};
|
||||
it.each<UnmappedKeyCase>([
|
||||
{ char: 'm', insertable: true },
|
||||
{ char: 'n', insertable: true },
|
||||
{ char: 'p', insertable: true },
|
||||
{ char: 'q', insertable: true },
|
||||
{ char: 's', insertable: true },
|
||||
{ char: 'v', insertable: true },
|
||||
{ char: 'y', insertable: true },
|
||||
{ char: 'z', insertable: true },
|
||||
{ char: 'H', insertable: true },
|
||||
{ char: 'J', insertable: true },
|
||||
{ char: 'K', insertable: true },
|
||||
{ char: 'L', insertable: true },
|
||||
{ char: 'M', insertable: true },
|
||||
{ char: 'N', insertable: true },
|
||||
{ char: 'P', insertable: true },
|
||||
{ char: 'Q', insertable: true },
|
||||
{ char: 'R', insertable: true },
|
||||
{ char: 'S', insertable: true },
|
||||
{ char: 'U', insertable: true },
|
||||
{ char: 'V', insertable: true },
|
||||
{ char: 'Y', insertable: true },
|
||||
{ char: 'Z', insertable: true },
|
||||
{ char: '/', insertable: true },
|
||||
{ char: '#', insertable: true },
|
||||
{ char: '%', insertable: true },
|
||||
{ char: '&', insertable: true },
|
||||
{ char: "'", insertable: true },
|
||||
{ char: '(', insertable: true },
|
||||
{ char: ')', insertable: true },
|
||||
{ char: '*', insertable: true },
|
||||
{ char: '+', insertable: true },
|
||||
{ char: '-', insertable: true },
|
||||
{ char: '/', insertable: true },
|
||||
{ char: ':', insertable: true },
|
||||
{ char: '<', insertable: true },
|
||||
{ char: '=', insertable: true },
|
||||
{ char: '>', insertable: true },
|
||||
{ char: '@', insertable: true },
|
||||
{ char: '[', insertable: true },
|
||||
{ char: '\\', insertable: true },
|
||||
{ char: ']', insertable: true },
|
||||
{ char: '_', insertable: true },
|
||||
{ char: '`', insertable: true },
|
||||
{ char: '{', insertable: true },
|
||||
{ char: '|', insertable: true },
|
||||
{ char: '}', insertable: true },
|
||||
])(
|
||||
'$char: should be swallowed and do nothing in Normal mode',
|
||||
async ({ char, insertable }) => {
|
||||
const { result } = await renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = result.current.handleInput(
|
||||
createKey({ sequence: char, name: char, insertable }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(mockVimContext.setVimMode).not.toHaveBeenCalledWith('INSERT');
|
||||
|
||||
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
|
||||
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', async () => {
|
||||
it('df{char}: executes delete-to-char, not a dangling operator', async () => {
|
||||
const { result } = await renderVimHook();
|
||||
|
||||
@@ -1486,6 +1486,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
// Unknown command, clear count and pending states
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
|
||||
// Ignore any Insertable key in Normal Mode
|
||||
if (normalizedKey.insertable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not handled by vim so allow other handlers to process it.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -93,13 +93,16 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
|
||||
// References are converted to text for the model
|
||||
result.push({ text: part.text });
|
||||
break;
|
||||
default:
|
||||
default: {
|
||||
const _exhaustiveCheck: never = part;
|
||||
void _exhaustiveCheck;
|
||||
debugLogger.warn(
|
||||
`Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`,
|
||||
);
|
||||
// Serialize unknown ContentPart variants instead of dropping them
|
||||
result.push({ text: JSON.stringify(part) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -9,16 +9,21 @@ import {
|
||||
supersedeStaleSnapshots,
|
||||
SNAPSHOT_SUPERSEDED_PLACEHOLDER,
|
||||
} from './snapshotSuperseder.js';
|
||||
import type { GeminiChat } from '../../core/geminiChat.js';
|
||||
import type { GeminiChat, HistoryTurn } from '../../core/geminiChat.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** Builds a minimal mock GeminiChat around a mutable history array. */
|
||||
function createMockChat(history: Content[]): GeminiChat {
|
||||
const getTurns = () => history.map((c) => ({ id: randomUUID(), content: c }));
|
||||
return {
|
||||
getHistory: vi.fn(() => [...history]),
|
||||
setHistory: vi.fn((newHistory: readonly Content[]) => {
|
||||
getHistoryTurns: vi.fn(() => getTurns()),
|
||||
setHistory: vi.fn((newHistory: ReadonlyArray<Content | HistoryTurn>) => {
|
||||
history.length = 0;
|
||||
history.push(...newHistory);
|
||||
for (const item of newHistory) {
|
||||
history.push('content' in item ? item.content : item);
|
||||
}
|
||||
}),
|
||||
} as unknown as GeminiChat;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
* model call so the model only ever sees the most recent snapshot in full.
|
||||
*/
|
||||
|
||||
import type { GeminiChat } from '../../core/geminiChat.js';
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { GeminiChat, HistoryTurn } from '../../core/geminiChat.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
const TAKE_SNAPSHOT_TOOL_NAME = 'take_snapshot';
|
||||
@@ -39,7 +39,7 @@ export const SNAPSHOT_SUPERSEDED_PLACEHOLDER =
|
||||
* Uses {@link GeminiChat.setHistory} to apply the modified history.
|
||||
*/
|
||||
export function supersedeStaleSnapshots(chat: GeminiChat): void {
|
||||
const history = chat.getHistory();
|
||||
const history = chat.getHistoryTurns();
|
||||
|
||||
// Locate all (contentIndex, partIndex) tuples for take_snapshot responses.
|
||||
const snapshotLocations: Array<{
|
||||
@@ -48,7 +48,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const parts = history[i].parts;
|
||||
const parts = history[i].content.parts;
|
||||
if (!parts) continue;
|
||||
for (let j = 0; j < parts.length; j++) {
|
||||
const part = parts[j];
|
||||
@@ -71,7 +71,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
|
||||
const staleLocations = snapshotLocations.slice(0, -1);
|
||||
const needsUpdate = staleLocations.some(({ contentIdx, partIdx }) => {
|
||||
const output = getResponseOutput(
|
||||
history[contentIdx].parts![partIdx].functionResponse?.response,
|
||||
history[contentIdx].content.parts![partIdx].functionResponse?.response,
|
||||
);
|
||||
return !output.includes(SNAPSHOT_SUPERSEDED_PLACEHOLDER);
|
||||
});
|
||||
@@ -81,15 +81,18 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
|
||||
}
|
||||
|
||||
// Shallow-copy the history and replace stale snapshots.
|
||||
const newHistory: Content[] = history.map((content) => ({
|
||||
...content,
|
||||
parts: content.parts ? [...content.parts] : undefined,
|
||||
const newHistory: HistoryTurn[] = history.map((turn) => ({
|
||||
id: turn.id,
|
||||
content: {
|
||||
...turn.content,
|
||||
parts: turn.content.parts ? [...turn.content.parts] : undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
let replacedCount = 0;
|
||||
|
||||
for (const { contentIdx, partIdx } of staleLocations) {
|
||||
const originalPart = newHistory[contentIdx].parts![partIdx];
|
||||
const originalPart = newHistory[contentIdx].content.parts![partIdx];
|
||||
if (!originalPart.functionResponse) continue;
|
||||
|
||||
// Check if already superseded
|
||||
@@ -106,7 +109,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
|
||||
},
|
||||
};
|
||||
|
||||
newHistory[contentIdx].parts![partIdx] = replacementPart;
|
||||
newHistory[contentIdx].content.parts![partIdx] = replacementPart;
|
||||
replacedCount++;
|
||||
}
|
||||
|
||||
|
||||
@@ -756,12 +756,19 @@ describe('LocalAgentExecutor', () => {
|
||||
|
||||
expect(startHistory).toBeDefined();
|
||||
expect(startHistory).toHaveLength(2);
|
||||
const history = startHistory!;
|
||||
|
||||
// Perform checks on defined objects to satisfy TS
|
||||
const firstPart = startHistory?.[0]?.parts?.[0];
|
||||
const firstPart =
|
||||
'content' in history[0]
|
||||
? history[0].content.parts?.[0]
|
||||
: history[0].parts?.[0];
|
||||
expect(firstPart?.text).toBe('Goal: TestGoal');
|
||||
|
||||
const secondPart = startHistory?.[1]?.parts?.[0];
|
||||
const secondPart =
|
||||
'content' in history[1]
|
||||
? history[1].content.parts?.[0]
|
||||
: history[1].parts?.[0];
|
||||
expect(secondPart?.text).toBe('OK, starting on TestGoal.');
|
||||
});
|
||||
|
||||
@@ -3601,7 +3608,14 @@ describe('LocalAgentExecutor', () => {
|
||||
|
||||
expect(mockCompress).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetHistory).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
|
||||
// History turns are now wrapped with IDs
|
||||
expect(mockSetHistory).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({ role: 'user' }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass hasFailedCompressionAttempt=true to compression after a failure', async () => {
|
||||
@@ -3706,7 +3720,14 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(mockCompress.mock.calls[2][5]).toBe(false);
|
||||
|
||||
expect(mockSetHistory).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
|
||||
// History turns are now wrapped with IDs
|
||||
expect(mockSetHistory).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({ role: 'user' }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -919,12 +919,20 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.hasFailedCompressionAttempt = true;
|
||||
} else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||
if (newHistory) {
|
||||
chat.setHistory(newHistory);
|
||||
const turns = newHistory.map((c) => ({
|
||||
id: randomUUID(),
|
||||
content: c,
|
||||
}));
|
||||
chat.setHistory(turns);
|
||||
this.hasFailedCompressionAttempt = false;
|
||||
}
|
||||
} else if (info.compressionStatus === CompressionStatus.CONTENT_TRUNCATED) {
|
||||
if (newHistory) {
|
||||
chat.setHistory(newHistory);
|
||||
const turns = newHistory.map((c) => ({
|
||||
id: randomUUID(),
|
||||
content: c,
|
||||
}));
|
||||
chat.setHistory(turns);
|
||||
// Do NOT reset hasFailedCompressionAttempt.
|
||||
// We only truncated content because summarization previously failed.
|
||||
// We want to keep avoiding expensive summarization calls.
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should truncate long input values', () => {
|
||||
it('should not truncate long input values', () => {
|
||||
const longTask = 'A'.repeat(100);
|
||||
const params = { task: longTask };
|
||||
const invocation = new LocalSubagentInvocation(
|
||||
@@ -131,13 +131,12 @@ describe('LocalSubagentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
const description = invocation.getDescription();
|
||||
// Default INPUT_PREVIEW_MAX_LENGTH is 50
|
||||
expect(description).toBe(
|
||||
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(50)} }`,
|
||||
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should truncate the overall description if it exceeds the limit', () => {
|
||||
it('should not truncate the overall description', () => {
|
||||
// Create a definition and inputs that result in a very long description
|
||||
const longNameDef: LocalAgentDefinition = {
|
||||
...testDefinition,
|
||||
@@ -154,8 +153,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
const description = invocation.getDescription();
|
||||
// Default DESCRIPTION_MAX_LENGTH is 200
|
||||
expect(description.length).toBe(200);
|
||||
expect(description.length).toBeGreaterThan(300);
|
||||
expect(
|
||||
description.startsWith(
|
||||
"Running subagent 'VeryLongAgentNameThatTakesUpSpace'",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -35,9 +35,6 @@ import {
|
||||
} from '../utils/agent-sanitization-utils.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
const INPUT_PREVIEW_MAX_LENGTH = 50;
|
||||
const DESCRIPTION_MAX_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Represents a validated, executable instance of a subagent tool.
|
||||
*
|
||||
@@ -80,14 +77,10 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
*/
|
||||
getDescription(): string {
|
||||
const inputSummary = Object.entries(this.params)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`,
|
||||
)
|
||||
.map(([key, value]) => `${key}: ${String(value)}`)
|
||||
.join(', ');
|
||||
|
||||
const description = `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
|
||||
return description.slice(0, DESCRIPTION_MAX_LENGTH);
|
||||
return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
|
||||
}
|
||||
|
||||
private publishActivity(activity: SubagentActivityItem): void {
|
||||
@@ -168,8 +161,11 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
const args = JSON.stringify(
|
||||
sanitizeToolArgs(activity.data['args']),
|
||||
);
|
||||
const callId = activity.data['callId']
|
||||
? String(activity.data['callId'])
|
||||
: randomUUID();
|
||||
recentActivity.push({
|
||||
id: randomUUID(),
|
||||
id: callId,
|
||||
type: 'tool_call',
|
||||
content: name,
|
||||
displayName,
|
||||
@@ -186,23 +182,28 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
break;
|
||||
}
|
||||
case 'TOOL_CALL_END': {
|
||||
const name = String(activity.data['name']);
|
||||
const data = activity.data['data'];
|
||||
const isError = isToolActivityError(data);
|
||||
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].content === name &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
) {
|
||||
recentActivity[i].status = isError
|
||||
? SubagentState.ERROR
|
||||
: SubagentState.COMPLETED;
|
||||
updated = true;
|
||||
const callId = activity.data['id']
|
||||
? String(activity.data['id'])
|
||||
: undefined;
|
||||
|
||||
this.publishActivity(recentActivity[i]);
|
||||
break;
|
||||
if (callId) {
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].id === callId &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
) {
|
||||
recentActivity[i].status = isError
|
||||
? SubagentState.ERROR
|
||||
: SubagentState.COMPLETED;
|
||||
updated = true;
|
||||
|
||||
this.publishActivity(recentActivity[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -218,31 +219,23 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
errorType === SubagentActivityErrorType.REJECTED ||
|
||||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
|
||||
|
||||
const toolName = activity.data['name']
|
||||
? String(activity.data['name'])
|
||||
const callId = activity.data['callId']
|
||||
? String(activity.data['callId'])
|
||||
: undefined;
|
||||
|
||||
if (toolName && (isCancellation || isRejection)) {
|
||||
if (callId) {
|
||||
const targetStatus =
|
||||
isCancellation || isRejection
|
||||
? SubagentState.CANCELLED
|
||||
: SubagentState.ERROR;
|
||||
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].content === toolName &&
|
||||
recentActivity[i].id === callId &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
) {
|
||||
recentActivity[i].status = SubagentState.CANCELLED;
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (toolName) {
|
||||
// Mark non-rejection/non-cancellation errors as 'error'
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].content === toolName &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
) {
|
||||
recentActivity[i].status = SubagentState.ERROR;
|
||||
recentActivity[i].status = targetStatus;
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
AgentTerminateMode,
|
||||
SubagentActivityErrorType,
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
type SubagentProgress,
|
||||
type LocalAgentDefinition,
|
||||
type AgentInputs,
|
||||
type SubagentActivityEvent,
|
||||
} from './types.js';
|
||||
import { LocalSessionInvocation } from './local-session-invocation.js';
|
||||
import { LocalSubagentSession } from './local-subagent-protocol.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { MessageBusType } from '../confirmation-bus/types.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
|
||||
vi.mock('./local-subagent-protocol.js');
|
||||
|
||||
const MockLocalSubagentSession = vi.mocked(LocalSubagentSession);
|
||||
|
||||
let capturedActivityCallback:
|
||||
| ((activity: SubagentActivityEvent) => void)
|
||||
| undefined;
|
||||
|
||||
const testDefinition: LocalAgentDefinition = {
|
||||
kind: 'local',
|
||||
name: 'MockAgent',
|
||||
description: 'A mock agent for testing.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { task: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
modelConfig: { model: 'test-model', generateContentConfig: {} },
|
||||
runConfig: { maxTimeMinutes: 1 },
|
||||
promptConfig: { systemPrompt: 'test' },
|
||||
};
|
||||
|
||||
function setupMockSession(config: {
|
||||
output?: { result: string; terminate_reason: AgentTerminateMode };
|
||||
error?: Error;
|
||||
}) {
|
||||
const mockSession = {
|
||||
send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }),
|
||||
getResult: config.error
|
||||
? vi.fn().mockRejectedValue(config.error)
|
||||
: vi.fn().mockResolvedValue(
|
||||
config.output ?? {
|
||||
result: 'done',
|
||||
terminate_reason: AgentTerminateMode.GOAL,
|
||||
},
|
||||
),
|
||||
abort: vi.fn(),
|
||||
subscribe: vi.fn().mockReturnValue(vi.fn()),
|
||||
};
|
||||
MockLocalSubagentSession.mockImplementation(
|
||||
(
|
||||
_def: LocalAgentDefinition,
|
||||
_ctx: AgentLoopContext,
|
||||
_bus: MessageBus,
|
||||
rawCallback?: (activity: SubagentActivityEvent) => void,
|
||||
) => {
|
||||
capturedActivityCallback = rawCallback;
|
||||
return mockSession as unknown as LocalSubagentSession;
|
||||
},
|
||||
);
|
||||
return mockSession;
|
||||
}
|
||||
|
||||
describe('LocalSessionInvocation', () => {
|
||||
let mockContext: AgentLoopContext;
|
||||
let mockMessageBus: MessageBus;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedActivityCallback = undefined;
|
||||
mockContext = makeFakeConfig() as unknown as AgentLoopContext;
|
||||
mockMessageBus = createMockMessageBus();
|
||||
});
|
||||
|
||||
it('should pass the messageBus to the parent constructor', () => {
|
||||
setupMockSession({});
|
||||
const params = { task: 'Analyze data' };
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
expect(
|
||||
(invocation as unknown as { messageBus: MessageBus }).messageBus,
|
||||
).toBe(mockMessageBus);
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
it('should format the description with inputs', () => {
|
||||
setupMockSession({});
|
||||
const params = { task: 'Analyze data', priority: 5 };
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
const description = invocation.getDescription();
|
||||
expect(description).toBe(
|
||||
"Running subagent 'MockAgent' with inputs: { task: Analyze data, priority: 5 }",
|
||||
);
|
||||
});
|
||||
|
||||
it('should not truncate long input values', () => {
|
||||
setupMockSession({});
|
||||
const longTask = 'A'.repeat(100);
|
||||
const params = { task: longTask };
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
const description = invocation.getDescription();
|
||||
expect(description).toBe(
|
||||
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not truncate the overall description', () => {
|
||||
setupMockSession({});
|
||||
const longNameDef: LocalAgentDefinition = {
|
||||
...testDefinition,
|
||||
name: 'VeryLongAgentNameThatTakesUpSpace',
|
||||
};
|
||||
const params: AgentInputs = {};
|
||||
for (let i = 0; i < 20; i++) {
|
||||
params[`input${i}`] = `value${i}`;
|
||||
}
|
||||
const invocation = new LocalSessionInvocation(
|
||||
longNameDef,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
const description = invocation.getDescription();
|
||||
expect(description.length).toBeGreaterThan(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should create session and run successfully', async () => {
|
||||
const mockOutput = {
|
||||
result: 'Analysis complete.',
|
||||
terminate_reason: AgentTerminateMode.GOAL,
|
||||
};
|
||||
const mockSession = setupMockSession({ output: mockOutput });
|
||||
const params = { query: 'Execute task' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(MockLocalSubagentSession).toHaveBeenCalledWith(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
mockMessageBus,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockSession.send).toHaveBeenCalledWith({
|
||||
message: { content: [{ type: 'text', text: 'Execute task' }] },
|
||||
});
|
||||
expect(result.llmContent).toEqual([
|
||||
{
|
||||
text: expect.stringContaining(
|
||||
"Subagent 'MockAgent' finished.\nTermination Reason: GOAL\nResult:\nAnalysis complete.",
|
||||
),
|
||||
},
|
||||
]);
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Analysis complete.');
|
||||
expect(display.terminateReason).toBe(AgentTerminateMode.GOAL);
|
||||
});
|
||||
|
||||
it('should stream THOUGHT_CHUNK activity', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'think' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
// Wait for send to be called so the activity callback is wired
|
||||
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
|
||||
|
||||
// Emit a thought chunk via captured callback
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'THOUGHT_CHUNK',
|
||||
data: { text: 'Analyzing...' },
|
||||
});
|
||||
|
||||
await executePromise;
|
||||
|
||||
// Find an updateOutput call containing the thought
|
||||
const progressCalls = updateOutput.mock.calls.map(
|
||||
(c) => c[0] as SubagentProgress,
|
||||
);
|
||||
const hasThought = progressCalls.some(
|
||||
(p) =>
|
||||
p.recentActivity &&
|
||||
p.recentActivity.some(
|
||||
(a) => a.type === 'thought' && a.content === 'Analyzing...',
|
||||
),
|
||||
);
|
||||
expect(hasThought).toBe(true);
|
||||
});
|
||||
|
||||
it('should stream TOOL_CALL_START and TOOL_CALL_END', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'run tool' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
|
||||
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'ls', args: {}, callId: 'call-123' },
|
||||
});
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: { name: 'ls', data: {}, id: 'call-123' },
|
||||
});
|
||||
|
||||
await executePromise;
|
||||
|
||||
const progressCalls = updateOutput.mock.calls.map(
|
||||
(c) => c[0] as SubagentProgress,
|
||||
);
|
||||
|
||||
// After TOOL_CALL_START, the immediate updateOutput call should show running
|
||||
const runningCalls = progressCalls.filter((p) => p.state === 'running');
|
||||
// The first running call with a tool_call should show 'running'
|
||||
const firstToolCall = runningCalls.find((p) =>
|
||||
p.recentActivity?.some(
|
||||
(a) => a.type === 'tool_call' && a.content === 'ls',
|
||||
),
|
||||
);
|
||||
expect(firstToolCall).toBeDefined();
|
||||
|
||||
// After TOOL_CALL_END, the tool should be completed
|
||||
const hasCompleted = progressCalls.some((p) =>
|
||||
p.recentActivity?.some(
|
||||
(a) =>
|
||||
a.type === 'tool_call' &&
|
||||
a.content === 'ls' &&
|
||||
a.status === 'completed',
|
||||
),
|
||||
);
|
||||
expect(hasCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle ERROR activity', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'fail' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
|
||||
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'ERROR',
|
||||
data: { error: 'Something broke' },
|
||||
});
|
||||
|
||||
await executePromise;
|
||||
|
||||
const progressCalls = updateOutput.mock.calls.map(
|
||||
(c) => c[0] as SubagentProgress,
|
||||
);
|
||||
const hasError = progressCalls.some((p) =>
|
||||
p.recentActivity?.some(
|
||||
(a) =>
|
||||
a.type === 'thought' &&
|
||||
a.content === 'Error: Something broke' &&
|
||||
a.status === 'error',
|
||||
),
|
||||
);
|
||||
expect(hasError).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle cancelled errors', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'cancel' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
|
||||
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'ERROR',
|
||||
data: {
|
||||
error: SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
errorType: SubagentActivityErrorType.CANCELLED,
|
||||
},
|
||||
});
|
||||
|
||||
await executePromise;
|
||||
|
||||
const progressCalls = updateOutput.mock.calls.map(
|
||||
(c) => c[0] as SubagentProgress,
|
||||
);
|
||||
const hasCancelled = progressCalls.some((p) =>
|
||||
p.recentActivity?.some(
|
||||
(a) => a.type === 'thought' && a.status === 'cancelled',
|
||||
),
|
||||
);
|
||||
expect(hasCancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle rejected errors', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'reject' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
|
||||
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'dangerous_tool', args: {}, callId: 'call-rej' },
|
||||
});
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'ERROR',
|
||||
data: {
|
||||
name: 'dangerous_tool',
|
||||
error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Rethink approach.`,
|
||||
errorType: SubagentActivityErrorType.REJECTED,
|
||||
callId: 'call-rej',
|
||||
},
|
||||
});
|
||||
|
||||
await executePromise;
|
||||
|
||||
const progressCalls = updateOutput.mock.calls.map(
|
||||
(c) => c[0] as SubagentProgress,
|
||||
);
|
||||
// Tool call should be marked cancelled
|
||||
const hasToolCancelled = progressCalls.some((p) =>
|
||||
p.recentActivity?.some(
|
||||
(a) =>
|
||||
a.type === 'tool_call' &&
|
||||
a.content === 'dangerous_tool' &&
|
||||
a.status === 'cancelled',
|
||||
),
|
||||
);
|
||||
expect(hasToolCancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('should trim recentActivity to MAX_RECENT_ACTIVITY', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'trim' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
|
||||
|
||||
// Emit 4+ activities to exceed MAX_RECENT_ACTIVITY (3)
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'tool1', args: {} },
|
||||
});
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'tool2', args: {} },
|
||||
});
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'tool3', args: {} },
|
||||
});
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'tool4', args: {} },
|
||||
});
|
||||
|
||||
await executePromise;
|
||||
|
||||
// After the 4th activity, the last updateOutput call before completion
|
||||
// should have only 3 items in recentActivity
|
||||
const progressCalls = updateOutput.mock.calls.map(
|
||||
(c) => c[0] as SubagentProgress,
|
||||
);
|
||||
// Find the call right after the 4th activity (before completion)
|
||||
const afterFourthActivity = progressCalls.filter(
|
||||
(p) => p.state === 'running' && p.recentActivity.length > 0,
|
||||
);
|
||||
const lastRunning = afterFourthActivity[afterFourthActivity.length - 1];
|
||||
expect(lastRunning.recentActivity.length).toBeLessThanOrEqual(3);
|
||||
// Should contain tool4 (the latest)
|
||||
expect(
|
||||
lastRunning.recentActivity.some((a) => a.content === 'tool4'),
|
||||
).toBe(true);
|
||||
// Should NOT contain tool1 (trimmed away)
|
||||
expect(
|
||||
lastRunning.recentActivity.some((a) => a.content === 'tool1'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle executor errors', async () => {
|
||||
const error = new Error('Model failed during execution.');
|
||||
setupMockSession({ error });
|
||||
const params = { query: 'fail hard' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(result.llmContent).toBe(
|
||||
`Subagent 'MockAgent' failed. Error: ${error.message}`,
|
||||
);
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe('error');
|
||||
expect(display.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
content: `Error: ${error.message}`,
|
||||
status: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle abort', async () => {
|
||||
const mockOutput = {
|
||||
result: '',
|
||||
terminate_reason: AgentTerminateMode.ABORTED,
|
||||
};
|
||||
setupMockSession({ output: mockOutput });
|
||||
const params = { query: 'abort me' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
await expect(
|
||||
invocation.execute({ abortSignal: signal, updateOutput }),
|
||||
).rejects.toThrow('Operation cancelled by user');
|
||||
|
||||
// Verify cancelled state was published
|
||||
const progressCalls = updateOutput.mock.calls.map(
|
||||
(c) => c[0] as SubagentProgress,
|
||||
);
|
||||
const hasCancelledState = progressCalls.some(
|
||||
(p) => p.state === 'cancelled',
|
||||
);
|
||||
expect(hasCancelledState).toBe(true);
|
||||
});
|
||||
|
||||
it('should wire abort signal to session.abort', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'abort wire' };
|
||||
const controller = new AbortController();
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: controller.signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
// Trigger abort
|
||||
controller.abort();
|
||||
|
||||
// The execute should complete (getResult returned GOAL by default)
|
||||
await executePromise.catch(() => {
|
||||
/* abort may throw */
|
||||
});
|
||||
|
||||
expect(mockSession.abort).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send non-query params as config update before query', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'Do something', extra_config: 'value123' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
await invocation.execute({ abortSignal: signal, updateOutput });
|
||||
|
||||
// First send: config update with non-query params
|
||||
expect(mockSession.send).toHaveBeenCalledWith({
|
||||
update: { config: { extra_config: 'value123' } },
|
||||
});
|
||||
// Second send: message with query
|
||||
expect(mockSession.send).toHaveBeenCalledWith({
|
||||
message: { content: [{ type: 'text', text: 'Do something' }] },
|
||||
});
|
||||
// Config update should come before message
|
||||
const sendCalls = mockSession.send.mock.calls;
|
||||
const configIdx = sendCalls.findIndex((c) => c[0]?.update?.config);
|
||||
const messageIdx = sendCalls.findIndex((c) => c[0]?.message);
|
||||
expect(configIdx).toBeLessThan(messageIdx);
|
||||
});
|
||||
|
||||
it('should publish SUBAGENT_ACTIVITY on messageBus', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'publish test' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
|
||||
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'THOUGHT_CHUNK',
|
||||
data: { text: 'Thinking...' },
|
||||
});
|
||||
|
||||
await executePromise;
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.SUBAGENT_ACTIVITY,
|
||||
subagentName: 'MockAgent',
|
||||
activity: expect.objectContaining({
|
||||
type: 'thought',
|
||||
content: 'Thinking...',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean up abort listener in finally', async () => {
|
||||
setupMockSession({});
|
||||
const params = { query: 'cleanup' };
|
||||
const controller = new AbortController();
|
||||
const removeEventListenerSpy = vi.spyOn(
|
||||
controller.signal,
|
||||
'removeEventListener',
|
||||
);
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
await invocation.execute({
|
||||
abortSignal: controller.signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'abort',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should unsubscribe parent observer in finally', async () => {
|
||||
const unsubscribeFn = vi.fn();
|
||||
const mockSession = setupMockSession({});
|
||||
mockSession.subscribe.mockReturnValue(unsubscribeFn);
|
||||
|
||||
const params = { query: 'unsub test' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const onAgentEvent = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
{ onAgentEvent },
|
||||
);
|
||||
|
||||
await invocation.execute({ abortSignal: signal, updateOutput });
|
||||
|
||||
expect(mockSession.subscribe).toHaveBeenCalledWith(onAgentEvent);
|
||||
expect(unsubscribeFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle TOOL_CALL_END with error data', async () => {
|
||||
const mockSession = setupMockSession({});
|
||||
const params = { query: 'tool error' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
|
||||
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'failing_tool', args: {}, callId: 'call-err' },
|
||||
});
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: { name: 'failing_tool', data: { isError: true }, id: 'call-err' },
|
||||
});
|
||||
|
||||
await executePromise;
|
||||
|
||||
const progressCalls = updateOutput.mock.calls.map(
|
||||
(c) => c[0] as SubagentProgress,
|
||||
);
|
||||
const hasToolError = progressCalls.some((p) =>
|
||||
p.recentActivity?.some(
|
||||
(a) =>
|
||||
a.type === 'tool_call' &&
|
||||
a.content === 'failing_tool' &&
|
||||
a.status === 'error',
|
||||
),
|
||||
);
|
||||
expect(hasToolError).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark running items as cancelled on abort', async () => {
|
||||
const abortError = new Error('Aborted');
|
||||
abortError.name = 'AbortError';
|
||||
const mockSession = setupMockSession({ error: abortError });
|
||||
const params = { query: 'mark cancelled' };
|
||||
const signal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new LocalSessionInvocation(
|
||||
testDefinition,
|
||||
mockContext,
|
||||
params,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
|
||||
|
||||
// Emit a running tool call before the abort
|
||||
capturedActivityCallback!({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'running_tool', args: {} },
|
||||
});
|
||||
|
||||
await expect(executePromise).rejects.toThrow('Aborted');
|
||||
|
||||
const progressCalls = updateOutput.mock.calls.map(
|
||||
(c) => c[0] as SubagentProgress,
|
||||
);
|
||||
// The final progress should show the tool as cancelled
|
||||
const lastProgress = progressCalls[progressCalls.length - 1];
|
||||
expect(lastProgress.state).toBe('cancelled');
|
||||
expect(lastProgress.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool_call',
|
||||
content: 'running_tool',
|
||||
status: 'cancelled',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { MessageBusType } from '../confirmation-bus/types.js';
|
||||
import {
|
||||
BaseToolInvocation,
|
||||
type ToolResult,
|
||||
type ExecuteOptions,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
type LocalAgentDefinition,
|
||||
type AgentInputs,
|
||||
type SubagentActivityEvent,
|
||||
type SubagentProgress,
|
||||
type SubagentActivityItem,
|
||||
AgentTerminateMode,
|
||||
SubagentActivityErrorType,
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
isToolActivityError,
|
||||
SubagentState,
|
||||
} from './types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import {
|
||||
sanitizeThoughtContent,
|
||||
sanitizeToolArgs,
|
||||
sanitizeErrorMessage,
|
||||
} from '../utils/agent-sanitization-utils.js';
|
||||
import { checkExhaustive } from '../utils/checks.js';
|
||||
import { LocalSubagentSession } from './local-subagent-protocol.js';
|
||||
import type { AgentEvent } from '../agent/types.js';
|
||||
|
||||
const MAX_RECENT_ACTIVITY = 3;
|
||||
|
||||
/** Optional configuration for subagent invocations. */
|
||||
export interface SubagentInvocationOptions {
|
||||
toolName?: string;
|
||||
toolDisplayName?: string;
|
||||
onAgentEvent?: (event: AgentEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-based local subagent invocation.
|
||||
*
|
||||
* This class orchestrates the execution of a defined agent by:
|
||||
* 1. Using {@link LocalSubagentSession} as the execution engine.
|
||||
* 2. Bridging the agent's streaming activity (e.g., thoughts) to the tool's
|
||||
* live output stream via the session's rawActivityCallback.
|
||||
* 3. Formatting the final result into a {@link ToolResult}.
|
||||
*/
|
||||
export class LocalSessionInvocation extends BaseToolInvocation<
|
||||
AgentInputs,
|
||||
ToolResult
|
||||
> {
|
||||
private readonly _onAgentEvent?: (event: AgentEvent) => void;
|
||||
|
||||
/**
|
||||
* @param definition The definition object that configures the agent.
|
||||
* @param context The agent loop context.
|
||||
* @param params The validated input parameters for the agent.
|
||||
* @param messageBus Message bus for policy enforcement.
|
||||
* @param options Optional overrides for tool name, display name, and event callback.
|
||||
*/
|
||||
constructor(
|
||||
private readonly definition: LocalAgentDefinition,
|
||||
private readonly context: AgentLoopContext,
|
||||
params: AgentInputs,
|
||||
messageBus: MessageBus,
|
||||
options?: SubagentInvocationOptions,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
options?.toolName ?? definition.name,
|
||||
options?.toolDisplayName ?? definition.displayName,
|
||||
);
|
||||
this._onAgentEvent = options?.onAgentEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a concise, human-readable description of the invocation.
|
||||
* Used for logging and display purposes.
|
||||
*/
|
||||
getDescription(): string {
|
||||
const inputSummary = Object.entries(this.params)
|
||||
.map(([key, value]) => `${key}: ${String(value)}`)
|
||||
.join(', ');
|
||||
|
||||
return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
|
||||
}
|
||||
|
||||
private publishActivity(activity: SubagentActivityItem): void {
|
||||
void this.messageBus.publish({
|
||||
type: MessageBusType.SUBAGENT_ACTIVITY,
|
||||
subagentName: this.definition.displayName ?? this.definition.name,
|
||||
activity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the subagent.
|
||||
*
|
||||
* @param options Options for tool execution including signal and output updates.
|
||||
* @returns A `Promise` that resolves with the final `ToolResult`.
|
||||
*/
|
||||
async execute(options: ExecuteOptions): Promise<ToolResult> {
|
||||
const { abortSignal: signal, updateOutput } = options;
|
||||
let recentActivity: SubagentActivityItem[] = [];
|
||||
|
||||
// Raw SubagentActivityEvent handler — preserves all existing progress display logic.
|
||||
// Passed as rawActivityCallback to LocalSubagentSession so the protocol can call it
|
||||
// before translating to AgentEvents.
|
||||
const onActivity = (activity: SubagentActivityEvent): void => {
|
||||
if (!updateOutput) return;
|
||||
|
||||
let updated = false;
|
||||
|
||||
switch (activity.type) {
|
||||
case 'THOUGHT_CHUNK': {
|
||||
const rawText = activity.data['text'];
|
||||
const text = typeof rawText === 'string' ? rawText : '';
|
||||
const lastItem = recentActivity[recentActivity.length - 1];
|
||||
|
||||
if (
|
||||
lastItem &&
|
||||
lastItem.type === 'thought' &&
|
||||
lastItem.status === SubagentState.RUNNING
|
||||
) {
|
||||
lastItem.content = sanitizeThoughtContent(text);
|
||||
} else {
|
||||
recentActivity.push({
|
||||
id: randomUUID(),
|
||||
type: 'thought',
|
||||
content: sanitizeThoughtContent(text),
|
||||
status: SubagentState.RUNNING,
|
||||
});
|
||||
}
|
||||
updated = true;
|
||||
|
||||
const latestThought = recentActivity[recentActivity.length - 1];
|
||||
if (latestThought) {
|
||||
this.publishActivity(latestThought);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'TOOL_CALL_START': {
|
||||
const rawName = activity.data['name'];
|
||||
const name = typeof rawName === 'string' ? rawName.trim() : '';
|
||||
const displayName = activity.data['displayName']
|
||||
? sanitizeErrorMessage(String(activity.data['displayName']).trim())
|
||||
: undefined;
|
||||
const description = activity.data['description']
|
||||
? sanitizeErrorMessage(String(activity.data['description']))
|
||||
: undefined;
|
||||
const args = JSON.stringify(sanitizeToolArgs(activity.data['args']));
|
||||
const callId = activity.data['callId']
|
||||
? String(activity.data['callId'])
|
||||
: randomUUID();
|
||||
recentActivity.push({
|
||||
id: callId,
|
||||
type: 'tool_call',
|
||||
content: name,
|
||||
displayName,
|
||||
description,
|
||||
args,
|
||||
status: SubagentState.RUNNING,
|
||||
});
|
||||
updated = true;
|
||||
|
||||
const latestTool = recentActivity[recentActivity.length - 1];
|
||||
if (latestTool) {
|
||||
this.publishActivity(latestTool);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'TOOL_CALL_END': {
|
||||
const data = activity.data['data'];
|
||||
const isError = isToolActivityError(data);
|
||||
|
||||
const callId = activity.data['id']
|
||||
? String(activity.data['id'])
|
||||
: undefined;
|
||||
|
||||
if (callId) {
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].id === callId &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
) {
|
||||
recentActivity[i].status = isError
|
||||
? SubagentState.ERROR
|
||||
: SubagentState.COMPLETED;
|
||||
updated = true;
|
||||
|
||||
this.publishActivity(recentActivity[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ERROR': {
|
||||
const rawError = activity.data['error'];
|
||||
const error = typeof rawError === 'string' ? rawError.trim() : '';
|
||||
const errorType = activity.data['errorType'];
|
||||
const sanitizedError = sanitizeErrorMessage(error);
|
||||
const isCancellation =
|
||||
errorType === SubagentActivityErrorType.CANCELLED ||
|
||||
error === SUBAGENT_CANCELLED_ERROR_MESSAGE;
|
||||
const isRejection =
|
||||
errorType === SubagentActivityErrorType.REJECTED ||
|
||||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
|
||||
|
||||
const callId = activity.data['callId']
|
||||
? String(activity.data['callId'])
|
||||
: undefined;
|
||||
|
||||
if (callId) {
|
||||
const targetStatus =
|
||||
isCancellation || isRejection
|
||||
? SubagentState.CANCELLED
|
||||
: SubagentState.ERROR;
|
||||
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].id === callId &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
) {
|
||||
recentActivity[i].status = targetStatus;
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentActivity.push({
|
||||
id: randomUUID(),
|
||||
type: 'thought',
|
||||
content:
|
||||
isCancellation || isRejection
|
||||
? sanitizedError
|
||||
: `Error: ${sanitizedError}`,
|
||||
status:
|
||||
isCancellation || isRejection
|
||||
? SubagentState.CANCELLED
|
||||
: SubagentState.ERROR,
|
||||
});
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
checkExhaustive(activity.type);
|
||||
break;
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
// Keep only the last N items
|
||||
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
|
||||
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
|
||||
}
|
||||
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity], // Copy to avoid mutation issues
|
||||
state: SubagentState.RUNNING,
|
||||
};
|
||||
|
||||
updateOutput(progress);
|
||||
}
|
||||
};
|
||||
|
||||
// Create session with the raw activity callback for rich progress display
|
||||
const session = new LocalSubagentSession(
|
||||
this.definition,
|
||||
this.context,
|
||||
this.messageBus,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
// Subscribe for parent session observability
|
||||
let unsubscribeParent: (() => void) | undefined;
|
||||
if (this._onAgentEvent) {
|
||||
unsubscribeParent = session.subscribe(this._onAgentEvent);
|
||||
}
|
||||
|
||||
// Wire external abort signal to session abort
|
||||
const abortListener = () => void session.abort();
|
||||
signal.addEventListener('abort', abortListener, { once: true });
|
||||
|
||||
try {
|
||||
if (updateOutput) {
|
||||
const initialProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [],
|
||||
state: SubagentState.RUNNING,
|
||||
};
|
||||
updateOutput(initialProgress);
|
||||
}
|
||||
|
||||
// Buffer non-query params, then send query as message to start execution
|
||||
const query = String(this.params['query'] ?? '');
|
||||
const otherParams = { ...this.params } as Record<string, unknown>;
|
||||
delete otherParams['query'];
|
||||
if (Object.keys(otherParams).length > 0) {
|
||||
await session.send({ update: { config: otherParams } });
|
||||
}
|
||||
await session.send({
|
||||
message: { content: [{ type: 'text', text: query }] },
|
||||
});
|
||||
|
||||
const output = await session.getResult();
|
||||
|
||||
if (output.terminate_reason === AgentTerminateMode.ABORTED) {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity],
|
||||
state: SubagentState.CANCELLED,
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(progress);
|
||||
}
|
||||
|
||||
const cancelError = new Error('Operation cancelled by user');
|
||||
cancelError.name = 'AbortError';
|
||||
throw cancelError;
|
||||
}
|
||||
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity],
|
||||
state: SubagentState.COMPLETED,
|
||||
result: output.result,
|
||||
terminateReason: output.terminate_reason,
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(progress);
|
||||
}
|
||||
|
||||
const resultContent = `Subagent '${this.definition.name}' finished.
|
||||
Termination Reason: ${output.terminate_reason}
|
||||
Result:
|
||||
${output.result}`;
|
||||
|
||||
return {
|
||||
llmContent: [{ text: resultContent }],
|
||||
returnDisplay: progress,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const isAbort =
|
||||
(error instanceof Error && error.name === 'AbortError') ||
|
||||
errorMessage.includes('Aborted');
|
||||
|
||||
// Mark any running items as error/cancelled
|
||||
for (const item of recentActivity) {
|
||||
if (item.status === SubagentState.RUNNING) {
|
||||
item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the error is reflected in the recent activity for display
|
||||
if (!isAbort) {
|
||||
const lastActivity = recentActivity[recentActivity.length - 1];
|
||||
if (!lastActivity || lastActivity.status !== SubagentState.ERROR) {
|
||||
recentActivity.push({
|
||||
id: randomUUID(),
|
||||
type: 'thought',
|
||||
content: `Error: ${errorMessage}`,
|
||||
status: SubagentState.ERROR,
|
||||
});
|
||||
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
|
||||
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity],
|
||||
state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR,
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(progress);
|
||||
}
|
||||
|
||||
if (isAbort) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`,
|
||||
returnDisplay: progress,
|
||||
};
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortListener);
|
||||
unsubscribeParent?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { AgentChatHistory } from '../core/agentChatHistory.js';
|
||||
import { isToolExecution, type ConcreteNode } from './graph/types.js';
|
||||
import type {
|
||||
AgentChatHistory,
|
||||
HistoryTurn,
|
||||
} from '../core/agentChatHistory.js';
|
||||
import type { ConcreteNode } from './graph/types.js';
|
||||
import type { ContextEventBus } from './eventBus.js';
|
||||
import type { ContextTracer } from './tracer.js';
|
||||
import type { ContextEnvironment } from './pipeline/environment.js';
|
||||
@@ -38,9 +41,11 @@ export class ContextManager {
|
||||
private lastRenderCache?: {
|
||||
nodesHash: string;
|
||||
result: {
|
||||
history: Content[];
|
||||
history: HistoryTurn[];
|
||||
apiHistory: Content[];
|
||||
didApplyManagement: boolean;
|
||||
baseUnits: number;
|
||||
processedNodes: readonly ConcreteNode[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -75,6 +80,21 @@ export class ContextManager {
|
||||
this.evaluateTriggers(event.newNodes);
|
||||
});
|
||||
this.eventBus.onProcessorResult((event) => {
|
||||
// Defensive: Verify all targets are still present in the buffer.
|
||||
// If a synchronous render or a previous async task already removed them,
|
||||
// this result is stale and should be dropped.
|
||||
const currentIds = new Set(this.buffer.nodes.map((n) => n.id));
|
||||
const allTargetsPresent = event.targets.every((t) =>
|
||||
currentIds.has(t.id),
|
||||
);
|
||||
|
||||
if (!allTargetsPresent) {
|
||||
debugLogger.log(
|
||||
`[ContextManager] Dropping stale processor result from ${event.processorId}. One or more targets were already removed.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.buffer = this.buffer.applyProcessorResult(
|
||||
event.processorId,
|
||||
event.targets,
|
||||
@@ -127,11 +147,11 @@ export class ContextManager {
|
||||
const agedOutNodes = new Set<string>();
|
||||
let rollingTokens = 0;
|
||||
|
||||
// Identify active tool calls that must NEVER be truncated
|
||||
// Identify nodes that must NEVER be truncated
|
||||
const protectedIds = this.getProtectedNodeIds(this.buffer.nodes);
|
||||
if (protectedIds.size > 0) {
|
||||
debugLogger.log(
|
||||
`[ContextManager] Pinning ${protectedIds.size} active tool call nodes to prevent truncation.`,
|
||||
`[ContextManager] Pinning ${protectedIds.size} nodes (recent_turn or external_active_task) to prevent truncation.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,24 +235,7 @@ export class ContextManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Identify active tool calls that must NEVER be truncated
|
||||
const calls = nodes.filter((n) => isToolExecution(n) && n.role === 'model');
|
||||
const responses = new Set(
|
||||
nodes
|
||||
.filter((n) => isToolExecution(n) && n.role === 'user')
|
||||
.map((n) => n.payload.functionResponse?.id)
|
||||
.filter((id): id is string => !!id),
|
||||
);
|
||||
|
||||
for (const call of calls) {
|
||||
const id = call.payload.functionCall?.id;
|
||||
// If we have a call but no response in the current graph, it's 'in flight'
|
||||
if (id && !responses.has(id)) {
|
||||
protectionMap.set(call.id, 'in_flight_tool_call');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Any externally requested protections
|
||||
// 2. Any externally requested protections
|
||||
for (const id of extraProtectedIds) {
|
||||
protectionMap.set(id, 'external_active_task');
|
||||
}
|
||||
@@ -278,13 +281,15 @@ export class ContextManager {
|
||||
* This is the primary method called by the agent framework before sending a request.
|
||||
*/
|
||||
async renderHistory(
|
||||
pendingRequest?: Content,
|
||||
pendingRequest?: HistoryTurn,
|
||||
activeTaskIds: Set<string> = new Set(),
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{
|
||||
history: Content[];
|
||||
history: HistoryTurn[];
|
||||
apiHistory: Content[];
|
||||
didApplyManagement: boolean;
|
||||
baseUnits: number;
|
||||
processedNodes: readonly ConcreteNode[];
|
||||
}> {
|
||||
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
|
||||
|
||||
@@ -302,6 +307,7 @@ export class ContextManager {
|
||||
const hotStartPromise = (async () => {
|
||||
if (!this.hasPerformedHotStart) {
|
||||
this.hasPerformedHotStart = true;
|
||||
|
||||
if (this.buffer.nodes.length > 0) {
|
||||
const nodesForHotStart = [...this.buffer.nodes, ...previewNodes];
|
||||
await this.performHotStartCalibration(nodesForHotStart, abortSignal);
|
||||
@@ -345,11 +351,7 @@ export class ContextManager {
|
||||
const protectionReasons = this.getProtectedNodeIds(nodes, activeTaskIds);
|
||||
|
||||
// Apply final GC Backstop pressure barrier synchronously before mapping
|
||||
const {
|
||||
history: renderedHistory,
|
||||
didApplyManagement,
|
||||
baseUnits,
|
||||
} = await render(
|
||||
const renderResult = await render(
|
||||
nodes,
|
||||
this.orchestrator,
|
||||
this.sidecar,
|
||||
@@ -361,21 +363,68 @@ export class ContextManager {
|
||||
previewNodeIds,
|
||||
);
|
||||
|
||||
const {
|
||||
history: renderedHistory,
|
||||
didApplyManagement,
|
||||
baseUnits,
|
||||
processedNodes,
|
||||
} = renderResult;
|
||||
|
||||
if (didApplyManagement) {
|
||||
// Commit the GC backstop results back to the master buffer.
|
||||
// We filter out preview nodes because they are ephemeral and will be
|
||||
// added to history naturally by the client after the turn completes.
|
||||
this.buffer = this.buffer.applyProcessorResult(
|
||||
'sync_backstop',
|
||||
this.buffer.nodes,
|
||||
processedNodes.filter((n) => !previewNodeIds.has(n.id)),
|
||||
);
|
||||
}
|
||||
|
||||
// Structural validation in debug mode
|
||||
checkContextInvariants(this.buffer.nodes, 'RenderHistory');
|
||||
|
||||
this.tracer.logEvent('ContextManager', 'Finished rendering');
|
||||
|
||||
const combinedHistory = header
|
||||
? [header, ...renderedHistory]
|
||||
// We must temporarily append the pendingRequest (if any) before hardening.
|
||||
// Otherwise, the hardener will see dangling functionCalls and inject sentinels
|
||||
// even though the pendingRequest provides the required functionResponses.
|
||||
const fullHistoryToHarden = pendingRequest
|
||||
? [...renderedHistory, pendingRequest]
|
||||
: renderedHistory;
|
||||
|
||||
const hardenedHistory = hardenHistory(fullHistoryToHarden, {
|
||||
sentinels: this.sidecar.sentinels,
|
||||
});
|
||||
|
||||
if (pendingRequest) {
|
||||
const last = hardenedHistory[hardenedHistory.length - 1];
|
||||
if (last && last.content.parts) {
|
||||
const numPartsToRemove = pendingRequest.content.parts?.length || 0;
|
||||
if (
|
||||
numPartsToRemove > 0 &&
|
||||
last.content.parts.length > numPartsToRemove
|
||||
) {
|
||||
last.content.parts.splice(-numPartsToRemove);
|
||||
} else {
|
||||
hardenedHistory.pop();
|
||||
}
|
||||
} else {
|
||||
hardenedHistory.pop();
|
||||
}
|
||||
}
|
||||
|
||||
const apiHistory = hardenedHistory.map((h) => h.content);
|
||||
if (header) {
|
||||
apiHistory.unshift(header);
|
||||
}
|
||||
|
||||
const result = {
|
||||
history: hardenHistory(combinedHistory, {
|
||||
sentinels: this.sidecar.sentinels,
|
||||
}),
|
||||
history: hardenedHistory,
|
||||
apiHistory,
|
||||
didApplyManagement,
|
||||
baseUnits,
|
||||
processedNodes,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
@@ -394,10 +443,11 @@ export class ContextManager {
|
||||
);
|
||||
|
||||
const contents = this.env.graphMapper.fromGraph(nodes);
|
||||
const rawContents = contents.map((h) => h.content);
|
||||
const header = this.headerProvider
|
||||
? await this.headerProvider()
|
||||
: undefined;
|
||||
const combinedHistory = header ? [header, ...contents] : contents;
|
||||
const combinedHistory = header ? [header, ...rawContents] : rawContents;
|
||||
|
||||
const baseUnits =
|
||||
this.advancedTokenCalculator.getRawBaseUnits(nodes) +
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fromGraph } from './fromGraph.js';
|
||||
import { NodeType, type ConcreteNode } from './types.js';
|
||||
import { NodeIdService } from './nodeIdService.js';
|
||||
|
||||
describe('fromGraph', () => {
|
||||
it('should reconstruct an empty history from empty nodes', () => {
|
||||
expect(fromGraph([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should reconstruct a single turn from a single node', () => {
|
||||
const nodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'node_1',
|
||||
turnId: 'turn_durable_1',
|
||||
role: 'user',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: { text: 'hello' },
|
||||
timestamp: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const history = fromGraph(nodes);
|
||||
expect(history).toEqual([
|
||||
{
|
||||
id: 'durable_1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should coalesce adjacent nodes with the same turnId into a single turn', () => {
|
||||
const nodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'node_1',
|
||||
turnId: 'turn_durable_1',
|
||||
role: 'user',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: { text: 'hello' },
|
||||
timestamp: 100,
|
||||
},
|
||||
{
|
||||
id: 'node_2',
|
||||
turnId: 'turn_durable_1',
|
||||
role: 'user',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: { text: 'world' },
|
||||
timestamp: 101,
|
||||
},
|
||||
];
|
||||
|
||||
const history = fromGraph(nodes);
|
||||
expect(history).toEqual([
|
||||
{
|
||||
id: 'durable_1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }, { text: 'world' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should split turns when the role changes', () => {
|
||||
const nodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'node_1',
|
||||
turnId: 'turn_durable_1',
|
||||
role: 'user',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: { text: 'hello' },
|
||||
timestamp: 100,
|
||||
},
|
||||
{
|
||||
id: 'node_2',
|
||||
turnId: 'turn_durable_2',
|
||||
role: 'model',
|
||||
type: NodeType.AGENT_THOUGHT,
|
||||
payload: { text: 'hi' },
|
||||
timestamp: 101,
|
||||
},
|
||||
];
|
||||
|
||||
const history = fromGraph(nodes);
|
||||
expect(history).toEqual([
|
||||
{
|
||||
id: 'durable_1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'durable_2',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'hi' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should split turns when the turnId changes, even if role is the same', () => {
|
||||
const nodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'node_1',
|
||||
turnId: 'turn_durable_1',
|
||||
role: 'user',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: { text: 'hello' },
|
||||
timestamp: 100,
|
||||
},
|
||||
{
|
||||
id: 'node_2',
|
||||
turnId: 'turn_durable_2',
|
||||
role: 'user',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: { text: 'world' },
|
||||
timestamp: 101,
|
||||
},
|
||||
];
|
||||
|
||||
const history = fromGraph(nodes);
|
||||
expect(history).toEqual([
|
||||
{
|
||||
id: 'durable_1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'durable_2',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'world' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly strip the turn_ prefix from turnId', () => {
|
||||
const nodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'node_1',
|
||||
turnId: 'turn_my_stable_id_123',
|
||||
role: 'user',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: { text: 'hello' },
|
||||
timestamp: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const history = fromGraph(nodes);
|
||||
expect(history[0].id).toBe('my_stable_id_123');
|
||||
});
|
||||
|
||||
it('should handle orphan nodes gracefully', () => {
|
||||
const nodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'node_1',
|
||||
role: 'user',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: { text: 'orphan part' },
|
||||
timestamp: 100,
|
||||
} as unknown as ConcreteNode,
|
||||
];
|
||||
|
||||
const history = fromGraph(nodes);
|
||||
expect(history[0].id).toBe('orphan');
|
||||
expect(history[0].content.parts).toEqual([{ text: 'orphan part' }]);
|
||||
});
|
||||
|
||||
it('should register identities with the NodeIdService if provided', () => {
|
||||
const idService = new NodeIdService();
|
||||
const payload = { text: 'hello' };
|
||||
const nodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'node_1',
|
||||
turnId: 'turn_1',
|
||||
role: 'user',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload,
|
||||
timestamp: 100,
|
||||
},
|
||||
];
|
||||
|
||||
fromGraph(nodes, idService);
|
||||
|
||||
// The payload object reference should map to the node ID
|
||||
expect(idService.get(payload)).toBe('node_1');
|
||||
});
|
||||
});
|
||||
@@ -7,22 +7,34 @@
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ConcreteNode } from './types.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import type { NodeIdService } from './nodeIdService.js';
|
||||
import type { HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
|
||||
/**
|
||||
* Reconstructs a valid Gemini Chat History from a list of Concrete Nodes.
|
||||
* Reconstructs a list of HistoryTurns from a list of Concrete Nodes.
|
||||
* This process is "role-alternation-aware" and uses turnId to
|
||||
* preserve original turn boundaries even if multiple turns have the same role.
|
||||
* preserve original turn boundaries and IDs.
|
||||
*/
|
||||
export function fromGraph(nodes: readonly ConcreteNode[]): Content[] {
|
||||
export function fromGraph(
|
||||
nodes: readonly ConcreteNode[],
|
||||
idService?: NodeIdService,
|
||||
): HistoryTurn[] {
|
||||
debugLogger.log(
|
||||
`[fromGraph] Reconstructing history from ${nodes.length} nodes`,
|
||||
);
|
||||
|
||||
const history: Content[] = [];
|
||||
let currentTurn: (Content & { _turnId?: string }) | null = null;
|
||||
const history: HistoryTurn[] = [];
|
||||
let currentTurn: { id: string; content: Content } | null = null;
|
||||
|
||||
for (const node of nodes) {
|
||||
const turnId = node.turnId;
|
||||
const turnId = node.turnId || 'orphan';
|
||||
const durableId = turnId.startsWith('turn_') ? turnId.slice(5) : turnId;
|
||||
|
||||
// Register the payload in the identity service to ensure stability
|
||||
// even if the turn content changes (e.g. after GC backstop).
|
||||
if (idService) {
|
||||
idService.set(node.payload, node.id);
|
||||
}
|
||||
|
||||
// We start a new turn if:
|
||||
// 1. We don't have a current turn.
|
||||
@@ -30,26 +42,25 @@ export function fromGraph(nodes: readonly ConcreteNode[]): Content[] {
|
||||
// 3. The turnId changes (Preserving distinct turns of the same role).
|
||||
if (
|
||||
!currentTurn ||
|
||||
currentTurn.role !== node.role ||
|
||||
currentTurn._turnId !== turnId
|
||||
currentTurn.content.role !== node.role ||
|
||||
currentTurn.id !== durableId
|
||||
) {
|
||||
currentTurn = {
|
||||
role: node.role,
|
||||
parts: [node.payload],
|
||||
_turnId: turnId,
|
||||
id: durableId,
|
||||
content: {
|
||||
role: node.role,
|
||||
parts: [node.payload],
|
||||
},
|
||||
};
|
||||
history.push(currentTurn);
|
||||
} else {
|
||||
currentTurn.parts = [...(currentTurn.parts || []), node.payload];
|
||||
currentTurn.content.parts = [
|
||||
...(currentTurn.content.parts || []),
|
||||
node.payload,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Final cleanup: remove our internal tracking field
|
||||
for (const turn of history) {
|
||||
const t = turn as Content & { _turnId?: string };
|
||||
delete t._turnId;
|
||||
}
|
||||
|
||||
debugLogger.log(`[fromGraph] Reconstructed ${history.length} turns`);
|
||||
return history;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ContextGraphMapper } from './mapper.js';
|
||||
import type { HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
import { hardenHistory } from '../../utils/historyHardening.js';
|
||||
|
||||
describe('ContextGraphMapper (Round-Trip Fidelity)', () => {
|
||||
it('should flawlessly round-trip a complex history containing parallel tool calls and responses', () => {
|
||||
// 1. Define a complex, worst-case scenario history
|
||||
const originalHistory: HistoryTurn[] = [
|
||||
{
|
||||
id: 'system_prompt_id',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: '<session_context>\nSystem Prompt here' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'user_turn_1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'Please read file A and file B at the same time.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'model_turn_1',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'I will read both files concurrently.' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_A',
|
||||
name: 'read_file',
|
||||
args: { path: 'A.txt' },
|
||||
},
|
||||
thoughtSignature: 'synthetic_sig_xyz',
|
||||
},
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_B',
|
||||
name: 'read_file',
|
||||
args: { path: 'B.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Note: GeminiChat records these as separate sequential user turns initially
|
||||
{
|
||||
id: 'tool_resp_B_id',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_B',
|
||||
name: 'read_file',
|
||||
response: { content: 'File B' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tool_resp_A_id',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_A',
|
||||
name: 'read_file',
|
||||
response: { content: 'File A' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 2. We harden the original history first. The core agent loop feeds the hardener the pure history.
|
||||
// We want our round-tripped history to match what the hardener WOULD have produced natively.
|
||||
const hardenedOriginal = hardenHistory(originalHistory);
|
||||
|
||||
// 3. Translate History -> Graph
|
||||
const mapper = new ContextGraphMapper();
|
||||
// Simulate the HistoryObserver capturing the push
|
||||
const nodes = mapper.applyEvent({
|
||||
type: 'SYNC_FULL',
|
||||
payload: originalHistory,
|
||||
});
|
||||
|
||||
// 4. Translate Graph -> History
|
||||
const reconstructedHistory = mapper.fromGraph(nodes);
|
||||
|
||||
// 5. Harden the reconstructed history (as the ContextManager does before sending to API)
|
||||
const hardenedReconstructed = hardenHistory(reconstructedHistory);
|
||||
|
||||
// 6. Assert Absolute Equality
|
||||
// The round-trip through the Context Graph and Hardener must exactly equal
|
||||
// the original history put through the Hardener.
|
||||
expect(hardenedReconstructed).toEqual(hardenedOriginal);
|
||||
});
|
||||
});
|
||||
@@ -5,23 +5,27 @@
|
||||
*/
|
||||
import type { ConcreteNode } from './types.js';
|
||||
import { ContextGraphBuilder } from './toGraph.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { HistoryEvent } from '../../core/agentChatHistory.js';
|
||||
import type { HistoryEvent, HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
import { fromGraph } from './fromGraph.js';
|
||||
import { NodeIdService } from './nodeIdService.js';
|
||||
|
||||
export class ContextGraphMapper {
|
||||
private readonly nodeIdentityMap = new WeakMap<object, string>();
|
||||
private readonly idService = new NodeIdService();
|
||||
private readonly builder: ContextGraphBuilder;
|
||||
|
||||
constructor() {
|
||||
this.builder = new ContextGraphBuilder(this.nodeIdentityMap);
|
||||
this.builder = new ContextGraphBuilder(this.idService);
|
||||
}
|
||||
|
||||
applyEvent(event: HistoryEvent): ConcreteNode[] {
|
||||
return this.builder.processHistory(event.payload);
|
||||
}
|
||||
|
||||
fromGraph(nodes: readonly ConcreteNode[]): Content[] {
|
||||
return fromGraph(nodes);
|
||||
fromGraph(nodes: readonly ConcreteNode[]): HistoryTurn[] {
|
||||
return fromGraph(nodes, this.idService);
|
||||
}
|
||||
|
||||
getIdService(): NodeIdService {
|
||||
return this.idService;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provides a durable mapping between history object references and their
|
||||
* corresponding graph node IDs. This ensures that context management logic
|
||||
* can track the identity of turns even after they are transformed (e.g. scrubbed
|
||||
* or hardened) without polluting the raw JSON sent to the Gemini API.
|
||||
*/
|
||||
export class NodeIdService {
|
||||
constructor(private readonly map: WeakMap<object, string> = new WeakMap()) {}
|
||||
|
||||
get(obj: object): string | undefined {
|
||||
return this.map.get(obj);
|
||||
}
|
||||
|
||||
set(obj: object, id: string): void {
|
||||
this.map.set(obj, id);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,10 @@ import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
|
||||
import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
import { performCalibration } from '../utils/tokenCalibration.js';
|
||||
import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import type { HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
|
||||
/**
|
||||
* Maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
|
||||
* Maps the Episodic Context Graph back into a list of HistoryTurns for transmission.
|
||||
* It applies synchronous context management (GC backstop) if the budget is exceeded.
|
||||
*/
|
||||
export async function render(
|
||||
@@ -28,9 +29,10 @@ export async function render(
|
||||
header?: Content,
|
||||
previewNodeIds: ReadonlySet<string> = new Set(),
|
||||
): Promise<{
|
||||
history: Content[];
|
||||
history: HistoryTurn[];
|
||||
didApplyManagement: boolean;
|
||||
baseUnits: number;
|
||||
processedNodes: readonly ConcreteNode[];
|
||||
}> {
|
||||
let headerTokens = 0;
|
||||
let headerBaseUnits = 0;
|
||||
@@ -52,7 +54,12 @@ export async function render(
|
||||
const baseUnits =
|
||||
advancedTokenCalculator.getRawBaseUnits(nodes) + headerBaseUnits;
|
||||
|
||||
return { history: contents, didApplyManagement: false, baseUnits };
|
||||
return {
|
||||
history: contents,
|
||||
didApplyManagement: false,
|
||||
baseUnits,
|
||||
processedNodes: nodes,
|
||||
};
|
||||
}
|
||||
|
||||
const maxTokens = sidecar.config.budget.maxTokens;
|
||||
@@ -92,11 +99,16 @@ export async function render(
|
||||
tracer.logEvent('Render', 'Render Context for LLM', {
|
||||
renderedContext: contents,
|
||||
});
|
||||
performCalibration(env, visibleNodes, contents);
|
||||
performCalibration(
|
||||
env,
|
||||
visibleNodes,
|
||||
contents.map((h) => h.content),
|
||||
);
|
||||
return {
|
||||
history: contents,
|
||||
didApplyManagement: false,
|
||||
baseUnits: graphBaseUnits + headerBaseUnits,
|
||||
processedNodes: nodes,
|
||||
};
|
||||
}
|
||||
const targetDelta = currentTokens - sidecar.config.budget.retainedTokens;
|
||||
@@ -145,11 +157,16 @@ export async function render(
|
||||
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
|
||||
renderedContextSanitized: contents,
|
||||
});
|
||||
performCalibration(env, visibleNodes, contents);
|
||||
performCalibration(
|
||||
env,
|
||||
visibleNodes,
|
||||
contents.map((h) => h.content),
|
||||
);
|
||||
return {
|
||||
history: contents,
|
||||
didApplyManagement: true,
|
||||
baseUnits:
|
||||
advancedTokenCalculator.getRawBaseUnits(visibleNodes) + headerBaseUnits,
|
||||
processedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,29 +4,42 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContextGraphBuilder } from './toGraph.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { BaseConcreteNode } from './types.js';
|
||||
import { NodeIdService } from './nodeIdService.js';
|
||||
import type { HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
|
||||
describe('ContextGraphBuilder', () => {
|
||||
describe('toGraph', () => {
|
||||
it('should skip legacy <session_context> headers even if they appear later in the history', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Message 1' }] },
|
||||
{ role: 'model', parts: [{ text: 'Reply 1' }] },
|
||||
const history: HistoryTurn[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: '<session_context>\nThis is the Gemini CLI\nSome context...',
|
||||
},
|
||||
],
|
||||
id: '1',
|
||||
content: { role: 'user', parts: [{ text: 'Message 1' }] },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
content: { role: 'model', parts: [{ text: 'Reply 1' }] },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: '<session_context>\nThis is the Gemini CLI\nSome context...',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
content: { role: 'user', parts: [{ text: 'Message 2' }] },
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'Message 2' }] },
|
||||
];
|
||||
|
||||
const builder = new ContextGraphBuilder();
|
||||
const builder = new ContextGraphBuilder(new NodeIdService());
|
||||
const nodes = builder.processHistory(history);
|
||||
|
||||
// We expect the first two messages and the last one to be present
|
||||
@@ -36,5 +49,81 @@ describe('ContextGraphBuilder', () => {
|
||||
expect((nodes[1] as BaseConcreteNode).payload.text).toBe('Reply 1');
|
||||
expect((nodes[2] as BaseConcreteNode).payload.text).toBe('Message 2');
|
||||
});
|
||||
|
||||
it('should generate completely deterministic graph structure and UUIDs across JSON serialization cycles', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(0);
|
||||
|
||||
const complexHistory: HistoryTurn[] = [
|
||||
{
|
||||
id: 'turn-1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'Step 1: complex analysis' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'turn-2',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Thinking about the tool to use.' },
|
||||
{
|
||||
functionCall: {
|
||||
name: 'fetch_data',
|
||||
args: { query: 'test data' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'turn-3',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'fetch_data',
|
||||
response: { status: 'success', data: [1, 2, 3] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'turn-4',
|
||||
content: { role: 'model', parts: [{ text: 'Analysis complete.' }] },
|
||||
},
|
||||
];
|
||||
|
||||
// 1. Initial Graph Generation
|
||||
const builder1 = new ContextGraphBuilder(new NodeIdService());
|
||||
const nodes1 = builder1.processHistory(complexHistory);
|
||||
|
||||
// 2. Serialize and Deserialize (Simulating saving and loading from disk)
|
||||
const serializedHistory = JSON.stringify(complexHistory);
|
||||
const parsedHistory = JSON.parse(serializedHistory) as HistoryTurn[];
|
||||
|
||||
// 3. Second Graph Generation from parsed JSON
|
||||
const builder2 = new ContextGraphBuilder(new NodeIdService());
|
||||
const nodes2 = builder2.processHistory(parsedHistory);
|
||||
|
||||
// Assertion: The arrays must be completely identical, including all generated UUIDs
|
||||
expect(nodes1).toEqual(nodes2);
|
||||
|
||||
// Sanity check to ensure IDs are actually populated and consistent
|
||||
expect(nodes1.length).toBeGreaterThan(0);
|
||||
nodes1.forEach((node, index) => {
|
||||
expect(node.id).toBeDefined();
|
||||
expect(node.id).toBe(nodes2[index].id);
|
||||
expect(node.timestamp).toBe(0);
|
||||
if ('turnId' in node) {
|
||||
expect(node.turnId).toBeDefined();
|
||||
expect(node.turnId).toBe((nodes2[index] as BaseConcreteNode).turnId);
|
||||
}
|
||||
});
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { Part } from '@google/genai';
|
||||
import { type ConcreteNode, NodeType } from './types.js';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
interface PartWithSynthId extends Part {
|
||||
_synthId?: string;
|
||||
}
|
||||
import type { NodeIdService } from './nodeIdService.js';
|
||||
import type { HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
import { isSnapshotState } from '../utils/snapshotGenerator.js';
|
||||
|
||||
// Global WeakMap to cache hashes for Part objects.
|
||||
// This optimizes getStableId by avoiding redundant stringify/hash operations
|
||||
@@ -62,34 +61,53 @@ function isFunctionResponsePart(
|
||||
);
|
||||
}
|
||||
|
||||
function isExecutableCodePart(
|
||||
part: Part,
|
||||
): part is Part & { executableCode: { code: string; language: string } } {
|
||||
return (
|
||||
typeof part.executableCode === 'object' &&
|
||||
part.executableCode !== null &&
|
||||
typeof part.executableCode.code === 'string' &&
|
||||
typeof part.executableCode.language === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isCodeExecutionResultPart(
|
||||
part: Part,
|
||||
): part is Part & { codeExecutionResult: { outcome: string; output: string } } {
|
||||
return (
|
||||
typeof part.codeExecutionResult === 'object' &&
|
||||
part.codeExecutionResult !== null &&
|
||||
typeof part.codeExecutionResult.output === 'string' &&
|
||||
typeof part.codeExecutionResult.outcome === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a stable ID for an object reference using a WeakMap.
|
||||
* Generates a stable ID for an object reference using a NodeIdService.
|
||||
* Falls back to content-based hashing for Part-like objects to ensure
|
||||
* stability across object re-creations (e.g. during history mapping).
|
||||
*/
|
||||
export function getStableId(
|
||||
obj: object,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
idService: NodeIdService,
|
||||
turnSalt: string = '',
|
||||
partIdx: number = 0,
|
||||
): string {
|
||||
let id = nodeIdentityMap.get(obj);
|
||||
let id = idService.get(obj);
|
||||
if (id) return id;
|
||||
|
||||
const cachedHash = PART_HASH_CACHE.get(obj);
|
||||
if (cachedHash) {
|
||||
id = `${cachedHash}_${turnSalt}_${partIdx}`;
|
||||
nodeIdentityMap.set(obj, id);
|
||||
idService.set(obj, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
const part = obj as PartWithSynthId;
|
||||
const part = obj as Part;
|
||||
let contentHash: string | undefined;
|
||||
|
||||
// If the object already has a synthetic ID property, use it.
|
||||
if (typeof part._synthId === 'string') {
|
||||
id = part._synthId;
|
||||
} else if (isTextPart(part)) {
|
||||
if (isTextPart(part)) {
|
||||
contentHash = createHash('sha256').update(part.text).digest('hex');
|
||||
id = `text_${contentHash}_${turnSalt}_${partIdx}`;
|
||||
} else if (isInlineDataPart(part)) {
|
||||
@@ -116,6 +134,20 @@ export function getStableId(
|
||||
)
|
||||
.digest('hex');
|
||||
id = `resp_h_${contentHash}_${turnSalt}_${partIdx}`;
|
||||
} else if (isExecutableCodePart(part)) {
|
||||
contentHash = createHash('sha256')
|
||||
.update(
|
||||
`exec:${part.executableCode.language}:${part.executableCode.code}`,
|
||||
)
|
||||
.digest('hex');
|
||||
id = `exec_${contentHash}_${turnSalt}_${partIdx}`;
|
||||
} else if (isCodeExecutionResultPart(part)) {
|
||||
contentHash = createHash('sha256')
|
||||
.update(
|
||||
`result:${part.codeExecutionResult.outcome}:${part.codeExecutionResult.output}`,
|
||||
)
|
||||
.digest('hex');
|
||||
id = `result_${contentHash}_${turnSalt}_${partIdx}`;
|
||||
}
|
||||
|
||||
if (contentHash) {
|
||||
@@ -123,10 +155,14 @@ export function getStableId(
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
id = randomUUID();
|
||||
if (turnSalt && partIdx === -1) {
|
||||
id = `turn_${turnSalt}`;
|
||||
} else {
|
||||
id = `${turnSalt}_f_${partIdx}`;
|
||||
}
|
||||
}
|
||||
|
||||
nodeIdentityMap.set(obj, id);
|
||||
idService.set(obj, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -135,18 +171,14 @@ export function getStableId(
|
||||
* Every Part in history is mapped to exactly one ConcreteNode.
|
||||
*/
|
||||
export class ContextGraphBuilder {
|
||||
constructor(
|
||||
private readonly nodeIdentityMap: WeakMap<object, string> = new WeakMap(),
|
||||
) {}
|
||||
constructor(private readonly idService: NodeIdService) {}
|
||||
|
||||
processHistory(history: readonly Content[]): ConcreteNode[] {
|
||||
processHistory(history: readonly HistoryTurn[]): ConcreteNode[] {
|
||||
const nodes: ConcreteNode[] = [];
|
||||
|
||||
// Tracks occurrences of identical turn content to ensure unique stable IDs
|
||||
const seenHashes = new Map<string, number>();
|
||||
|
||||
for (let turnIdx = 0; turnIdx < history.length; turnIdx++) {
|
||||
const msg = history[turnIdx];
|
||||
const turn = history[turnIdx];
|
||||
const msg = turn.content;
|
||||
if (!msg.parts) continue;
|
||||
|
||||
// Defensive: Skip legacy environment header regardless of where it appears.
|
||||
@@ -164,15 +196,8 @@ export class ContextGraphBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a stable salt for this turn based on its role and content
|
||||
const turnContent = JSON.stringify(msg.parts);
|
||||
const h = createHash('md5')
|
||||
.update(`${msg.role}:${turnContent}`)
|
||||
.digest('hex');
|
||||
const occurrence = (seenHashes.get(h) || 0) + 1;
|
||||
seenHashes.set(h, occurrence);
|
||||
const turnSalt = `${h}_${occurrence}`;
|
||||
const turnId = getStableId(msg, this.nodeIdentityMap, turnSalt, -1);
|
||||
const turnSalt = turn.id;
|
||||
const turnId = `turn_${turnSalt}`;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
|
||||
@@ -180,34 +205,46 @@ export class ContextGraphBuilder {
|
||||
const apiId =
|
||||
isFunctionResponsePart(part) &&
|
||||
typeof part.functionResponse.id === 'string'
|
||||
? `resp_${part.functionResponse.id}_${turnSalt}_${partIdx}`
|
||||
? part.functionResponse.id
|
||||
: isFunctionCallPart(part) &&
|
||||
typeof part.functionCall.id === 'string'
|
||||
? `call_${part.functionCall.id}_${turnSalt}_${partIdx}`
|
||||
? part.functionCall.id
|
||||
: undefined;
|
||||
const id =
|
||||
apiId || getStableId(part, this.nodeIdentityMap, turnSalt, partIdx);
|
||||
|
||||
const isSnapshot = isTextPart(part) && isSnapshotState(part.text);
|
||||
|
||||
// Use stable API ID if available, otherwise anchor to the turn and index.
|
||||
const id = apiId
|
||||
? `${apiId}_${turnSalt}_${partIdx}`
|
||||
: `${turnSalt}_${partIdx}`;
|
||||
|
||||
const node: ConcreteNode = {
|
||||
id,
|
||||
timestamp: Date.now(),
|
||||
type: isFunctionResponsePart(part)
|
||||
? NodeType.TOOL_EXECUTION
|
||||
: NodeType.USER_PROMPT,
|
||||
: isSnapshot
|
||||
? NodeType.SNAPSHOT
|
||||
: NodeType.USER_PROMPT,
|
||||
role: 'user',
|
||||
payload: part,
|
||||
turnId,
|
||||
};
|
||||
nodes.push(node);
|
||||
this.idService.set(part, id);
|
||||
}
|
||||
} else if (msg.role === 'model') {
|
||||
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
|
||||
const part = msg.parts[partIdx];
|
||||
const apiId =
|
||||
isFunctionCallPart(part) && typeof part.functionCall.id === 'string'
|
||||
? `call_${part.functionCall.id}_${turnSalt}_${partIdx}`
|
||||
? part.functionCall.id
|
||||
: undefined;
|
||||
const id =
|
||||
apiId || getStableId(part, this.nodeIdentityMap, turnSalt, partIdx);
|
||||
|
||||
const id = apiId
|
||||
? `${apiId}_${turnSalt}_${partIdx}`
|
||||
: `${turnSalt}_${partIdx}`;
|
||||
|
||||
const node: ConcreteNode = {
|
||||
id,
|
||||
timestamp: Date.now(),
|
||||
@@ -219,6 +256,7 @@ export class ContextGraphBuilder {
|
||||
turnId,
|
||||
};
|
||||
nodes.push(node);
|
||||
this.idService.set(part, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { StateSnapshotAsyncProcessorOptionsSchema } from './processors/stateSnap
|
||||
import { RollingSummaryProcessorOptionsSchema } from './processors/rollingSummaryProcessor.js';
|
||||
import { getEnvironmentContext } from '../utils/environmentContext.js';
|
||||
import { AdaptiveTokenCalculator } from './utils/adaptiveTokenCalculator.js';
|
||||
import { estimateContextBreakdown } from '../core/loggingContentGenerator.js';
|
||||
import { NodeBehaviorRegistry } from './graph/behaviorRegistry.js';
|
||||
import { registerBuiltInBehaviors } from './graph/builtinBehaviors.js';
|
||||
|
||||
@@ -92,10 +93,26 @@ export async function initializeContextManager(
|
||||
const behaviorRegistry = new NodeBehaviorRegistry();
|
||||
registerBuiltInBehaviors(behaviorRegistry);
|
||||
|
||||
const getOverheadTokens = () => {
|
||||
const breakdown = estimateContextBreakdown([], {
|
||||
systemInstruction: {
|
||||
role: 'system',
|
||||
parts: [{ text: chat.getSystemInstruction() }],
|
||||
},
|
||||
tools: chat.getTools(),
|
||||
});
|
||||
return (
|
||||
breakdown.system_instructions +
|
||||
breakdown.tool_definitions +
|
||||
breakdown.mcp_servers
|
||||
);
|
||||
};
|
||||
|
||||
const calculator = new AdaptiveTokenCalculator(
|
||||
charsPerToken,
|
||||
behaviorRegistry,
|
||||
eventBus,
|
||||
getOverheadTokens,
|
||||
);
|
||||
|
||||
const env = new ContextEnvironmentImpl(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deriveStableId } from '../../utils/cryptoUtils.js';
|
||||
import type { JSONSchemaType } from 'ajv';
|
||||
import type { ProcessArgs, ContextProcessor } from '../pipeline.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
@@ -62,7 +62,8 @@ export function createBlobDegradationProcessor(
|
||||
if (payload.inlineData?.data && payload.inlineData?.mimeType) {
|
||||
await ensureDir();
|
||||
const ext = payload.inlineData.mimeType.split('/')[1] || 'bin';
|
||||
const fileName = `blob_${Date.now()}_${randomUUID()}.${ext}`;
|
||||
// Use a stable filename based on the node ID
|
||||
const fileName = `blob_${deriveStableId([node.id])}.${ext}`;
|
||||
const filePath = path.join(blobOutputsDir, fileName);
|
||||
|
||||
const buffer = Buffer.from(payload.inlineData.data, 'base64');
|
||||
@@ -92,7 +93,7 @@ export function createBlobDegradationProcessor(
|
||||
if (newText && tokensSaved > 0) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: randomUUID(),
|
||||
id: deriveStableId([node.id, 'degraded']),
|
||||
payload: { text: newText },
|
||||
replacesId: node.id,
|
||||
turnId: node.turnId,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deriveStableId } from '../../utils/cryptoUtils.js';
|
||||
import type { JSONSchemaType } from 'ajv';
|
||||
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
|
||||
import { type ConcreteNode, NodeType } from '../graph/types.js';
|
||||
@@ -99,9 +99,10 @@ export function createNodeDistillationProcessor(
|
||||
if (newTokens < oldTokens) {
|
||||
const distilledPayload = updatePart(payload, { text: summary });
|
||||
|
||||
const newId = deriveStableId([node.id, 'distilled']);
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: randomUUID(),
|
||||
id: newId,
|
||||
payload: distilledPayload,
|
||||
replacesId: node.id,
|
||||
timestamp: node.timestamp,
|
||||
@@ -158,9 +159,10 @@ export function createNodeDistillationProcessor(
|
||||
functionResponse: newFR,
|
||||
});
|
||||
|
||||
const newId = deriveStableId([node.id, 'distilled']);
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: randomUUID(),
|
||||
id: newId,
|
||||
payload: distilledPayload,
|
||||
replacesId: node.id,
|
||||
timestamp: node.timestamp,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deriveStableId } from '../../utils/cryptoUtils.js';
|
||||
import type { JSONSchemaType } from 'ajv';
|
||||
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
|
||||
import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
@@ -79,9 +79,10 @@ export function createNodeTruncationProcessor(
|
||||
if (text) {
|
||||
const squashResult = tryApplySquash(text, limitChars);
|
||||
if (squashResult) {
|
||||
const newId = deriveStableId([node.id, 'truncated']);
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: randomUUID(),
|
||||
id: newId,
|
||||
payload: { ...payload, text: squashResult.text },
|
||||
replacesId: node.id,
|
||||
turnId: node.turnId,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deriveStableId } from '../../utils/cryptoUtils.js';
|
||||
import type { JSONSchemaType } from 'ajv';
|
||||
import type {
|
||||
ContextProcessor,
|
||||
@@ -112,7 +112,8 @@ export function createRollingSummaryProcessor(
|
||||
try {
|
||||
// Synthesize the rolling summary synchronously
|
||||
const snapshotText = await generateRollingSummary(nodesToSummarize);
|
||||
const newId = randomUUID();
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
const newId = deriveStableId(consumedIds);
|
||||
|
||||
const summaryNode: RollingSummary = {
|
||||
id: newId,
|
||||
@@ -121,10 +122,9 @@ export function createRollingSummaryProcessor(
|
||||
timestamp: nodesToSummarize[nodesToSummarize.length - 1].timestamp,
|
||||
role: 'user',
|
||||
payload: { text: snapshotText },
|
||||
abstractsIds: nodesToSummarize.map((n) => n.id),
|
||||
abstractsIds: consumedIds,
|
||||
};
|
||||
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
const returnedNodes = targets.filter(
|
||||
(t) => !consumedIds.includes(t.id),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deriveStableId } from '../../utils/cryptoUtils.js';
|
||||
import type { JSONSchemaType } from 'ajv';
|
||||
import type {
|
||||
ContextProcessor,
|
||||
@@ -90,8 +90,11 @@ export function createStateSnapshotProcessor(
|
||||
const isValid = consumedIds.every((id) => targetIds.has(id));
|
||||
|
||||
if (isValid) {
|
||||
debugLogger.log(
|
||||
`[StateSnapshotProcessor] Successfully spliced PROPOSED_SNAPSHOT from Inbox into Graph. Consumed ${consumedIds.length} nodes.`,
|
||||
);
|
||||
// If valid, apply it!
|
||||
const newId = randomUUID();
|
||||
const newId = deriveStableId(consumedIds);
|
||||
|
||||
const snapshotNode: Snapshot = {
|
||||
id: newId,
|
||||
@@ -120,6 +123,10 @@ export function createStateSnapshotProcessor(
|
||||
|
||||
inbox.consume(proposed.id);
|
||||
return returnedNodes;
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[StateSnapshotProcessor] Rejected PROPOSED_SNAPSHOT from Inbox because one or more target IDs were missing from the current graph window.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,11 +186,11 @@ export function createStateSnapshotProcessor(
|
||||
maxStateTokens: options.maxStateTokens,
|
||||
},
|
||||
);
|
||||
const newId = randomUUID();
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
if (baselineIdToConsume && !consumedIds.includes(baselineIdToConsume)) {
|
||||
consumedIds.push(baselineIdToConsume);
|
||||
}
|
||||
const newId = deriveStableId(consumedIds);
|
||||
|
||||
const snapshotNode: Snapshot = {
|
||||
id: newId,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deriveStableId } from '../../utils/cryptoUtils.js';
|
||||
import type { JSONSchemaType } from 'ajv';
|
||||
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
@@ -120,7 +120,7 @@ export function createToolMaskingProcessor(
|
||||
directoryCreated = true;
|
||||
}
|
||||
|
||||
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${randomUUID()}.txt`;
|
||||
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${deriveStableId([content])}.txt`;
|
||||
const filePath = path.join(toolOutputsDir, fileName);
|
||||
|
||||
await fs.writeFile(filePath, content);
|
||||
@@ -214,9 +214,10 @@ export function createToolMaskingProcessor(
|
||||
functionCall: newFC,
|
||||
});
|
||||
|
||||
const newId = deriveStableId([node.id, 'masked']);
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: randomUUID(),
|
||||
id: newId,
|
||||
payload: maskedPart,
|
||||
replacesId: node.id,
|
||||
turnId: node.turnId,
|
||||
@@ -242,9 +243,10 @@ export function createToolMaskingProcessor(
|
||||
functionResponse: newFR,
|
||||
});
|
||||
|
||||
const newId = deriveStableId([node.id, 'masked']);
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: randomUUID(),
|
||||
id: newId,
|
||||
payload: maskedPart,
|
||||
replacesId: node.id,
|
||||
turnId: node.turnId,
|
||||
|
||||
+298
-198
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ import { SimulationHarness } from './simulationHarness.js';
|
||||
import { createMockLlmClient } from '../testing/contextTestUtils.js';
|
||||
import type { ContextProfile } from '../config/profiles.js';
|
||||
import { generalistProfile } from '../config/profiles.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import { type HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
|
||||
describe('Context Manager Hysteresis Tests', () => {
|
||||
const mockLlmClient = createMockLlmClient(['<SNAPSHOT>']);
|
||||
@@ -18,6 +18,7 @@ describe('Context Manager Hysteresis Tests', () => {
|
||||
...generalistProfile,
|
||||
name: 'Hysteresis Stress Test',
|
||||
config: {
|
||||
...generalistProfile.config,
|
||||
budget: {
|
||||
maxTokens: 5000,
|
||||
retainedTokens: 1000,
|
||||
@@ -26,9 +27,13 @@ describe('Context Manager Hysteresis Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const getProjectionTokens = (proj: Content[], harness: SimulationHarness) =>
|
||||
const getProjectionTokens = (
|
||||
proj: HistoryTurn[],
|
||||
harness: SimulationHarness,
|
||||
) =>
|
||||
proj.reduce(
|
||||
(sum, c) => sum + harness.env.tokenCalculator.calculateContentTokens(c),
|
||||
(sum, c) =>
|
||||
sum + harness.env.tokenCalculator.calculateContentTokens(c.content),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -57,7 +62,7 @@ describe('Context Manager Hysteresis Tests', () => {
|
||||
// No snapshot because maxTokens (5000) not exceeded, and deficit < threshold.
|
||||
expect(
|
||||
state.finalProjection.some((c) =>
|
||||
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
|
||||
c.content.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
@@ -79,7 +84,7 @@ describe('Context Manager Hysteresis Tests', () => {
|
||||
state = await harness.getGoldenState();
|
||||
expect(
|
||||
state.finalProjection.some((c) =>
|
||||
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
|
||||
c.content.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -108,7 +113,7 @@ describe('Context Manager Hysteresis Tests', () => {
|
||||
let state = await harness.getGoldenState();
|
||||
expect(
|
||||
state.finalProjection.some((c) =>
|
||||
c.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
|
||||
c.content.parts?.some((p) => p.text?.includes('<SNAPSHOT>')),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ expect.addSnapshotSerializer({
|
||||
(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(
|
||||
val,
|
||||
) ||
|
||||
/^[0-9a-f]{32}$/i.test(val) ||
|
||||
/[\\/]tmp[\\/]sim/.test(val)),
|
||||
print: (val) => {
|
||||
if (typeof val !== 'string') return `"${val}"`;
|
||||
@@ -25,6 +26,7 @@ expect.addSnapshotSerializer({
|
||||
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
|
||||
'<UUID>',
|
||||
)
|
||||
.replace(/\b[0-9a-f]{32}\b/gi, '<UUID>')
|
||||
.replace(/[\\/]tmp[\\/]sim[^\s"'\]]*/g, '<MOCKED_DIR>');
|
||||
|
||||
// Also scrub timestamps in filenames like blob_1234567890_...
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ContextManager } from '../contextManager.js';
|
||||
import { AgentChatHistory } from '../../core/agentChatHistory.js';
|
||||
import type { Content } from '@google/genai';
|
||||
@@ -98,7 +99,8 @@ export class SimulationHarness {
|
||||
async simulateTurn(messages: Content[]) {
|
||||
// 1. Append the new messages
|
||||
const currentHistory = this.chatHistory.get();
|
||||
this.chatHistory.set([...currentHistory, ...messages]);
|
||||
const turns = messages.map((m) => ({ id: randomUUID(), content: m }));
|
||||
this.chatHistory.set([...currentHistory, ...turns]);
|
||||
|
||||
// 2. Measure tokens immediately after append
|
||||
const tokensBefore = this.env.tokenCalculator.calculateConcreteListTokens(
|
||||
|
||||
@@ -122,4 +122,69 @@ describe('AdaptiveTokenCalculator', () => {
|
||||
|
||||
expect(calculator.getLearnedWeight()).toBe(1.0);
|
||||
});
|
||||
|
||||
it('should subtract overhead tokens from actual tokens when determining target weight', () => {
|
||||
const eventBus = new ContextEventBus();
|
||||
const getOverheadTokens = () => 40;
|
||||
const calculator = new AdaptiveTokenCalculator(
|
||||
charsPerToken,
|
||||
registry,
|
||||
eventBus,
|
||||
getOverheadTokens,
|
||||
);
|
||||
|
||||
// Initial state: weight = 1.0
|
||||
|
||||
// Simulate an event where the API reported 100 tokens, and our base units were 100
|
||||
// But overhead is 40.
|
||||
// actualGraphTokens = 100 - 40 = 60
|
||||
// rawTargetWeight = 60 / 100 = 0.6
|
||||
// targetWeight = Math.max(0.5, 0.6) = 0.6
|
||||
// newWeight = 1.0 * 0.8 + 0.6 * 0.2 = 0.8 + 0.12 = 0.92
|
||||
eventBus.emitTokenGroundTruth({
|
||||
actualTokens: 100,
|
||||
promptBaseUnits: 100,
|
||||
});
|
||||
|
||||
expect(calculator.getLearnedWeight()).toBeCloseTo(0.92, 5);
|
||||
});
|
||||
|
||||
it('should enforce the maxStep limit to prevent violent oscillation from massive outliers', () => {
|
||||
const eventBus = new ContextEventBus();
|
||||
const maxStep = 0.05; // Tight limit
|
||||
const calculator = new AdaptiveTokenCalculator(
|
||||
charsPerToken,
|
||||
registry,
|
||||
eventBus,
|
||||
undefined,
|
||||
{ maxStep },
|
||||
);
|
||||
|
||||
// Initial state: weight = 1.0
|
||||
|
||||
// Simulate a massive outlier where the API reports 10,000 tokens for 100 base units.
|
||||
// rawTargetWeight = 100
|
||||
// targetWeight = Math.min(100, 1.0 * 2.0) = 2.0
|
||||
// emaWeight = 1.0 * 0.8 + 2.0 * 0.2 = 1.2
|
||||
// BUT maxStep is 0.05, so the actual step is clamped.
|
||||
// finalWeight = 1.0 + 0.05 = 1.05
|
||||
eventBus.emitTokenGroundTruth({
|
||||
actualTokens: 10000,
|
||||
promptBaseUnits: 100,
|
||||
});
|
||||
|
||||
expect(calculator.getLearnedWeight()).toBeCloseTo(1.05, 5);
|
||||
|
||||
// Simulate a massive under-estimation
|
||||
// rawTargetWeight = 0
|
||||
// targetWeight = Math.max(0, 1.05 * 0.5) = 0.525
|
||||
// emaWeight = 1.05 * 0.8 + 0.525 * 0.2 = 0.84 + 0.105 = 0.945
|
||||
// BUT maxStep is 0.05, so step is clamped: 1.05 - 0.05 = 1.0
|
||||
eventBus.emitTokenGroundTruth({
|
||||
actualTokens: 0,
|
||||
promptBaseUnits: 100,
|
||||
});
|
||||
|
||||
expect(calculator.getLearnedWeight()).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,13 @@ import type { NodeBehaviorRegistry } from '../graph/behaviorRegistry.js';
|
||||
import type { ContextEventBus, TokenGroundTruthEvent } from '../eventBus.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
export interface AdaptiveLearningConfig {
|
||||
/** The momentum factor for the Exponential Moving Average (EMA). Defaults to 0.2. */
|
||||
learningRate?: number;
|
||||
/** The absolute maximum change allowed to the weight in a single turn. Defaults to 0.15. */
|
||||
maxStep?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An Adaptive Token Calculator that dynamically learns the true token cost of the user's
|
||||
* conversation by applying an Exponential Moving Average (EMA) gradient descent to
|
||||
@@ -26,12 +33,18 @@ import { debugLogger } from '../../utils/debugLogger.js';
|
||||
export class AdaptiveTokenCalculator implements AdvancedTokenCalculator {
|
||||
private learnedWeight = 1.0;
|
||||
private readonly baseCalculator: StaticTokenCalculator;
|
||||
private readonly learningRate: number;
|
||||
private readonly maxStep: number;
|
||||
|
||||
constructor(
|
||||
charsPerToken: number,
|
||||
registry: NodeBehaviorRegistry,
|
||||
eventBus: ContextEventBus,
|
||||
private readonly getOverheadTokens?: () => number,
|
||||
config?: AdaptiveLearningConfig,
|
||||
) {
|
||||
this.learningRate = config?.learningRate ?? 0.2;
|
||||
this.maxStep = config?.maxStep ?? 0.15;
|
||||
this.baseCalculator = new StaticTokenCalculator(charsPerToken, registry);
|
||||
eventBus.onTokenGroundTruth((event: TokenGroundTruthEvent) => {
|
||||
this.handleGroundTruth(event.actualTokens, event.promptBaseUnits);
|
||||
@@ -41,21 +54,44 @@ export class AdaptiveTokenCalculator implements AdvancedTokenCalculator {
|
||||
private handleGroundTruth(actualTokens: number, promptBaseUnits: number) {
|
||||
if (promptBaseUnits <= 0) return;
|
||||
|
||||
const overheadTokens = this.getOverheadTokens
|
||||
? this.getOverheadTokens()
|
||||
: 0;
|
||||
|
||||
// The Gemini API token count includes the static overhead (system instruction + tools)
|
||||
// and the dynamic chat history (which we measure as promptBaseUnits).
|
||||
// We subtract the overhead so the adaptive calculator is comparing "apples to apples"
|
||||
// when learning the weight multiplier for the graph nodes.
|
||||
const actualGraphTokens = Math.max(0, actualTokens - overheadTokens);
|
||||
|
||||
// Determine what ratio we should have used
|
||||
const targetWeight = actualTokens / promptBaseUnits;
|
||||
const rawTargetWeight = actualGraphTokens / promptBaseUnits;
|
||||
const oldWeight = this.learnedWeight;
|
||||
|
||||
// Apply Momentum (Learning Rate)
|
||||
const learningRate = 0.2;
|
||||
const newWeight =
|
||||
oldWeight * (1 - learningRate) + targetWeight * learningRate;
|
||||
// Dampen extreme outliers *before* applying the EMA by capping the target weight
|
||||
// to a reasonable multiple of the current weight. This prevents a single massive
|
||||
// anomaly from destroying the running average.
|
||||
const targetWeight = Math.max(
|
||||
oldWeight * 0.5,
|
||||
Math.min(rawTargetWeight, oldWeight * 2.0),
|
||||
);
|
||||
|
||||
// Clamp to reasonable safety bounds to prevent rogue metadata poisoning the system
|
||||
// Apply Momentum (Learning Rate)
|
||||
let newWeight =
|
||||
oldWeight * (1 - this.learningRate) + targetWeight * this.learningRate;
|
||||
|
||||
// Hard limit the maximum step size per turn to prevent violent oscillation
|
||||
if (newWeight > oldWeight + this.maxStep)
|
||||
newWeight = oldWeight + this.maxStep;
|
||||
if (newWeight < oldWeight - this.maxStep)
|
||||
newWeight = oldWeight - this.maxStep;
|
||||
|
||||
// Clamp to reasonable absolute safety bounds
|
||||
this.learnedWeight = Math.max(0.5, Math.min(newWeight, 2.0));
|
||||
|
||||
debugLogger.log(
|
||||
`[AdaptiveTokenCalculator] Learned weight updated to ${this.learnedWeight.toFixed(3)} ` +
|
||||
`(API Tokens: ${actualTokens}, Base Units: ${promptBaseUnits}, Target Ratio: ${targetWeight.toFixed(3)})`,
|
||||
`(API Tokens: ${actualTokens}, Overhead: ${overheadTokens}, Graph Tokens: ${actualGraphTokens}, Base Units: ${promptBaseUnits}, Target Ratio: ${targetWeight.toFixed(3)})`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,132 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SnapshotGenerator, type SnapshotState } from './snapshotGenerator.js';
|
||||
import {
|
||||
SnapshotGenerator,
|
||||
type SnapshotState,
|
||||
SnapshotStateHelper,
|
||||
} from './snapshotGenerator.js';
|
||||
import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
import { NodeType, type ConcreteNode } from '../graph/types.js';
|
||||
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
describe('SnapshotStateHelper', () => {
|
||||
describe('exportState', () => {
|
||||
it('should flatten nested abstractsIds to pristine IDs', () => {
|
||||
// Setup a graph with nested snapshots
|
||||
// S3 abstracts [S2, N5]
|
||||
// S2 abstracts [S1, N3, N4]
|
||||
// S1 abstracts [N1, N2]
|
||||
|
||||
const nodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'N1',
|
||||
type: NodeType.USER_PROMPT,
|
||||
timestamp: 10,
|
||||
role: 'user',
|
||||
payload: { text: '1' },
|
||||
turnId: 'T1',
|
||||
},
|
||||
{
|
||||
id: 'N2',
|
||||
type: NodeType.AGENT_THOUGHT,
|
||||
timestamp: 20,
|
||||
role: 'model',
|
||||
payload: { text: '2' },
|
||||
turnId: 'T1',
|
||||
},
|
||||
{
|
||||
id: 'S1',
|
||||
type: NodeType.SNAPSHOT,
|
||||
timestamp: 30,
|
||||
role: 'user',
|
||||
payload: { text: 'State 1' },
|
||||
turnId: 'S1',
|
||||
abstractsIds: ['N1', 'N2'],
|
||||
},
|
||||
{
|
||||
id: 'N3',
|
||||
type: NodeType.USER_PROMPT,
|
||||
timestamp: 40,
|
||||
role: 'user',
|
||||
payload: { text: '3' },
|
||||
turnId: 'T2',
|
||||
},
|
||||
{
|
||||
id: 'N4',
|
||||
type: NodeType.AGENT_THOUGHT,
|
||||
timestamp: 50,
|
||||
role: 'model',
|
||||
payload: { text: '4' },
|
||||
turnId: 'T2',
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
type: NodeType.SNAPSHOT,
|
||||
timestamp: 60,
|
||||
role: 'user',
|
||||
payload: { text: 'State 2' },
|
||||
turnId: 'S2',
|
||||
abstractsIds: ['S1', 'N3', 'N4'],
|
||||
},
|
||||
{
|
||||
id: 'N5',
|
||||
type: NodeType.USER_PROMPT,
|
||||
timestamp: 70,
|
||||
role: 'user',
|
||||
payload: { text: '5' },
|
||||
turnId: 'T3',
|
||||
},
|
||||
{
|
||||
id: 'S3',
|
||||
type: NodeType.SNAPSHOT,
|
||||
timestamp: 80,
|
||||
role: 'user',
|
||||
payload: { text: 'State 3' },
|
||||
turnId: 'S3',
|
||||
abstractsIds: ['S2', 'N5'],
|
||||
},
|
||||
];
|
||||
|
||||
const state = SnapshotStateHelper.exportState(nodes);
|
||||
|
||||
expect(state.snapshot).toBeDefined();
|
||||
expect(state.snapshot?.text).toBe('State 3');
|
||||
|
||||
// Should be flattened to only the "pristine" (non-snapshot) IDs
|
||||
const consumedIds = state.snapshot?.consumedIds;
|
||||
expect(consumedIds).toContain('N1');
|
||||
expect(consumedIds).toContain('N2');
|
||||
expect(consumedIds).toContain('N3');
|
||||
expect(consumedIds).toContain('N4');
|
||||
expect(consumedIds).toContain('N5');
|
||||
|
||||
// Should NOT contain the intermediate snapshot IDs
|
||||
expect(consumedIds).not.toContain('S1');
|
||||
expect(consumedIds).not.toContain('S2');
|
||||
|
||||
expect(consumedIds?.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should return empty state if no snapshot baseline is found', () => {
|
||||
const nodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'N1',
|
||||
type: NodeType.USER_PROMPT,
|
||||
timestamp: 10,
|
||||
role: 'user',
|
||||
payload: { text: '1' },
|
||||
turnId: 'T1',
|
||||
},
|
||||
];
|
||||
|
||||
const state = SnapshotStateHelper.exportState(nodes);
|
||||
expect(state).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnapshotGenerator', () => {
|
||||
let mockEnv: ContextEnvironment;
|
||||
let mockGenerateJson: Mock;
|
||||
|
||||
@@ -48,10 +48,39 @@ export interface SnapshotState {
|
||||
recent_arc: string[];
|
||||
}
|
||||
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
export function isSnapshotState(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (!isRecord(parsed)) return false;
|
||||
const isSnap =
|
||||
Array.isArray(parsed['active_tasks']) &&
|
||||
Array.isArray(parsed['discovered_facts']) &&
|
||||
Array.isArray(parsed['constraints_and_preferences']) &&
|
||||
Array.isArray(parsed['recent_arc']);
|
||||
if (!isSnap) {
|
||||
debugLogger.log(
|
||||
'[isSnapshotState] FAILED FOR JSON:',
|
||||
JSON.stringify(parsed),
|
||||
);
|
||||
}
|
||||
return isSnap;
|
||||
} catch {
|
||||
debugLogger.log('[isSnapshotState] PARSE FAILED FOR:', trimmed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BaselineSnapshotInfo {
|
||||
text: string;
|
||||
abstractsIds: string[];
|
||||
id: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +90,20 @@ export interface BaselineSnapshotInfo {
|
||||
export function findLatestSnapshotBaseline(
|
||||
targets: readonly ConcreteNode[],
|
||||
): BaselineSnapshotInfo | undefined {
|
||||
debugLogger.log(
|
||||
'[findLatestSnapshotBaseline] Targets:',
|
||||
targets.map((t) => ({
|
||||
id: t.id,
|
||||
type: t.type,
|
||||
text:
|
||||
t.payload &&
|
||||
typeof t.payload === 'object' &&
|
||||
'text' in t.payload &&
|
||||
typeof t.payload.text === 'string'
|
||||
? t.payload.text.substring(0, 20)
|
||||
: '',
|
||||
})),
|
||||
);
|
||||
const lastSnapshotNode = [...targets]
|
||||
.reverse()
|
||||
.find((n) => n.type === NodeType.SNAPSHOT && n.payload.text);
|
||||
@@ -72,8 +115,10 @@ export function findLatestSnapshotBaseline(
|
||||
? [...lastSnapshotNode.abstractsIds]
|
||||
: [],
|
||||
id: lastSnapshotNode.id,
|
||||
timestamp: lastSnapshotNode.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -326,3 +371,61 @@ ${formatNodesForLlm(nodes)}`;
|
||||
return JSON.stringify(newState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared logic for working with Snapshot node state.
|
||||
*/
|
||||
export class SnapshotStateHelper {
|
||||
/**
|
||||
* Flatten nested abstract IDs to only the "pristine" (non-snapshot) IDs.
|
||||
*/
|
||||
static flattenAbstracts(
|
||||
nodes: ConcreteNode[],
|
||||
abstractsIds: readonly string[],
|
||||
): string[] {
|
||||
const pristineIds: string[] = [];
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
const walk = (ids: readonly string[]) => {
|
||||
for (const id of ids) {
|
||||
const node = nodeMap.get(id);
|
||||
if (!node) {
|
||||
// Fallback: if node not in map, treat as pristine ID
|
||||
pristineIds.push(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === NodeType.SNAPSHOT && node.abstractsIds) {
|
||||
walk(node.abstractsIds);
|
||||
} else {
|
||||
pristineIds.push(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(abstractsIds);
|
||||
return Array.from(new Set(pristineIds)); // Dedupe
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract state from the most recent snapshot in a list of nodes.
|
||||
*/
|
||||
static exportState(nodes: ConcreteNode[]): {
|
||||
snapshot?: { text: string; consumedIds: string[] };
|
||||
} {
|
||||
const baseline = findLatestSnapshotBaseline(nodes);
|
||||
if (!baseline) return {};
|
||||
|
||||
const node = nodes.find((n) => n.id === baseline.id);
|
||||
if (!node || node.type !== NodeType.SNAPSHOT) return {};
|
||||
|
||||
const consumedIds = this.flattenAbstracts(nodes, node.abstractsIds || []);
|
||||
|
||||
return {
|
||||
snapshot: {
|
||||
text: baseline.text,
|
||||
consumedIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,35 @@
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
|
||||
/**
|
||||
* A durable wrapper for Gemini Content that carries a stable ID.
|
||||
* This ID is preserved across all transformations and is used as the anchor
|
||||
* for context graph node identity.
|
||||
*/
|
||||
export interface HistoryTurn {
|
||||
readonly id: string;
|
||||
readonly content: Content;
|
||||
}
|
||||
|
||||
export type HistoryEventType = 'PUSH' | 'SYNC_FULL' | 'CLEAR' | 'SILENT_SYNC';
|
||||
|
||||
export interface HistoryEvent {
|
||||
type: HistoryEventType;
|
||||
payload: readonly Content[];
|
||||
payload: readonly HistoryTurn[];
|
||||
}
|
||||
|
||||
export type HistoryListener = (event: HistoryEvent) => void;
|
||||
|
||||
/**
|
||||
* The 'Strong Owner' of chat history turns.
|
||||
* It ensures that every turn in the session is associated with a durable ID.
|
||||
*/
|
||||
export class AgentChatHistory {
|
||||
private history: Content[];
|
||||
private history: HistoryTurn[] = [];
|
||||
private listeners: Set<HistoryListener> = new Set();
|
||||
|
||||
constructor(initialHistory: Content[] = []) {
|
||||
this.history = [...initialHistory];
|
||||
constructor(initialTurns: HistoryTurn[] = []) {
|
||||
this.history = [...initialTurns];
|
||||
}
|
||||
|
||||
subscribe(listener: HistoryListener): () => void {
|
||||
@@ -30,20 +44,27 @@ export class AgentChatHistory {
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(type: HistoryEventType, payload: readonly Content[]) {
|
||||
private notify(type: HistoryEventType, payload: readonly HistoryTurn[]) {
|
||||
const event: HistoryEvent = { type, payload };
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
push(content: Content) {
|
||||
this.history.push(content);
|
||||
this.notify('PUSH', [content]);
|
||||
/**
|
||||
* Adds a new turn to the history.
|
||||
* Every turn must have a durable ID, usually provided by the ChatRecordingService.
|
||||
*/
|
||||
push(turn: HistoryTurn) {
|
||||
this.history.push(turn);
|
||||
this.notify('PUSH', [turn]);
|
||||
}
|
||||
|
||||
set(history: readonly Content[], options: { silent?: boolean } = {}) {
|
||||
this.history = [...history];
|
||||
/**
|
||||
* Overwrites the entire history with a new list of turns.
|
||||
*/
|
||||
set(turns: readonly HistoryTurn[], options: { silent?: boolean } = {}) {
|
||||
this.history = [...turns];
|
||||
this.notify(options.silent ? 'SILENT_SYNC' : 'SYNC_FULL', this.history);
|
||||
}
|
||||
|
||||
@@ -52,20 +73,28 @@ export class AgentChatHistory {
|
||||
this.notify('CLEAR', []);
|
||||
}
|
||||
|
||||
get(): readonly Content[] {
|
||||
get(): readonly HistoryTurn[] {
|
||||
return this.history;
|
||||
}
|
||||
|
||||
map(callback: (value: Content, index: number, array: Content[]) => Content) {
|
||||
this.history = this.history.map(callback);
|
||||
this.notify('SYNC_FULL', this.history);
|
||||
/**
|
||||
* Returns a copy of the raw Gemini Content[] for API consumption.
|
||||
*/
|
||||
getContents(): Content[] {
|
||||
return this.history.map((h) => h.content);
|
||||
}
|
||||
|
||||
map<U>(
|
||||
callback: (value: HistoryTurn, index: number, array: HistoryTurn[]) => U,
|
||||
): U[] {
|
||||
return this.history.map(callback);
|
||||
}
|
||||
|
||||
flatMap<U>(
|
||||
callback: (
|
||||
value: Content,
|
||||
value: HistoryTurn,
|
||||
index: number,
|
||||
array: Content[],
|
||||
array: HistoryTurn[],
|
||||
) => U | readonly U[],
|
||||
): U[] {
|
||||
return this.history.flatMap(callback);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,6 +36,18 @@ export type FakeResponse =
|
||||
response: EmbedContentResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for the FakeContentGenerator.
|
||||
*/
|
||||
export interface FakeContentGeneratorOptions {
|
||||
/**
|
||||
* If true, the generator will find the first available response that matches
|
||||
* the requested method, rather than strictly following the input order.
|
||||
* Useful for non-deterministic background tasks.
|
||||
*/
|
||||
nonStrict?: boolean;
|
||||
}
|
||||
|
||||
// A ContentGenerator that responds with canned responses.
|
||||
//
|
||||
// Typically these would come from a file, provided by the `--fake-responses`
|
||||
@@ -46,22 +58,45 @@ export class FakeContentGenerator implements ContentGenerator {
|
||||
userTierName?: string;
|
||||
paidTier?: GeminiUserTier;
|
||||
|
||||
constructor(private readonly responses: FakeResponse[]) {}
|
||||
private readonly responses: FakeResponse[];
|
||||
|
||||
static async fromFile(filePath: string): Promise<FakeContentGenerator> {
|
||||
constructor(
|
||||
responses: FakeResponse[],
|
||||
private readonly options: FakeContentGeneratorOptions = {},
|
||||
) {
|
||||
this.responses = structuredClone(responses);
|
||||
}
|
||||
|
||||
static async fromFile(
|
||||
filePath: string,
|
||||
options: FakeContentGeneratorOptions = {},
|
||||
): Promise<FakeContentGenerator> {
|
||||
const fileContent = await promises.readFile(filePath, 'utf-8');
|
||||
const responses = fileContent
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
.map((line) => JSON.parse(line) as FakeResponse);
|
||||
return new FakeContentGenerator(responses);
|
||||
return new FakeContentGenerator(responses, options);
|
||||
}
|
||||
|
||||
private getNextResponse<
|
||||
M extends FakeResponse['method'],
|
||||
R = Extract<FakeResponse, { method: M }>['response'],
|
||||
>(method: M, request: unknown): R {
|
||||
if (this.options.nonStrict) {
|
||||
const index = this.responses.findIndex((r) => r.method === method);
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
`No more mock responses for ${method}, got request:\n` +
|
||||
safeJsonStringify(request),
|
||||
);
|
||||
}
|
||||
const response = this.responses.splice(index, 1)[0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return response.response as R;
|
||||
}
|
||||
|
||||
const response = this.responses[this.callCounter++];
|
||||
if (!response) {
|
||||
throw new Error(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
type StreamEvent,
|
||||
stripToolCallIdPrefixes,
|
||||
type HistoryTurn,
|
||||
} from './geminiChat.js';
|
||||
import {
|
||||
type CompletedToolCall,
|
||||
@@ -40,6 +41,7 @@ import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils
|
||||
import type { HookSystem } from '../hooks/hookSystem.js';
|
||||
import { LlmRole } from '../telemetry/types.js';
|
||||
import { BINARY_INJECTION_KEY } from '../utils/generateContentResponseUtilities.js';
|
||||
import type { ResumedSessionData } from '../services/chatRecordingTypes.js';
|
||||
|
||||
// Mock fs module to prevent actual file system operations during tests
|
||||
const mockFileSystem = new Map<string, string>();
|
||||
@@ -234,9 +236,9 @@ describe('GeminiChat', () => {
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize lastPromptTokenCount based on history size', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
|
||||
{ id: '2', content: { role: 'model', parts: [{ text: 'Hi there' }] } },
|
||||
];
|
||||
const chatWithHistory = new GeminiChat(mockConfig, '', [], history);
|
||||
// 'Hello': 5 chars * 0.25 = 1.25
|
||||
@@ -249,12 +251,66 @@ describe('GeminiChat', () => {
|
||||
const chatEmpty = new GeminiChat(mockConfig);
|
||||
expect(chatEmpty.getLastPromptTokenCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should prioritize in-memory history over resumedSessionData', () => {
|
||||
// This test simulates a "hot restart" after a context management operation
|
||||
// like compression, where the in-memory history is shorter and more up-to-date
|
||||
// than the session data that might be on disk.
|
||||
|
||||
// 1. A stale, longer history from a persisted session record
|
||||
const resumedSessionData = {
|
||||
conversation: {
|
||||
messages: [
|
||||
{
|
||||
id: 'a',
|
||||
type: 'user',
|
||||
content: [{ text: 'turn 1' }],
|
||||
create_time: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'turn 2' }],
|
||||
create_time: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
type: 'user',
|
||||
content: [{ text: 'turn 3' }],
|
||||
create_time: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as ResumedSessionData;
|
||||
|
||||
// 2. A fresh, compressed in-memory history
|
||||
const compressedHistory: HistoryTurn[] = [
|
||||
{
|
||||
id: 'summary-1',
|
||||
content: { role: 'user', parts: [{ text: 'summary of turns 1-3' }] },
|
||||
},
|
||||
];
|
||||
|
||||
// 3. Instantiate the chat, providing both.
|
||||
const chat = new GeminiChat(
|
||||
mockConfig,
|
||||
'',
|
||||
[],
|
||||
compressedHistory, // This should be prioritized
|
||||
resumedSessionData, // This should be ignored
|
||||
);
|
||||
|
||||
// 4. Assert that the shorter, in-memory history was used.
|
||||
const finalHistory = chat.getHistoryTurns();
|
||||
expect(finalHistory).toHaveLength(1);
|
||||
expect(finalHistory[0].id).toBe('summary-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setHistory', () => {
|
||||
it('should recalculate lastPromptTokenCount when history is updated', () => {
|
||||
const initialHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
const initialHistory: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'Hello' }] } },
|
||||
];
|
||||
const chatWithHistory = new GeminiChat(
|
||||
mockConfig,
|
||||
@@ -264,14 +320,17 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
const initialCount = chatWithHistory.getLastPromptTokenCount();
|
||||
|
||||
const newHistory: Content[] = [
|
||||
const newHistory: HistoryTurn[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: 'This is a much longer history item that should result in more tokens than just hello.',
|
||||
},
|
||||
],
|
||||
id: '2',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: 'This is a much longer history item that should result in more tokens than just hello.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
chatWithHistory.setHistory(newHistory);
|
||||
@@ -331,9 +390,9 @@ describe('GeminiChat', () => {
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// 3. Verify history was recorded correctly
|
||||
const history = chat.getHistory();
|
||||
const history = chat.getHistoryTurns();
|
||||
expect(history.length).toBe(2); // user turn + model turn
|
||||
const modelTurn = history[1];
|
||||
const modelTurn = history[1].content;
|
||||
expect(modelTurn?.parts?.length).toBe(1); // The empty part is discarded
|
||||
expect(modelTurn?.parts![0].functionCall).toBeDefined();
|
||||
});
|
||||
@@ -433,9 +492,9 @@ describe('GeminiChat', () => {
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// 3. Verify history was recorded correctly with only the valid part.
|
||||
const history = chat.getHistory();
|
||||
const history = chat.getHistoryTurns();
|
||||
expect(history.length).toBe(2); // user turn + model turn
|
||||
const modelTurn = history[1];
|
||||
const modelTurn = history[1].content;
|
||||
expect(modelTurn?.parts?.length).toBe(1);
|
||||
expect(modelTurn?.parts![0].text).toBe('Initial valid content...');
|
||||
});
|
||||
@@ -478,9 +537,9 @@ describe('GeminiChat', () => {
|
||||
}
|
||||
|
||||
// 3. Assert: Check that the final history was correctly consolidated.
|
||||
const history = chat.getHistory();
|
||||
const history = chat.getHistoryTurns();
|
||||
expect(history.length).toBe(2);
|
||||
const modelTurn = history[1];
|
||||
const modelTurn = history[1].content;
|
||||
expect(modelTurn?.parts?.length).toBe(1);
|
||||
expect(modelTurn?.parts![0].text).toBe('Hello World!');
|
||||
});
|
||||
@@ -538,12 +597,12 @@ describe('GeminiChat', () => {
|
||||
}
|
||||
|
||||
// 3. Assert: Check that the final history was correctly consolidated.
|
||||
const history = chat.getHistory();
|
||||
const history = chat.getHistoryTurns();
|
||||
|
||||
// The history should contain the user's turn and ONE consolidated model turn.
|
||||
expect(history.length).toBe(2);
|
||||
|
||||
const modelTurn = history[1];
|
||||
const modelTurn = history[1].content;
|
||||
expect(modelTurn.role).toBe('model');
|
||||
|
||||
// The model turn should have 3 distinct parts: the merged text, the function call, and the final text.
|
||||
@@ -599,10 +658,10 @@ describe('GeminiChat', () => {
|
||||
}
|
||||
|
||||
// 3. Assert: Check that the final history contains both function calls.
|
||||
const history = chat.getHistory();
|
||||
const history = chat.getHistoryTurns();
|
||||
expect(history.length).toBe(2);
|
||||
|
||||
const modelTurn = history[1];
|
||||
const modelTurn = history[1].content;
|
||||
expect(modelTurn.role).toBe('model');
|
||||
expect(modelTurn.parts?.length).toBe(2);
|
||||
expect(modelTurn.parts![0].functionCall?.name).toBe('tool_A');
|
||||
@@ -647,8 +706,8 @@ describe('GeminiChat', () => {
|
||||
// Consume the stream to trigger history recording
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
const modelTurn = history[1];
|
||||
const history = chat.getHistoryTurns();
|
||||
const modelTurn = history[1].content;
|
||||
expect(modelTurn.parts?.length).toBe(2);
|
||||
expect(modelTurn.parts![0].functionCall?.name).toBe('tool_X');
|
||||
expect(modelTurn.parts![0].functionCall?.args).toEqual({ id: 1 });
|
||||
@@ -694,12 +753,12 @@ describe('GeminiChat', () => {
|
||||
}
|
||||
|
||||
// 3. Assert: Check the final state of the history.
|
||||
const history = chat.getHistory();
|
||||
const history = chat.getHistoryTurns();
|
||||
|
||||
// The history should contain two turns: the user's message and the model's response.
|
||||
expect(history.length).toBe(2);
|
||||
|
||||
const modelTurn = history[1];
|
||||
const modelTurn = history[1].content;
|
||||
expect(modelTurn.role).toBe('model');
|
||||
|
||||
// CRUCIAL ASSERTION:
|
||||
@@ -713,21 +772,27 @@ describe('GeminiChat', () => {
|
||||
|
||||
it('should throw an error when a tool call is followed by an empty stream response', async () => {
|
||||
// 1. Setup: A history where the model has just made a function call.
|
||||
const initialHistory: Content[] = [
|
||||
const initialHistory: HistoryTurn[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Find a good Italian restaurant for me.' }],
|
||||
id: '1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'Find a good Italian restaurant for me.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'find_restaurant',
|
||||
args: { cuisine: 'Italian' },
|
||||
id: '2',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'find_restaurant',
|
||||
args: { cuisine: 'Italian' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
chat.setHistory(initialHistory);
|
||||
@@ -1251,31 +1316,40 @@ describe('GeminiChat', () => {
|
||||
|
||||
describe('addHistory', () => {
|
||||
it('should add a new content item to the history', () => {
|
||||
const newContent: Content = {
|
||||
role: 'user',
|
||||
parts: [{ text: 'A new message' }],
|
||||
const newTurn: HistoryTurn = {
|
||||
id: '1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'A new message' }],
|
||||
},
|
||||
};
|
||||
chat.addHistory(newContent);
|
||||
const history = chat.getHistory();
|
||||
chat.addHistory(newTurn);
|
||||
const history = chat.getHistoryTurns();
|
||||
expect(history.length).toBe(1);
|
||||
expect(history[0]).toEqual(newContent);
|
||||
expect(history[0]).toEqual(newTurn);
|
||||
});
|
||||
|
||||
it('should add multiple items correctly', () => {
|
||||
const content1: Content = {
|
||||
role: 'user',
|
||||
parts: [{ text: 'Message 1' }],
|
||||
const turn1: HistoryTurn = {
|
||||
id: '1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'Message 1' }],
|
||||
},
|
||||
};
|
||||
const content2: Content = {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Message 2' }],
|
||||
const turn2: HistoryTurn = {
|
||||
id: '2',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Message 2' }],
|
||||
},
|
||||
};
|
||||
chat.addHistory(content1);
|
||||
chat.addHistory(content2);
|
||||
const history = chat.getHistory();
|
||||
chat.addHistory(turn1);
|
||||
chat.addHistory(turn2);
|
||||
const history = chat.getHistoryTurns();
|
||||
expect(history.length).toBe(2);
|
||||
expect(history[0]).toEqual(content1);
|
||||
expect(history[1]).toEqual(content2);
|
||||
expect(history[0]).toEqual(turn1);
|
||||
expect(history[1]).toEqual(turn2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
type GenerateContentParameters,
|
||||
type FunctionCall,
|
||||
} from '@google/genai';
|
||||
import { AgentChatHistory } from './agentChatHistory.js';
|
||||
export { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
|
||||
import { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { toParts } from '../code_assist/converter.js';
|
||||
import {
|
||||
retryWithBackoff,
|
||||
@@ -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.
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ describe('HookRunner', () => {
|
||||
);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/bash|powershell/),
|
||||
expect.stringMatching(/bash|pwsh|powershell/),
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/['"]?\/test\/project['"]?\/hooks\/test\.sh/),
|
||||
]),
|
||||
@@ -408,7 +408,7 @@ describe('HookRunner', () => {
|
||||
);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/bash|powershell/),
|
||||
expect.stringMatching(/bash|pwsh|powershell/),
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(
|
||||
/ls ['"]\/test\/project\/plans with spaces['"]/,
|
||||
@@ -447,7 +447,7 @@ describe('HookRunner', () => {
|
||||
|
||||
// If secure, spawn will be called with the shell executable and escaped command
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/bash|powershell/),
|
||||
expect.stringMatching(/bash|pwsh|powershell/),
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/ls (['"]).*echo.*pwned.*\1/),
|
||||
]),
|
||||
|
||||
@@ -36,6 +36,7 @@ export * from './commands/types.js';
|
||||
export * from './core/baseLlmClient.js';
|
||||
export * from './core/client.js';
|
||||
export * from './core/contentGenerator.js';
|
||||
export * from './core/fakeContentGenerator.js';
|
||||
export * from './core/loggingContentGenerator.js';
|
||||
export * from './core/geminiChat.js';
|
||||
export * from './core/logger.js';
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user