mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-31 04:01:01 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03ef280120 | |||
| 60b01761b8 | |||
| df81bfe1f2 | |||
| 1c926921ed | |||
| d1ca874dd1 | |||
| 470228e7a0 | |||
| 9d12980baa | |||
| b85a3bafe5 | |||
| 72ea38b306 | |||
| 57f3c9ca1a | |||
| 5ea957c84b |
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.github
|
||||
.gcp
|
||||
bundle
|
||||
evals
|
||||
integration-tests
|
||||
docs
|
||||
packages/cli
|
||||
packages/vscode-ide-companion
|
||||
packages/test-utils
|
||||
**/*.test.ts
|
||||
**/*.test.js
|
||||
**/src/**/*.ts
|
||||
!packages/a2a-server/dist/**
|
||||
!packages/core/dist/**
|
||||
@@ -25,7 +25,7 @@ You are an expert at fixing behavioral evaluations.
|
||||
the same scenario. We don't want to lose test fidelity by making the prompts too
|
||||
direct (i.e.: easy).
|
||||
- Your primary mechanism for improving the agent's behavior is to make changes to
|
||||
tool instructions, system prompt (snippets.ts), and/or modules that contribute to the prompt.
|
||||
tool instructions, prompt.ts, and/or modules that contribute to the prompt.
|
||||
- If prompt and description changes are unsuccessful, use logs and debugging to
|
||||
confirm that everything is working as expected.
|
||||
- If unable to fix the test, you can make recommendations for architecture changes
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"experimental": {
|
||||
"plan": true,
|
||||
"enableAgentHarness": true
|
||||
"toolOutputMasking": {
|
||||
"enabled": true
|
||||
},
|
||||
"plan": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -131,8 +131,7 @@ available combinations.
|
||||
- `!` on an empty prompt: Enter or exit shell mode.
|
||||
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press
|
||||
`Esc`, `Backspace`, or any printable key to close it. Press `?` again to close
|
||||
the panel and insert a `?` into the prompt. You can hide only the hint text
|
||||
via `ui.showShortcutsHint`, without changing this keyboard behavior.
|
||||
the panel and insert a `?` into the prompt.
|
||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||
mode.
|
||||
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||
|
||||
+7
-87
@@ -30,8 +30,6 @@ implementation strategy.
|
||||
- [The Planning Workflow](#the-planning-workflow)
|
||||
- [Exiting Plan Mode](#exiting-plan-mode)
|
||||
- [Tool Restrictions](#tool-restrictions)
|
||||
- [Customizing Planning with Skills](#customizing-planning-with-skills)
|
||||
- [Customizing Policies](#customizing-policies)
|
||||
|
||||
## Starting in Plan Mode
|
||||
|
||||
@@ -63,18 +61,15 @@ You can enter Plan Mode in three ways:
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||
(`Default` -> `Plan` -> `Auto-Edit`).
|
||||
2. **Command:** Type `/plan` in the input box.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
|
||||
then call the [`enter_plan_mode`] tool to switch modes.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...".
|
||||
|
||||
### The Planning Workflow
|
||||
|
||||
1. **Requirements:** The agent clarifies goals using [`ask_user`].
|
||||
1. **Requirements:** The agent clarifies goals using `ask_user`.
|
||||
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
|
||||
the codebase and validate assumptions.
|
||||
3. **Design:** The agent proposes alternative approaches with a recommended
|
||||
solution for you to choose from.
|
||||
4. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
5. **Review:** You review the plan.
|
||||
3. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
4. **Review:** You review the plan.
|
||||
- **Approve:** Exit Plan Mode and start implementation (switching to
|
||||
Auto-Edit or Default approval mode).
|
||||
- **Iterate:** Provide feedback to refine the plan.
|
||||
@@ -84,8 +79,8 @@ You can enter Plan Mode in three ways:
|
||||
To exit Plan Mode:
|
||||
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the
|
||||
finalized plan for your approval.
|
||||
1. **Tool:** The agent calls the `exit_plan_mode` tool to present the finalized
|
||||
plan for your approval.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
@@ -95,80 +90,11 @@ These are the only allowed tools:
|
||||
|
||||
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
|
||||
- **Search:** [`grep_search`], [`google_web_search`]
|
||||
- **Interaction:** [`ask_user`]
|
||||
- **Interaction:** `ask_user`
|
||||
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
|
||||
`postgres_read_schema`) are allowed.
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
||||
files in the `~/.gemini/tmp/<project>/plans/` directory.
|
||||
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
|
||||
resources in a read-only manner)
|
||||
|
||||
### Customizing Planning with Skills
|
||||
|
||||
You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI
|
||||
approaches planning for specific types of tasks. When a skill is activated
|
||||
during Plan Mode, its specialized instructions and procedural workflows will
|
||||
guide the research and design phases.
|
||||
|
||||
For example:
|
||||
|
||||
- A **"Database Migration"** skill could ensure the plan includes data safety
|
||||
checks and rollback strategies.
|
||||
- A **"Security Audit"** skill could prompt the agent to look for specific
|
||||
vulnerabilities during codebase exploration.
|
||||
- A **"Frontend Design"** skill could guide the agent to use specific UI
|
||||
components and accessibility standards in its proposal.
|
||||
|
||||
To use a skill in Plan Mode, you can explicitly ask the agent to "use the
|
||||
[skill-name] skill to plan..." or the agent may autonomously activate it based
|
||||
on the task description.
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
Plan Mode is designed to be read-only by default to ensure safety during the
|
||||
research phase. However, you may occasionally need to allow specific tools to
|
||||
assist in your planning.
|
||||
|
||||
Because user policies (Tier 2) have a higher base priority than built-in
|
||||
policies (Tier 1), you can override Plan Mode's default restrictions by creating
|
||||
a rule in your `~/.gemini/policies/` directory.
|
||||
|
||||
#### Example: Allow `git status` and `git diff` in Plan Mode
|
||||
|
||||
This rule allows you to check the repository status and see changes while in
|
||||
Plan Mode.
|
||||
|
||||
`~/.gemini/policies/git-research.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = ["git status", "git diff"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research sub-agents in Plan Mode
|
||||
|
||||
You can enable [experimental research sub-agents] like `codebase_investigator`
|
||||
to help gather architecture details during the planning phase.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
Tell the agent it can use these tools in your prompt, for example: _"You can
|
||||
check ongoing changes in git."_
|
||||
|
||||
For more information on how the policy engine works, see the [Policy Engine
|
||||
Guide].
|
||||
|
||||
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
||||
@@ -178,9 +104,3 @@ Guide].
|
||||
[`google_web_search`]: /docs/tools/web-search.md
|
||||
[`replace`]: /docs/tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: /docs/tools/mcp-server.md
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[experimental research sub-agents]: /docs/core/subagents.md
|
||||
[Policy Engine Guide]: /docs/core/policy-engine.md
|
||||
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
|
||||
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
[`ask_user`]: /docs/tools/ask-user.md
|
||||
|
||||
+4
-12
@@ -49,7 +49,6 @@ they appear in the UI.
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
@@ -117,19 +116,12 @@ they appear in the UI.
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
|
||||
### Advanced
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| --------------------------------- | ------------------------------ | --------------------------------------------- | ------- |
|
||||
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# Agent harness architecture
|
||||
|
||||
This document provides a detailed walkthrough of the architectural shift from
|
||||
linear turn-based execution to the unified hierarchical loop model used by the
|
||||
Agent Harness.
|
||||
|
||||
> **Note:** This is a preview feature currently under active development.
|
||||
|
||||
## Overview
|
||||
|
||||
The Agent Harness represents a fundamental evolution in how Gemini CLI manages
|
||||
interactions with Large Language Models (LLMs) and tools. It unifies the
|
||||
execution logic for both the main CLI agent and subagents, providing parity in
|
||||
features like model routing, history management, and tool execution.
|
||||
|
||||
## Legacy architecture: Linear turns
|
||||
|
||||
The legacy system operates on a "Stop-and-Go" model where the UI manages the
|
||||
execution turn-by-turn.
|
||||
|
||||
In this model, when you send a prompt, the system follows these steps:
|
||||
|
||||
1. **Orchestration:** The `GeminiClient` and the `useGeminiStream` hook manage
|
||||
the flow.
|
||||
2. **Execution:** Gemini returns a single response containing text or tool
|
||||
calls.
|
||||
3. **UI Interruption:** The execution stops at the UI layer. If Gemini calls
|
||||
tools, the UI schedules them, waits for results, and then re-submits the
|
||||
entire history as a brand-new turn.
|
||||
4. **Subagents:** Subagents are treated as "Black Box" tools. The main agent
|
||||
calls a subagent (for example, `codebase_investigator`), waits for it to
|
||||
complete its private loop using `LocalAgentExecutor`, and receives a single
|
||||
string result.
|
||||
|
||||
This model results in duplicated logic for subagents and prevents them from
|
||||
using advanced features available to the main agent.
|
||||
|
||||
## New architecture: Unified agent harness
|
||||
|
||||
The Agent Harness treats the ReAct (Reasoning and Action) loop as a first-class,
|
||||
autonomous process.
|
||||
|
||||
The new model introduces several key improvements:
|
||||
|
||||
1. **Continuous Loop:** The `AgentHarness` manages the entire lifecycle
|
||||
internally. It handles LLM calls, tool execution, and reasoning without
|
||||
relinquishing control to the UI until it reaches the final goal.
|
||||
2. **Event Stream:** The harness yields a continuous stream of events
|
||||
(`GeminiEvent`) that the UI listens to and renders in real-time.
|
||||
3. **Hierarchical Delegation:** Because the harness is unified, a subagent is
|
||||
simply another instance of `AgentHarness` running inside a tool call of the
|
||||
parent harness.
|
||||
4. **Feature Parity:** Subagents can now use the same features as the main
|
||||
agent, including dynamic model routing, history compression, and complex
|
||||
interactive tools.
|
||||
|
||||
## UI synchronization challenges
|
||||
|
||||
Moving to a hierarchical model introduces complexity in how the UI maintains a
|
||||
consistent history.
|
||||
|
||||
The `HistoryManager` expects a flat list of messages, but the harness provides a
|
||||
nested, multi-turn stream. This creates two primary challenges:
|
||||
|
||||
1. **History Persistence:** Legacy code may clear the "active" turn state
|
||||
prematurely when a turn boundary is crossed. The harness uses a
|
||||
`TurnFinished` event to signal when to "lock in" reasoning without ending
|
||||
the overall session.
|
||||
2. **Hierarchical Boxes:** In a hierarchical model, internal subagent tool
|
||||
calls (for example, reading a file) shouldn't clutter the main history. The
|
||||
UI uses `SubagentActivity` events to update a single, persistent subagent
|
||||
box rather than rendering every internal step as a top-level item.
|
||||
|
||||
## Isolation strategy
|
||||
|
||||
To ensure stability during this transition, the project uses a "Dual
|
||||
Implementation" strategy.
|
||||
|
||||
This strategy isolates the experimental logic from the stable codebase:
|
||||
|
||||
- **Hook Isolation:** `useAgentHarness.ts` provides a dedicated hook for the new
|
||||
event model, leaving the stable `useGeminiStream` untouched.
|
||||
- **Logic Isolation:** `HarnessSubagentInvocation.ts` manages subagent execution
|
||||
specifically for the harness, while `LocalSubagentInvocation.ts` continues to
|
||||
serve the legacy path.
|
||||
- **Conditional Forking:** The system switches between these paths based on the
|
||||
`experimental-agent-harness` configuration flag.
|
||||
@@ -119,17 +119,9 @@ For example:
|
||||
|
||||
Approval modes allow the policy engine to apply different sets of rules based on
|
||||
the CLI's operational mode. A rule can be associated with one or more modes
|
||||
(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is
|
||||
running in one of its specified modes. If a rule has no modes specified, it is
|
||||
always active.
|
||||
|
||||
- `default`: The standard interactive mode where most write tools require
|
||||
confirmation.
|
||||
- `autoEdit`: Optimized for automated code editing; some write tools may be
|
||||
auto-approved.
|
||||
- `plan`: A strict, read-only mode for research and design. See [Customizing
|
||||
Plan Mode Policies].
|
||||
- `yolo`: A mode where all tools are auto-approved (use with extreme caution).
|
||||
(e.g., `yolo`, `autoEdit`). The rule will only be active if the CLI is running
|
||||
in one of its specified modes. If a rule has no modes specified, it is always
|
||||
active.
|
||||
|
||||
## Rule matching
|
||||
|
||||
@@ -311,5 +303,3 @@ out-of-the-box experience.
|
||||
- In **`yolo`** mode, a high-priority rule allows all tools.
|
||||
- In **`autoEdit`** mode, rules allow certain write operations to happen without
|
||||
prompting.
|
||||
|
||||
[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies
|
||||
|
||||
@@ -179,6 +179,9 @@ precedence.
|
||||
|
||||
### Settings
|
||||
|
||||
_Note: This is an experimental feature. We do not yet recommend extension
|
||||
authors introduce settings as part of their core flows._
|
||||
|
||||
Extensions can define settings that the user will be prompted to provide upon
|
||||
installation. This is useful for things like API keys, URLs, or other
|
||||
configuration that the extension needs to function.
|
||||
|
||||
@@ -220,10 +220,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Hide helpful tips in the UI
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showShortcutsHint`** (boolean):
|
||||
- **Description:** Show the "? for shortcuts" hint above the input.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.hideBanner`** (boolean):
|
||||
- **Description:** Hide the application banner
|
||||
- **Default:** `false`
|
||||
@@ -447,12 +443,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-2.5-flash"
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-base": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
@@ -508,7 +498,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-search": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"tools": [
|
||||
@@ -520,7 +510,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-fetch": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"tools": [
|
||||
@@ -532,25 +522,25 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-fetch-fallback": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"loop-detection": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"loop-detection-double-check": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-pro-preview"
|
||||
"model": "gemini-2.5-pro"
|
||||
}
|
||||
},
|
||||
"llm-edit-fixer": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"next-speaker-checker": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"chat-compression-3-pro": {
|
||||
@@ -580,7 +570,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"chat-compression-default": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-pro-preview"
|
||||
"model": "gemini-2.5-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -858,28 +848,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.toolOutputMasking.enabled`** (boolean):
|
||||
- **Description:** Enables tool output masking to save tokens.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.toolProtectionThreshold`** (number):
|
||||
- **Description:** Minimum number of tokens to protect from masking (most
|
||||
recent tool outputs).
|
||||
- **Default:** `50000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.minPrunableTokensThreshold`** (number):
|
||||
- **Description:** Minimum prunable tokens required to trigger a masking pass.
|
||||
- **Default:** `30000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.protectLatestTurn`** (boolean):
|
||||
- **Description:** Ensures the absolute latest turn is never masked,
|
||||
regardless of token count.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents. Warning: Experimental
|
||||
feature, uses YOLO mode for subagents
|
||||
|
||||
@@ -88,10 +88,6 @@
|
||||
"label": "Sub-agents (experimental)",
|
||||
"slug": "docs/core/subagents"
|
||||
},
|
||||
{
|
||||
"label": "Agent harness architecture (experimental)",
|
||||
"slug": "docs/core/agent-harness-architecture"
|
||||
},
|
||||
{
|
||||
"label": "Remote subagents (experimental)",
|
||||
"slug": "docs/core/remote-agents"
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Ask User Tool
|
||||
|
||||
The `ask_user` tool allows the agent to ask you one or more questions to gather
|
||||
preferences, clarify requirements, or make decisions. It supports multiple
|
||||
question types including multiple-choice, free-form text, and Yes/No
|
||||
confirmation.
|
||||
|
||||
## `ask_user` (Ask User)
|
||||
|
||||
- **Tool name:** `ask_user`
|
||||
- **Display name:** Ask User
|
||||
- **File:** `ask-user.ts`
|
||||
- **Parameters:**
|
||||
- `questions` (array of objects, required): A list of 1 to 4 questions to ask.
|
||||
Each question object has the following properties:
|
||||
- `question` (string, required): The complete question text.
|
||||
- `header` (string, required): A short label (max 16 chars) displayed as a
|
||||
chip/tag (e.g., "Auth", "Database").
|
||||
- `type` (string, optional): The type of question. Defaults to `'choice'`.
|
||||
- `'choice'`: Multiple-choice with options (supports multi-select).
|
||||
- `'text'`: Free-form text input.
|
||||
- `'yesno'`: Yes/No confirmation.
|
||||
- `options` (array of objects, optional): Required for `'choice'` type. 2-4
|
||||
selectable options.
|
||||
- `label` (string, required): Display text (1-5 words).
|
||||
- `description` (string, required): Brief explanation.
|
||||
- `multiSelect` (boolean, optional): For `'choice'` type, allows selecting
|
||||
multiple options.
|
||||
- `placeholder` (string, optional): Hint text for input fields.
|
||||
|
||||
- **Behavior:**
|
||||
- Presents an interactive dialog to the user with the specified questions.
|
||||
- Pauses execution until the user provides answers or dismisses the dialog.
|
||||
- Returns the user's answers to the model.
|
||||
|
||||
- **Output (`llmContent`):** A JSON string containing the user's answers,
|
||||
indexed by question position (e.g.,
|
||||
`{"answers":{"0": "Option A", "1": "Some text"}}`).
|
||||
|
||||
- **Confirmation:** Yes. The tool inherently involves user interaction.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Multiple Choice Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Database",
|
||||
"question": "Which database would you like to use?",
|
||||
"type": "choice",
|
||||
"options": [
|
||||
{
|
||||
"label": "PostgreSQL",
|
||||
"description": "Powerful, open source object-relational database system."
|
||||
},
|
||||
{
|
||||
"label": "SQLite",
|
||||
"description": "C-library that implements a SQL database engine."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Text Input Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Project Name",
|
||||
"question": "What is the name of your new project?",
|
||||
"type": "text",
|
||||
"placeholder": "e.g., my-awesome-app"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Yes/No Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Deploy",
|
||||
"question": "Do you want to deploy the application now?",
|
||||
"type": "yesno"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -86,9 +86,6 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
|
||||
information across sessions.
|
||||
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
|
||||
requests.
|
||||
- **[Planning Tools](./planning.md):** For entering and exiting Plan Mode.
|
||||
- **[Ask User Tool](./ask-user.md) (`ask_user`):** For gathering user input and
|
||||
making decisions.
|
||||
|
||||
Additionally, these tools incorporate:
|
||||
|
||||
|
||||
@@ -739,10 +739,21 @@ The MCP integration tracks several states:
|
||||
cautiously and only for servers you completely control
|
||||
- **Access tokens:** Be security-aware when configuring environment variables
|
||||
containing API keys or tokens
|
||||
- **Environment variable redaction:** By default, the Gemini CLI redacts
|
||||
sensitive environment variables (such as `GEMINI_API_KEY`, `GOOGLE_API_KEY`,
|
||||
and variables matching patterns like `*TOKEN*`, `*SECRET*`, `*PASSWORD*`) when
|
||||
spawning MCP servers using the `stdio` transport. This prevents unintended
|
||||
exposure of your credentials to third-party servers.
|
||||
- **Explicit environment variables:** If you need to pass a specific environment
|
||||
variable to an MCP server, you should define it explicitly in the `env`
|
||||
property of the server configuration in `settings.json`.
|
||||
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
|
||||
available within the sandbox environment
|
||||
available within the sandbox environment.
|
||||
- **Private data:** Using broadly scoped personal access tokens can lead to
|
||||
information leakage between repositories.
|
||||
- **Untrusted servers:** Be extremely cautious when adding MCP servers from
|
||||
untrusted or third-party sources. Malicious servers could attempt to
|
||||
exfiltrate data or perform unauthorized actions through the tools they expose.
|
||||
|
||||
### Performance and resource management
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# Gemini CLI planning tools
|
||||
|
||||
Planning tools allow the Gemini model to switch into a safe, read-only "Plan
|
||||
Mode" for researching and planning complex changes, and to signal the
|
||||
finalization of a plan to the user.
|
||||
|
||||
## 1. `enter_plan_mode` (EnterPlanMode)
|
||||
|
||||
`enter_plan_mode` switches the CLI to Plan Mode. This tool is typically called
|
||||
by the agent when you ask it to "start a plan" using natural language. In this
|
||||
mode, the agent is restricted to read-only tools to allow for safe exploration
|
||||
and planning.
|
||||
|
||||
- **Tool name:** `enter_plan_mode`
|
||||
- **Display name:** Enter Plan Mode
|
||||
- **File:** `enter-plan-mode.ts`
|
||||
- **Parameters:**
|
||||
- `reason` (string, optional): A short reason explaining why the agent is
|
||||
entering plan mode (e.g., "Starting a complex feature implementation").
|
||||
- **Behavior:**
|
||||
- Switches the CLI's approval mode to `PLAN`.
|
||||
- Notifies the user that the agent has entered Plan Mode.
|
||||
- **Output (`llmContent`):** A message indicating the switch, e.g.,
|
||||
`Switching to Plan mode.`
|
||||
- **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode.
|
||||
|
||||
## 2. `exit_plan_mode` (ExitPlanMode)
|
||||
|
||||
`exit_plan_mode` signals that the planning phase is complete. It presents the
|
||||
finalized plan to the user and requests approval to start the implementation.
|
||||
|
||||
- **Tool name:** `exit_plan_mode`
|
||||
- **Display name:** Exit Plan Mode
|
||||
- **File:** `exit-plan-mode.ts`
|
||||
- **Parameters:**
|
||||
- `plan_path` (string, required): The path to the finalized Markdown plan
|
||||
file. This file MUST be located within the project's temporary plans
|
||||
directory (e.g., `~/.gemini/tmp/<project>/plans/`).
|
||||
- **Behavior:**
|
||||
- Validates that the `plan_path` is within the allowed directory and that the
|
||||
file exists and has content.
|
||||
- Presents the plan to the user for review.
|
||||
- If the user approves the plan:
|
||||
- Switches the CLI's approval mode to the user's chosen approval mode (
|
||||
`DEFAULT` or `AUTO_EDIT`).
|
||||
- Marks the plan as approved for implementation.
|
||||
- If the user rejects the plan:
|
||||
- Stays in Plan Mode.
|
||||
- Returns user feedback to the model to refine the plan.
|
||||
- **Output (`llmContent`):**
|
||||
- On approval: A message indicating the plan was approved and the new approval
|
||||
mode.
|
||||
- On rejection: A message containing the user's feedback.
|
||||
- **Confirmation:** Yes. Shows the finalized plan and asks for user approval to
|
||||
proceed with implementation.
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('Edits location eval', () => {
|
||||
/**
|
||||
* Ensure that Gemini CLI always updates existing test files, if present,
|
||||
* instead of creating a new one.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should update existing test file instead of creating a new one',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'test-location-repro',
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
test: 'vitest run',
|
||||
},
|
||||
devDependencies: {
|
||||
vitest: '^1.0.0',
|
||||
typescript: '^5.0.0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'src/math.ts': `
|
||||
export function add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
export function subtract(a: number, b: number): number {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
export function multiply(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
`,
|
||||
'src/math.test.ts': `
|
||||
import { expect, test } from 'vitest';
|
||||
import { add, subtract } from './math';
|
||||
|
||||
test('add adds two numbers', () => {
|
||||
expect(add(2, 3)).toBe(5);
|
||||
});
|
||||
|
||||
test('subtract subtracts two numbers', () => {
|
||||
expect(subtract(5, 3)).toBe(2);
|
||||
});
|
||||
`,
|
||||
'src/utils.ts': `
|
||||
export function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
`,
|
||||
'src/utils.test.ts': `
|
||||
import { expect, test } from 'vitest';
|
||||
import { capitalize } from './utils';
|
||||
|
||||
test('capitalize capitalizes the first letter', () => {
|
||||
expect(capitalize('hello')).toBe('Hello');
|
||||
});
|
||||
`,
|
||||
},
|
||||
prompt: 'Fix the bug in src/math.ts. Do not run the code.',
|
||||
timeout: 180000,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const replaceCalls = toolLogs.filter(
|
||||
(t) => t.toolRequest.name === 'replace',
|
||||
);
|
||||
const writeFileCalls = toolLogs.filter(
|
||||
(t) => t.toolRequest.name === 'write_file',
|
||||
);
|
||||
|
||||
expect(replaceCalls.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
writeFileCalls.some((file) =>
|
||||
file.toolRequest.args.includes('.test.ts'),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const targetFiles = replaceCalls.map((t) => {
|
||||
try {
|
||||
return JSON.parse(t.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('DEBUG: targetFiles', targetFiles);
|
||||
|
||||
expect(
|
||||
new Set(targetFiles).size,
|
||||
'Expected only two files changed',
|
||||
).greaterThanOrEqual(2);
|
||||
expect(targetFiles.some((f) => f?.endsWith('src/math.ts'))).toBe(true);
|
||||
expect(targetFiles.some((f) => f?.endsWith('src/math.test.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
/**
|
||||
* Evals to verify that the agent uses search tools efficiently (frugally)
|
||||
* by utilizing limiting parameters like `total_max_matches` and `max_matches_per_file`.
|
||||
* This ensures the agent doesn't flood the context window with unnecessary search results.
|
||||
*/
|
||||
describe('Frugal Search', () => {
|
||||
const getGrepParams = (call: any): any => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use targeted search with limit',
|
||||
prompt: 'find me a sample usage of path.resolve() in the codebase',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
main: 'dist/index.js',
|
||||
scripts: {
|
||||
build: 'tsc',
|
||||
test: 'vitest',
|
||||
},
|
||||
dependencies: {
|
||||
typescript: '^5.0.0',
|
||||
'@types/node': '^20.0.0',
|
||||
vitest: '^1.0.0',
|
||||
},
|
||||
}),
|
||||
'src/index.ts': `
|
||||
import { App } from './app.ts';
|
||||
|
||||
const app = new App();
|
||||
app.start();
|
||||
`,
|
||||
'src/app.ts': `
|
||||
import * as path from 'path';
|
||||
import { UserController } from './controllers/user.ts';
|
||||
|
||||
export class App {
|
||||
constructor() {
|
||||
console.log('App initialized');
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
const userController = new UserController();
|
||||
console.log('Static path:', path.resolve(__dirname, '../public'));
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/utils.ts': `
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function resolvePath(p: string): string {
|
||||
return path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
const absolutePath = path.resolve(dirPath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
fs.mkdirSync(absolutePath, { recursive: true });
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/config.ts': `
|
||||
import * as path from 'path';
|
||||
|
||||
export const config = {
|
||||
dbPath: path.resolve(process.cwd(), 'data/db.sqlite'),
|
||||
logLevel: 'info',
|
||||
};
|
||||
`,
|
||||
'src/controllers/user.ts': `
|
||||
import * as path from 'path';
|
||||
|
||||
export class UserController {
|
||||
public getUsers(): any[] {
|
||||
console.log('Loading users from:', path.resolve('data/users.json'));
|
||||
return [{ id: 1, name: 'Alice' }];
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tests/app.test.ts': `
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('App', () => {
|
||||
it('should resolve paths', () => {
|
||||
const p = path.resolve('test');
|
||||
expect(p).toBeDefined();
|
||||
});
|
||||
});
|
||||
`,
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const grepCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'grep_search',
|
||||
);
|
||||
|
||||
expect(grepCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const grepParams = grepCalls.map(getGrepParams);
|
||||
|
||||
const hasTotalMaxLimit = grepParams.some(
|
||||
(p) => p.total_max_matches !== undefined && p.total_max_matches <= 100,
|
||||
);
|
||||
expect(
|
||||
hasTotalMaxLimit,
|
||||
`Expected agent to use a small total_max_matches (<= 100) for a sample usage request. Actual values: ${JSON.stringify(
|
||||
grepParams.map((p) => p.total_max_matches),
|
||||
)}`,
|
||||
).toBe(true);
|
||||
|
||||
const hasMaxMatchesPerFileLimit = grepParams.some(
|
||||
(p) =>
|
||||
p.max_matches_per_file !== undefined && p.max_matches_per_file <= 5,
|
||||
);
|
||||
expect(
|
||||
hasMaxMatchesPerFileLimit,
|
||||
`Expected agent to use a small max_matches_per_file (<= 5) for a sample usage request. Actual values: ${JSON.stringify(
|
||||
grepParams.map((p) => p.max_matches_per_file),
|
||||
)}`,
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -42,12 +42,11 @@ When asked for my favorite fruit, always say "Cherry".
|
||||
</project_context>
|
||||
|
||||
What is my favorite fruit? Tell me just the name of the fruit.`,
|
||||
assert: async (rig) => {
|
||||
const stdout = rig._lastRunStdout!;
|
||||
assertModelHasOutput(stdout);
|
||||
expect(stdout).toMatch(/Cherry/i);
|
||||
expect(stdout).not.toMatch(/Apple/i);
|
||||
expect(stdout).not.toMatch(/Banana/i);
|
||||
assert: async (_rig, result) => {
|
||||
assertModelHasOutput(result);
|
||||
expect(result).toMatch(/Cherry/i);
|
||||
expect(result).not.toMatch(/Apple/i);
|
||||
expect(result).not.toMatch(/Banana/i);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,12 +80,11 @@ Provide the answer as an XML block like this:
|
||||
<extension>Instruction ...</extension>
|
||||
<project>Instruction ...</project>
|
||||
</results>`,
|
||||
assert: async (rig) => {
|
||||
const stdout = rig._lastRunStdout!;
|
||||
assertModelHasOutput(stdout);
|
||||
expect(stdout).toMatch(/<global>.*Instruction A/i);
|
||||
expect(stdout).toMatch(/<extension>.*Instruction B/i);
|
||||
expect(stdout).toMatch(/<project>.*Instruction C/i);
|
||||
assert: async (_rig, result) => {
|
||||
assertModelHasOutput(result);
|
||||
expect(result).toMatch(/<global>.*Instruction A/i);
|
||||
expect(result).toMatch(/<extension>.*Instruction B/i);
|
||||
expect(result).toMatch(/<project>.*Instruction C/i);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,11 +108,10 @@ Set the theme to "Dark".
|
||||
</extension_context>
|
||||
|
||||
What theme should I use? Tell me just the name of the theme.`,
|
||||
assert: async (rig) => {
|
||||
const stdout = rig._lastRunStdout!;
|
||||
assertModelHasOutput(stdout);
|
||||
expect(stdout).toMatch(/Dark/i);
|
||||
expect(stdout).not.toMatch(/Light/i);
|
||||
assert: async (_rig, result) => {
|
||||
assertModelHasOutput(result);
|
||||
expect(result).toMatch(/Dark/i);
|
||||
expect(result).not.toMatch(/Light/i);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+11
-11
@@ -14,7 +14,7 @@ import {
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -36,7 +36,7 @@ describe('save_memory', () => {
|
||||
},
|
||||
});
|
||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -57,7 +57,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -79,7 +79,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringTemporaryInformation =
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -104,7 +104,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -125,7 +125,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -147,7 +147,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringDbSchemaLocation =
|
||||
"Agent ignores workspace's database schema location";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringDbSchemaLocation,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -178,7 +178,7 @@ describe('save_memory', () => {
|
||||
|
||||
const rememberingCodingStyle =
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -200,7 +200,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringBuildArtifactLocation =
|
||||
'Agent ignores workspace build artifact location';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringBuildArtifactLocation,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -230,7 +230,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringMainEntryPoint,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -260,7 +260,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingBirthday = "Agent remembers user's birthday";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
|
||||
@@ -26,7 +26,7 @@ class MockClient implements acp.Client {
|
||||
};
|
||||
}
|
||||
|
||||
describe.skip('ACP Environment and Auth', () => {
|
||||
describe('ACP Environment and Auth', () => {
|
||||
let rig: TestRig;
|
||||
let child: ChildProcess | undefined;
|
||||
|
||||
@@ -55,19 +55,15 @@ describe.skip('ACP Environment and Auth', () => {
|
||||
|
||||
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
|
||||
|
||||
const customEnv = {
|
||||
...process.env,
|
||||
GEMINI_CLI_HOME: rig.homeDir!,
|
||||
VERBOSE: 'true',
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (customEnv as any).GEMINI_API_KEY;
|
||||
|
||||
child = spawn('node', [bundlePath, '--experimental-acp'], {
|
||||
cwd: rig.homeDir!,
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
env: customEnv as any,
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_CLI_HOME: rig.homeDir!,
|
||||
GEMINI_API_KEY: undefined,
|
||||
VERBOSE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
const input = Writable.toWeb(child.stdin!);
|
||||
@@ -124,19 +120,15 @@ describe.skip('ACP Environment and Auth', () => {
|
||||
|
||||
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
|
||||
|
||||
const customEnv = {
|
||||
...process.env,
|
||||
GEMINI_CLI_HOME: rig.homeDir!,
|
||||
VERBOSE: 'true',
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (customEnv as any).GEMINI_API_KEY;
|
||||
|
||||
child = spawn('node', [bundlePath, '--experimental-acp'], {
|
||||
cwd: rig.homeDir!,
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
env: customEnv as any,
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_CLI_HOME: rig.homeDir!,
|
||||
GEMINI_API_KEY: undefined,
|
||||
VERBOSE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
const input = Writable.toWeb(child.stdin!);
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
describe('Agent Harness E2E', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should execute a simple prompt using the agent harness', async () => {
|
||||
await rig.setup('agent-harness-simple');
|
||||
|
||||
// Run with the harness enabled via env var
|
||||
// Turn 1
|
||||
const result1 = await rig.run({
|
||||
args: ['chat', 'My name is GeminiUser'],
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_ENABLE_AGENT_HARNESS: 'true',
|
||||
},
|
||||
});
|
||||
expect(result1).toBeDefined();
|
||||
|
||||
// Turn 2
|
||||
const result2 = await rig.run({
|
||||
args: ['chat', 'What is my name?', '--resume', 'latest'],
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_ENABLE_AGENT_HARNESS: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result2).toContain('GeminiUser');
|
||||
}, 120000);
|
||||
|
||||
it('should delegate to codebase_investigator and synthesize results', async () => {
|
||||
await rig.setup('agent-harness-delegation');
|
||||
|
||||
// Create a dummy file for CBI to find
|
||||
const historyDir = path.join(rig.testDir!, 'packages/core/src');
|
||||
fs.mkdirSync(historyDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(historyDir, 'history.ts'),
|
||||
`
|
||||
/** ChatHistory maintains the message history for the session. */
|
||||
export class ChatHistory {
|
||||
private messages: any[] = [];
|
||||
addMessage(msg: any) { this.messages.push(msg); }
|
||||
}
|
||||
`,
|
||||
);
|
||||
const result = await rig.run({
|
||||
args: [
|
||||
'chat',
|
||||
'use @codebase_investigator to tell me about how chat history is maintained',
|
||||
],
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_ENABLE_AGENT_HARNESS: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify synthesis: CBI should have found ChatHistory or history.ts
|
||||
const output = result.toLowerCase();
|
||||
expect(output).toMatch(/history|chat/);
|
||||
|
||||
// Verify single delegation: CBI should only be called once.
|
||||
// We check the tool logs for 'codebase_investigator'
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const cbiCalls = toolLogs.filter(
|
||||
(log) => log.toolRequest?.name === 'codebase_investigator',
|
||||
);
|
||||
|
||||
if (cbiCalls.length < 1) {
|
||||
console.log('DEBUG: Full tool logs:', JSON.stringify(toolLogs, null, 2));
|
||||
if (rig._lastRunStdout) {
|
||||
console.log('DEBUG: Full stdout length:', rig._lastRunStdout.length);
|
||||
}
|
||||
}
|
||||
|
||||
expect(cbiCalls.length).toBeGreaterThanOrEqual(1);
|
||||
}, 240000);
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import * as os from 'node:os';
|
||||
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
|
||||
import { Config } from '../packages/core/src/config/config.js';
|
||||
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
|
||||
import { createMockMessageBus } from '../packages/core/src/test-utils/mock-message-bus.js';
|
||||
|
||||
// Mock Config to provide necessary context
|
||||
class MockConfig {
|
||||
@@ -67,7 +66,7 @@ describe('ripgrep-real-direct', () => {
|
||||
await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n');
|
||||
|
||||
const config = new MockConfig(tempDir) as unknown as Config;
|
||||
tool = new RipGrepTool(config, createMockMessageBus());
|
||||
tool = new RipGrepTool(config);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -109,24 +108,4 @@ describe('ripgrep-real-direct', () => {
|
||||
expect(result.llmContent).toContain('script.js');
|
||||
expect(result.llmContent).not.toContain('file1.txt');
|
||||
});
|
||||
|
||||
it('should support context parameters', async () => {
|
||||
// Create a file with multiple lines
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'context.txt'),
|
||||
'line1\nline2\nline3 match\nline4\nline5\n',
|
||||
);
|
||||
|
||||
const invocation = tool.build({
|
||||
pattern: 'match',
|
||||
context: 1,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('context.txt');
|
||||
expect(result.llmContent).toContain('L2- line2');
|
||||
expect(result.llmContent).toContain('L3: line3 match');
|
||||
expect(result.llmContent).toContain('L4- line4');
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+1150
-310
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -64,7 +64,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.10",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -126,7 +126,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.10",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"simple-git": "^3.28.0"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Pre-built production image for a2a-server
|
||||
# Used with Cloud Build: npm install + build runs in step 1, then Docker copies artifacts
|
||||
FROM docker.io/library/node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 curl git jq ripgrep ca-certificates \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything including pre-installed node_modules and pre-built dist
|
||||
COPY package.json package-lock.json ./
|
||||
COPY node_modules/ node_modules/
|
||||
COPY packages/core/ packages/core/
|
||||
COPY packages/a2a-server/ packages/a2a-server/
|
||||
|
||||
# Create workspace directory for agent operations
|
||||
RUN mkdir -p /workspace && chown -R node:node /workspace
|
||||
|
||||
USER node
|
||||
|
||||
ENV CODER_AGENT_WORKSPACE_PATH=/workspace
|
||||
ENV CODER_AGENT_PORT=8080
|
||||
ENV NODE_ENV=production
|
||||
# Prevent git from prompting for credentials interactively — fails fast instead of hanging
|
||||
ENV GIT_TERMINAL_PROMPT=0
|
||||
ENV CODER_AGENT_HOST=0.0.0.0
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["node", "packages/a2a-server/dist/src/http/server.js"]
|
||||
@@ -0,0 +1,35 @@
|
||||
steps:
|
||||
# Step 1: Install all dependencies and build
|
||||
- name: 'node:20-slim'
|
||||
entrypoint: 'bash'
|
||||
args:
|
||||
- '-c'
|
||||
- |
|
||||
apt-get update && apt-get install -y python3 make g++ git
|
||||
npm pkg delete scripts.prepare
|
||||
npm install
|
||||
npm run build
|
||||
env:
|
||||
- 'HUSKY=0'
|
||||
|
||||
# Step 2: Build Docker image (using pre-built dist/ from step 1)
|
||||
- name: 'gcr.io/cloud-builders/docker'
|
||||
args:
|
||||
- 'build'
|
||||
- '-t'
|
||||
- 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest'
|
||||
- '-f'
|
||||
- 'packages/a2a-server/Dockerfile'
|
||||
- '.'
|
||||
|
||||
# Step 3: Push to Artifact Registry
|
||||
- name: 'gcr.io/cloud-builders/docker'
|
||||
args:
|
||||
- 'push'
|
||||
- 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest'
|
||||
|
||||
images:
|
||||
- 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest'
|
||||
timeout: '1800s'
|
||||
options:
|
||||
machineType: 'E2_HIGHCPU_8'
|
||||
@@ -0,0 +1,74 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gemini-a2a-server
|
||||
labels:
|
||||
app: gemini-a2a-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: gemini-a2a-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: gemini-a2a-server
|
||||
spec:
|
||||
containers:
|
||||
- name: a2a-server
|
||||
image: us-central1-docker.pkg.dev/adamfweidman-test/gemini-a2a/a2a-server:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: CODER_AGENT_PORT
|
||||
value: "8080"
|
||||
- name: CODER_AGENT_HOST
|
||||
value: "0.0.0.0"
|
||||
- name: CODER_AGENT_WORKSPACE_PATH
|
||||
value: "/workspace"
|
||||
- name: GEMINI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gemini-secrets
|
||||
key: api-key
|
||||
- name: GEMINI_YOLO_MODE
|
||||
value: "true"
|
||||
- name: CHAT_BRIDGE_A2A_URL
|
||||
value: "http://localhost:8080"
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "2000m"
|
||||
memory: "2Gi"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /.well-known/agent-card.json
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /.well-known/agent-card.json
|
||||
port: 8080
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gemini-a2a-server
|
||||
labels:
|
||||
app: gemini-a2a-server
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: gemini-a2a-server
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"express": "^5.1.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"tar": "^7.5.2",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builder functions for A2UI standard catalog components.
|
||||
* These create the component objects that go into updateComponents messages.
|
||||
*/
|
||||
|
||||
import type { A2UIComponent } from './a2ui-extension.js';
|
||||
|
||||
// Layout components
|
||||
|
||||
export function column(
|
||||
id: string,
|
||||
children: string[],
|
||||
opts?: { align?: string; justify?: string; weight?: number },
|
||||
): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'Column',
|
||||
children,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
export function row(
|
||||
id: string,
|
||||
children: string[],
|
||||
opts?: { align?: string; justify?: string },
|
||||
): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'Row',
|
||||
children,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
export function card(
|
||||
id: string,
|
||||
child: string,
|
||||
opts?: Record<string, unknown>,
|
||||
): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'Card',
|
||||
child,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
// Content components
|
||||
|
||||
export function text(
|
||||
id: string,
|
||||
textContent: string | { path: string },
|
||||
opts?: { variant?: string },
|
||||
): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'Text',
|
||||
text: textContent,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
export function icon(id: string, name: string): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'Icon',
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
export function divider(
|
||||
id: string,
|
||||
axis: 'horizontal' | 'vertical' = 'horizontal',
|
||||
): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'Divider',
|
||||
axis,
|
||||
};
|
||||
}
|
||||
|
||||
// Interactive components
|
||||
|
||||
export function button(
|
||||
id: string,
|
||||
child: string,
|
||||
action: {
|
||||
event?: { name: string; context: Record<string, unknown> };
|
||||
functionCall?: { call: string; args: Record<string, unknown> };
|
||||
},
|
||||
opts?: { variant?: 'primary' | 'borderless' },
|
||||
): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'Button',
|
||||
child,
|
||||
action,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
export function textField(
|
||||
id: string,
|
||||
label: string,
|
||||
valuePath: string,
|
||||
opts?: {
|
||||
variant?: 'shortText' | 'longText';
|
||||
checks?: Array<{
|
||||
call: string;
|
||||
args: Record<string, unknown>;
|
||||
message: string;
|
||||
}>;
|
||||
},
|
||||
): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'TextField',
|
||||
label,
|
||||
value: { path: valuePath },
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkBox(
|
||||
id: string,
|
||||
label: string,
|
||||
valuePath: string,
|
||||
): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'CheckBox',
|
||||
label,
|
||||
value: { path: valuePath },
|
||||
};
|
||||
}
|
||||
|
||||
export function choicePicker(
|
||||
id: string,
|
||||
options: Array<{ label: string; value: string }>,
|
||||
valuePath: string,
|
||||
opts?: { variant?: 'mutuallyExclusive' | 'multiSelect' },
|
||||
): A2UIComponent {
|
||||
return {
|
||||
id,
|
||||
component: 'ChoicePicker',
|
||||
options,
|
||||
value: { path: valuePath },
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* A2UI (Agent-to-UI) Extension for A2A protocol.
|
||||
* Implements the A2UI v0.10 specification for generating declarative UI
|
||||
* messages that clients can render natively.
|
||||
*
|
||||
* @see https://a2ui.org/specification/v0_10/docs/a2ui_protocol.md
|
||||
* @see https://a2ui.org/specification/v0_10/docs/a2ui_extension_specification.md
|
||||
*/
|
||||
|
||||
import type { Part } from '@a2a-js/sdk';
|
||||
|
||||
// Extension constants
|
||||
export const A2UI_EXTENSION_URI = 'https://a2ui.org/a2a-extension/a2ui/v0.10';
|
||||
export const A2UI_MIME_TYPE = 'application/json+a2ui';
|
||||
export const A2UI_VERSION = 'v0.10';
|
||||
export const STANDARD_CATALOG_ID =
|
||||
'https://a2ui.org/specification/v0_10/standard_catalog.json';
|
||||
|
||||
// Metadata keys
|
||||
export const MIME_TYPE_KEY = 'mimeType';
|
||||
export const A2UI_CLIENT_CAPABILITIES_KEY = 'a2uiClientCapabilities';
|
||||
export const A2UI_CLIENT_DATA_MODEL_KEY = 'a2uiClientDataModel';
|
||||
|
||||
/**
|
||||
* A2UI message types (server-to-client).
|
||||
*/
|
||||
export interface CreateSurfaceMessage {
|
||||
version: typeof A2UI_VERSION;
|
||||
createSurface: {
|
||||
surfaceId: string;
|
||||
catalogId: string;
|
||||
theme?: Record<string, unknown>;
|
||||
sendDataModel?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateComponentsMessage {
|
||||
version: typeof A2UI_VERSION;
|
||||
updateComponents: {
|
||||
surfaceId: string;
|
||||
components: A2UIComponent[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateDataModelMessage {
|
||||
version: typeof A2UI_VERSION;
|
||||
updateDataModel: {
|
||||
surfaceId: string;
|
||||
path?: string;
|
||||
value?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeleteSurfaceMessage {
|
||||
version: typeof A2UI_VERSION;
|
||||
deleteSurface: {
|
||||
surfaceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type A2UIServerMessage =
|
||||
| CreateSurfaceMessage
|
||||
| UpdateComponentsMessage
|
||||
| UpdateDataModelMessage
|
||||
| DeleteSurfaceMessage;
|
||||
|
||||
/**
|
||||
* A2UI component definition.
|
||||
*/
|
||||
export interface A2UIComponent {
|
||||
id: string;
|
||||
component: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI client-to-server action message.
|
||||
*/
|
||||
export interface A2UIActionMessage {
|
||||
version: typeof A2UI_VERSION;
|
||||
action: {
|
||||
name: string;
|
||||
surfaceId: string;
|
||||
sourceComponentId: string;
|
||||
timestamp: string;
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI client capabilities sent in metadata.
|
||||
*/
|
||||
export interface A2UIClientCapabilities {
|
||||
supportedCatalogIds: string[];
|
||||
inlineCatalogs?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an A2A DataPart containing A2UI messages.
|
||||
* Per the spec, the data field contains an ARRAY of A2UI messages.
|
||||
*/
|
||||
export function createA2UIPart(messages: A2UIServerMessage[]): Part {
|
||||
|
||||
return {
|
||||
kind: 'data',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
data: messages as unknown as Record<string, unknown>,
|
||||
metadata: {
|
||||
[MIME_TYPE_KEY]: A2UI_MIME_TYPE,
|
||||
},
|
||||
} as Part;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single A2A DataPart from one A2UI message.
|
||||
*/
|
||||
export function createA2UISinglePart(message: A2UIServerMessage): Part {
|
||||
return createA2UIPart([message]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an A2A Part contains A2UI data.
|
||||
*/
|
||||
export function isA2UIPart(part: Part): boolean {
|
||||
return (
|
||||
part.kind === 'data' &&
|
||||
part.metadata != null &&
|
||||
part.metadata[MIME_TYPE_KEY] === A2UI_MIME_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts A2UI action messages from an A2A Part.
|
||||
*/
|
||||
export function extractA2UIActions(part: Part): A2UIActionMessage[] {
|
||||
if (!isA2UIPart(part)) return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = (part as unknown as { data?: unknown[] }).data;
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.filter(
|
||||
(msg): msg is A2UIActionMessage =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'action' in msg &&
|
||||
'version' in msg,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the A2UI AgentExtension configuration for the AgentCard.
|
||||
*/
|
||||
export function getA2UIAgentExtension(
|
||||
supportedCatalogIds: string[] = [STANDARD_CATALOG_ID],
|
||||
acceptsInlineCatalogs = false,
|
||||
): {
|
||||
uri: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
params: Record<string, unknown>;
|
||||
} {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (supportedCatalogIds.length > 0) {
|
||||
params['supportedCatalogIds'] = supportedCatalogIds;
|
||||
}
|
||||
if (acceptsInlineCatalogs) {
|
||||
params['acceptsInlineCatalogs'] = true;
|
||||
}
|
||||
|
||||
return {
|
||||
uri: A2UI_EXTENSION_URI,
|
||||
description: 'Provides agent driven UI using the A2UI JSON format.',
|
||||
required: false,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the A2UI extension was requested via extension headers or message.
|
||||
*/
|
||||
export function isA2UIRequested(
|
||||
requestedExtensions?: string[],
|
||||
messageExtensions?: string[],
|
||||
): boolean {
|
||||
return (
|
||||
(requestedExtensions?.includes(A2UI_EXTENSION_URI) ?? false) ||
|
||||
(messageExtensions?.includes(A2UI_EXTENSION_URI) ?? false)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Manages A2UI surfaces for the Gemini CLI A2A server.
|
||||
* Creates and updates surfaces for:
|
||||
* - Tool call approval UIs
|
||||
* - Agent text/thought streaming displays
|
||||
* - Task status indicators
|
||||
*/
|
||||
|
||||
import type { Part } from '@a2a-js/sdk';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import {
|
||||
A2UI_VERSION,
|
||||
STANDARD_CATALOG_ID,
|
||||
createA2UIPart,
|
||||
type A2UIServerMessage,
|
||||
type A2UIComponent,
|
||||
} from './a2ui-extension.js';
|
||||
import {
|
||||
column,
|
||||
row,
|
||||
text,
|
||||
button,
|
||||
card,
|
||||
icon,
|
||||
divider,
|
||||
} from './a2ui-components.js';
|
||||
|
||||
/**
|
||||
* Generates A2UI parts for tool call approval surfaces.
|
||||
*/
|
||||
export function createToolCallApprovalSurface(
|
||||
taskId: string,
|
||||
toolCall: {
|
||||
callId: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
args?: Record<string, unknown>;
|
||||
kind?: string;
|
||||
},
|
||||
): Part {
|
||||
const surfaceId = `tool_approval_${taskId}_${toolCall.callId}`;
|
||||
const toolDisplayName = toolCall.displayName || toolCall.name;
|
||||
const argsPreview = toolCall.args
|
||||
? JSON.stringify(toolCall.args, null, 2).substring(0, 500)
|
||||
: 'No arguments';
|
||||
|
||||
logger.info(
|
||||
`[A2UI] Creating tool approval surface: ${surfaceId} for tool: ${toolDisplayName}`,
|
||||
);
|
||||
|
||||
const messages: A2UIServerMessage[] = [
|
||||
// 1. Create the surface
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
createSurface: {
|
||||
surfaceId,
|
||||
catalogId: STANDARD_CATALOG_ID,
|
||||
theme: {
|
||||
primaryColor: '#1a73e8',
|
||||
agentDisplayName: 'Gemini CLI Agent',
|
||||
},
|
||||
sendDataModel: true,
|
||||
},
|
||||
},
|
||||
// 2. Define the components
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
updateComponents: {
|
||||
surfaceId,
|
||||
components: buildToolApprovalComponents(
|
||||
taskId,
|
||||
toolCall.callId,
|
||||
toolDisplayName,
|
||||
toolCall.description || '',
|
||||
argsPreview,
|
||||
toolCall.kind || 'tool',
|
||||
),
|
||||
},
|
||||
},
|
||||
// 3. Populate the data model
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
updateDataModel: {
|
||||
surfaceId,
|
||||
value: {
|
||||
tool: {
|
||||
callId: toolCall.callId,
|
||||
name: toolCall.name,
|
||||
displayName: toolDisplayName,
|
||||
description: toolCall.description || '',
|
||||
args: argsPreview,
|
||||
kind: toolCall.kind || 'tool',
|
||||
status: 'awaiting_approval',
|
||||
},
|
||||
taskId,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return createA2UIPart(messages);
|
||||
}
|
||||
|
||||
function buildToolApprovalComponents(
|
||||
taskId: string,
|
||||
callId: string,
|
||||
toolName: string,
|
||||
description: string,
|
||||
argsPreview: string,
|
||||
kind: string,
|
||||
): A2UIComponent[] {
|
||||
return [
|
||||
// Root card
|
||||
card('root', 'main_column'),
|
||||
|
||||
// Main vertical layout
|
||||
column(
|
||||
'main_column',
|
||||
[
|
||||
'header_row',
|
||||
'description_text',
|
||||
'divider_1',
|
||||
'args_label',
|
||||
'args_text',
|
||||
'divider_2',
|
||||
'action_row',
|
||||
],
|
||||
{ align: 'stretch' },
|
||||
),
|
||||
|
||||
// Header with icon and tool name
|
||||
row('header_row', ['tool_icon', 'tool_name_text'], {
|
||||
align: 'center',
|
||||
}),
|
||||
icon('tool_icon', kind === 'shell' ? 'terminal' : 'build'),
|
||||
text('tool_name_text', `**${toolName}** requires approval`, {
|
||||
variant: 'h3',
|
||||
}),
|
||||
|
||||
// Description
|
||||
text(
|
||||
'description_text',
|
||||
description || 'This tool needs your permission to execute.',
|
||||
),
|
||||
|
||||
divider('divider_1'),
|
||||
|
||||
// Arguments preview
|
||||
text('args_label', '**Arguments:**', { variant: 'caption' }),
|
||||
text('args_text', `\`\`\`\n${argsPreview}\n\`\`\``),
|
||||
|
||||
divider('divider_2'),
|
||||
|
||||
// Action buttons row
|
||||
row(
|
||||
'action_row',
|
||||
['approve_button', 'approve_always_button', 'reject_button'],
|
||||
{ justify: 'spaceBetween' },
|
||||
),
|
||||
|
||||
// Approve button
|
||||
text('approve_label', 'Approve'),
|
||||
button(
|
||||
'approve_button',
|
||||
'approve_label',
|
||||
{
|
||||
event: {
|
||||
name: 'tool_confirmation',
|
||||
context: {
|
||||
taskId,
|
||||
callId,
|
||||
outcome: 'proceed_once',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ variant: 'primary' },
|
||||
),
|
||||
|
||||
// Approve always button
|
||||
text('approve_always_label', 'Always Allow'),
|
||||
button('approve_always_button', 'approve_always_label', {
|
||||
event: {
|
||||
name: 'tool_confirmation',
|
||||
context: {
|
||||
taskId,
|
||||
callId,
|
||||
outcome: 'proceed_always_tool',
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Reject button
|
||||
text('reject_label', 'Reject'),
|
||||
button('reject_button', 'reject_label', {
|
||||
event: {
|
||||
name: 'tool_confirmation',
|
||||
context: {
|
||||
taskId,
|
||||
callId,
|
||||
outcome: 'cancel',
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an A2UI surface update for tool execution status.
|
||||
*/
|
||||
export function updateToolCallStatus(
|
||||
taskId: string,
|
||||
callId: string,
|
||||
status: string,
|
||||
output?: string,
|
||||
): Part {
|
||||
const surfaceId = `tool_approval_${taskId}_${callId}`;
|
||||
|
||||
logger.info(
|
||||
`[A2UI] Updating tool status surface: ${surfaceId} status: ${status}`,
|
||||
);
|
||||
|
||||
const messages: A2UIServerMessage[] = [
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
updateDataModel: {
|
||||
surfaceId,
|
||||
path: '/tool/status',
|
||||
value: status,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// If tool completed, update the UI to show result
|
||||
if (['success', 'error', 'cancelled'].includes(status)) {
|
||||
messages.push({
|
||||
version: A2UI_VERSION,
|
||||
updateComponents: {
|
||||
surfaceId,
|
||||
components: [
|
||||
// Replace action row with status indicator
|
||||
row('action_row', ['status_icon', 'status_text'], {
|
||||
align: 'center',
|
||||
}),
|
||||
icon(
|
||||
'status_icon',
|
||||
status === 'success'
|
||||
? 'check_circle'
|
||||
: status === 'error'
|
||||
? 'error'
|
||||
: 'cancel',
|
||||
),
|
||||
text(
|
||||
'status_text',
|
||||
status === 'success'
|
||||
? 'Tool executed successfully'
|
||||
: status === 'error'
|
||||
? 'Tool execution failed'
|
||||
: 'Tool execution cancelled',
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (output) {
|
||||
messages.push({
|
||||
version: A2UI_VERSION,
|
||||
updateDataModel: {
|
||||
surfaceId,
|
||||
path: '/tool/output',
|
||||
value: output,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return createA2UIPart(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an A2UI text content surface for agent messages.
|
||||
*/
|
||||
export function createTextContentPart(
|
||||
taskId: string,
|
||||
content: string,
|
||||
surfaceId?: string,
|
||||
): Part {
|
||||
const sid = surfaceId || `agent_text_${taskId}`;
|
||||
|
||||
const messages: A2UIServerMessage[] = [
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/content/text',
|
||||
value: content,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return createA2UIPart(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the initial agent response surface.
|
||||
*/
|
||||
export function createAgentResponseSurface(taskId: string): Part {
|
||||
const surfaceId = `agent_response_${taskId}`;
|
||||
|
||||
logger.info(`[A2UI] Creating agent response surface: ${surfaceId}`);
|
||||
|
||||
const messages: A2UIServerMessage[] = [
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
createSurface: {
|
||||
surfaceId,
|
||||
catalogId: STANDARD_CATALOG_ID,
|
||||
theme: {
|
||||
primaryColor: '#1a73e8',
|
||||
agentDisplayName: 'Gemini CLI Agent',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
updateComponents: {
|
||||
surfaceId,
|
||||
components: [
|
||||
card('root', 'response_column'),
|
||||
column('response_column', ['response_text', 'status_text'], {
|
||||
align: 'stretch',
|
||||
}),
|
||||
text('response_text', { path: '/response/text' }),
|
||||
text(
|
||||
'status_text',
|
||||
{ path: '/response/status' },
|
||||
{
|
||||
variant: 'caption',
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
updateDataModel: {
|
||||
surfaceId,
|
||||
value: {
|
||||
response: {
|
||||
text: '',
|
||||
status: 'Working...',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return createA2UIPart(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the agent response surface with new text content.
|
||||
*/
|
||||
export function updateAgentResponseText(
|
||||
taskId: string,
|
||||
content: string,
|
||||
status?: string,
|
||||
): Part {
|
||||
const surfaceId = `agent_response_${taskId}`;
|
||||
|
||||
const messages: A2UIServerMessage[] = [
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
updateDataModel: {
|
||||
surfaceId,
|
||||
path: '/response/text',
|
||||
value: content,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (status) {
|
||||
messages.push({
|
||||
version: A2UI_VERSION,
|
||||
updateDataModel: {
|
||||
surfaceId,
|
||||
path: '/response/status',
|
||||
value: status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createA2UIPart(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an A2UI thought surface.
|
||||
*/
|
||||
export function createThoughtPart(
|
||||
taskId: string,
|
||||
subject: string,
|
||||
description: string,
|
||||
): Part {
|
||||
const surfaceId = `thought_${taskId}_${Date.now()}`;
|
||||
|
||||
const messages: A2UIServerMessage[] = [
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
createSurface: {
|
||||
surfaceId,
|
||||
catalogId: STANDARD_CATALOG_ID,
|
||||
theme: {
|
||||
primaryColor: '#7c4dff',
|
||||
agentDisplayName: 'Gemini CLI Agent',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
updateComponents: {
|
||||
surfaceId,
|
||||
components: [
|
||||
card('root', 'thought_column'),
|
||||
column('thought_column', ['thought_icon_row', 'thought_desc'], {
|
||||
align: 'stretch',
|
||||
}),
|
||||
row('thought_icon_row', ['thought_icon', 'thought_subject'], {
|
||||
align: 'center',
|
||||
}),
|
||||
icon('thought_icon', 'psychology'),
|
||||
text('thought_subject', `*${subject}*`, { variant: 'h4' }),
|
||||
text('thought_desc', description),
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return createA2UIPart(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a tool approval surface after resolution.
|
||||
*/
|
||||
export function deleteToolApprovalSurface(
|
||||
taskId: string,
|
||||
callId: string,
|
||||
): Part {
|
||||
const surfaceId = `tool_approval_${taskId}_${callId}`;
|
||||
|
||||
logger.info(`[A2UI] Deleting tool approval surface: ${surfaceId}`);
|
||||
|
||||
const messages: A2UIServerMessage[] = [
|
||||
{
|
||||
version: A2UI_VERSION,
|
||||
deleteSurface: {
|
||||
surfaceId,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return createA2UIPart(messages);
|
||||
}
|
||||
@@ -36,6 +36,10 @@ import { loadExtensions } from '../config/extension.js';
|
||||
import { Task } from './task.js';
|
||||
import { requestStorage } from '../http/requestStorage.js';
|
||||
import { pushTaskStateFailed } from '../utils/executor_utils.js';
|
||||
import {
|
||||
A2UI_CLIENT_CAPABILITIES_KEY,
|
||||
A2UI_EXTENSION_URI,
|
||||
} from '../a2ui/a2ui-extension.js';
|
||||
|
||||
/**
|
||||
* Provides a wrapper for Task. Passes data from Task to SDKTask.
|
||||
@@ -73,6 +77,24 @@ class TaskWrapper {
|
||||
artifacts: [],
|
||||
};
|
||||
sdkTask.metadata!['_contextId'] = this.task.contextId;
|
||||
|
||||
// Persist conversation history for session resumability.
|
||||
// GCSTaskStore saves this as a separate object and restores it on load.
|
||||
try {
|
||||
const conversationHistory = this.task.geminiClient.getHistory();
|
||||
if (conversationHistory.length > 0) {
|
||||
sdkTask.metadata!['_conversationHistory'] = conversationHistory;
|
||||
logger.info(
|
||||
`Task ${this.task.id}: Persisting ${conversationHistory.length} conversation history entries.`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// GeminiClient may not be initialized yet
|
||||
logger.warn(
|
||||
`Task ${this.task.id}: Could not get conversation history for persistence.`,
|
||||
);
|
||||
}
|
||||
|
||||
return sdkTask;
|
||||
}
|
||||
}
|
||||
@@ -127,7 +149,25 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
agentSettings.autoExecute,
|
||||
);
|
||||
runtimeTask.taskState = persistedState._taskState;
|
||||
await runtimeTask.geminiClient.initialize();
|
||||
|
||||
// Restore conversation history if available from the TaskStore.
|
||||
// This enables session resumability — the LLM gets full context of
|
||||
// prior interactions rather than starting with a blank slate.
|
||||
const conversationHistory = metadata['_conversationHistory'];
|
||||
if (Array.isArray(conversationHistory) && conversationHistory.length > 0) {
|
||||
logger.info(
|
||||
`Task ${sdkTask.id}: Resuming with ${conversationHistory.length} conversation history entries.`,
|
||||
);
|
||||
// History was serialized from GeminiClient.getHistory() which returns
|
||||
// Content[]. After JSON round-trip it's structurally identical.
|
||||
await runtimeTask.geminiClient.initialize();
|
||||
runtimeTask.geminiClient.setHistory(
|
||||
|
||||
conversationHistory,
|
||||
);
|
||||
} else {
|
||||
await runtimeTask.geminiClient.initialize();
|
||||
}
|
||||
|
||||
const wrapper = new TaskWrapper(runtimeTask, agentSettings);
|
||||
this.tasks.set(sdkTask.id, wrapper);
|
||||
@@ -435,6 +475,22 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
|
||||
const currentTask = wrapper.task;
|
||||
|
||||
// Detect A2UI extension activation from the request
|
||||
// Check if user message metadata contains A2UI client capabilities
|
||||
// or if the extensions header includes the A2UI URI
|
||||
const messageMetadata = userMessage.metadata;
|
||||
const hasA2UICapabilities =
|
||||
messageMetadata?.[A2UI_CLIENT_CAPABILITIES_KEY] != null;
|
||||
// Also check if extension URI is referenced in message extensions
|
||||
const messageExtensions = messageMetadata?.['extensions'];
|
||||
const hasA2UIExtension =
|
||||
Array.isArray(messageExtensions) &&
|
||||
messageExtensions.includes(A2UI_EXTENSION_URI);
|
||||
if (hasA2UICapabilities || hasA2UIExtension) {
|
||||
currentTask.a2uiEnabled = true;
|
||||
logger.info(`[CoderAgentExecutor] A2UI enabled for task ${taskId}`);
|
||||
}
|
||||
|
||||
if (['canceled', 'failed', 'completed'].includes(currentTask.taskState)) {
|
||||
logger.warn(
|
||||
`[CoderAgentExecutor] Attempted to execute task ${taskId} which is already in state ${currentTask.taskState}. Ignoring.`,
|
||||
@@ -552,6 +608,9 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
logger.info(
|
||||
`[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`,
|
||||
);
|
||||
// Finalize A2UI surfaces before marking complete
|
||||
currentTask.finalizeA2UISurfaces();
|
||||
|
||||
const stateChange: StateChange = {
|
||||
kind: CoderAgentEvent.StateChangeEvent,
|
||||
};
|
||||
|
||||
@@ -56,6 +56,15 @@ import type {
|
||||
Citation,
|
||||
} from '../types.js';
|
||||
import type { PartUnion, Part as genAiPart } from '@google/genai';
|
||||
import {
|
||||
createToolCallApprovalSurface,
|
||||
updateToolCallStatus,
|
||||
createAgentResponseSurface,
|
||||
updateAgentResponseText,
|
||||
createThoughtPart as createA2UIThoughtPart,
|
||||
deleteToolApprovalSurface,
|
||||
} from '../a2ui/a2ui-surface-manager.js';
|
||||
import { isA2UIPart, extractA2UIActions } from '../a2ui/a2ui-extension.js';
|
||||
|
||||
type UnionKeys<T> = T extends T ? keyof T : never;
|
||||
|
||||
@@ -75,6 +84,11 @@ export class Task {
|
||||
promptCount = 0;
|
||||
autoExecute: boolean;
|
||||
|
||||
// A2UI support
|
||||
a2uiEnabled = false;
|
||||
private accumulatedText = '';
|
||||
private a2uiResponseSurfaceCreated = false;
|
||||
|
||||
// For tool waiting logic
|
||||
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
|
||||
private toolCompletionPromise?: Promise<void>;
|
||||
@@ -391,6 +405,44 @@ export class Task {
|
||||
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
|
||||
const message = this.toolStatusMessage(tc, this.id, this.contextId);
|
||||
|
||||
// Add A2UI parts for tool call updates if A2UI is enabled
|
||||
if (this.a2uiEnabled) {
|
||||
try {
|
||||
if (tc.status === 'awaiting_approval') {
|
||||
const a2uiPart = createToolCallApprovalSurface(this.id, {
|
||||
callId: tc.request.callId,
|
||||
name: tc.request.name,
|
||||
displayName: tc.tool?.displayName || tc.tool?.name,
|
||||
description: tc.tool?.description,
|
||||
args: tc.request.args as Record<string, unknown> | undefined,
|
||||
kind: tc.tool?.kind,
|
||||
});
|
||||
message.parts.push(a2uiPart);
|
||||
logger.info(
|
||||
`[Task] A2UI: Added tool approval surface for ${tc.request.callId}`,
|
||||
);
|
||||
} else if (['success', 'error', 'cancelled'].includes(tc.status)) {
|
||||
const output =
|
||||
'liveOutput' in tc ? String(tc.liveOutput) : undefined;
|
||||
const a2uiPart = updateToolCallStatus(
|
||||
this.id,
|
||||
tc.request.callId,
|
||||
tc.status,
|
||||
output,
|
||||
);
|
||||
message.parts.push(a2uiPart);
|
||||
logger.info(
|
||||
`[Task] A2UI: Updated tool status for ${tc.request.callId}: ${tc.status}`,
|
||||
);
|
||||
}
|
||||
} catch (a2uiError) {
|
||||
logger.error(
|
||||
'[Task] A2UI: Error generating tool call surface:',
|
||||
a2uiError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const event = this._createStatusUpdateEvent(
|
||||
this.taskState,
|
||||
coderAgentMessage,
|
||||
@@ -954,7 +1006,66 @@ export class Task {
|
||||
let anyConfirmationHandled = false;
|
||||
let hasContentForLlm = false;
|
||||
|
||||
// Reset A2UI accumulated text for new user turn
|
||||
if (this.a2uiEnabled) {
|
||||
this.accumulatedText = '';
|
||||
this.a2uiResponseSurfaceCreated = false;
|
||||
}
|
||||
|
||||
for (const part of userMessage.parts) {
|
||||
// Handle A2UI action messages (e.g., button clicks for tool approval)
|
||||
if (this.a2uiEnabled && isA2UIPart(part)) {
|
||||
const actions = extractA2UIActions(part);
|
||||
for (const action of actions) {
|
||||
if (action.action.name === 'tool_confirmation') {
|
||||
const ctx = action.action.context;
|
||||
// Convert A2UI action to a tool confirmation data part
|
||||
const syntheticPart: Part = {
|
||||
kind: 'data',
|
||||
data: {
|
||||
callId: ctx['callId'],
|
||||
outcome: ctx['outcome'],
|
||||
},
|
||||
} as Part;
|
||||
const handled =
|
||||
await this._handleToolConfirmationPart(syntheticPart);
|
||||
if (handled) {
|
||||
anyConfirmationHandled = true;
|
||||
// Emit a delete surface part for the approval UI
|
||||
try {
|
||||
const deletePart = deleteToolApprovalSurface(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(ctx['taskId'] as string) || this.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ctx['callId'] as string,
|
||||
);
|
||||
const deleteMessage: Message = {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
parts: [deletePart],
|
||||
messageId: uuidv4(),
|
||||
taskId: this.id,
|
||||
contextId: this.contextId,
|
||||
};
|
||||
const event = this._createStatusUpdateEvent(
|
||||
this.taskState,
|
||||
{ kind: CoderAgentEvent.ToolCallUpdateEvent },
|
||||
deleteMessage,
|
||||
false,
|
||||
);
|
||||
this.eventBus?.publish(event);
|
||||
} catch (a2uiError) {
|
||||
logger.error(
|
||||
'[Task] A2UI: Error deleting approval surface:',
|
||||
a2uiError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const confirmationHandled = await this._handleToolConfirmationPart(part);
|
||||
if (confirmationHandled) {
|
||||
anyConfirmationHandled = true;
|
||||
@@ -1020,6 +1131,33 @@ export class Task {
|
||||
}
|
||||
logger.info('[Task] Sending text content to event bus.');
|
||||
const message = this._createTextMessage(content);
|
||||
|
||||
// Add A2UI response surface parts if A2UI is enabled
|
||||
if (this.a2uiEnabled) {
|
||||
try {
|
||||
this.accumulatedText += content;
|
||||
if (!this.a2uiResponseSurfaceCreated) {
|
||||
const surfacePart = createAgentResponseSurface(this.id);
|
||||
message.parts.push(surfacePart);
|
||||
this.a2uiResponseSurfaceCreated = true;
|
||||
logger.info(
|
||||
`[Task] A2UI: Created agent response surface for task ${this.id}`,
|
||||
);
|
||||
}
|
||||
const updatePart = updateAgentResponseText(
|
||||
this.id,
|
||||
this.accumulatedText,
|
||||
'Working...',
|
||||
);
|
||||
message.parts.push(updatePart);
|
||||
} catch (a2uiError) {
|
||||
logger.error(
|
||||
'[Task] A2UI: Error generating text content surface:',
|
||||
a2uiError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const textContent: TextContent = {
|
||||
kind: CoderAgentEvent.TextContentEvent,
|
||||
};
|
||||
@@ -1041,15 +1179,35 @@ export class Task {
|
||||
return;
|
||||
}
|
||||
logger.info('[Task] Sending thought to event bus.');
|
||||
const parts: Part[] = [
|
||||
{
|
||||
kind: 'data',
|
||||
data: content,
|
||||
} as Part,
|
||||
];
|
||||
|
||||
// Add A2UI thought surface if A2UI is enabled
|
||||
if (this.a2uiEnabled) {
|
||||
try {
|
||||
const a2uiPart = createA2UIThoughtPart(
|
||||
this.id,
|
||||
content.subject || 'Thinking...',
|
||||
content.description || '',
|
||||
);
|
||||
parts.push(a2uiPart);
|
||||
logger.info(`[Task] A2UI: Added thought surface for task ${this.id}`);
|
||||
} catch (a2uiError) {
|
||||
logger.error(
|
||||
'[Task] A2UI: Error generating thought surface:',
|
||||
a2uiError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const message: Message = {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
parts: [
|
||||
{
|
||||
kind: 'data',
|
||||
data: content,
|
||||
} as Part,
|
||||
],
|
||||
parts,
|
||||
messageId: uuidv4(),
|
||||
taskId: this.id,
|
||||
contextId: this.contextId,
|
||||
@@ -1070,6 +1228,43 @@ export class Task {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes A2UI surfaces when the agent turn is complete.
|
||||
* Updates the response surface status to "Done".
|
||||
*/
|
||||
finalizeA2UISurfaces(): void {
|
||||
if (!this.a2uiEnabled || !this.a2uiResponseSurfaceCreated) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const finalPart = updateAgentResponseText(
|
||||
this.id,
|
||||
this.accumulatedText,
|
||||
'Done',
|
||||
);
|
||||
const message: Message = {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
parts: [finalPart],
|
||||
messageId: uuidv4(),
|
||||
taskId: this.id,
|
||||
contextId: this.contextId,
|
||||
};
|
||||
const event = this._createStatusUpdateEvent(
|
||||
this.taskState,
|
||||
{ kind: CoderAgentEvent.TextContentEvent },
|
||||
message,
|
||||
false,
|
||||
);
|
||||
this.eventBus?.publish(event);
|
||||
logger.info(
|
||||
`[Task] A2UI: Finalized response surface for task ${this.id}`,
|
||||
);
|
||||
} catch (a2uiError) {
|
||||
logger.error('[Task] A2UI: Error finalizing surfaces:', a2uiError);
|
||||
}
|
||||
}
|
||||
|
||||
_sendCitation(citation: string) {
|
||||
if (!citation || citation.trim() === '') {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* A2A client wrapper for the Google Chat bridge.
|
||||
* Connects to the A2A server (local or remote) and sends/receives messages.
|
||||
* Follows the patterns from core/agents/a2a-client-manager.ts and
|
||||
* core/agents/remote-invocation.ts.
|
||||
*/
|
||||
|
||||
import type { Message, Task, Part, MessageSendParams } from '@a2a-js/sdk';
|
||||
import {
|
||||
type Client,
|
||||
ClientFactory,
|
||||
ClientFactoryOptions,
|
||||
DefaultAgentCardResolver,
|
||||
RestTransportFactory,
|
||||
JsonRpcTransportFactory,
|
||||
} from '@a2a-js/sdk/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { A2UI_EXTENSION_URI, A2UI_MIME_TYPE } from '../a2ui/a2ui-extension.js';
|
||||
|
||||
export type A2AResponse = Message | Task;
|
||||
|
||||
/**
|
||||
* Extracts contextId and taskId from an A2A response.
|
||||
* Follows extractIdsFromResponse pattern from a2aUtils.ts.
|
||||
*/
|
||||
export function extractIdsFromResponse(result: A2AResponse): {
|
||||
contextId?: string;
|
||||
taskId?: string;
|
||||
} {
|
||||
if (result.kind === 'message') {
|
||||
return {
|
||||
contextId: result.contextId,
|
||||
taskId: result.taskId,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.kind === 'task') {
|
||||
const contextId = result.contextId;
|
||||
let taskId: string | undefined = result.id;
|
||||
|
||||
// Clear taskId on terminal states so next interaction starts a fresh task
|
||||
const state = result.status?.state;
|
||||
if (state === 'completed' || state === 'failed' || state === 'canceled') {
|
||||
taskId = undefined;
|
||||
}
|
||||
|
||||
return { contextId, taskId };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all parts from an A2A response.
|
||||
* For Tasks, checks history (accumulated from intermediate status-update events),
|
||||
* the final status message, and artifacts. The blocking DefaultRequestHandler
|
||||
* accumulates intermediate events into task.history, so the A2UI response content
|
||||
* from "working" events lives there even if the final status message is empty.
|
||||
*/
|
||||
export function extractAllParts(result: A2AResponse): Part[] {
|
||||
const parts: Part[] = [];
|
||||
|
||||
if (result.kind === 'message') {
|
||||
parts.push(...(result.parts ?? []));
|
||||
} else if (result.kind === 'task') {
|
||||
// Parts from task history (accumulated intermediate status-update messages)
|
||||
if (result.history) {
|
||||
for (const msg of result.history) {
|
||||
if (msg.parts) {
|
||||
parts.push(...msg.parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parts from the final status message
|
||||
if (result.status?.message?.parts) {
|
||||
parts.push(...result.status.message.parts);
|
||||
}
|
||||
// Parts from artifacts
|
||||
if (result.artifacts) {
|
||||
for (const artifact of result.artifacts) {
|
||||
parts.push(...(artifact.parts ?? []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts plain text content from response parts.
|
||||
*/
|
||||
export function extractTextFromParts(parts: Part[]): string {
|
||||
return parts
|
||||
.filter((p) => p.kind === 'text')
|
||||
.map(
|
||||
(p) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(p as unknown as { text: string }).text,
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts A2UI data parts from response parts.
|
||||
* A2UI parts are DataParts with metadata.mimeType === 'application/json+a2ui'.
|
||||
*/
|
||||
export function extractA2UIParts(parts: Part[]): unknown[][] {
|
||||
const a2uiMessages: unknown[][] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (
|
||||
part.kind === 'data' &&
|
||||
part.metadata != null &&
|
||||
part.metadata['mimeType'] === A2UI_MIME_TYPE
|
||||
) {
|
||||
// The data field is an array of A2UI messages
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = (part as unknown as { data: unknown }).data;
|
||||
if (Array.isArray(data)) {
|
||||
a2uiMessages.push(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a2uiMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2A client for the chat bridge.
|
||||
* Manages connection to the A2A server and provides message send/receive.
|
||||
*/
|
||||
export class A2ABridgeClient {
|
||||
private client: Client | null = null;
|
||||
private agentUrl: string;
|
||||
|
||||
constructor(agentUrl: string) {
|
||||
this.agentUrl = agentUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the client connection to the A2A server.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.client) return;
|
||||
|
||||
const resolver = new DefaultAgentCardResolver({});
|
||||
const options = ClientFactoryOptions.createFrom(
|
||||
ClientFactoryOptions.default,
|
||||
{
|
||||
transports: [
|
||||
new RestTransportFactory({}),
|
||||
new JsonRpcTransportFactory({}),
|
||||
],
|
||||
cardResolver: resolver,
|
||||
},
|
||||
);
|
||||
|
||||
const factory = new ClientFactory(options);
|
||||
// createFromUrl expects the agent card URL, not just the base URL
|
||||
const agentCardUrl =
|
||||
this.agentUrl.replace(/\/$/, '') + '/.well-known/agent-card.json';
|
||||
this.client = await factory.createFromUrl(agentCardUrl, '');
|
||||
|
||||
const card = await this.client.getAgentCard();
|
||||
logger.info(
|
||||
`[ChatBridge] Connected to A2A agent: ${card.name} (${card.url})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a text message to the A2A server using blocking mode.
|
||||
* The blocking DefaultRequestHandler accumulates all intermediate events
|
||||
* (including A2UI content from "working" status updates) into the Task's
|
||||
* history array, so extractAllParts can find them.
|
||||
*/
|
||||
async sendMessage(
|
||||
text: string,
|
||||
options: { contextId?: string; taskId?: string },
|
||||
): Promise<A2AResponse> {
|
||||
if (!this.client) {
|
||||
throw new Error('A2A client not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
const params: MessageSendParams = {
|
||||
message: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
messageId: uuidv4(),
|
||||
parts: [{ kind: 'text', text }],
|
||||
contextId: options.contextId,
|
||||
taskId: options.taskId,
|
||||
// Signal A2UI support in message metadata
|
||||
metadata: {
|
||||
extensions: [A2UI_EXTENSION_URI],
|
||||
},
|
||||
},
|
||||
configuration: {
|
||||
blocking: true,
|
||||
},
|
||||
};
|
||||
|
||||
return this.client.sendMessage(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a tool confirmation action back to the A2A server.
|
||||
* The action is sent as a DataPart containing the A2UI action message.
|
||||
*/
|
||||
async sendToolConfirmation(
|
||||
callId: string,
|
||||
outcome: string,
|
||||
taskId: string,
|
||||
options: { contextId?: string },
|
||||
): Promise<A2AResponse> {
|
||||
if (!this.client) {
|
||||
throw new Error('A2A client not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
// Build the A2UI action message as a DataPart
|
||||
const actionPart: Part = {
|
||||
kind: 'data',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
data: [
|
||||
{
|
||||
version: 'v0.10',
|
||||
action: {
|
||||
name: 'tool_confirmation',
|
||||
surfaceId: `tool_approval_${taskId}_${callId}`,
|
||||
sourceComponentId:
|
||||
outcome === 'cancel' ? 'reject_button' : 'approve_button',
|
||||
timestamp: new Date().toISOString(),
|
||||
context: { callId, outcome, taskId },
|
||||
},
|
||||
},
|
||||
] as unknown as Record<string, unknown>,
|
||||
metadata: {
|
||||
mimeType: A2UI_MIME_TYPE,
|
||||
},
|
||||
} as Part;
|
||||
|
||||
const params: MessageSendParams = {
|
||||
message: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
messageId: uuidv4(),
|
||||
parts: [actionPart],
|
||||
contextId: options.contextId,
|
||||
taskId,
|
||||
metadata: {
|
||||
extensions: [A2UI_EXTENSION_URI],
|
||||
},
|
||||
},
|
||||
configuration: {
|
||||
blocking: true,
|
||||
},
|
||||
};
|
||||
|
||||
return this.client.sendMessage(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Google Chat webhook handler.
|
||||
* Processes incoming Google Chat events, forwards them to the A2A server,
|
||||
* and converts responses back to Google Chat format.
|
||||
*/
|
||||
|
||||
import type { ChatEvent, ChatResponse, ChatBridgeConfig } from './types.js';
|
||||
import { SessionStore } from './session-store.js';
|
||||
import {
|
||||
A2ABridgeClient,
|
||||
extractIdsFromResponse,
|
||||
} from './a2a-bridge-client.js';
|
||||
import { renderResponse, extractToolApprovals } from './response-renderer.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export class ChatBridgeHandler {
|
||||
private sessionStore: SessionStore;
|
||||
private a2aClient: A2ABridgeClient;
|
||||
private initialized = false;
|
||||
|
||||
constructor(private config: ChatBridgeConfig) {
|
||||
this.sessionStore = new SessionStore(config.gcsBucket);
|
||||
this.a2aClient = new A2ABridgeClient(config.a2aServerUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the A2A client connection and restores persisted sessions.
|
||||
* Must be called before handling events.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
await this.a2aClient.initialize();
|
||||
await this.sessionStore.restore();
|
||||
this.initialized = true;
|
||||
logger.info(
|
||||
`[ChatBridge] Handler initialized, connected to ${this.config.a2aServerUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for handling Google Chat webhook events.
|
||||
*/
|
||||
async handleEvent(event: ChatEvent): Promise<ChatResponse> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] Received event: type=${event.type}, space=${event.space.name}`,
|
||||
);
|
||||
|
||||
switch (event.type) {
|
||||
case 'MESSAGE':
|
||||
return this.handleMessage(event);
|
||||
case 'CARD_CLICKED':
|
||||
return this.handleCardClicked(event);
|
||||
case 'ADDED_TO_SPACE':
|
||||
return this.handleAddedToSpace(event);
|
||||
case 'REMOVED_FROM_SPACE':
|
||||
return this.handleRemovedFromSpace(event);
|
||||
default:
|
||||
logger.warn(`[ChatBridge] Unknown event type: ${event.type}`);
|
||||
return { text: 'Unknown event type.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a MESSAGE event: user sent a text message in Chat.
|
||||
*/
|
||||
private async handleMessage(event: ChatEvent): Promise<ChatResponse> {
|
||||
const message = event.message;
|
||||
if (!message?.thread?.name) {
|
||||
return { text: 'Error: Missing thread information.' };
|
||||
}
|
||||
|
||||
const text = message.argumentText || message.text || '';
|
||||
if (!text.trim()) {
|
||||
return { text: "I didn't receive any text. Please try again." };
|
||||
}
|
||||
|
||||
const threadName = message.thread.name;
|
||||
const spaceName = event.space.name;
|
||||
|
||||
// Handle slash commands
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
if (
|
||||
trimmed === '/reset' ||
|
||||
trimmed === '/clear' ||
|
||||
trimmed === 'reset' ||
|
||||
trimmed === 'clear'
|
||||
) {
|
||||
this.sessionStore.remove(threadName);
|
||||
logger.info(`[ChatBridge] Session cleared for thread ${threadName}`);
|
||||
return { text: 'Session cleared. Send a new message to start fresh.' };
|
||||
}
|
||||
|
||||
const session = this.sessionStore.getOrCreate(threadName, spaceName);
|
||||
|
||||
if (trimmed === '/yolo') {
|
||||
session.yoloMode = true;
|
||||
logger.info(`[ChatBridge] YOLO mode enabled for thread ${threadName}`);
|
||||
return {
|
||||
text: 'YOLO mode enabled. All tool calls will be auto-approved.',
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed === '/safe') {
|
||||
session.yoloMode = false;
|
||||
logger.info(`[ChatBridge] YOLO mode disabled for thread ${threadName}`);
|
||||
return { text: 'Safe mode enabled. Tool calls will require approval.' };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] MESSAGE from ${event.user.displayName}: "${text.substring(0, 100)}"`,
|
||||
);
|
||||
|
||||
// Handle text-based tool approval responses
|
||||
const lowerText = trimmed;
|
||||
if (
|
||||
session.pendingToolApproval &&
|
||||
(lowerText === 'approve' ||
|
||||
lowerText === 'yes' ||
|
||||
lowerText === 'y' ||
|
||||
lowerText === 'reject' ||
|
||||
lowerText === 'no' ||
|
||||
lowerText === 'n' ||
|
||||
lowerText === 'always allow')
|
||||
) {
|
||||
const approval = session.pendingToolApproval;
|
||||
const isReject =
|
||||
lowerText === 'reject' || lowerText === 'no' || lowerText === 'n';
|
||||
const isAlwaysAllow = lowerText === 'always allow';
|
||||
const outcome = isReject
|
||||
? 'cancel'
|
||||
: isAlwaysAllow
|
||||
? 'proceed_always_tool'
|
||||
: 'proceed_once';
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] Text-based tool ${outcome}: callId=${approval.callId}, taskId=${approval.taskId}`,
|
||||
);
|
||||
|
||||
session.pendingToolApproval = undefined;
|
||||
|
||||
try {
|
||||
const response = await this.a2aClient.sendToolConfirmation(
|
||||
approval.callId,
|
||||
outcome,
|
||||
approval.taskId,
|
||||
{ contextId: session.contextId },
|
||||
);
|
||||
|
||||
const { contextId: newCtxId, taskId: newTaskId } =
|
||||
extractIdsFromResponse(response);
|
||||
if (newCtxId) session.contextId = newCtxId;
|
||||
this.sessionStore.updateTaskId(threadName, newTaskId);
|
||||
|
||||
const threadKey = message.thread.threadKey || threadName;
|
||||
return renderResponse(response, threadKey, threadName);
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(
|
||||
`[ChatBridge] Error sending tool confirmation: ${errorMsg}`,
|
||||
error,
|
||||
);
|
||||
return { text: `Error processing tool confirmation: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.a2aClient.sendMessage(text, {
|
||||
contextId: session.contextId,
|
||||
taskId: session.taskId,
|
||||
});
|
||||
|
||||
// Update session with new IDs from response
|
||||
const { contextId, taskId } = extractIdsFromResponse(response);
|
||||
if (contextId) {
|
||||
session.contextId = contextId;
|
||||
}
|
||||
this.sessionStore.updateTaskId(threadName, taskId);
|
||||
|
||||
// Check for pending tool approvals and store for text-based confirmation
|
||||
const approvals = extractToolApprovals(response);
|
||||
if (approvals.length > 0) {
|
||||
const firstApproval = approvals[0];
|
||||
session.pendingToolApproval = {
|
||||
callId: firstApproval.callId,
|
||||
taskId: firstApproval.taskId,
|
||||
toolName: firstApproval.displayName || firstApproval.name,
|
||||
};
|
||||
logger.info(
|
||||
`[ChatBridge] Pending tool approval: ${firstApproval.displayName || firstApproval.name} callId=${firstApproval.callId}`,
|
||||
);
|
||||
} else {
|
||||
session.pendingToolApproval = undefined;
|
||||
}
|
||||
|
||||
// Convert A2A response to Chat format
|
||||
const threadKey = message.thread.threadKey || threadName;
|
||||
return renderResponse(response, threadKey, threadName);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`[ChatBridge] Error handling message: ${errorMsg}`, error);
|
||||
return {
|
||||
text: `Sorry, I encountered an error processing your request: ${errorMsg}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a CARD_CLICKED event: user clicked a button on a card.
|
||||
* Used for tool approval/rejection flows.
|
||||
*/
|
||||
private async handleCardClicked(event: ChatEvent): Promise<ChatResponse> {
|
||||
const action = event.action;
|
||||
if (!action) {
|
||||
return { text: 'Error: Missing action data.' };
|
||||
}
|
||||
|
||||
const threadName = event.message?.thread?.name;
|
||||
if (!threadName) {
|
||||
return { text: 'Error: Missing thread information.' };
|
||||
}
|
||||
|
||||
const session = this.sessionStore.get(threadName);
|
||||
if (!session) {
|
||||
return { text: 'Error: No active session found for this thread.' };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] CARD_CLICKED: function=${action.actionMethodName}`,
|
||||
);
|
||||
|
||||
if (action.actionMethodName === 'tool_confirmation') {
|
||||
return this.handleToolConfirmation(event, session.contextId);
|
||||
}
|
||||
|
||||
return { text: `Unknown action: ${action.actionMethodName}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles tool confirmation actions from card button clicks.
|
||||
*/
|
||||
private async handleToolConfirmation(
|
||||
event: ChatEvent,
|
||||
contextId: string,
|
||||
): Promise<ChatResponse> {
|
||||
const params = event.action?.parameters || [];
|
||||
const paramMap = new Map(params.map((p) => [p.key, p.value]));
|
||||
|
||||
const callId = paramMap.get('callId');
|
||||
const outcome = paramMap.get('outcome');
|
||||
const taskId = paramMap.get('taskId');
|
||||
|
||||
if (!callId || !outcome || !taskId) {
|
||||
return { text: 'Error: Missing tool confirmation parameters.' };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] Tool confirmation: callId=${callId}, outcome=${outcome}, taskId=${taskId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await this.a2aClient.sendToolConfirmation(
|
||||
callId,
|
||||
outcome,
|
||||
taskId,
|
||||
{ contextId },
|
||||
);
|
||||
|
||||
// Update session
|
||||
const threadName = event.message?.thread?.name;
|
||||
if (threadName) {
|
||||
const { contextId: newContextId, taskId: newTaskId } =
|
||||
extractIdsFromResponse(response);
|
||||
if (newContextId) {
|
||||
const session = this.sessionStore.get(threadName);
|
||||
if (session) session.contextId = newContextId;
|
||||
}
|
||||
this.sessionStore.updateTaskId(threadName, newTaskId);
|
||||
}
|
||||
|
||||
return renderResponse(response);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(
|
||||
`[ChatBridge] Error sending tool confirmation: ${errorMsg}`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
text: `Error processing tool confirmation: ${errorMsg}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ADDED_TO_SPACE event: bot was added to a space or DM.
|
||||
*/
|
||||
private handleAddedToSpace(event: ChatEvent): ChatResponse {
|
||||
const spaceType = event.space.type === 'DM' ? 'DM' : 'space';
|
||||
logger.info(`[ChatBridge] Bot added to ${spaceType}: ${event.space.name}`);
|
||||
return {
|
||||
text:
|
||||
`Hello! I'm the Gemini CLI Agent. Send me a message to get started with code generation and development tasks.\n\n` +
|
||||
`I can:\n` +
|
||||
`- Generate code from natural language\n` +
|
||||
`- Edit files and run commands\n` +
|
||||
`- Answer questions about code\n\n` +
|
||||
`I'll ask for your approval before executing tools.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles REMOVED_FROM_SPACE event: bot was removed from a space.
|
||||
*/
|
||||
private handleRemovedFromSpace(event: ChatEvent): ChatResponse {
|
||||
logger.info(`[ChatBridge] Bot removed from space: ${event.space.name}`);
|
||||
// Clean up any sessions for this space
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts A2A/A2UI responses into Google Chat messages and Cards V2.
|
||||
*
|
||||
* This renderer understands the A2UI v0.10 surface structures produced by our
|
||||
* a2a-server (tool approval surfaces, agent response surfaces, thought surfaces)
|
||||
* and converts them to Google Chat's Cards V2 format.
|
||||
*
|
||||
* Inspired by the A2UI web_core message processor pattern but simplified for
|
||||
* server-side rendering to a constrained card format.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChatResponse,
|
||||
ChatCardV2,
|
||||
ChatCardSection,
|
||||
ChatWidget,
|
||||
} from './types.js';
|
||||
import {
|
||||
type A2AResponse,
|
||||
extractAllParts,
|
||||
extractTextFromParts,
|
||||
extractA2UIParts,
|
||||
} from './a2a-bridge-client.js';
|
||||
|
||||
export interface ToolApprovalInfo {
|
||||
taskId: string;
|
||||
callId: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
args: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AgentResponseInfo {
|
||||
text: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool approval info from an A2A response.
|
||||
* Used by the handler to track pending approvals for text-based confirmation.
|
||||
*/
|
||||
export function extractToolApprovals(
|
||||
response: A2AResponse,
|
||||
): ToolApprovalInfo[] {
|
||||
const parts = extractAllParts(response);
|
||||
const a2uiMessageGroups = extractA2UIParts(parts);
|
||||
const toolApprovals: ToolApprovalInfo[] = [];
|
||||
const agentResponses: AgentResponseInfo[] = [];
|
||||
const thoughts: Array<{ subject: string; description: string }> = [];
|
||||
|
||||
for (const messages of a2uiMessageGroups) {
|
||||
parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts);
|
||||
}
|
||||
|
||||
return deduplicateToolApprovals(toolApprovals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an A2A response as a Google Chat response.
|
||||
* Extracts text content and A2UI surfaces, converting them to Chat format.
|
||||
*/
|
||||
export function renderResponse(
|
||||
response: A2AResponse,
|
||||
threadKey?: string,
|
||||
threadName?: string,
|
||||
): ChatResponse {
|
||||
const parts = extractAllParts(response);
|
||||
const textContent = extractTextFromParts(parts);
|
||||
const a2uiMessageGroups = extractA2UIParts(parts);
|
||||
|
||||
// Parse A2UI surfaces for known types
|
||||
const toolApprovals: ToolApprovalInfo[] = [];
|
||||
const agentResponses: AgentResponseInfo[] = [];
|
||||
const thoughts: Array<{ subject: string; description: string }> = [];
|
||||
|
||||
for (const messages of a2uiMessageGroups) {
|
||||
parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts);
|
||||
}
|
||||
|
||||
// Deduplicate tool approvals by surfaceId — A2UI history contains both
|
||||
// initial 'awaiting_approval' and later 'success' events for auto-approved tools.
|
||||
const dedupedApprovals = deduplicateToolApprovals(toolApprovals);
|
||||
|
||||
const cards: ChatCardV2[] = [];
|
||||
|
||||
// Only render tool approval cards for tools still awaiting approval.
|
||||
// In YOLO mode, tools are auto-approved and their status becomes "success"
|
||||
// so we skip rendering approval cards for those.
|
||||
for (const approval of dedupedApprovals) {
|
||||
if (approval.status === 'awaiting_approval') {
|
||||
cards.push(renderToolApprovalCard(approval));
|
||||
}
|
||||
}
|
||||
|
||||
// Build text response from agent responses and plain text
|
||||
const responseTexts: string[] = [];
|
||||
|
||||
// Add thought summaries
|
||||
for (const thought of thoughts) {
|
||||
responseTexts.push(`_${thought.subject}_: ${thought.description}`);
|
||||
}
|
||||
|
||||
// Add agent response text (from A2UI surfaces).
|
||||
// Use only the last non-empty response since later updates supersede earlier
|
||||
// ones for the same surface (history contains multiple status-update messages).
|
||||
for (let i = agentResponses.length - 1; i >= 0; i--) {
|
||||
if (agentResponses[i].text) {
|
||||
responseTexts.push(agentResponses[i].text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to plain text content if no A2UI response text
|
||||
if (responseTexts.length === 0 && textContent) {
|
||||
responseTexts.push(textContent);
|
||||
}
|
||||
|
||||
// Add task state info
|
||||
if (response.kind === 'task' && response.status) {
|
||||
const state = response.status.state;
|
||||
if (state === 'input-required' && cards.length > 0) {
|
||||
responseTexts.push('*Waiting for your approval to continue...*');
|
||||
} else if (state === 'failed') {
|
||||
responseTexts.push('*Task failed.*');
|
||||
} else if (state === 'canceled') {
|
||||
responseTexts.push('*Task was cancelled.*');
|
||||
}
|
||||
}
|
||||
|
||||
const chatResponse: ChatResponse = {};
|
||||
|
||||
if (responseTexts.length > 0) {
|
||||
chatResponse.text = responseTexts.join('\n\n');
|
||||
}
|
||||
|
||||
if (cards.length > 0) {
|
||||
chatResponse.cardsV2 = cards;
|
||||
}
|
||||
|
||||
if (threadKey || threadName) {
|
||||
chatResponse.thread = {};
|
||||
if (threadKey) chatResponse.thread.threadKey = threadKey;
|
||||
if (threadName) chatResponse.thread.name = threadName;
|
||||
}
|
||||
|
||||
// Ensure we always return something
|
||||
if (!chatResponse.text && !chatResponse.cardsV2) {
|
||||
chatResponse.text = '_Agent is processing..._';
|
||||
}
|
||||
|
||||
return chatResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a CARD_CLICKED acknowledgment response.
|
||||
*/
|
||||
export function renderActionAcknowledgment(
|
||||
action: string,
|
||||
outcome: string,
|
||||
): ChatResponse {
|
||||
const emoji =
|
||||
outcome === 'cancel'
|
||||
? 'Rejected'
|
||||
: outcome === 'proceed_always_tool'
|
||||
? 'Always Allowed'
|
||||
: 'Approved';
|
||||
return {
|
||||
actionResponse: { type: 'UPDATE_MESSAGE' },
|
||||
text: `*Tool ${emoji}* - Processing...`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Safely extracts a string property from an unknown object. */
|
||||
function str(obj: Record<string, unknown>, key: string): string {
|
||||
const v = obj[key];
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
/** Safely checks if an unknown value is a record. */
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** Safely extracts a nested object property. */
|
||||
function obj(
|
||||
parent: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const v = parent[key];
|
||||
return isRecord(v) ? v : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates tool approvals by surfaceId, keeping the last entry per surface.
|
||||
* In blocking mode, A2UI history accumulates ALL intermediate events — a tool
|
||||
* surface may appear first as 'awaiting_approval' then as 'success' (YOLO mode).
|
||||
* By keeping only the last entry per surfaceId, auto-approved tools show 'success'.
|
||||
*/
|
||||
function deduplicateToolApprovals(
|
||||
approvals: ToolApprovalInfo[],
|
||||
): ToolApprovalInfo[] {
|
||||
const byId = new Map<string, ToolApprovalInfo>();
|
||||
for (const a of approvals) {
|
||||
const key = `${a.taskId}_${a.callId}`;
|
||||
byId.set(key, a);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses A2UI v0.10 messages to extract known surface types.
|
||||
* Our server produces specific surfaces: tool approval, agent response, thought.
|
||||
*/
|
||||
function parseA2UIMessages(
|
||||
messages: unknown[],
|
||||
toolApprovals: ToolApprovalInfo[],
|
||||
agentResponses: AgentResponseInfo[],
|
||||
thoughts: Array<{ subject: string; description: string }>,
|
||||
): void {
|
||||
for (const msg of messages) {
|
||||
if (!isRecord(msg)) continue;
|
||||
|
||||
// Look for updateDataModel messages that contain tool approval or response data
|
||||
const updateDM = obj(msg, 'updateDataModel');
|
||||
if (updateDM) {
|
||||
const surfaceId = str(updateDM, 'surfaceId');
|
||||
const value = obj(updateDM, 'value');
|
||||
const path = str(updateDM, 'path');
|
||||
|
||||
if (value && !path) {
|
||||
// Full data model update (initial) - check for known structures
|
||||
const tool = obj(value, 'tool');
|
||||
if (surfaceId.startsWith('tool_approval_') && tool) {
|
||||
toolApprovals.push({
|
||||
taskId: str(value, 'taskId'),
|
||||
callId: str(tool, 'callId'),
|
||||
name: str(tool, 'name'),
|
||||
displayName: str(tool, 'displayName'),
|
||||
description: str(tool, 'description'),
|
||||
args: str(tool, 'args'),
|
||||
kind: str(tool, 'kind') || 'tool',
|
||||
status: str(tool, 'status') || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
const resp = obj(value, 'response');
|
||||
if (surfaceId.startsWith('agent_response_') && resp) {
|
||||
agentResponses.push({
|
||||
text: str(resp, 'text'),
|
||||
status: str(resp, 'status'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Partial data model updates (path-based)
|
||||
if (path === '/response/text' && updateDM['value'] != null) {
|
||||
agentResponses.push({
|
||||
text: String(updateDM['value']),
|
||||
status: '',
|
||||
});
|
||||
}
|
||||
|
||||
// Tool status updates (e.g., YOLO mode changes status to 'success')
|
||||
if (
|
||||
surfaceId.startsWith('tool_approval_') &&
|
||||
path === '/tool/status' &&
|
||||
typeof updateDM['value'] === 'string'
|
||||
) {
|
||||
// Find existing tool approval for this surface and update its status
|
||||
const existing = toolApprovals.find(
|
||||
(a) => `tool_approval_${a.taskId}_${a.callId}` === surfaceId,
|
||||
);
|
||||
if (existing) {
|
||||
existing.status = updateDM['value'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for updateComponents to extract thought text
|
||||
const updateComp = obj(msg, 'updateComponents');
|
||||
if (updateComp) {
|
||||
const surfaceId = str(updateComp, 'surfaceId');
|
||||
const components = updateComp['components'];
|
||||
|
||||
if (surfaceId.startsWith('thought_') && Array.isArray(components)) {
|
||||
const subject = extractComponentText(components, 'thought_subject');
|
||||
const desc = extractComponentText(components, 'thought_desc');
|
||||
if (subject || desc) {
|
||||
thoughts.push({
|
||||
subject: subject || 'Thinking',
|
||||
description: desc || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the text content from a named component in a component array.
|
||||
* Components use our a2ui-components.ts builder format.
|
||||
*/
|
||||
function extractComponentText(
|
||||
components: unknown[],
|
||||
componentId: string,
|
||||
): string {
|
||||
for (const comp of components) {
|
||||
if (!isRecord(comp)) continue;
|
||||
if (comp['id'] === componentId && comp['component'] === 'text') {
|
||||
return str(comp, 'text');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a concise command summary from tool approval args.
|
||||
* For shell tools, returns just the command string.
|
||||
* For file tools, returns the file path.
|
||||
*/
|
||||
function extractCommandSummary(approval: ToolApprovalInfo): string {
|
||||
if (!approval.args || approval.args === 'No arguments') return '';
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(approval.args);
|
||||
if (isRecord(parsed)) {
|
||||
// Shell tool: {"command": "ls -F"}
|
||||
if (typeof parsed['command'] === 'string') {
|
||||
return parsed['command'];
|
||||
}
|
||||
// File tools: {"file_path": "/path/to/file", ...}
|
||||
if (typeof parsed['file_path'] === 'string') {
|
||||
const action =
|
||||
approval.name || approval.displayName || 'File operation';
|
||||
return `${action}: ${parsed['file_path']}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, return as-is if short enough
|
||||
if (approval.args.length <= 200) return approval.args;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a tool approval surface as a Google Chat Card V2.
|
||||
*/
|
||||
function renderToolApprovalCard(approval: ToolApprovalInfo): ChatCardV2 {
|
||||
const widgets: ChatWidget[] = [];
|
||||
|
||||
// Show a concise summary of what the tool will do.
|
||||
// For shell commands, extract just the command string from the args JSON.
|
||||
const commandSummary = extractCommandSummary(approval);
|
||||
if (commandSummary) {
|
||||
widgets.push({
|
||||
decoratedText: {
|
||||
text: `\`${commandSummary}\``,
|
||||
topLabel: approval.displayName || approval.name,
|
||||
startIcon: { knownIcon: 'DESCRIPTION' },
|
||||
wrapText: true,
|
||||
},
|
||||
});
|
||||
} else if (approval.args && approval.args !== 'No arguments') {
|
||||
// Fallback: show truncated args
|
||||
const truncatedArgs =
|
||||
approval.args.length > 300
|
||||
? approval.args.substring(0, 300) + '...'
|
||||
: approval.args;
|
||||
widgets.push({
|
||||
decoratedText: {
|
||||
text: truncatedArgs,
|
||||
topLabel: approval.displayName || approval.name,
|
||||
startIcon: { knownIcon: 'DESCRIPTION' },
|
||||
wrapText: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Text-based approval instructions (card click buttons don't work
|
||||
// with the current Add-ons routing configuration)
|
||||
widgets.push({
|
||||
textParagraph: {
|
||||
text: 'Reply <b>approve</b>, <b>always allow</b>, or <b>reject</b>',
|
||||
},
|
||||
});
|
||||
|
||||
const sections: ChatCardSection[] = [
|
||||
{
|
||||
widgets,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
cardId: `tool_approval_${approval.callId}`,
|
||||
card: {
|
||||
header: {
|
||||
title: 'Tool Approval Required',
|
||||
subtitle: approval.displayName || approval.name,
|
||||
},
|
||||
sections,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Express routes for the Google Chat bridge webhook.
|
||||
* Adds a POST /chat/webhook endpoint to the existing Express app.
|
||||
* Includes JWT verification for Google Chat requests when configured.
|
||||
*/
|
||||
|
||||
import type { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Router as createRouter } from 'express';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import type { ChatEvent, ChatBridgeConfig, ChatResponse } from './types.js';
|
||||
import { ChatBridgeHandler } from './handler.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const CHAT_ISSUER = 'chat@system.gserviceaccount.com';
|
||||
|
||||
/**
|
||||
* Creates middleware that verifies Google Chat JWT tokens.
|
||||
*
|
||||
* On Cloud Run (detected via K_SERVICE env var), authentication is handled by
|
||||
* Cloud Run's IAM layer — only principals with roles/run.invoker can reach the
|
||||
* container. Cloud Run strips the Authorization header after validation, so our
|
||||
* middleware cannot re-verify the token. We trust Cloud Run's IAM instead.
|
||||
*
|
||||
* When NOT on Cloud Run and projectNumber is set, requests must include a valid
|
||||
* Bearer token signed by Google Chat with the correct audience.
|
||||
*
|
||||
* When neither condition applies, verification is skipped (local testing).
|
||||
*/
|
||||
function createAuthMiddleware(
|
||||
projectNumber: string | undefined,
|
||||
): (req: Request, res: Response, next: NextFunction) => void {
|
||||
// On Cloud Run, IAM handles auth — the Authorization header is stripped
|
||||
// before reaching the container, so we cannot verify it ourselves.
|
||||
if (process.env['K_SERVICE']) {
|
||||
logger.info(
|
||||
'[ChatBridge] Running on Cloud Run — auth delegated to Cloud Run IAM.',
|
||||
);
|
||||
return (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
if (!projectNumber) {
|
||||
logger.warn(
|
||||
'[ChatBridge] CHAT_PROJECT_NUMBER not set — JWT verification disabled. ' +
|
||||
'Set it in production to verify requests come from Google Chat.',
|
||||
);
|
||||
return (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
const authClient = new OAuth2Client();
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('[ChatBridge] Missing or invalid Authorization header');
|
||||
res.status(401).json({ error: 'Unauthorized: missing Bearer token' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// Debug: decode token payload without verification to inspect claims
|
||||
try {
|
||||
const payloadB64 = token.split('.')[1];
|
||||
if (payloadB64) {
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(payloadB64, 'base64').toString(),
|
||||
);
|
||||
logger.info(
|
||||
`[ChatBridge] Token claims: iss=${String(decoded.iss ?? 'none')} ` +
|
||||
`aud=${String(decoded.aud ?? 'none')} ` +
|
||||
`email=${String(decoded.email ?? 'none')} ` +
|
||||
`sub=${String(decoded.sub ?? 'none')}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.warn('[ChatBridge] Could not decode token for debug logging');
|
||||
}
|
||||
|
||||
authClient
|
||||
.verifyIdToken({
|
||||
idToken: token,
|
||||
audience: projectNumber,
|
||||
})
|
||||
.then((ticket) => {
|
||||
const payload = ticket.getPayload();
|
||||
if (payload?.iss !== CHAT_ISSUER) {
|
||||
logger.warn(
|
||||
`[ChatBridge] Invalid token issuer: ${payload?.iss ?? 'unknown'}`,
|
||||
);
|
||||
res.status(403).json({ error: 'Forbidden: invalid token issuer' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.warn(`[ChatBridge] Token verification failed: ${msg}`);
|
||||
res.status(401).json({ error: 'Unauthorized: invalid token' });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Safely extract a string from an unknown record. */
|
||||
function str(obj: Record<string, unknown>, key: string): string {
|
||||
const v = obj[key];
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
/** Safely check if a value is a plain object. */
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a Google Chat event to the legacy ChatEvent format.
|
||||
* Workspace Add-ons send: {chat: {messagePayload, user, ...}, commonEventObject}
|
||||
* Legacy format: {type: "MESSAGE", message: {...}, space: {...}, user: {...}}
|
||||
*/
|
||||
function normalizeEvent(raw: Record<string, unknown>): ChatEvent | null {
|
||||
// Already in legacy format
|
||||
if (typeof raw['type'] === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return raw as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
// Workspace Add-ons format
|
||||
const chat = raw['chat'];
|
||||
if (!isObj(chat)) return null;
|
||||
|
||||
const user = isObj(chat['user']) ? chat['user'] : {};
|
||||
const eventTime = str(chat, 'eventTime');
|
||||
|
||||
// Check for card click actions (button clicks) via commonEventObject
|
||||
const common = raw['commonEventObject'];
|
||||
if (isObj(common) && typeof common['invokedFunction'] === 'string') {
|
||||
const invokedFunction = common['invokedFunction'];
|
||||
const params = isObj(common['parameters']) ? common['parameters'] : {};
|
||||
|
||||
// Build action parameters array from commonEventObject.parameters
|
||||
const actionParams = Object.entries(params)
|
||||
.filter(([, v]) => typeof v === 'string')
|
||||
.map(([key, value]) => ({ key, value: String(value) }));
|
||||
|
||||
// Extract message/thread/space from chat object
|
||||
const message = isObj(chat['message']) ? chat['message'] : {};
|
||||
const thread = isObj(message['thread']) ? message['thread'] : {};
|
||||
const space = isObj(chat['space'])
|
||||
? chat['space']
|
||||
: isObj(message['space'])
|
||||
? message['space']
|
||||
: {};
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] Add-ons CARD_CLICKED: function=${invokedFunction} ` +
|
||||
`params=${JSON.stringify(params)} thread=${str(thread, 'name')}`,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
type: 'CARD_CLICKED',
|
||||
eventTime,
|
||||
message: { ...message, thread, space },
|
||||
space,
|
||||
user,
|
||||
action: {
|
||||
actionMethodName: invokedFunction,
|
||||
parameters: actionParams,
|
||||
},
|
||||
} as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
// Determine event type from which payload field is present
|
||||
if (isObj(chat['messagePayload'])) {
|
||||
const payload = chat['messagePayload'];
|
||||
const message = isObj(payload['message']) ? payload['message'] : {};
|
||||
const space = isObj(payload['space'])
|
||||
? payload['space']
|
||||
: isObj(message['space'])
|
||||
? message['space']
|
||||
: {};
|
||||
const thread = isObj(message['thread']) ? message['thread'] : {};
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] Add-ons MESSAGE: text="${str(message, 'text')}" ` +
|
||||
`space=${str(space, 'name')} thread=${str(thread, 'name')}`,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
type: 'MESSAGE',
|
||||
eventTime,
|
||||
message: {
|
||||
...message,
|
||||
sender: message['sender'] ?? user,
|
||||
thread,
|
||||
space,
|
||||
},
|
||||
space,
|
||||
user,
|
||||
} as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
if (isObj(chat['addedToSpacePayload'])) {
|
||||
const payload = chat['addedToSpacePayload'];
|
||||
const space = isObj(payload['space']) ? payload['space'] : {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
type: 'ADDED_TO_SPACE',
|
||||
eventTime,
|
||||
space,
|
||||
user,
|
||||
} as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
if (isObj(chat['removedFromSpacePayload'])) {
|
||||
const payload = chat['removedFromSpacePayload'];
|
||||
const space = isObj(payload['space']) ? payload['space'] : {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
type: 'REMOVED_FROM_SPACE',
|
||||
eventTime,
|
||||
space,
|
||||
user,
|
||||
} as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[ChatBridge] Unknown Add-ons event, chat keys: ${Object.keys(chat).join(',')}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a legacy ChatResponse in the Workspace Add-ons response format.
|
||||
* Add-ons expects: {hostAppDataAction: {chatDataAction: {createMessageAction: {message}}}}
|
||||
*/
|
||||
function wrapAddOnsResponse(response: ChatResponse): Record<string, unknown> {
|
||||
// Build the message object for the Add-ons format
|
||||
const message: Record<string, unknown> = {};
|
||||
if (response.text) {
|
||||
message['text'] = response.text;
|
||||
}
|
||||
if (response.cardsV2) {
|
||||
message['cardsV2'] = response.cardsV2;
|
||||
}
|
||||
// Include thread info so the reply goes to the user's thread
|
||||
// instead of appearing as a top-level message
|
||||
if (response.thread) {
|
||||
const thread: Record<string, string> = {};
|
||||
if (response.thread.name) thread['name'] = response.thread.name;
|
||||
if (response.thread.threadKey)
|
||||
thread['threadKey'] = response.thread.threadKey;
|
||||
message['thread'] = thread;
|
||||
}
|
||||
|
||||
// For action responses (like CARD_CLICKED acknowledgments), use updateMessageAction
|
||||
if (response.actionResponse?.type === 'UPDATE_MESSAGE') {
|
||||
return {
|
||||
hostAppDataAction: {
|
||||
chatDataAction: {
|
||||
updateMessageAction: { message },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hostAppDataAction: {
|
||||
chatDataAction: {
|
||||
createMessageAction: { message },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Express routes for the Google Chat bridge.
|
||||
*/
|
||||
export function createChatBridgeRoutes(config: ChatBridgeConfig): Router {
|
||||
const router = createRouter();
|
||||
const handler = new ChatBridgeHandler(config);
|
||||
const authMiddleware = createAuthMiddleware(config.projectNumber);
|
||||
|
||||
// Google Chat sends webhook events as POST requests
|
||||
router.post(
|
||||
'/chat/webhook',
|
||||
authMiddleware,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const rawBody = req.body as Record<string, unknown>;
|
||||
|
||||
// Normalize to legacy ChatEvent format. Google Chat HTTP endpoints
|
||||
// configured as Workspace Add-ons send a different event structure:
|
||||
// {chat: {messagePayload, user, eventTime}, commonEventObject: {...}}
|
||||
// We convert to the legacy format our handler expects:
|
||||
// {type: "MESSAGE", message: {...}, space: {...}, user: {...}}
|
||||
const event = normalizeEvent(rawBody);
|
||||
|
||||
if (!event || !event.type) {
|
||||
logger.warn(
|
||||
`[ChatBridge] Could not parse event. Keys: ${Object.keys(rawBody).join(',')}`,
|
||||
);
|
||||
res.status(400).json({ error: 'Invalid event: missing type field' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[ChatBridge] Webhook received: type=${event.type}`);
|
||||
|
||||
// Detect if the request came in Add-ons format
|
||||
const isAddOnsFormat = Boolean(rawBody['chat'] && !rawBody['type']);
|
||||
|
||||
const response = await handler.handleEvent(event);
|
||||
|
||||
// For CARD_CLICKED events, force UPDATE_MESSAGE so the card is
|
||||
// replaced in-place rather than posting a new message.
|
||||
if (event.type === 'CARD_CLICKED' && !response.actionResponse) {
|
||||
response.actionResponse = { type: 'UPDATE_MESSAGE' };
|
||||
}
|
||||
|
||||
if (isAddOnsFormat) {
|
||||
// Wrap in Workspace Add-ons response format
|
||||
const addOnsResponse = wrapAddOnsResponse(response);
|
||||
logger.info(
|
||||
`[ChatBridge] Add-ons response: ${JSON.stringify(addOnsResponse).substring(0, 200)}`,
|
||||
);
|
||||
res.json(addOnsResponse);
|
||||
} else {
|
||||
res.json(response);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`[ChatBridge] Webhook error: ${errorMsg}`, error);
|
||||
res.status(500).json({
|
||||
text: `Internal error: ${errorMsg}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Health check endpoint for the chat bridge (no auth required)
|
||||
router.get('/chat/health', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
bridge: 'google-chat',
|
||||
a2aServerUrl: config.a2aServerUrl,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Manages mapping between Google Chat threads and A2A sessions.
|
||||
* Each Google Chat thread maintains a persistent contextId (conversation)
|
||||
* and a transient taskId (active task within that conversation).
|
||||
*
|
||||
* Supports optional GCS persistence so session mappings survive
|
||||
* Cloud Run instance restarts.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export interface PendingToolApproval {
|
||||
callId: string;
|
||||
taskId: string;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
/** A2A contextId - persists for the lifetime of the Chat thread. */
|
||||
contextId: string;
|
||||
/** A2A taskId - cleared on terminal states, reused on input-required. */
|
||||
taskId?: string;
|
||||
/** Space name for async messaging. */
|
||||
spaceName: string;
|
||||
/** Thread name for async messaging. */
|
||||
threadName: string;
|
||||
/** Last activity timestamp. */
|
||||
lastActivity: number;
|
||||
/** Pending tool approval waiting for text-based response. */
|
||||
pendingToolApproval?: PendingToolApproval;
|
||||
/** When true, all tool calls are auto-approved. */
|
||||
yoloMode?: boolean;
|
||||
}
|
||||
|
||||
/** Serializable subset of SessionInfo for GCS persistence. */
|
||||
interface PersistedSession {
|
||||
contextId: string;
|
||||
taskId?: string;
|
||||
spaceName: string;
|
||||
threadName: string;
|
||||
lastActivity: number;
|
||||
yoloMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session store mapping Google Chat thread names to A2A sessions.
|
||||
* Optionally backed by GCS for persistence across restarts.
|
||||
*/
|
||||
export class SessionStore {
|
||||
private sessions = new Map<string, SessionInfo>();
|
||||
private gcsBucket?: string;
|
||||
private gcsObjectPath = 'chat-bridge/sessions.json';
|
||||
private dirty = false;
|
||||
private flushTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(gcsBucket?: string) {
|
||||
this.gcsBucket = gcsBucket;
|
||||
if (gcsBucket) {
|
||||
// Flush to GCS every 30 seconds if dirty
|
||||
this.flushTimer = setInterval(() => {
|
||||
if (this.dirty) {
|
||||
this.persistToGCS().catch((err) =>
|
||||
logger.warn(`[ChatBridge] GCS session flush failed:`, err),
|
||||
);
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores sessions from GCS on startup.
|
||||
*/
|
||||
async restore(): Promise<void> {
|
||||
if (!this.gcsBucket) return;
|
||||
|
||||
try {
|
||||
const { Storage } = await import('@google-cloud/storage');
|
||||
const storage = new Storage();
|
||||
const file = storage.bucket(this.gcsBucket).file(this.gcsObjectPath);
|
||||
const [exists] = await file.exists();
|
||||
if (!exists) {
|
||||
logger.info('[ChatBridge] No persisted sessions found in GCS.');
|
||||
return;
|
||||
}
|
||||
|
||||
const [contents] = await file.download();
|
||||
const persisted: PersistedSession[] = JSON.parse(contents.toString());
|
||||
for (const s of persisted) {
|
||||
this.sessions.set(s.threadName, {
|
||||
contextId: s.contextId,
|
||||
taskId: s.taskId,
|
||||
spaceName: s.spaceName,
|
||||
threadName: s.threadName,
|
||||
lastActivity: s.lastActivity,
|
||||
yoloMode: s.yoloMode,
|
||||
});
|
||||
}
|
||||
logger.info(
|
||||
`[ChatBridge] Restored ${persisted.length} sessions from GCS.`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(`[ChatBridge] Could not restore sessions from GCS:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists current sessions to GCS.
|
||||
*/
|
||||
private async persistToGCS(): Promise<void> {
|
||||
if (!this.gcsBucket) return;
|
||||
|
||||
try {
|
||||
const { Storage } = await import('@google-cloud/storage');
|
||||
const storage = new Storage();
|
||||
const file = storage.bucket(this.gcsBucket).file(this.gcsObjectPath);
|
||||
|
||||
const persisted: PersistedSession[] = [];
|
||||
for (const session of this.sessions.values()) {
|
||||
persisted.push({
|
||||
contextId: session.contextId,
|
||||
taskId: session.taskId,
|
||||
spaceName: session.spaceName,
|
||||
threadName: session.threadName,
|
||||
lastActivity: session.lastActivity,
|
||||
yoloMode: session.yoloMode,
|
||||
});
|
||||
}
|
||||
|
||||
await file.save(JSON.stringify(persisted), {
|
||||
contentType: 'application/json',
|
||||
});
|
||||
this.dirty = false;
|
||||
logger.info(
|
||||
`[ChatBridge] Persisted ${persisted.length} sessions to GCS.`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(`[ChatBridge] Failed to persist sessions to GCS:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a session for a Google Chat thread.
|
||||
*/
|
||||
getOrCreate(threadName: string, spaceName: string): SessionInfo {
|
||||
let session = this.sessions.get(threadName);
|
||||
if (!session) {
|
||||
session = {
|
||||
contextId: uuidv4(),
|
||||
spaceName,
|
||||
threadName,
|
||||
lastActivity: Date.now(),
|
||||
};
|
||||
this.sessions.set(threadName, session);
|
||||
this.dirty = true;
|
||||
logger.info(
|
||||
`[ChatBridge] New session for thread ${threadName}: contextId=${session.contextId}`,
|
||||
);
|
||||
}
|
||||
session.lastActivity = Date.now();
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an existing session by thread name.
|
||||
*/
|
||||
get(threadName: string): SessionInfo | undefined {
|
||||
return this.sessions.get(threadName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the taskId for a session.
|
||||
*/
|
||||
updateTaskId(threadName: string, taskId: string | undefined): void {
|
||||
const session = this.sessions.get(threadName);
|
||||
if (session) {
|
||||
session.taskId = taskId;
|
||||
this.dirty = true;
|
||||
logger.info(
|
||||
`[ChatBridge] Session ${threadName}: taskId=${taskId ?? 'cleared'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a session (e.g. when bot is removed from space).
|
||||
*/
|
||||
remove(threadName: string): void {
|
||||
this.sessions.delete(threadName);
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up stale sessions older than the given max age (ms).
|
||||
*/
|
||||
cleanup(maxAgeMs: number = 24 * 60 * 60 * 1000): void {
|
||||
const now = Date.now();
|
||||
for (const [threadName, session] of this.sessions.entries()) {
|
||||
if (now - session.lastActivity > maxAgeMs) {
|
||||
this.sessions.delete(threadName);
|
||||
this.dirty = true;
|
||||
logger.info(`[ChatBridge] Cleaned up stale session: ${threadName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces an immediate flush to GCS.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (this.dirty) {
|
||||
await this.persistToGCS();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the periodic flush timer.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.flushTimer) {
|
||||
clearInterval(this.flushTimer);
|
||||
this.flushTimer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Google Chat HTTP endpoint event types.
|
||||
* @see https://developers.google.com/workspace/chat/api/reference/rest/v1/Event
|
||||
*/
|
||||
|
||||
export interface ChatUser {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type?: 'HUMAN' | 'BOT';
|
||||
}
|
||||
|
||||
export interface ChatThread {
|
||||
name: string;
|
||||
threadKey?: string;
|
||||
}
|
||||
|
||||
export interface ChatSpace {
|
||||
name: string;
|
||||
type: 'DM' | 'ROOM' | 'SPACE';
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
name: string;
|
||||
sender: ChatUser;
|
||||
createTime: string;
|
||||
text?: string;
|
||||
argumentText?: string;
|
||||
thread: ChatThread;
|
||||
space: ChatSpace;
|
||||
cardsV2?: ChatCardV2[];
|
||||
}
|
||||
|
||||
export interface ChatActionParameter {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ChatAction {
|
||||
actionMethodName: string;
|
||||
parameters: ChatActionParameter[];
|
||||
}
|
||||
|
||||
export type ChatEventType =
|
||||
| 'MESSAGE'
|
||||
| 'CARD_CLICKED'
|
||||
| 'ADDED_TO_SPACE'
|
||||
| 'REMOVED_FROM_SPACE';
|
||||
|
||||
export interface ChatEvent {
|
||||
type: ChatEventType;
|
||||
eventTime: string;
|
||||
message?: ChatMessage;
|
||||
space: ChatSpace;
|
||||
user: ChatUser;
|
||||
action?: ChatAction;
|
||||
common?: Record<string, unknown>;
|
||||
threadKey?: string;
|
||||
}
|
||||
|
||||
// Google Chat Cards V2 response types
|
||||
|
||||
export interface ChatCardV2 {
|
||||
cardId: string;
|
||||
card: ChatCard;
|
||||
}
|
||||
|
||||
export interface ChatCard {
|
||||
header?: ChatCardHeader;
|
||||
sections: ChatCardSection[];
|
||||
}
|
||||
|
||||
export interface ChatCardHeader {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
imageType?: 'CIRCLE' | 'SQUARE';
|
||||
}
|
||||
|
||||
export interface ChatCardSection {
|
||||
header?: string;
|
||||
widgets: ChatWidget[];
|
||||
collapsible?: boolean;
|
||||
uncollapsibleWidgetsCount?: number;
|
||||
}
|
||||
|
||||
export type ChatWidget =
|
||||
| { textParagraph: { text: string } }
|
||||
| { decoratedText: ChatDecoratedText }
|
||||
| { buttonList: { buttons: ChatButton[] } }
|
||||
| { divider: Record<string, never> };
|
||||
|
||||
export interface ChatDecoratedText {
|
||||
text: string;
|
||||
topLabel?: string;
|
||||
bottomLabel?: string;
|
||||
startIcon?: { knownIcon: string };
|
||||
wrapText?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatButton {
|
||||
text: string;
|
||||
onClick: ChatOnClick;
|
||||
color?: { red: number; green: number; blue: number; alpha?: number };
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatOnClick {
|
||||
action: {
|
||||
function: string;
|
||||
parameters: ChatActionParameter[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
text?: string;
|
||||
cardsV2?: ChatCardV2[];
|
||||
thread?: { threadKey?: string; name?: string };
|
||||
actionResponse?: {
|
||||
type: 'NEW_MESSAGE' | 'UPDATE_MESSAGE' | 'REQUEST_CONFIG';
|
||||
};
|
||||
}
|
||||
|
||||
// Bridge configuration
|
||||
|
||||
export interface ChatBridgeConfig {
|
||||
/** URL of the A2A server to connect to (e.g. http://localhost:8080) */
|
||||
a2aServerUrl: string;
|
||||
/** Google Chat project number for verification (optional) */
|
||||
projectNumber?: string;
|
||||
/** Whether to enable debug logging */
|
||||
debug?: boolean;
|
||||
/** GCS bucket name for session persistence (optional) */
|
||||
gcsBucket?: string;
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import { commandRegistry } from '../commands/command-registry.js';
|
||||
import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core';
|
||||
import type { Command, CommandArgument } from '../commands/types.js';
|
||||
import { GitService } from '@google/gemini-cli-core';
|
||||
import { getA2UIAgentExtension } from '../a2ui/a2ui-extension.js';
|
||||
import { createChatBridgeRoutes } from '../chat-bridge/routes.js';
|
||||
|
||||
type CommandResponse = {
|
||||
name: string;
|
||||
@@ -46,11 +48,12 @@ const coderAgentCard: AgentCard = {
|
||||
url: 'https://google.com',
|
||||
},
|
||||
protocolVersion: '0.3.0',
|
||||
version: '0.0.2', // Incremented version
|
||||
version: '0.1.0', // A2UI-enabled version
|
||||
capabilities: {
|
||||
streaming: true,
|
||||
pushNotifications: false,
|
||||
pushNotifications: true,
|
||||
stateTransitionHistory: true,
|
||||
extensions: [getA2UIAgentExtension()],
|
||||
},
|
||||
securitySchemes: undefined,
|
||||
security: undefined,
|
||||
@@ -200,6 +203,28 @@ export async function createApp() {
|
||||
requestStorage.run({ req }, next);
|
||||
});
|
||||
|
||||
// Mount Google Chat bridge routes BEFORE A2A SDK routes.
|
||||
// The A2A SDK's setupRoutes registers a catch-all jsonRpcHandler middleware
|
||||
// at baseUrl="" that intercepts ALL POST requests and returns 400 for
|
||||
// non-JSON-RPC payloads. Chat bridge must be registered first.
|
||||
const chatBridgeUrl =
|
||||
process.env['CHAT_BRIDGE_A2A_URL'] || process.env['CODER_AGENT_PORT']
|
||||
? `http://localhost:${process.env['CODER_AGENT_PORT'] || '8080'}`
|
||||
: undefined;
|
||||
if (chatBridgeUrl) {
|
||||
expressApp.use(express.json());
|
||||
const chatRoutes = createChatBridgeRoutes({
|
||||
a2aServerUrl: chatBridgeUrl,
|
||||
projectNumber: process.env['CHAT_PROJECT_NUMBER'],
|
||||
debug: process.env['CHAT_BRIDGE_DEBUG'] === 'true',
|
||||
gcsBucket: process.env['GCS_BUCKET_NAME'],
|
||||
});
|
||||
expressApp.use(chatRoutes);
|
||||
logger.info(
|
||||
`[CoreAgent] Google Chat bridge enabled at /chat/webhook (A2A: ${chatBridgeUrl})`,
|
||||
);
|
||||
}
|
||||
|
||||
const appBuilder = new A2AExpressApp(requestHandler);
|
||||
expressApp = appBuilder.setupRoutes(expressApp, '');
|
||||
expressApp.use(express.json());
|
||||
@@ -330,7 +355,8 @@ export async function main() {
|
||||
const expressApp = await createApp();
|
||||
const port = Number(process.env['CODER_AGENT_PORT'] || 0);
|
||||
|
||||
const server = expressApp.listen(port, 'localhost', () => {
|
||||
const host = process.env['CODER_AGENT_HOST'] || 'localhost';
|
||||
const server = expressApp.listen(port, host, () => {
|
||||
const address = server.address();
|
||||
let actualPort;
|
||||
if (process.env['CODER_AGENT_PORT']) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { setTargetDir } from '../config/config.js';
|
||||
import { getPersistedState, type PersistedTaskMetadata } from '../types.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type ObjectType = 'metadata' | 'workspace';
|
||||
type ObjectType = 'metadata' | 'workspace' | 'conversation';
|
||||
|
||||
const getTmpArchiveFilename = (taskId: string): string =>
|
||||
`task-${taskId}-workspace-${uuidv4()}.tar.gz`;
|
||||
@@ -224,6 +224,28 @@ export class GCSTaskStore implements TaskStore {
|
||||
`Workspace directory ${workDir} not found, skipping workspace save for task ${taskId}.`,
|
||||
);
|
||||
}
|
||||
// Save conversation history if present in metadata
|
||||
const rawHistory = dataToStore?.['_conversationHistory'];
|
||||
const conversationHistory = Array.isArray(rawHistory)
|
||||
? rawHistory
|
||||
: undefined;
|
||||
if (conversationHistory && conversationHistory.length > 0) {
|
||||
const conversationObjectPath = this.getObjectPath(
|
||||
taskId,
|
||||
'conversation',
|
||||
);
|
||||
const historyJson = JSON.stringify(conversationHistory);
|
||||
const compressedHistory = gzipSync(Buffer.from(historyJson));
|
||||
const conversationFile = this.storage
|
||||
.bucket(this.bucketName)
|
||||
.file(conversationObjectPath);
|
||||
await conversationFile.save(compressedHistory, {
|
||||
contentType: 'application/gzip',
|
||||
});
|
||||
logger.info(
|
||||
`Task ${taskId} conversation history saved to GCS: gs://${this.bucketName}/${conversationObjectPath} (${conversationHistory.length} entries)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save task ${taskId} to GCS:`, error);
|
||||
throw error;
|
||||
@@ -280,6 +302,29 @@ export class GCSTaskStore implements TaskStore {
|
||||
logger.info(`Task ${taskId} workspace archive not found in GCS.`);
|
||||
}
|
||||
|
||||
// Restore conversation history if available
|
||||
const conversationObjectPath = this.getObjectPath(taskId, 'conversation');
|
||||
const conversationFile = this.storage
|
||||
.bucket(this.bucketName)
|
||||
.file(conversationObjectPath);
|
||||
const [conversationExists] = await conversationFile.exists();
|
||||
if (conversationExists) {
|
||||
try {
|
||||
const [compressedHistory] = await conversationFile.download();
|
||||
const historyJson = gunzipSync(compressedHistory).toString();
|
||||
const conversationHistory: unknown[] = JSON.parse(historyJson);
|
||||
loadedMetadata['_conversationHistory'] = conversationHistory;
|
||||
logger.info(
|
||||
`Task ${taskId} conversation history restored from GCS (${conversationHistory.length} entries)`,
|
||||
);
|
||||
} catch (historyError) {
|
||||
logger.warn(
|
||||
`Task ${taskId} conversation history could not be restored:`,
|
||||
historyError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskId,
|
||||
contextId: loadedMetadata._contextId || uuidv4(),
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Test script for the Google Chat bridge webhook endpoint.
|
||||
# Simulates Google Chat events to verify the bridge works.
|
||||
#
|
||||
# Usage: ./test-chat-bridge.sh [PORT]
|
||||
# Default port: 9090 (for kubectl port-forward)
|
||||
|
||||
PORT=${1:-9090}
|
||||
BASE_URL="http://localhost:${PORT}"
|
||||
|
||||
echo "Testing chat bridge at ${BASE_URL}..."
|
||||
|
||||
# 1. Test health endpoint
|
||||
echo -e "\n--- Health Check ---"
|
||||
curl -s "${BASE_URL}/chat/health" | jq .
|
||||
|
||||
# 2. Test ADDED_TO_SPACE event
|
||||
echo -e "\n--- ADDED_TO_SPACE ---"
|
||||
curl -s -X POST "${BASE_URL}/chat/webhook" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ADDED_TO_SPACE",
|
||||
"eventTime": "2026-01-01T00:00:00Z",
|
||||
"space": { "name": "spaces/test123", "type": "DM" },
|
||||
"user": { "name": "users/123", "displayName": "Test User" }
|
||||
}' | jq .
|
||||
|
||||
# 3. Test MESSAGE event
|
||||
echo -e "\n--- MESSAGE (Hello) ---"
|
||||
curl -s -X POST "${BASE_URL}/chat/webhook" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "MESSAGE",
|
||||
"eventTime": "2026-01-01T00:01:00Z",
|
||||
"message": {
|
||||
"name": "spaces/test123/messages/msg1",
|
||||
"sender": { "name": "users/123", "displayName": "Test User" },
|
||||
"createTime": "2026-01-01T00:01:00Z",
|
||||
"text": "Hello, write me a python hello world",
|
||||
"argumentText": "Hello, write me a python hello world",
|
||||
"thread": { "name": "spaces/test123/threads/thread1" },
|
||||
"space": { "name": "spaces/test123", "type": "DM" }
|
||||
},
|
||||
"space": { "name": "spaces/test123", "type": "DM" },
|
||||
"user": { "name": "users/123", "displayName": "Test User" }
|
||||
}' | jq .
|
||||
|
||||
echo -e "\nDone."
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -34,11 +34,10 @@
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"ansi-escapes": "^7.3.0",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cli-spinners": "^2.9.2",
|
||||
"clipboardy": "^5.0.0",
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^8.0.3",
|
||||
@@ -47,7 +46,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.10",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
@@ -57,6 +56,7 @@
|
||||
"prompts": "^2.4.2",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"react": "^19.2.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"string-width": "^8.1.0",
|
||||
@@ -65,21 +65,29 @@
|
||||
"tar": "^7.5.2",
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"pretty-format": "^30.0.2",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
|
||||
@@ -128,6 +128,13 @@ async function addMcpServer(
|
||||
|
||||
settings.setValue(settingsScope, 'mcpServers', mcpServers);
|
||||
|
||||
if (transport === 'stdio') {
|
||||
debugLogger.warn(
|
||||
'Security Warning: Running MCP servers with stdio transport can expose inherited environment variables. ' +
|
||||
'While the Gemini CLI redacts common API keys and secrets by default, you should only run servers from trusted sources.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isExistingServer) {
|
||||
debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`);
|
||||
} else {
|
||||
|
||||
@@ -78,8 +78,6 @@ export interface CliArgs {
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
allowedTools: string[] | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
experimentalAgentHarness: boolean | undefined;
|
||||
experimentalEnableAgents: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
resume: string | typeof RESUME_LATEST | undefined;
|
||||
@@ -164,14 +162,6 @@ export async function parseArguments(
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('experimental-agent-harness', {
|
||||
type: 'boolean',
|
||||
description: 'Enable the new unified agent harness',
|
||||
})
|
||||
.option('experimental-enable-agents', {
|
||||
type: 'boolean',
|
||||
description: 'Enable local and remote subagents',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -797,16 +787,7 @@ export async function loadCliConfig(
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents:
|
||||
argv.experimentalEnableAgents ?? settings.experimental?.enableAgents,
|
||||
enableAgentHarness:
|
||||
argv.experimentalAgentHarness ??
|
||||
(process.env['GEMINI_ENABLE_AGENT_HARNESS'] === 'true'
|
||||
? true
|
||||
: process.env['GEMINI_ENABLE_AGENT_HARNESS'] === 'false'
|
||||
? false
|
||||
: settings.experimental?.enableAgentHarness),
|
||||
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
|
||||
@@ -2546,50 +2546,6 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reactivity & Snapshots', () => {
|
||||
let loadedSettings: LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
const emptySettingsFile: SettingsFile = {
|
||||
path: '/mock/path',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
};
|
||||
|
||||
loadedSettings = new LoadedSettings(
|
||||
{ ...emptySettingsFile, path: getSystemSettingsPath() },
|
||||
{ ...emptySettingsFile, path: getSystemDefaultsPath() },
|
||||
{ ...emptySettingsFile, path: USER_SETTINGS_PATH },
|
||||
{ ...emptySettingsFile, path: MOCK_WORKSPACE_SETTINGS_PATH },
|
||||
true, // isTrusted
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('getSnapshot() should return stable reference if no changes occur', () => {
|
||||
const snap1 = loadedSettings.getSnapshot();
|
||||
const snap2 = loadedSettings.getSnapshot();
|
||||
expect(snap1).toBe(snap2);
|
||||
});
|
||||
|
||||
it('setValue() should create a new snapshot reference and emit event', () => {
|
||||
const oldSnapshot = loadedSettings.getSnapshot();
|
||||
const oldUserRef = oldSnapshot.user.settings;
|
||||
|
||||
loadedSettings.setValue(SettingScope.User, 'ui.theme', 'high-contrast');
|
||||
|
||||
const newSnapshot = loadedSettings.getSnapshot();
|
||||
|
||||
expect(newSnapshot).not.toBe(oldSnapshot);
|
||||
expect(newSnapshot.user.settings).not.toBe(oldUserRef);
|
||||
expect(newSnapshot.user.settings.ui?.theme).toBe('high-contrast');
|
||||
|
||||
expect(newSnapshot.system.settings).not.toBe(oldSnapshot.system.settings);
|
||||
|
||||
expect(mockCoreEvents.emitSettingsChanged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security and Sandbox', () => {
|
||||
let originalArgv: string[];
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { platform } from 'node:os';
|
||||
import * as dotenv from 'dotenv';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
CoreEvent,
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
getErrorMessage,
|
||||
@@ -285,20 +284,6 @@ export function createTestMergedSettings(
|
||||
) as MergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* An immutable snapshot of settings state.
|
||||
* Used with useSyncExternalStore for reactive updates.
|
||||
*/
|
||||
export interface LoadedSettingsSnapshot {
|
||||
system: SettingsFile;
|
||||
systemDefaults: SettingsFile;
|
||||
user: SettingsFile;
|
||||
workspace: SettingsFile;
|
||||
isTrusted: boolean;
|
||||
errors: SettingsError[];
|
||||
merged: MergedSettings;
|
||||
}
|
||||
|
||||
export class LoadedSettings {
|
||||
constructor(
|
||||
system: SettingsFile,
|
||||
@@ -318,7 +303,6 @@ export class LoadedSettings {
|
||||
: this.createEmptyWorkspace(workspace);
|
||||
this.errors = errors;
|
||||
this._merged = this.computeMergedSettings();
|
||||
this._snapshot = this.computeSnapshot();
|
||||
}
|
||||
|
||||
readonly system: SettingsFile;
|
||||
@@ -330,7 +314,6 @@ export class LoadedSettings {
|
||||
|
||||
private _workspaceFile: SettingsFile;
|
||||
private _merged: MergedSettings;
|
||||
private _snapshot: LoadedSettingsSnapshot;
|
||||
private _remoteAdminSettings: Partial<Settings> | undefined;
|
||||
|
||||
get merged(): MergedSettings {
|
||||
@@ -385,36 +368,6 @@ export class LoadedSettings {
|
||||
return merged;
|
||||
}
|
||||
|
||||
private computeSnapshot(): LoadedSettingsSnapshot {
|
||||
const cloneSettingsFile = (file: SettingsFile): SettingsFile => ({
|
||||
path: file.path,
|
||||
rawJson: file.rawJson,
|
||||
settings: structuredClone(file.settings),
|
||||
originalSettings: structuredClone(file.originalSettings),
|
||||
});
|
||||
return {
|
||||
system: cloneSettingsFile(this.system),
|
||||
systemDefaults: cloneSettingsFile(this.systemDefaults),
|
||||
user: cloneSettingsFile(this.user),
|
||||
workspace: cloneSettingsFile(this.workspace),
|
||||
isTrusted: this.isTrusted,
|
||||
errors: [...this.errors],
|
||||
merged: structuredClone(this._merged),
|
||||
};
|
||||
}
|
||||
|
||||
// Passing this along with getSnapshot to useSyncExternalStore allows for idiomatic reactivity on settings changes
|
||||
// React will pass a listener fn into this subscribe fn
|
||||
// that listener fn will perform an object identity check on the snapshot and trigger a React re render if the snapshot has changed
|
||||
subscribe(listener: () => void): () => void {
|
||||
coreEvents.on(CoreEvent.SettingsChanged, listener);
|
||||
return () => coreEvents.off(CoreEvent.SettingsChanged, listener);
|
||||
}
|
||||
|
||||
getSnapshot(): LoadedSettingsSnapshot {
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
forScope(scope: LoadableSettingScope): SettingsFile {
|
||||
switch (scope) {
|
||||
case SettingScope.User:
|
||||
@@ -456,7 +409,6 @@ export class LoadedSettings {
|
||||
}
|
||||
|
||||
this._merged = this.computeMergedSettings();
|
||||
this._snapshot = this.computeSnapshot();
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -186,9 +186,6 @@ describe('SettingsSchema', () => {
|
||||
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -227,7 +224,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().advanced.properties.autoConfigureMemory
|
||||
.showInDialog,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should infer Settings type correctly', () => {
|
||||
@@ -331,28 +328,6 @@ describe('SettingsSchema', () => {
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
|
||||
it('should have showShortcutsHint setting in schema', () => {
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint).toBeDefined();
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.category).toBe(
|
||||
'UI',
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.default).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.requiresRestart,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.description,
|
||||
).toBe('Show the "? for shortcuts" hint above the input.');
|
||||
});
|
||||
|
||||
it('should have enableAgents setting in schema', () => {
|
||||
const setting = getSettingsSchema().experimental.properties.enableAgents;
|
||||
expect(setting).toBeDefined();
|
||||
|
||||
@@ -462,15 +462,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showShortcutsHint: {
|
||||
type: 'boolean',
|
||||
label: 'Show Shortcuts Hint',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Show the "? for shortcuts" hint above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
@@ -1422,7 +1413,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Automatically configure Node.js memory limits',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
dnsResolutionOrder: {
|
||||
type: 'string',
|
||||
@@ -1471,7 +1462,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: false,
|
||||
ignoreInDocs: true,
|
||||
default: {},
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
@@ -1482,9 +1473,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
default: false,
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
toolProtectionThreshold: {
|
||||
type: 'number',
|
||||
@@ -1528,15 +1519,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableAgentHarness: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Agent Harness',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable the new unified agent harness (experimental).',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Management',
|
||||
|
||||
@@ -238,15 +238,18 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
}));
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
let originalIsTTY: boolean | undefined;
|
||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||
[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Store and clear sandbox-related env variables to ensure a consistent test environment
|
||||
vi.stubEnv('GEMINI_SANDBOX', '');
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX'];
|
||||
originalEnvSandbox = process.env['SANDBOX'];
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
delete process.env['SANDBOX'];
|
||||
|
||||
initialUnhandledRejectionListeners =
|
||||
process.listeners('unhandledRejection');
|
||||
@@ -257,6 +260,18 @@ describe('gemini.tsx main function', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env variables
|
||||
if (originalEnvGeminiSandbox !== undefined) {
|
||||
process.env['GEMINI_SANDBOX'] = originalEnvGeminiSandbox;
|
||||
} else {
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
}
|
||||
if (originalEnvSandbox !== undefined) {
|
||||
process.env['SANDBOX'] = originalEnvSandbox;
|
||||
} else {
|
||||
delete process.env['SANDBOX'];
|
||||
}
|
||||
|
||||
const currentListeners = process.listeners('unhandledRejection');
|
||||
currentListeners.forEach((listener) => {
|
||||
if (!initialUnhandledRejectionListeners.includes(listener)) {
|
||||
@@ -267,7 +282,6 @@ describe('gemini.tsx main function', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).isTTY = originalIsTTY;
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -467,8 +481,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
experimentalAcp: undefined,
|
||||
experimentalAgentHarness: undefined,
|
||||
experimentalEnableAgents: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: undefined,
|
||||
includeDirectories: undefined,
|
||||
@@ -1197,12 +1209,7 @@ describe('startInteractiveUI', () => {
|
||||
registerTelemetryConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1301,7 +1308,7 @@ describe('startInteractiveUI', () => {
|
||||
|
||||
// Verify all startup tasks were called
|
||||
expect(getVersion).toHaveBeenCalledTimes(1);
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(4);
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Verify cleanup handler is registered with unmount function
|
||||
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
|
||||
|
||||
+20
-33
@@ -57,8 +57,8 @@ import {
|
||||
writeToStderr,
|
||||
disableMouseEvents,
|
||||
enableMouseEvents,
|
||||
enterAlternateScreen,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
ExitCodes,
|
||||
@@ -89,7 +89,6 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { useTerminalSize } from './ui/hooks/useTerminalSize.js';
|
||||
import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
@@ -215,13 +214,9 @@ export async function startInteractiveUI(
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
useKittyKeyboardProtocol();
|
||||
const { columns, rows } = useTerminalSize();
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider
|
||||
@@ -239,7 +234,6 @@ export async function startInteractiveUI(
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
key={`${columns}-${rows}`}
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
@@ -256,17 +250,6 @@ export async function startInteractiveUI(
|
||||
);
|
||||
};
|
||||
|
||||
if (isShpool) {
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
// shpool is a persistence tool that restores terminal state by replaying it.
|
||||
// This delay gives shpool time to finish its restoration replay and send
|
||||
// the actual terminal size (often via an immediate SIGWINCH) before we
|
||||
// render the first TUI frame. Without this, the first frame may be
|
||||
// garbled or rendered at an incorrect size, which disabling incremental
|
||||
// rendering alone cannot fix for the initial frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -290,19 +273,10 @@ export async function startInteractiveUI(
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
settings.merged.ui.incrementalRendering !== false && useAlternateBuffer,
|
||||
},
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
@@ -616,13 +590,26 @@ export async function main() {
|
||||
// input showing up in the output.
|
||||
process.stdin.setRawMode(true);
|
||||
|
||||
if (
|
||||
shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(settings),
|
||||
config.getScreenReader(),
|
||||
)
|
||||
) {
|
||||
enterAlternateScreen();
|
||||
disableLineWrapping();
|
||||
|
||||
// Ink will cleanup so there is no need for us to manually cleanup.
|
||||
}
|
||||
|
||||
// This cleanup isn't strictly needed but may help in certain situations.
|
||||
process.on('SIGTERM', () => {
|
||||
const restoreRawMode = () => {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
});
|
||||
};
|
||||
process.off('SIGTERM', restoreRawMode);
|
||||
process.on('SIGTERM', restoreRawMode);
|
||||
process.off('SIGINT', restoreRawMode);
|
||||
process.on('SIGINT', restoreRawMode);
|
||||
}
|
||||
|
||||
await setupTerminalAndTheme(config, settings);
|
||||
|
||||
@@ -84,7 +84,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import { mergeSettings, type LoadedSettings } from '../config/settings.js';
|
||||
import { type LoadedSettings, mergeSettings } from '../config/settings.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
@@ -92,7 +92,6 @@ import {
|
||||
UIActionsContext,
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||
|
||||
// Mock useStdout to capture terminal title writes
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -134,6 +133,7 @@ vi.mock('./hooks/useGeminiStream.js');
|
||||
vi.mock('./hooks/vim.js');
|
||||
vi.mock('./hooks/useFocus.js');
|
||||
vi.mock('./hooks/useBracketedPaste.js');
|
||||
vi.mock('./hooks/useKeypress.js');
|
||||
vi.mock('./hooks/useLoadingIndicator.js');
|
||||
vi.mock('./hooks/useFolderTrust.js');
|
||||
vi.mock('./hooks/useIdeTrustListener.js');
|
||||
@@ -197,7 +197,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useKeypress } from './hooks/useKeypress.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { measureElement } from 'ink';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import {
|
||||
@@ -232,15 +232,13 @@ describe('AppContainer State Management', () => {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
} = {}) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -270,6 +268,7 @@ describe('AppContainer State Management', () => {
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
const mockedUseLogger = useLogger as Mock;
|
||||
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
||||
@@ -1771,36 +1770,47 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
// Helper function to reduce boilerplate in tests
|
||||
const setupKeypressTest = async () => {
|
||||
const renderResult = renderAppContainer();
|
||||
stdin = renderResult.stdin;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
rerender = () => {
|
||||
renderResult.rerender(getAppContainer());
|
||||
};
|
||||
rerender = () => renderResult.rerender(getAppContainer());
|
||||
unmount = renderResult.unmount;
|
||||
};
|
||||
|
||||
const pressKey = (sequence: string, times = 1) => {
|
||||
const pressKey = (key: Partial<Key>, times = 1) => {
|
||||
for (let i = 0; i < times; i++) {
|
||||
act(() => {
|
||||
stdin.write(sequence);
|
||||
handleGlobalKeypress({
|
||||
name: 'c',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
rerender();
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the keypress handler from the AppContainer
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock slash command handler
|
||||
mockHandleSlashCommand = vi.fn();
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
@@ -1845,7 +1855,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
@@ -1855,7 +1865,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should quit on second press', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey('\x03', 2); // Ctrl+C
|
||||
pressKey({ name: 'c', ctrl: true }, 2);
|
||||
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
@@ -1870,7 +1880,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer past the reset threshold
|
||||
@@ -1878,7 +1888,7 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||
});
|
||||
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -1888,7 +1898,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should quit on second press if buffer is empty', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey('\x04', 2); // Ctrl+D
|
||||
pressKey({ name: 'd', ctrl: true }, 2);
|
||||
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
@@ -1899,7 +1909,7 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT quit if buffer is not empty', async () => {
|
||||
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
@@ -1909,12 +1919,30 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
// Capture return value
|
||||
let result = true;
|
||||
const originalPressKey = (key: Partial<Key>) => {
|
||||
act(() => {
|
||||
result = handleGlobalKeypress({
|
||||
name: 'd',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
|
||||
// Should only be called once, so count is 1, not quitting yet.
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
|
||||
// AppContainer's handler should return true if it reaches it
|
||||
expect(result).toBe(true);
|
||||
// But it should only be called once, so count is 1, not quitting yet.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
// Now count is 2, it should quit.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
@@ -1928,7 +1956,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
pressKey({ name: 'd', ctrl: true });
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer past the reset threshold
|
||||
@@ -1936,7 +1964,7 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||
});
|
||||
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
pressKey({ name: 'd', ctrl: true });
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -1954,7 +1982,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should focus shell input on Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey('\t');
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
unmount();
|
||||
@@ -1964,11 +1992,11 @@ describe('AppContainer State Management', () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
// Focus first
|
||||
pressKey('\t');
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Unfocus via Shift+Tab
|
||||
pressKey('\x1b[Z');
|
||||
pressKey({ name: 'tab', shift: true });
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
@@ -1987,7 +2015,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Focus it
|
||||
act(() => {
|
||||
renderResult.stdin.write('\t');
|
||||
handleGlobalKeypress({
|
||||
name: 'tab',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
} as Key);
|
||||
});
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
@@ -2022,7 +2056,7 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Tab
|
||||
pressKey('\t');
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
@@ -2050,7 +2084,7 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey('\x02');
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
@@ -2079,7 +2113,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey('\x02');
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
@@ -2092,14 +2126,11 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
const setupCopyModeTest = async (
|
||||
isAlternateMode = false,
|
||||
childHandler?: Mock,
|
||||
) => {
|
||||
const setupCopyModeTest = async (isAlternateMode = false) => {
|
||||
// Update settings for this test run
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const testSettings = {
|
||||
@@ -2113,39 +2144,23 @@ describe('AppContainer State Management', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
function TestChild() {
|
||||
useKeypress(childHandler || (() => {}), {
|
||||
isActive: !!childHandler,
|
||||
priority: true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const getTree = (settings: LoadedSettings) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
const renderResult = render(getTree(testSettings));
|
||||
stdin = renderResult.stdin;
|
||||
const renderResult = renderAppContainer({ settings: testSettings });
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
rerender = () => renderResult.rerender(getTree(testSettings));
|
||||
rerender = () =>
|
||||
renderResult.rerender(getAppContainer({ settings: testSettings }));
|
||||
unmount = renderResult.unmount;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2171,7 +2186,15 @@ describe('AppContainer State Management', () => {
|
||||
mocks.mockStdout.write.mockClear(); // Clear initial enable call
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2190,14 +2213,30 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Turn it on (disable mouse)
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
});
|
||||
rerender();
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
|
||||
// Turn it off (enable mouse)
|
||||
act(() => {
|
||||
stdin.write('a'); // Any key should exit copy mode
|
||||
handleGlobalKeypress({
|
||||
name: 'any', // Any key should exit copy mode
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2210,7 +2249,15 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Enter copy mode
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2218,7 +2265,15 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Press any other key
|
||||
act(() => {
|
||||
stdin.write('a');
|
||||
handleGlobalKeypress({
|
||||
name: 'a',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2226,37 +2281,6 @@ describe('AppContainer State Management', () => {
|
||||
expect(enableMouseEvents).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should have higher priority than other priority listeners when enabled', async () => {
|
||||
// 1. Initial state with a child component's priority listener (already subscribed)
|
||||
// It should NOT handle Ctrl+S so we can enter copy mode.
|
||||
const childHandler = vi.fn().mockReturnValue(false);
|
||||
await setupCopyModeTest(true, childHandler);
|
||||
|
||||
// 2. Enter copy mode
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
// 3. Verify we are in copy mode
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
|
||||
// 4. Press any key
|
||||
childHandler.mockClear();
|
||||
// Now childHandler should return true for other keys, simulating a greedy listener
|
||||
childHandler.mockReturnValue(true);
|
||||
|
||||
act(() => {
|
||||
stdin.write('a');
|
||||
});
|
||||
rerender();
|
||||
|
||||
// 5. Verify that the exit handler took priority and childHandler was NOT called
|
||||
expect(childHandler).not.toHaveBeenCalled();
|
||||
expect(enableMouseEvents).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,14 +95,12 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useAgentHarness } from './hooks/useAgentHarness.js';
|
||||
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { KeypressPriority } from './contexts/KeypressContext.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
@@ -365,9 +363,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
(async () => {
|
||||
// Note: the program will not work if this fails so let errors be
|
||||
// handled by the global catch.
|
||||
if (!config.isInitialized()) {
|
||||
await config.initialize();
|
||||
}
|
||||
await config.initialize();
|
||||
setConfigInitialized(true);
|
||||
startupProfiler.flush(config);
|
||||
|
||||
@@ -967,9 +963,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}, [pendingRestorePrompt, inputHistory, historyManager.history]);
|
||||
|
||||
const isAgentHarnessEnabled = config.isAgentHarnessEnabled();
|
||||
|
||||
const legacyStream = useGeminiStream(
|
||||
const {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
initError,
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
cancelOngoingRequest,
|
||||
pendingToolCalls,
|
||||
handleApprovalModeChange,
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
retryStatus,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
historyManager.history,
|
||||
historyManager.addItem,
|
||||
@@ -990,40 +1003,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
const harnessStream = useAgentHarness(
|
||||
historyManager.addItem,
|
||||
config,
|
||||
onCancelSubmit,
|
||||
);
|
||||
|
||||
const activeStream = isAgentHarnessEnabled ? harnessStream : legacyStream;
|
||||
|
||||
const {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
initError,
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
cancelOngoingRequest,
|
||||
toolCalls: pendingToolCalls,
|
||||
handleApprovalModeChange,
|
||||
activePtyId: rawActivePtyId,
|
||||
loopDetectionConfirmationRequest: rawLoopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells: rawBackgroundShells,
|
||||
dismissBackgroundShell,
|
||||
retryStatus: rawRetryStatus,
|
||||
} = activeStream;
|
||||
|
||||
const activePtyId = rawActivePtyId ?? undefined;
|
||||
const loopDetectionConfirmationRequest = rawLoopDetectionConfirmationRequest;
|
||||
const backgroundShells = rawBackgroundShells;
|
||||
const retryStatus = rawRetryStatus;
|
||||
|
||||
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
||||
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
|
||||
backgroundShellsRef.current = backgroundShells;
|
||||
@@ -1502,6 +1481,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key): boolean => {
|
||||
if (copyModeEnabled) {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
// We don't want to process any other keys if we're in copy mode.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Debug log keystrokes if enabled
|
||||
if (settings.merged.general.debugKeystrokeLogging) {
|
||||
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
|
||||
@@ -1534,15 +1520,28 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||
if (settings.merged.general.devtools) {
|
||||
void (async () => {
|
||||
const { toggleDevToolsPanel } = await import(
|
||||
'../utils/devtoolsService.js'
|
||||
);
|
||||
await toggleDevToolsPanel(
|
||||
config,
|
||||
showErrorDetails,
|
||||
() => setShowErrorDetails((prev) => !prev),
|
||||
() => setShowErrorDetails(true),
|
||||
);
|
||||
try {
|
||||
const { startDevToolsServer } = await import(
|
||||
'../utils/devtoolsService.js'
|
||||
);
|
||||
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const url = await startDevToolsServer(config);
|
||||
if (shouldLaunchBrowser()) {
|
||||
try {
|
||||
await openBrowserSecurely(url);
|
||||
} catch (e) {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
debugLogger.warn('Failed to open browser securely:', e);
|
||||
}
|
||||
} else {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
}
|
||||
} catch (e) {
|
||||
setShowErrorDetails(true);
|
||||
debugLogger.error('Failed to start DevTools server:', e);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
@@ -1628,7 +1627,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return false;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentShell?.();
|
||||
backgroundCurrentShell();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
toggleBackgroundShell();
|
||||
@@ -1669,6 +1668,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
refreshStatic,
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
isAlternateBuffer,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
@@ -1679,26 +1679,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
tabFocusTimeoutRef,
|
||||
showTransientMessage,
|
||||
settings.merged.general.devtools,
|
||||
showErrorDetails,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
|
||||
useKeypress(
|
||||
() => {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
return true;
|
||||
},
|
||||
{
|
||||
isActive: copyModeEnabled,
|
||||
// We need to receive keypresses first so they do not bubble to other
|
||||
// handlers.
|
||||
priority: KeypressPriority.Critical,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
if (settings.merged.ui.hideWindowTitle) return;
|
||||
|
||||
@@ -110,7 +110,6 @@ describe('ApiAuthDialog', () => {
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
@@ -21,14 +21,6 @@ const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
};
|
||||
|
||||
describe('AskUserDialog', () => {
|
||||
// Ensure keystrokes appear spaced in time to avoid bufferFastReturn
|
||||
// converting Enter into Shift+Enter during synchronous test execution.
|
||||
let mockTime: number;
|
||||
beforeEach(() => {
|
||||
mockTime = 0;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => (mockTime += 50));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -166,57 +158,6 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('supports multi-line input for "Other" option in choice questions', async () => {
|
||||
const authQuestionWithOther: Question[] = [
|
||||
{
|
||||
question: 'Which authentication method?',
|
||||
header: 'Auth',
|
||||
options: [{ label: 'OAuth 2.0', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestionWithOther}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Navigate to "Other" option
|
||||
writeKey(stdin, '\x1b[B'); // Down to "Other"
|
||||
|
||||
// Type first line
|
||||
for (const char of 'Line 1') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
// Insert newline using \ + Enter (handled by bufferBackslashEnter)
|
||||
writeKey(stdin, '\\');
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
// Type second line
|
||||
for (const char of 'Line 2') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Line 1');
|
||||
expect(lastFrame()).toContain('Line 2');
|
||||
});
|
||||
|
||||
// Press Enter to submit
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ useAlternateBuffer: true, expectedArrows: false },
|
||||
{ useAlternateBuffer: false, expectedArrows: true },
|
||||
@@ -822,7 +763,7 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('submits empty text as unanswered', async () => {
|
||||
it('does not submit empty text', () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the class name:',
|
||||
@@ -844,9 +785,8 @@ describe('AskUserDialog', () => {
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({});
|
||||
});
|
||||
// onSubmit should not be called for empty text
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears text on Ctrl+C', async () => {
|
||||
|
||||
@@ -93,7 +93,6 @@ type AskUserDialogAction =
|
||||
payload: {
|
||||
index: number;
|
||||
answer: string;
|
||||
submit?: boolean;
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } }
|
||||
@@ -115,7 +114,7 @@ function askUserDialogReducerLogic(
|
||||
|
||||
switch (action.type) {
|
||||
case 'SET_ANSWER': {
|
||||
const { index, answer, submit } = action.payload;
|
||||
const { index, answer } = action.payload;
|
||||
const hasAnswer =
|
||||
answer !== undefined && answer !== null && answer.trim() !== '';
|
||||
const newAnswers = { ...state.answers };
|
||||
@@ -129,7 +128,6 @@ function askUserDialogReducerLogic(
|
||||
return {
|
||||
...state,
|
||||
answers: newAnswers,
|
||||
submitted: submit ? true : state.submitted,
|
||||
};
|
||||
}
|
||||
case 'SET_EDITING_CUSTOM': {
|
||||
@@ -285,8 +283,8 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: initialAnswer,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 3 },
|
||||
singleLine: false,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
});
|
||||
|
||||
const { text: textValue } = buffer;
|
||||
@@ -319,7 +317,9 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(val: string) => {
|
||||
onAnswer(val.trim());
|
||||
if (val.trim()) {
|
||||
onAnswer(val.trim());
|
||||
}
|
||||
},
|
||||
[onAnswer],
|
||||
);
|
||||
@@ -561,8 +561,8 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
const customBuffer = useTextBuffer({
|
||||
initialText: initialCustomText,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 3 },
|
||||
singleLine: false,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
});
|
||||
|
||||
const customOptionText = customBuffer.text;
|
||||
@@ -850,22 +850,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
buffer={customBuffer}
|
||||
placeholder={placeholder}
|
||||
focus={context.isSelected}
|
||||
onSubmit={(val) => {
|
||||
if (question.multiSelect) {
|
||||
const fullAnswer = buildAnswerString(
|
||||
selectedIndices,
|
||||
true,
|
||||
val,
|
||||
);
|
||||
if (fullAnswer) {
|
||||
onAnswer(fullAnswer);
|
||||
}
|
||||
} else if (val.trim()) {
|
||||
onAnswer(val.trim());
|
||||
}
|
||||
}}
|
||||
onSubmit={() => handleSelect(optionItem)}
|
||||
/>
|
||||
{isChecked && !question.multiSelect && !context.isSelected && (
|
||||
{isChecked && !question.multiSelect && (
|
||||
<Text color={theme.status.success}> ✓</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1025,27 +1012,21 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
(answer: string) => {
|
||||
if (submitted) return;
|
||||
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
},
|
||||
});
|
||||
|
||||
if (questions.length > 1) {
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
},
|
||||
});
|
||||
goToNextTab();
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
submit: true,
|
||||
},
|
||||
});
|
||||
dispatch({ type: 'SUBMIT' });
|
||||
}
|
||||
},
|
||||
[currentQuestionIndex, questions, submitted, goToNextTab],
|
||||
[currentQuestionIndex, questions.length, submitted, goToNextTab],
|
||||
);
|
||||
|
||||
const handleReviewSubmit = useCallback(() => {
|
||||
|
||||
@@ -650,19 +650,6 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
describe('Shortcuts Hint', () => {
|
||||
it('hides shortcuts hint when showShortcutsHint setting is false', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showShortcutsHint: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => {
|
||||
const uiState = createMockUIState({
|
||||
customDialog: (
|
||||
|
||||
@@ -133,8 +133,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{settings.merged.ui.showShortcutsHint &&
|
||||
!hasPendingActionRequired && <ShortcutsHint />}
|
||||
{!hasPendingActionRequired && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
|
||||
|
||||
@@ -47,13 +47,6 @@ const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
// Advance timers to simulate time passing between keystrokes.
|
||||
// This avoids bufferFastReturn converting Enter to Shift+Enter.
|
||||
if (vi.isFakeTimers()) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe('ExitPlanModeDialog', () => {
|
||||
@@ -241,6 +234,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
// Navigate to feedback option
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type feedback
|
||||
for (const char of 'Add tests') {
|
||||
@@ -518,6 +512,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
// Navigate to feedback option and start typing
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type some feedback
|
||||
for (const char of 'test') {
|
||||
|
||||
@@ -281,10 +281,7 @@ describe('InputPrompt', () => {
|
||||
navigateDown: vi.fn(),
|
||||
handleSubmit: vi.fn(),
|
||||
};
|
||||
mockedUseInputHistory.mockImplementation(({ onSubmit }) => {
|
||||
mockInputHistory.handleSubmit = vi.fn((val) => onSubmit(val));
|
||||
return mockInputHistory;
|
||||
});
|
||||
mockedUseInputHistory.mockReturnValue(mockInputHistory);
|
||||
|
||||
mockReverseSearchCompletion = {
|
||||
suggestions: [],
|
||||
@@ -4096,7 +4093,7 @@ describe('InputPrompt', () => {
|
||||
beforeEach(() => {
|
||||
props.userMessages = ['first message', 'second message'];
|
||||
// Mock useInputHistory to actually call onChange
|
||||
mockedUseInputHistory.mockImplementation(({ onChange, onSubmit }) => ({
|
||||
mockedUseInputHistory.mockImplementation(({ onChange }) => ({
|
||||
navigateUp: () => {
|
||||
onChange('second message', 'start');
|
||||
return true;
|
||||
@@ -4105,7 +4102,7 @@ describe('InputPrompt', () => {
|
||||
onChange('first message', 'end');
|
||||
return true;
|
||||
},
|
||||
handleSubmit: vi.fn((val) => onSubmit(val)),
|
||||
handleSubmit: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -4296,30 +4293,6 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
describe('shortcuts help visibility', () => {
|
||||
it('opens shortcuts help with ? on empty prompt even when showShortcutsHint is false', async () => {
|
||||
const setShortcutsHelpVisible = vi.fn();
|
||||
const settings = createMockSettings({
|
||||
ui: { showShortcutsHint: false },
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
settings,
|
||||
uiActions: { setShortcutsHelpVisible },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('?');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'terminal paste event occurs',
|
||||
|
||||
@@ -337,6 +337,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedMessage = submittedValue.trim();
|
||||
const isSlash = isSlashCommand(trimmedMessage);
|
||||
|
||||
const isShell = shellModeActive;
|
||||
if (
|
||||
(isSlash || isShell) &&
|
||||
streamingState === StreamingState.Responding
|
||||
) {
|
||||
setQueueErrorMessage(
|
||||
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleSubmitAndClear(trimmedMessage);
|
||||
},
|
||||
[
|
||||
handleSubmitAndClear,
|
||||
shellModeActive,
|
||||
streamingState,
|
||||
setQueueErrorMessage,
|
||||
],
|
||||
);
|
||||
|
||||
const customSetTextAndResetCompletionSignal = useCallback(
|
||||
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
|
||||
buffer.setText(newText, cursorPosition);
|
||||
@@ -356,26 +381,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onChange: customSetTextAndResetCompletionSignal,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedMessage = submittedValue.trim();
|
||||
const isSlash = isSlashCommand(trimmedMessage);
|
||||
|
||||
const isShell = shellModeActive;
|
||||
if (
|
||||
(isSlash || isShell) &&
|
||||
streamingState === StreamingState.Responding
|
||||
) {
|
||||
setQueueErrorMessage(
|
||||
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
inputHistory.handleSubmit(trimmedMessage);
|
||||
},
|
||||
[inputHistory, shellModeActive, streamingState, setQueueErrorMessage],
|
||||
);
|
||||
|
||||
// Effect to reset completion if history navigation just occurred and set the text
|
||||
useEffect(() => {
|
||||
if (suppressCompletion) {
|
||||
@@ -853,7 +858,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
showSuggestions && activeSuggestionIndex > -1
|
||||
? suggestions[activeSuggestionIndex].value
|
||||
: buffer.text;
|
||||
handleSubmit(textToSubmit);
|
||||
handleSubmitAndClear(textToSubmit);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return true;
|
||||
@@ -1150,6 +1155,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setShellModeActive,
|
||||
onClearScreen,
|
||||
inputHistory,
|
||||
handleSubmitAndClear,
|
||||
handleSubmit,
|
||||
shellHistory,
|
||||
reverseSearchCompletion,
|
||||
|
||||
@@ -89,8 +89,6 @@ describe('MainContent', () => {
|
||||
historyRemountKey: 0,
|
||||
bannerData: { defaultText: '', warningText: '' },
|
||||
bannerVisible: false,
|
||||
copyModeEnabled: false,
|
||||
terminalWidth: 100,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -175,7 +173,6 @@ describe('MainContent', () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
|
||||
const ptyId = 123;
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [],
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
|
||||
@@ -110,8 +110,7 @@ export const Notifications = () => {
|
||||
marginBottom={1}
|
||||
>
|
||||
<Text color={theme.status.error}>
|
||||
Initialization Error:{' '}
|
||||
{initError instanceof Error ? initError.message : initError}
|
||||
Initialization Error: {initError}
|
||||
</Text>
|
||||
<Text color={theme.status.error}>
|
||||
{' '}
|
||||
|
||||
@@ -54,3 +54,14 @@ exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`
|
||||
│ ● 2. tail -f log.txt (PID: 1002) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > selects the current process and closes the list when Ctrl+L is pressed in list view 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ │
|
||||
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
|
||||
│ │
|
||||
│ ● 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
@@ -21,7 +21,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
● 3. Type your feedback... ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
@@ -47,7 +47,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
● 3. Add tests ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
@@ -127,7 +127,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
● 3. Type your feedback... ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
@@ -153,7 +153,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
● 3. Add tests ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
@@ -107,9 +107,9 @@ ShowMoreLines"
|
||||
exports[`MainContent > does not constrain height in alternate buffer mode 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
✦ Hi there
|
||||
ShowMoreLines
|
||||
"
|
||||
|
||||
@@ -14,12 +14,6 @@ import { MouseProvider } from '../../contexts/MouseContext.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
copyModeEnabled: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock useStdout to provide a fixed size for testing
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
|
||||
@@ -13,7 +13,6 @@ import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
|
||||
export interface TextInputProps {
|
||||
buffer: TextBuffer;
|
||||
@@ -46,7 +45,7 @@ export function TextInput({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.SUBMIT](key) && onSubmit) {
|
||||
if (key.name === 'return' && onSubmit) {
|
||||
onSubmit(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -16,13 +16,6 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { UIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
copyModeEnabled: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -331,39 +324,4 @@ describe('<VirtualizedList />', () => {
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(4);
|
||||
});
|
||||
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
const { useUIState } = await import('../../contexts/UIStateContext.js');
|
||||
vi.mocked(useUIState).mockReturnValue({
|
||||
copyModeEnabled: true,
|
||||
} as Partial<UIState> as UIState);
|
||||
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// Use copy mode
|
||||
const { lastFrame } = render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={50}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
// Item 50 should be visible
|
||||
expect(lastFrame()).toContain('Item 50');
|
||||
// And surrounding items
|
||||
expect(lastFrame()).toContain('Item 59');
|
||||
// But far away items should not be (ensures we are actually scrolled)
|
||||
expect(lastFrame()).not.toContain('Item 0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
import { type DOMElement, measureElement, Box } from 'ink';
|
||||
|
||||
@@ -79,7 +78,6 @@ function VirtualizedList<T>(
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
} = props;
|
||||
const { copyModeEnabled } = useUIState();
|
||||
const dataRef = useRef(data);
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
@@ -476,21 +474,16 @@ function VirtualizedList<T>(
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={copyModeEnabled ? 0 : 1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
marginTop={copyModeEnabled ? -scrollTop : 0}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||
import { useStdin } from 'ink';
|
||||
import { MultiMap } from 'mnemonist';
|
||||
import type React from 'react';
|
||||
import {
|
||||
createContext,
|
||||
@@ -27,13 +26,6 @@ export const ESC_TIMEOUT = 50;
|
||||
export const PASTE_TIMEOUT = 30_000;
|
||||
export const FAST_RETURN_TIMEOUT = 30;
|
||||
|
||||
export enum KeypressPriority {
|
||||
Low = -100,
|
||||
Normal = 0,
|
||||
High = 100,
|
||||
Critical = 200,
|
||||
}
|
||||
|
||||
// Parse the key itself
|
||||
const KEY_INFO_MAP: Record<
|
||||
string,
|
||||
@@ -653,10 +645,7 @@ export interface Key {
|
||||
export type KeypressHandler = (key: Key) => boolean | void;
|
||||
|
||||
interface KeypressContextValue {
|
||||
subscribe: (
|
||||
handler: KeypressHandler,
|
||||
priority?: KeypressPriority | boolean,
|
||||
) => void;
|
||||
subscribe: (handler: KeypressHandler, priority?: boolean) => void;
|
||||
unsubscribe: (handler: KeypressHandler) => void;
|
||||
}
|
||||
|
||||
@@ -685,75 +674,44 @@ export function KeypressProvider({
|
||||
}) {
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
const subscribersToPriority = useRef<Map<KeypressHandler, number>>(
|
||||
new Map(),
|
||||
).current;
|
||||
const subscribers = useRef(
|
||||
new MultiMap<number, KeypressHandler>(Set),
|
||||
).current;
|
||||
const sortedPriorities = useRef<number[]>([]);
|
||||
const prioritySubscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
||||
const normalSubscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
||||
|
||||
const subscribe = useCallback(
|
||||
(
|
||||
handler: KeypressHandler,
|
||||
priority: KeypressPriority | boolean = KeypressPriority.Normal,
|
||||
) => {
|
||||
const p =
|
||||
typeof priority === 'boolean'
|
||||
? priority
|
||||
? KeypressPriority.High
|
||||
: KeypressPriority.Normal
|
||||
: priority;
|
||||
|
||||
subscribersToPriority.set(handler, p);
|
||||
const hadPriority = subscribers.has(p);
|
||||
subscribers.set(p, handler);
|
||||
|
||||
if (!hadPriority) {
|
||||
// Cache sorted priorities only when a new priority level is added
|
||||
sortedPriorities.current = Array.from(subscribers.keys()).sort(
|
||||
(a, b) => b - a,
|
||||
);
|
||||
}
|
||||
(handler: KeypressHandler, priority = false) => {
|
||||
const set = priority ? prioritySubscribers : normalSubscribers;
|
||||
set.add(handler);
|
||||
},
|
||||
[subscribers, subscribersToPriority],
|
||||
[prioritySubscribers, normalSubscribers],
|
||||
);
|
||||
|
||||
const unsubscribe = useCallback(
|
||||
(handler: KeypressHandler) => {
|
||||
const p = subscribersToPriority.get(handler);
|
||||
if (p !== undefined) {
|
||||
subscribers.remove(p, handler);
|
||||
subscribersToPriority.delete(handler);
|
||||
|
||||
if (!subscribers.has(p)) {
|
||||
// Cache sorted priorities only when a priority level is completely removed
|
||||
sortedPriorities.current = Array.from(subscribers.keys()).sort(
|
||||
(a, b) => b - a,
|
||||
);
|
||||
}
|
||||
}
|
||||
prioritySubscribers.delete(handler);
|
||||
normalSubscribers.delete(handler);
|
||||
},
|
||||
[subscribers, subscribersToPriority],
|
||||
[prioritySubscribers, normalSubscribers],
|
||||
);
|
||||
|
||||
const broadcast = useCallback(
|
||||
(key: Key) => {
|
||||
// Use cached sorted priorities to avoid sorting on every keypress
|
||||
for (const p of sortedPriorities.current) {
|
||||
const set = subscribers.get(p);
|
||||
if (!set) continue;
|
||||
// Process priority subscribers first, in reverse order (stack behavior: last subscribed is first to handle)
|
||||
const priorityHandlers = Array.from(prioritySubscribers).reverse();
|
||||
for (const handler of priorityHandlers) {
|
||||
if (handler(key) === true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Within a priority level, use stack behavior (last subscribed is first to handle)
|
||||
const handlers = Array.from(set).reverse();
|
||||
for (const handler of handlers) {
|
||||
if (handler(key) === true) {
|
||||
return;
|
||||
}
|
||||
// Then process normal subscribers, also in reverse order
|
||||
const normalHandlers = Array.from(normalSubscribers).reverse();
|
||||
for (const handler of normalHandlers) {
|
||||
if (handler(key) === true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[subscribers],
|
||||
[prioritySubscribers, normalSubscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Component, type ReactNode } from 'react';
|
||||
import { renderHook, render } from '../../test-utils/render.js';
|
||||
import { act } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SettingsContext, useSettingsStore } from './SettingsContext.js';
|
||||
import {
|
||||
type LoadedSettings,
|
||||
SettingScope,
|
||||
type LoadedSettingsSnapshot,
|
||||
type SettingsFile,
|
||||
createTestMergedSettings,
|
||||
} from '../../config/settings.js';
|
||||
|
||||
const createMockSettingsFile = (path: string): SettingsFile => ({
|
||||
path,
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
});
|
||||
|
||||
const mockSnapshot: LoadedSettingsSnapshot = {
|
||||
system: createMockSettingsFile('/system'),
|
||||
systemDefaults: createMockSettingsFile('/defaults'),
|
||||
user: createMockSettingsFile('/user'),
|
||||
workspace: createMockSettingsFile('/workspace'),
|
||||
isTrusted: true,
|
||||
errors: [],
|
||||
merged: createTestMergedSettings({
|
||||
ui: { theme: 'default-theme' },
|
||||
}),
|
||||
};
|
||||
|
||||
class ErrorBoundary extends Component<
|
||||
{ children: ReactNode; onError: (error: Error) => void },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: ReactNode; onError: (error: Error) => void }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(_error: Error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return null;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const TestHarness = () => {
|
||||
useSettingsStore();
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('SettingsContext', () => {
|
||||
let mockLoadedSettings: LoadedSettings;
|
||||
let listeners: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
listeners = [];
|
||||
|
||||
mockLoadedSettings = {
|
||||
subscribe: vi.fn((listener: () => void) => {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}),
|
||||
getSnapshot: vi.fn(() => mockSnapshot),
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SettingsContext.Provider value={mockLoadedSettings}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
it('should provide the correct initial state', () => {
|
||||
const { result } = renderHook(() => useSettingsStore(), { wrapper });
|
||||
|
||||
expect(result.current.settings.merged).toEqual(mockSnapshot.merged);
|
||||
expect(result.current.settings.isTrusted).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow accessing settings for a specific scope', () => {
|
||||
const { result } = renderHook(() => useSettingsStore(), { wrapper });
|
||||
|
||||
const userSettings = result.current.settings.forScope(SettingScope.User);
|
||||
expect(userSettings).toBe(mockSnapshot.user);
|
||||
|
||||
const workspaceSettings = result.current.settings.forScope(
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
expect(workspaceSettings).toBe(mockSnapshot.workspace);
|
||||
});
|
||||
|
||||
it('should trigger re-renders when settings change (external event)', () => {
|
||||
const { result } = renderHook(() => useSettingsStore(), { wrapper });
|
||||
|
||||
expect(result.current.settings.merged.ui?.theme).toBe('default-theme');
|
||||
|
||||
const newSnapshot = {
|
||||
...mockSnapshot,
|
||||
merged: { ui: { theme: 'new-theme' } },
|
||||
};
|
||||
(
|
||||
mockLoadedSettings.getSnapshot as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(newSnapshot);
|
||||
|
||||
// Trigger the listeners (simulate coreEvents emission)
|
||||
act(() => {
|
||||
listeners.forEach((l) => l());
|
||||
});
|
||||
|
||||
expect(result.current.settings.merged.ui?.theme).toBe('new-theme');
|
||||
});
|
||||
|
||||
it('should call store.setValue when setSetting is called', () => {
|
||||
const { result } = renderHook(() => useSettingsStore(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setSetting(SettingScope.User, 'ui.theme', 'dark');
|
||||
});
|
||||
|
||||
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui.theme',
|
||||
'dark',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if used outside provider', () => {
|
||||
const onError = vi.fn();
|
||||
// Suppress console.error (React logs error boundary info)
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<TestHarness />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'useSettingsStore must be used within a SettingsProvider',
|
||||
}),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -4,81 +4,17 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useContext, useMemo, useSyncExternalStore } from 'react';
|
||||
import type {
|
||||
LoadableSettingScope,
|
||||
LoadedSettings,
|
||||
LoadedSettingsSnapshot,
|
||||
SettingsFile,
|
||||
} from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import React, { useContext } from 'react';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export const SettingsContext = React.createContext<LoadedSettings | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useSettings = (): LoadedSettings => {
|
||||
export const useSettings = () => {
|
||||
const context = useContext(SettingsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSettings must be used within a SettingsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export interface SettingsState extends LoadedSettingsSnapshot {
|
||||
forScope: (scope: LoadableSettingScope) => SettingsFile;
|
||||
}
|
||||
|
||||
export interface SettingsStoreValue {
|
||||
settings: SettingsState;
|
||||
setSetting: (
|
||||
scope: LoadableSettingScope,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// Components that call this hook will re render when a settings change event is emitted
|
||||
export const useSettingsStore = (): SettingsStoreValue => {
|
||||
const store = useContext(SettingsContext);
|
||||
if (store === undefined) {
|
||||
throw new Error('useSettingsStore must be used within a SettingsProvider');
|
||||
}
|
||||
|
||||
// React passes a listener fn into the subscribe function
|
||||
// When the listener runs, it re renders the component if the snapshot changed
|
||||
const snapshot = useSyncExternalStore(
|
||||
(listener) => store.subscribe(listener),
|
||||
() => store.getSnapshot(),
|
||||
);
|
||||
|
||||
const settings: SettingsState = useMemo(
|
||||
() => ({
|
||||
...snapshot,
|
||||
forScope: (scope: LoadableSettingScope) => {
|
||||
switch (scope) {
|
||||
case SettingScope.User:
|
||||
return snapshot.user;
|
||||
case SettingScope.Workspace:
|
||||
return snapshot.workspace;
|
||||
case SettingScope.System:
|
||||
return snapshot.system;
|
||||
case SettingScope.SystemDefaults:
|
||||
return snapshot.systemDefaults;
|
||||
default:
|
||||
throw new Error(`Invalid scope: ${scope}`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[snapshot],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
settings,
|
||||
setSetting: (scope: LoadableSettingScope, key: string, value: unknown) =>
|
||||
store.setValue(scope, key, value),
|
||||
}),
|
||||
[settings, store],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ export interface UIState {
|
||||
permissionConfirmationRequest: PermissionConfirmationRequest | null;
|
||||
geminiMdFileCount: number;
|
||||
streamingState: StreamingState;
|
||||
initError: string | Error | null;
|
||||
initError: string | null;
|
||||
pendingGeminiHistoryItems: HistoryItemWithoutId[];
|
||||
thought: ThoughtSummary | null;
|
||||
shellModeActive: boolean;
|
||||
|
||||
@@ -61,13 +61,10 @@ export function mapToDisplay(
|
||||
|
||||
const displayName = call.tool?.displayName ?? call.request.name;
|
||||
|
||||
if (call.status === 'error' || !call.invocation) {
|
||||
if (call.status === 'error') {
|
||||
description = JSON.stringify(call.request.args);
|
||||
} else {
|
||||
description = call.invocation.getDescription();
|
||||
}
|
||||
|
||||
if (call.tool) {
|
||||
renderOutputAsMarkdown = call.tool.isOutputMarkdown;
|
||||
}
|
||||
|
||||
@@ -89,12 +86,12 @@ export function mapToDisplay(
|
||||
|
||||
switch (call.status) {
|
||||
case 'success':
|
||||
resultDisplay = call.response?.resultDisplay;
|
||||
outputFile = call.response?.outputFile;
|
||||
resultDisplay = call.response.resultDisplay;
|
||||
outputFile = call.response.outputFile;
|
||||
break;
|
||||
case 'error':
|
||||
case 'cancelled':
|
||||
resultDisplay = call.response?.resultDisplay;
|
||||
resultDisplay = call.response.resultDisplay;
|
||||
break;
|
||||
case 'awaiting_approval':
|
||||
correlationId = call.correlationId;
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHookWithProviders } from '../../test-utils/render.js';
|
||||
import { useAgentHarness } from './useAgentHarness.js';
|
||||
import {
|
||||
GeminiEventType as ServerGeminiEventType,
|
||||
ROOT_SCHEDULER_ID,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { makeFakeConfig } from '../../../../core/src/test-utils/config.js';
|
||||
import type {
|
||||
Config,
|
||||
ServerGeminiStreamEvent as GeminiEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { StreamingState, MessageType } from '../types.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
AgentFactory: {
|
||||
createHarness: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('useAgentHarness', () => {
|
||||
let mockAddItem: Mock;
|
||||
let mockConfig: Config;
|
||||
let mockOnCancelSubmit: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAddItem = vi.fn();
|
||||
mockConfig = makeFakeConfig();
|
||||
mockOnCancelSubmit = vi.fn();
|
||||
|
||||
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue({
|
||||
getTool: vi.fn().mockReturnValue({
|
||||
displayName: 'codebase_investigator',
|
||||
createInvocation: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Test Tool Description',
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.spyOn(mockConfig, 'getMessageBus').mockReturnValue({
|
||||
subscribe: vi.fn().mockReturnValue(vi.fn()),
|
||||
unsubscribe: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes in Idle state', () => {
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
|
||||
);
|
||||
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
expect(result.current.isResponding).toBe(false);
|
||||
});
|
||||
|
||||
it('updates state live during processEvent', async () => {
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
|
||||
);
|
||||
|
||||
// 1. Send content
|
||||
await act(async () => {
|
||||
result.current.processEvent({
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
} as GeminiEvent);
|
||||
});
|
||||
expect(result.current.streamingContent).toBe('Hello');
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
|
||||
// 2. Send thought
|
||||
await act(async () => {
|
||||
result.current.processEvent({
|
||||
type: ServerGeminiEventType.Thought,
|
||||
value: { subject: 'Thinking' },
|
||||
} as GeminiEvent);
|
||||
});
|
||||
expect(result.current.thought?.subject).toBe('Thinking');
|
||||
|
||||
// 3. Send tool request
|
||||
await act(async () => {
|
||||
result.current.processEvent({
|
||||
type: ServerGeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
name: 'tool_1',
|
||||
callId: 'c1',
|
||||
args: {},
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
},
|
||||
} as GeminiEvent);
|
||||
});
|
||||
expect(result.current.toolCalls).toHaveLength(1);
|
||||
expect(result.current.toolCalls[0].request.name).toBe('tool_1');
|
||||
});
|
||||
|
||||
it('merges subagent activity into active tool calls', async () => {
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
|
||||
);
|
||||
|
||||
// Start a delegation tool
|
||||
await act(async () => {
|
||||
result.current.processEvent({
|
||||
type: ServerGeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
name: 'subagent_tool',
|
||||
callId: 'c1',
|
||||
args: {},
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
},
|
||||
} as GeminiEvent);
|
||||
});
|
||||
|
||||
// Send subagent activity
|
||||
await act(async () => {
|
||||
result.current.processEvent({
|
||||
type: ServerGeminiEventType.SubagentActivity,
|
||||
value: {
|
||||
agentName: 'codebase_investigator',
|
||||
type: 'THOUGHT',
|
||||
data: { subject: 'Analyzing logs' },
|
||||
},
|
||||
} as GeminiEvent);
|
||||
});
|
||||
|
||||
// Verify the tool box resultDisplay was updated with the thought
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(result.current.toolCalls[0] as any).response?.resultDisplay,
|
||||
).toContain('🤖💭 Analyzing logs');
|
||||
|
||||
// Send another activity
|
||||
await act(async () => {
|
||||
result.current.processEvent({
|
||||
type: ServerGeminiEventType.SubagentActivity,
|
||||
value: {
|
||||
agentName: 'codebase_investigator',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'list_directory' },
|
||||
},
|
||||
} as GeminiEvent);
|
||||
});
|
||||
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(result.current.toolCalls[0] as any).response?.resultDisplay,
|
||||
).toContain('🛠️ Calling codebase_investigator...');
|
||||
});
|
||||
|
||||
it('flushes to history on TurnFinished', async () => {
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
|
||||
);
|
||||
|
||||
// Setup some state
|
||||
await act(async () => {
|
||||
result.current.processEvent({
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'Done',
|
||||
} as GeminiEvent);
|
||||
result.current.processEvent({
|
||||
type: ServerGeminiEventType.TurnFinished,
|
||||
} as GeminiEvent);
|
||||
});
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.GEMINI,
|
||||
text: 'Done',
|
||||
}),
|
||||
);
|
||||
expect(result.current.streamingContent).toBe(''); // Should be cleared
|
||||
});
|
||||
});
|
||||
@@ -1,446 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
GeminiEventType as ServerGeminiEventType,
|
||||
ROOT_SCHEDULER_ID,
|
||||
AgentFactory,
|
||||
MessageBusType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
ServerGeminiStreamEvent as GeminiEvent,
|
||||
ThoughtSummary,
|
||||
RetryAttemptPayload,
|
||||
ToolCallsUpdateMessage,
|
||||
ValidatingToolCall,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type PartListUnion, type Part } from '@google/genai';
|
||||
import {
|
||||
StreamingState,
|
||||
MessageType,
|
||||
type HistoryItemWithoutId,
|
||||
type LoopDetectionConfirmationRequest,
|
||||
} from '../types.js';
|
||||
import { useStateAndRef } from './useStateAndRef.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
|
||||
import type { TrackedToolCall } from './useToolScheduler.js';
|
||||
import { type BackgroundShell } from './shellReducer.js';
|
||||
|
||||
export interface UseAgentHarnessReturn {
|
||||
streamingState: StreamingState;
|
||||
isResponding: boolean;
|
||||
thought: ThoughtSummary | null;
|
||||
streamingContent: string;
|
||||
toolCalls: TrackedToolCall[];
|
||||
submitQuery: (query: PartListUnion) => Promise<void>;
|
||||
processEvent: (event: GeminiEvent) => void;
|
||||
cancelOngoingRequest: () => void;
|
||||
reset: () => void;
|
||||
// Legacy compatibility properties
|
||||
initError: Error | null;
|
||||
pendingHistoryItems: HistoryItemWithoutId[];
|
||||
handleApprovalModeChange: (mode: string) => void;
|
||||
activePtyId: number | null;
|
||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||
lastOutputTime: number;
|
||||
backgroundShellCount: number;
|
||||
isBackgroundShellVisible: boolean;
|
||||
toggleBackgroundShell: () => void;
|
||||
backgroundCurrentShell: (() => void) | null;
|
||||
backgroundShells: Map<number, BackgroundShell>;
|
||||
dismissBackgroundShell: (pid: number) => void;
|
||||
retryStatus: RetryAttemptPayload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A specialized hook for processing streams from the AgentHarness.
|
||||
* COMPLETELY FORKED from useGeminiStream to ensure zero regressions in legacy mode.
|
||||
*/
|
||||
export const useAgentHarness = (
|
||||
addItem: UseHistoryManagerReturn['addItem'],
|
||||
config: Config,
|
||||
onCancelSubmit: (fullReset: boolean) => void,
|
||||
): UseAgentHarnessReturn => {
|
||||
const [streamingState, setStreamingState] = useState<StreamingState>(
|
||||
StreamingState.Idle,
|
||||
);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const streamingContentRef = useRef('');
|
||||
|
||||
const [thought, thoughtRef, setThought] =
|
||||
useStateAndRef<ThoughtSummary | null>(null);
|
||||
|
||||
// Tools for the CURRENT turn of the main agent
|
||||
const [toolCalls, setToolCalls] = useState<TrackedToolCall[]>([]);
|
||||
const toolCallsRef = useRef<TrackedToolCall[]>([]);
|
||||
|
||||
// Sync ref with state (still useful for some parts)
|
||||
useEffect(() => {
|
||||
toolCallsRef.current = toolCalls;
|
||||
}, [toolCalls]);
|
||||
|
||||
const pushedToolCallIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Listen to the MessageBus for live tool updates (e.g. from subagents or long-running tools)
|
||||
useEffect(() => {
|
||||
const bus = config.getMessageBus();
|
||||
const handler = (event: ToolCallsUpdateMessage) => {
|
||||
setToolCalls((prev) => {
|
||||
const next = [...prev];
|
||||
for (const coreCall of event.toolCalls) {
|
||||
const index = next.findIndex(
|
||||
(tc) => tc.request.callId === coreCall.request.callId,
|
||||
);
|
||||
if (index !== -1) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
...coreCall,
|
||||
};
|
||||
}
|
||||
}
|
||||
toolCallsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
bus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
|
||||
return () => {
|
||||
bus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const pendingHistoryItems = useMemo(() => {
|
||||
const items: HistoryItemWithoutId[] = [];
|
||||
|
||||
// Only show the top-level thought if we aren't currently executing tools (delegations)
|
||||
// Subagent internal thoughts are merged into the tool box via SubagentActivity handler.
|
||||
if (thought && toolCalls.length === 0) {
|
||||
items.push({
|
||||
type: MessageType.THINKING,
|
||||
thought,
|
||||
} as HistoryItemWithoutId);
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
const unpushed = toolCalls.filter(
|
||||
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
|
||||
);
|
||||
if (unpushed.length > 0) {
|
||||
items.push(
|
||||
mapToDisplayInternal(unpushed, {
|
||||
borderBottom: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (streamingContent) {
|
||||
items.push({ type: MessageType.GEMINI, text: streamingContent });
|
||||
}
|
||||
return items;
|
||||
}, [thought, toolCalls, streamingContent]);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStreamingState(StreamingState.Idle);
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
setThought(null);
|
||||
setToolCalls([]);
|
||||
toolCallsRef.current = [];
|
||||
pushedToolCallIdsRef.current.clear();
|
||||
}, [setThought]);
|
||||
|
||||
const cancelOngoingRequest = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
onCancelSubmit(true);
|
||||
reset();
|
||||
}, [onCancelSubmit, reset]);
|
||||
|
||||
const processEvent = useCallback(
|
||||
(event: GeminiEvent) => {
|
||||
switch (event.type) {
|
||||
case ServerGeminiEventType.Content:
|
||||
setStreamingState(StreamingState.Responding);
|
||||
{
|
||||
const nextContent =
|
||||
streamingContentRef.current + (event.value || '');
|
||||
streamingContentRef.current = nextContent;
|
||||
setStreamingContent(nextContent);
|
||||
}
|
||||
break;
|
||||
|
||||
case ServerGeminiEventType.Thought:
|
||||
setThought(event.value);
|
||||
break;
|
||||
|
||||
case ServerGeminiEventType.ToolCallRequest:
|
||||
{
|
||||
setThought(null);
|
||||
const tool = config.getToolRegistry().getTool(event.value.name);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
const invocation = (tool as any)?.createInvocation?.(
|
||||
event.value.args,
|
||||
config.getMessageBus(),
|
||||
);
|
||||
|
||||
// In Harness mode, top-level calls might not have schedulerId set yet.
|
||||
// We default to ROOT_SCHEDULER_ID to ensure they are visible.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newCall: TrackedToolCall = {
|
||||
request: {
|
||||
...event.value,
|
||||
schedulerId: event.value.schedulerId || ROOT_SCHEDULER_ID,
|
||||
},
|
||||
status: 'validating',
|
||||
schedulerId: event.value.schedulerId || ROOT_SCHEDULER_ID,
|
||||
tool: tool || undefined,
|
||||
invocation: invocation || undefined,
|
||||
} as ValidatingToolCall;
|
||||
|
||||
const nextCalls = [...toolCallsRef.current, newCall];
|
||||
toolCallsRef.current = nextCalls;
|
||||
setToolCalls(nextCalls);
|
||||
}
|
||||
break;
|
||||
|
||||
case ServerGeminiEventType.ToolCallResponse:
|
||||
{
|
||||
const response = event.value;
|
||||
const nextCalls = toolCallsRef.current.map((tc) =>
|
||||
tc.request.callId === response.callId
|
||||
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
({
|
||||
...tc,
|
||||
status: 'success',
|
||||
response,
|
||||
} as unknown as TrackedToolCall)
|
||||
: tc,
|
||||
);
|
||||
toolCallsRef.current = nextCalls;
|
||||
setToolCalls(nextCalls);
|
||||
}
|
||||
break;
|
||||
|
||||
case ServerGeminiEventType.TurnFinished:
|
||||
// MAIN AGENT turn finished. Flush current state to history.
|
||||
if (thoughtRef.current) {
|
||||
addItem({
|
||||
type: MessageType.THINKING,
|
||||
thought: thoughtRef.current,
|
||||
} as HistoryItemWithoutId);
|
||||
setThought(null);
|
||||
}
|
||||
|
||||
if (toolCallsRef.current.length > 0) {
|
||||
const unpushed = toolCallsRef.current.filter(
|
||||
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
|
||||
);
|
||||
if (unpushed.length > 0) {
|
||||
addItem(
|
||||
mapToDisplayInternal(unpushed, {
|
||||
borderBottom: true,
|
||||
}),
|
||||
);
|
||||
unpushed.forEach((tc) =>
|
||||
pushedToolCallIdsRef.current.add(tc.request.callId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (streamingContentRef.current) {
|
||||
addItem({
|
||||
type: MessageType.GEMINI,
|
||||
text: streamingContentRef.current,
|
||||
});
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
}
|
||||
|
||||
toolCallsRef.current = [];
|
||||
setToolCalls([]);
|
||||
break;
|
||||
|
||||
case ServerGeminiEventType.SubagentActivity:
|
||||
{
|
||||
const activity = event.value;
|
||||
let matched = false;
|
||||
|
||||
const nextCalls = toolCallsRef.current.map((tc) => {
|
||||
// Try to find the tool box that belongs to this agent.
|
||||
// Note: We search ALL tool calls, not just 'executing', in case of race conditions.
|
||||
if (
|
||||
tc.request.name === activity.agentName ||
|
||||
(tc.tool?.displayName || tc.request.name) === activity.agentName
|
||||
) {
|
||||
matched = true;
|
||||
let output = '';
|
||||
if (
|
||||
tc.status === 'success' ||
|
||||
tc.status === 'error' ||
|
||||
tc.status === 'cancelled'
|
||||
) {
|
||||
output = String(tc.response.resultDisplay || '');
|
||||
}
|
||||
if (typeof output !== 'string') output = '';
|
||||
|
||||
if (activity.type === 'TOOL_CALL_START') {
|
||||
const rawName = String(activity.data['name'] || 'a tool');
|
||||
const tool = config.getToolRegistry().getTool(rawName);
|
||||
const displayName = tool?.displayName || rawName;
|
||||
output += `🛠️ Calling ${displayName}...\n`;
|
||||
} else if (activity.type === 'THOUGHT') {
|
||||
const subject = String(
|
||||
activity.data['subject'] || 'Thinking',
|
||||
);
|
||||
output += `🤖💭 ${subject}\n`;
|
||||
}
|
||||
|
||||
const currentResponse =
|
||||
tc.status === 'success' ||
|
||||
tc.status === 'error' ||
|
||||
tc.status === 'cancelled'
|
||||
? tc.response
|
||||
: {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
...tc,
|
||||
response: {
|
||||
...currentResponse,
|
||||
resultDisplay: output,
|
||||
},
|
||||
} as unknown as TrackedToolCall;
|
||||
}
|
||||
return tc;
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
toolCallsRef.current = nextCalls;
|
||||
setToolCalls(nextCalls);
|
||||
} else {
|
||||
// Fallback: If no tool box matches, show it as a standalone item
|
||||
if (activity.type === 'THOUGHT') {
|
||||
addItem({
|
||||
type: MessageType.GEMINI,
|
||||
text: `🤖💭 [${activity.agentName}] ${activity.data['subject']}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ServerGeminiEventType.Finished:
|
||||
setStreamingState(StreamingState.Idle);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[addItem, config, setThought, thoughtRef],
|
||||
);
|
||||
|
||||
// Listen for nested subagent activity on the MessageBus
|
||||
useEffect(() => {
|
||||
const bus = config.getMessageBus();
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion */
|
||||
const handler = (event: any) => {
|
||||
processEvent({
|
||||
type: ServerGeminiEventType.SubagentActivity,
|
||||
value: event.activity,
|
||||
} as any as GeminiEvent);
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion */
|
||||
bus.subscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
|
||||
return () => {
|
||||
bus.unsubscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
|
||||
};
|
||||
}, [config, processEvent]);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (parts: PartListUnion) => {
|
||||
reset();
|
||||
setStreamingState(StreamingState.Responding);
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
const harness = AgentFactory.createHarness(config);
|
||||
|
||||
// Convert parts to Part[] array for harness
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
const requestParts: Part[] = Array.isArray(parts)
|
||||
? (parts as Part[])
|
||||
: [{ text: String(parts) }];
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
|
||||
const stream = harness.run(
|
||||
requestParts,
|
||||
abortControllerRef.current.signal,
|
||||
);
|
||||
|
||||
try {
|
||||
for await (const event of stream) {
|
||||
processEvent(event);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
addItem({ type: MessageType.ERROR, text: msg });
|
||||
} finally {
|
||||
setStreamingState(StreamingState.Idle);
|
||||
}
|
||||
},
|
||||
[config, reset, processEvent, addItem],
|
||||
);
|
||||
|
||||
return {
|
||||
streamingState,
|
||||
isResponding: streamingState !== StreamingState.Idle,
|
||||
thought,
|
||||
streamingContent,
|
||||
toolCalls,
|
||||
submitQuery,
|
||||
processEvent,
|
||||
cancelOngoingRequest,
|
||||
reset,
|
||||
initError: null,
|
||||
pendingHistoryItems,
|
||||
handleApprovalModeChange: () => {},
|
||||
activePtyId: null,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: 0,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
toggleBackgroundShell: () => {},
|
||||
backgroundCurrentShell: null,
|
||||
backgroundShells: new Map<number, BackgroundShell>(),
|
||||
dismissBackgroundShell: () => {},
|
||||
retryStatus: null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal mapper to ensure we don't accidentally leak subagent-internal tools
|
||||
* into the main UI boxes while in Harness Mode.
|
||||
*/
|
||||
function mapToDisplayInternal(
|
||||
calls: TrackedToolCall[],
|
||||
options: { borderTop?: boolean; borderBottom?: boolean },
|
||||
): HistoryItemWithoutId {
|
||||
// We filter out any tool calls that are NOT part of the root harness level.
|
||||
// This prevents internal subagent work (like list_directory) from appearing
|
||||
// as loose tool boxes in the main chat.
|
||||
const filtered = calls.filter(
|
||||
(c) =>
|
||||
// Only show tools belonging to the main top-level session.
|
||||
c.schedulerId === ROOT_SCHEDULER_ID,
|
||||
);
|
||||
|
||||
return mapTrackedToolCallsToDisplay(filtered, options);
|
||||
}
|
||||
@@ -31,7 +31,6 @@ function consoleMessagesReducer(
|
||||
state: ConsoleMessageItem[],
|
||||
action: Action,
|
||||
): ConsoleMessageItem[] {
|
||||
const MAX_CONSOLE_MESSAGES = 1000;
|
||||
switch (action.type) {
|
||||
case 'ADD_MESSAGES': {
|
||||
const newMessages = [...state];
|
||||
@@ -52,12 +51,6 @@ function consoleMessagesReducer(
|
||||
newMessages.push({ ...queuedMessage, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of messages to prevent memory issues
|
||||
if (newMessages.length > MAX_CONSOLE_MESSAGES) {
|
||||
return newMessages.slice(newMessages.length - MAX_CONSOLE_MESSAGES);
|
||||
}
|
||||
|
||||
return newMessages;
|
||||
}
|
||||
case 'CLEAR':
|
||||
@@ -98,17 +91,9 @@ export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsoleLog = (payload: ConsoleLogPayload) => {
|
||||
let content = payload.content;
|
||||
const MAX_CONSOLE_MSG_LENGTH = 10000;
|
||||
if (content.length > MAX_CONSOLE_MSG_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_CONSOLE_MSG_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_CONSOLE_MSG_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
handleNewMessage({
|
||||
type: payload.type,
|
||||
content,
|
||||
content: payload.content,
|
||||
count: 1,
|
||||
});
|
||||
};
|
||||
@@ -117,18 +102,10 @@ export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
isStderr: boolean;
|
||||
chunk: Uint8Array | string;
|
||||
}) => {
|
||||
let content =
|
||||
const content =
|
||||
typeof payload.chunk === 'string'
|
||||
? payload.chunk
|
||||
: new TextDecoder().decode(payload.chunk);
|
||||
|
||||
const MAX_OUTPUT_CHUNK_LENGTH = 10000;
|
||||
if (content.length > MAX_OUTPUT_CHUNK_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_OUTPUT_CHUNK_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_OUTPUT_CHUNK_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
// It would be nice if we could show stderr as 'warn' but unfortunately
|
||||
// we log non warning info to stderr before the app starts so that would
|
||||
// be misleading.
|
||||
|
||||
@@ -1168,12 +1168,6 @@ export const useGeminiStream = (
|
||||
case ServerGeminiEventType.InvalidStream:
|
||||
// Will add the missing logic later
|
||||
break;
|
||||
case ServerGeminiEventType.SubagentActivity:
|
||||
// TODO: UI implementation for subagent activity
|
||||
break;
|
||||
case ServerGeminiEventType.TurnFinished:
|
||||
// No-op for now to satisfy exhaustive switch
|
||||
break;
|
||||
default: {
|
||||
// enforces exhaustive switch-case
|
||||
const unreachable: never = event;
|
||||
@@ -1683,7 +1677,7 @@ export const useGeminiStream = (
|
||||
pendingHistoryItems,
|
||||
thought,
|
||||
cancelOngoingRequest,
|
||||
toolCalls,
|
||||
pendingToolCalls: toolCalls,
|
||||
handleApprovalModeChange,
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
|
||||
@@ -5,12 +5,8 @@
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
useKeypressContext,
|
||||
type KeypressHandler,
|
||||
type Key,
|
||||
type KeypressPriority,
|
||||
} from '../contexts/KeypressContext.js';
|
||||
import type { KeypressHandler, Key } from '../contexts/KeypressContext.js';
|
||||
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
||||
|
||||
export type { Key };
|
||||
|
||||
@@ -20,14 +16,11 @@ export type { Key };
|
||||
* @param onKeypress - The callback function to execute on each keypress.
|
||||
* @param options - Options to control the hook's behavior.
|
||||
* @param options.isActive - Whether the hook should be actively listening for input.
|
||||
* @param options.priority - Priority level (integer or KeypressPriority enum) or boolean for backward compatibility.
|
||||
* @param options.priority - Whether the hook should have priority over normal subscribers.
|
||||
*/
|
||||
export function useKeypress(
|
||||
onKeypress: KeypressHandler,
|
||||
{
|
||||
isActive,
|
||||
priority,
|
||||
}: { isActive: boolean; priority?: KeypressPriority | boolean },
|
||||
{ isActive, priority }: { isActive: boolean; priority?: boolean },
|
||||
) {
|
||||
const { subscribe, unsubscribe } = useKeypressContext();
|
||||
|
||||
|
||||
@@ -31,9 +31,7 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
flexDirection="column"
|
||||
width={uiState.terminalWidth}
|
||||
height={isAlternateBuffer ? terminalHeight : undefined}
|
||||
paddingBottom={
|
||||
isAlternateBuffer && !uiState.copyModeEnabled ? 1 : undefined
|
||||
}
|
||||
paddingBottom={isAlternateBuffer ? 1 : undefined}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
|
||||
@@ -373,7 +373,6 @@ export enum MessageType {
|
||||
AGENTS_LIST = 'agents_list',
|
||||
MCP_STATUS = 'mcp_status',
|
||||
CHAT_LIST = 'chat_list',
|
||||
THINKING = 'thinking',
|
||||
HOOKS_LIST = 'hooks_list',
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getPlainTextLength } from './InlineMarkdownRenderer.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('getPlainTextLength', () => {
|
||||
it.each([
|
||||
['**Primary Go', 12],
|
||||
['*Primary Go', 11],
|
||||
['**Primary Go**', 10],
|
||||
['*Primary Go*', 10],
|
||||
['**', 2],
|
||||
['*', 1],
|
||||
['compile-time**', 14],
|
||||
])(
|
||||
'should measure markdown text length correctly for "%s"',
|
||||
(input, expected) => {
|
||||
expect(getPlainTextLength(input)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import stringWidth from 'string-width';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
// Constants for Markdown parsing
|
||||
@@ -170,3 +171,19 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
|
||||
};
|
||||
|
||||
export const RenderInline = React.memo(RenderInlineInternal);
|
||||
|
||||
/**
|
||||
* Utility function to get the plain text length of a string with markdown formatting
|
||||
* This is useful for calculating column widths in tables
|
||||
*/
|
||||
export const getPlainTextLength = (text: string): number => {
|
||||
const cleanText = text
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/_(.*?)_/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/`(.*?)`/g, '$1')
|
||||
.replace(/<u>(.*?)<\/u>/g, '$1')
|
||||
.replace(/.*\[(.*?)\]\(.*\)/g, '$1');
|
||||
return stringWidth(cleanText);
|
||||
};
|
||||
|
||||
@@ -61,288 +61,4 @@ describe('TableRenderer', () => {
|
||||
expect(output).toContain('Data 3.4');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('wraps long cell content correctly', () => {
|
||||
const headers = ['Col 1', 'Col 2', 'Col 3'];
|
||||
const rows = [
|
||||
[
|
||||
'Short',
|
||||
'This is a very long cell content that should wrap to multiple lines',
|
||||
'Short',
|
||||
],
|
||||
];
|
||||
const terminalWidth = 50;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('This is a very');
|
||||
expect(output).toContain('long cell');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('wraps all long columns correctly', () => {
|
||||
const headers = ['Col 1', 'Col 2', 'Col 3'];
|
||||
const rows = [
|
||||
[
|
||||
'This is a very long text that needs wrapping in column 1',
|
||||
'This is also a very long text that needs wrapping in column 2',
|
||||
'And this is the third long text that needs wrapping in column 3',
|
||||
],
|
||||
];
|
||||
const terminalWidth = 60;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('wrapping in');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('wraps mixed long and short columns correctly', () => {
|
||||
const headers = ['Short', 'Long', 'Medium'];
|
||||
const rows = [
|
||||
[
|
||||
'Tiny',
|
||||
'This is a very long text that definitely needs to wrap to the next line',
|
||||
'Not so long',
|
||||
],
|
||||
];
|
||||
const terminalWidth = 50;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Tiny');
|
||||
expect(output).toContain('definitely needs');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// The snapshot looks weird but checked on VS Code terminal and it looks fine
|
||||
it('wraps columns with punctuation correctly', () => {
|
||||
const headers = ['Punctuation 1', 'Punctuation 2', 'Punctuation 3'];
|
||||
const rows = [
|
||||
[
|
||||
'Start. Stop. Comma, separated. Exclamation! Question? hyphen-ated',
|
||||
'Semi; colon: Pipe| Slash/ Backslash\\',
|
||||
'At@ Hash# Dollar$ Percent% Caret^ Ampersand& Asterisk*',
|
||||
],
|
||||
];
|
||||
const terminalWidth = 60;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Start. Stop.');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('strips bold markers from headers and renders them correctly', () => {
|
||||
const headers = ['**Bold Header**', 'Normal Header', '**Another Bold**'];
|
||||
const rows = [['Data 1', 'Data 2', 'Data 3']];
|
||||
const terminalWidth = 50;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
// The output should NOT contain the literal '**'
|
||||
expect(output).not.toContain('**Bold Header**');
|
||||
expect(output).toContain('Bold Header');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('handles wrapped bold headers without showing markers', () => {
|
||||
const headers = [
|
||||
'**Very Long Bold Header That Will Wrap**',
|
||||
'Short',
|
||||
'**Another Long Header**',
|
||||
];
|
||||
const rows = [['Data 1', 'Data 2', 'Data 3']];
|
||||
const terminalWidth = 40;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
// Markers should be gone
|
||||
expect(output).not.toContain('**');
|
||||
expect(output).toContain('Very Long');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders a complex table with mixed content lengths correctly', () => {
|
||||
const headers = [
|
||||
'Comprehensive Architectural Specification for the Distributed Infrastructure Layer',
|
||||
'Implementation Details for the High-Throughput Asynchronous Message Processing Pipeline with Extended Scalability Features and Redundancy Protocols',
|
||||
'Longitudinal Performance Analysis Across Multi-Regional Cloud Deployment Clusters',
|
||||
'Strategic Security Framework for Mitigating Sophisticated Cross-Site Scripting Vulnerabilities',
|
||||
'Key',
|
||||
'Status',
|
||||
'Version',
|
||||
'Owner',
|
||||
];
|
||||
const rows = [
|
||||
[
|
||||
'The primary architecture utilizes a decoupled microservices approach, leveraging container orchestration for scalability and fault tolerance in high-load scenarios.\n\nThis layer provides the fundamental building blocks for service discovery, load balancing, and inter-service communication via highly efficient protocol buffers.\n\nAdvanced telemetry and logging integrations allow for real-time monitoring of system health and rapid identification of bottlenecks within the service mesh.',
|
||||
'Each message is processed through a series of specialized workers that handle data transformation, validation, and persistent storage using a persistent queue.\n\nThe pipeline features built-in retry mechanisms with exponential backoff to ensure message delivery integrity even during transient network or service failures.\n\nHorizontal autoscaling is triggered automatically based on the depth of the processing queue, ensuring consistent performance during unexpected traffic spikes.',
|
||||
'Historical data indicates a significant reduction in tail latency when utilizing edge computing nodes closer to the geographic location of the end-user base.\n\nMonitoring tools have captured a steady increase in throughput efficiency since the introduction of the vectorized query engine in the primary data warehouse.\n\nResource utilization metrics demonstrate that the transition to serverless compute for intermittent tasks has resulted in a thirty percent cost optimization.',
|
||||
'A multi-layered defense strategy incorporates content security policies, input sanitization libraries, and regular automated penetration testing routines.\n\nDevelopers are required to undergo mandatory security training focusing on the OWASP Top Ten to ensure that security is integrated into the initial design phase.\n\nThe implementation of a robust Identity and Access Management system ensures that the principle of least privilege is strictly enforced across all environments.',
|
||||
'INF',
|
||||
'Active',
|
||||
'v2.4',
|
||||
'J. Doe',
|
||||
],
|
||||
];
|
||||
|
||||
const terminalWidth = 160;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
{ width: terminalWidth },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Comprehensive Architectural');
|
||||
expect(output).toContain('protocol buffers');
|
||||
expect(output).toContain('exponential backoff');
|
||||
expect(output).toContain('vectorized query engine');
|
||||
expect(output).toContain('OWASP Top Ten');
|
||||
expect(output).toContain('INF');
|
||||
expect(output).toContain('Active');
|
||||
expect(output).toContain('v2.4');
|
||||
// "J. Doe" might wrap due to column width constraints
|
||||
expect(output).toContain('J.');
|
||||
expect(output).toContain('Doe');
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'handles non-ASCII characters (emojis and Asian scripts) correctly',
|
||||
headers: ['Emoji 😃', 'Asian 汉字', 'Mixed 🚀 Text'],
|
||||
rows: [
|
||||
['Start 🌟 End', '你好世界', 'Rocket 🚀 Man'],
|
||||
['Thumbs 👍 Up', 'こんにちは', 'Fire 🔥'],
|
||||
],
|
||||
terminalWidth: 60,
|
||||
expected: ['Emoji 😃', 'Asian 汉字', '你好世界'],
|
||||
},
|
||||
{
|
||||
name: 'renders a table with only emojis and text correctly',
|
||||
headers: ['Happy 😀', 'Rocket 🚀', 'Heart ❤️'],
|
||||
rows: [
|
||||
['Smile 😃', 'Fire 🔥', 'Love 💖'],
|
||||
['Cool 😎', 'Star ⭐', 'Blue 💙'],
|
||||
],
|
||||
terminalWidth: 60,
|
||||
expected: ['Happy 😀', 'Smile 😃', 'Fire 🔥'],
|
||||
},
|
||||
{
|
||||
name: 'renders a table with only Asian characters and text correctly',
|
||||
headers: ['Chinese 中文', 'Japanese 日本語', 'Korean 한국어'],
|
||||
rows: [
|
||||
['你好', 'こんにちは', '안녕하세요'],
|
||||
['世界', '世界', '세계'],
|
||||
],
|
||||
terminalWidth: 60,
|
||||
expected: ['Chinese 中文', '你好', 'こんにちは'],
|
||||
},
|
||||
{
|
||||
name: 'renders a table with mixed emojis, Asian characters, and text correctly',
|
||||
headers: ['Mixed 😃 中文', 'Complex 🚀 日本語', 'Text 📝 한국어'],
|
||||
rows: [
|
||||
['你好 😃', 'こんにちは 🚀', '안녕하세요 📝'],
|
||||
['World 🌍', 'Code 💻', 'Pizza 🍕'],
|
||||
],
|
||||
terminalWidth: 80,
|
||||
expected: ['Mixed 😃 中文', '你好 😃', 'こんにちは 🚀'],
|
||||
},
|
||||
])('$name', ({ headers, rows, terminalWidth, expected }) => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
{ width: terminalWidth },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expected.forEach((text) => {
|
||||
expect(output).toContain(text);
|
||||
});
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'renders correctly when headers are empty but rows have data',
|
||||
headers: [] as string[],
|
||||
rows: [['Data 1', 'Data 2']],
|
||||
expected: ['Data 1', 'Data 2'],
|
||||
},
|
||||
{
|
||||
name: 'renders correctly when there are more headers than columns in rows',
|
||||
headers: ['Header 1', 'Header 2', 'Header 3'],
|
||||
rows: [['Data 1', 'Data 2']],
|
||||
expected: ['Header 1', 'Header 2', 'Header 3', 'Data 1', 'Data 2'],
|
||||
},
|
||||
])('$name', ({ headers, rows, expected }) => {
|
||||
const terminalWidth = 50;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expected.forEach((text) => {
|
||||
expect(output).toContain(text);
|
||||
});
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,19 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import {
|
||||
type StyledChar,
|
||||
toStyledCharacters,
|
||||
styledCharsToString,
|
||||
styledCharsWidth,
|
||||
wordBreakStyledChars,
|
||||
wrapStyledChars,
|
||||
widestLineFromStyledChars,
|
||||
} from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { RenderInline } from './InlineMarkdownRenderer.js';
|
||||
import { RenderInline, getPlainTextLength } from './InlineMarkdownRenderer.js';
|
||||
|
||||
interface TableRendererProps {
|
||||
headers: string[];
|
||||
@@ -24,25 +15,6 @@ interface TableRendererProps {
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
const MIN_COLUMN_WIDTH = 5;
|
||||
const COLUMN_PADDING = 2;
|
||||
const TABLE_MARGIN = 2;
|
||||
|
||||
const calculateWidths = (styledChars: StyledChar[]) => {
|
||||
const contentWidth = styledCharsWidth(styledChars);
|
||||
|
||||
const words: StyledChar[][] = wordBreakStyledChars(styledChars);
|
||||
const maxWordWidth = widestLineFromStyledChars(words);
|
||||
|
||||
return { contentWidth, maxWordWidth };
|
||||
};
|
||||
|
||||
// Used to reduce redundant parsing and cache the widths for each line
|
||||
interface ProcessedLine {
|
||||
text: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom table renderer for markdown tables
|
||||
* We implement our own instead of using ink-table due to module compatibility issues
|
||||
@@ -52,163 +24,89 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
rows,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
// Clean headers: remove bold markers since we already render headers as bold
|
||||
// and having them can break wrapping when the markers are split across lines.
|
||||
const cleanedHeaders = useMemo(
|
||||
() => headers.map((header) => header.replace(/\*\*(.*?)\*\*/g, '$1')),
|
||||
[headers],
|
||||
// Calculate column widths using actual display width after markdown processing
|
||||
const columnWidths = headers.map((header, index) => {
|
||||
const headerWidth = getPlainTextLength(header);
|
||||
const maxRowWidth = Math.max(
|
||||
...rows.map((row) => getPlainTextLength(row[index] || '')),
|
||||
);
|
||||
return Math.max(headerWidth, maxRowWidth) + 2; // Add padding
|
||||
});
|
||||
|
||||
// Ensure table fits within terminal width
|
||||
// We calculate scale based on content width vs available width (terminal - borders)
|
||||
// First, extract content widths by removing the 2-char padding.
|
||||
const contentWidths = columnWidths.map((width) => Math.max(0, width - 2));
|
||||
const totalContentWidth = contentWidths.reduce(
|
||||
(sum, width) => sum + width,
|
||||
0,
|
||||
);
|
||||
|
||||
const styledHeaders = useMemo(
|
||||
() => cleanedHeaders.map((header) => toStyledCharacters(header)),
|
||||
[cleanedHeaders],
|
||||
// Fixed overhead includes padding (2 per column) and separators (1 per column + 1 final).
|
||||
const fixedOverhead = headers.length * 2 + (headers.length + 1);
|
||||
|
||||
// Subtract 1 from available width to avoid edge-case wrapping on some terminals
|
||||
const availableWidth = Math.max(0, terminalWidth - fixedOverhead - 1);
|
||||
|
||||
const scaleFactor =
|
||||
totalContentWidth > availableWidth ? availableWidth / totalContentWidth : 1;
|
||||
const adjustedWidths = contentWidths.map(
|
||||
(width) => Math.floor(width * scaleFactor) + 2,
|
||||
);
|
||||
|
||||
const styledRows = useMemo(
|
||||
() => rows.map((row) => row.map((cell) => toStyledCharacters(cell))),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const { wrappedHeaders, wrappedRows, adjustedWidths } = useMemo(() => {
|
||||
const numColumns = styledRows.reduce(
|
||||
(max, row) => Math.max(max, row.length),
|
||||
styledHeaders.length,
|
||||
);
|
||||
|
||||
// --- Define Constraints per Column ---
|
||||
const constraints = Array.from({ length: numColumns }).map(
|
||||
(_, colIndex) => {
|
||||
const headerStyledChars = styledHeaders[colIndex] || [];
|
||||
let { contentWidth: maxContentWidth, maxWordWidth } =
|
||||
calculateWidths(headerStyledChars);
|
||||
|
||||
styledRows.forEach((row) => {
|
||||
const cellStyledChars = row[colIndex] || [];
|
||||
const { contentWidth: cellWidth, maxWordWidth: cellWordWidth } =
|
||||
calculateWidths(cellStyledChars);
|
||||
|
||||
maxContentWidth = Math.max(maxContentWidth, cellWidth);
|
||||
maxWordWidth = Math.max(maxWordWidth, cellWordWidth);
|
||||
});
|
||||
|
||||
const minWidth = maxWordWidth;
|
||||
const maxWidth = Math.max(minWidth, maxContentWidth);
|
||||
|
||||
return { minWidth, maxWidth };
|
||||
},
|
||||
);
|
||||
|
||||
// --- Calculate Available Space ---
|
||||
// Fixed overhead: borders (n+1) + padding (2n)
|
||||
const fixedOverhead = numColumns + 1 + numColumns * COLUMN_PADDING;
|
||||
const availableWidth = Math.max(
|
||||
0,
|
||||
terminalWidth - fixedOverhead - TABLE_MARGIN,
|
||||
);
|
||||
|
||||
// --- Allocation Algorithm ---
|
||||
const totalMinWidth = constraints.reduce((sum, c) => sum + c.minWidth, 0);
|
||||
let finalContentWidths: number[];
|
||||
|
||||
if (totalMinWidth > availableWidth) {
|
||||
// We must scale all the columns except the ones that are very short(<=5 characters)
|
||||
const shortColumns = constraints.filter(
|
||||
(c) => c.maxWidth <= MIN_COLUMN_WIDTH,
|
||||
);
|
||||
const totalShortColumnWidth = shortColumns.reduce(
|
||||
(sum, c) => sum + c.minWidth,
|
||||
0,
|
||||
);
|
||||
|
||||
const finalTotalShortColumnWidth =
|
||||
totalShortColumnWidth >= availableWidth ? 0 : totalShortColumnWidth;
|
||||
|
||||
const scale =
|
||||
(availableWidth - finalTotalShortColumnWidth) /
|
||||
(totalMinWidth - finalTotalShortColumnWidth);
|
||||
finalContentWidths = constraints.map((c) => {
|
||||
if (c.maxWidth <= MIN_COLUMN_WIDTH && finalTotalShortColumnWidth > 0) {
|
||||
return c.minWidth;
|
||||
}
|
||||
return Math.floor(c.minWidth * scale);
|
||||
});
|
||||
} else {
|
||||
const surplus = availableWidth - totalMinWidth;
|
||||
const totalGrowthNeed = constraints.reduce(
|
||||
(sum, c) => sum + (c.maxWidth - c.minWidth),
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalGrowthNeed === 0) {
|
||||
finalContentWidths = constraints.map((c) => c.minWidth);
|
||||
} else {
|
||||
finalContentWidths = constraints.map((c) => {
|
||||
const growthNeed = c.maxWidth - c.minWidth;
|
||||
const share = growthNeed / totalGrowthNeed;
|
||||
const extra = Math.floor(surplus * share);
|
||||
return Math.min(c.maxWidth, c.minWidth + extra);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pre-wrap and Optimize Widths ---
|
||||
const actualColumnWidths = new Array(numColumns).fill(0);
|
||||
|
||||
const wrapAndProcessRow = (row: StyledChar[][]) => {
|
||||
const rowResult: ProcessedLine[][] = [];
|
||||
// Ensure we iterate up to numColumns, filling with empty cells if needed
|
||||
for (let colIndex = 0; colIndex < numColumns; colIndex++) {
|
||||
const cellStyledChars = row[colIndex] || [];
|
||||
const allocatedWidth = finalContentWidths[colIndex];
|
||||
const contentWidth = Math.max(1, allocatedWidth);
|
||||
|
||||
const wrappedStyledLines = wrapStyledChars(
|
||||
cellStyledChars,
|
||||
contentWidth,
|
||||
);
|
||||
|
||||
const maxLineWidth = widestLineFromStyledChars(wrappedStyledLines);
|
||||
actualColumnWidths[colIndex] = Math.max(
|
||||
actualColumnWidths[colIndex],
|
||||
maxLineWidth,
|
||||
);
|
||||
|
||||
const lines = wrappedStyledLines.map((line) => ({
|
||||
text: styledCharsToString(line),
|
||||
width: styledCharsWidth(line),
|
||||
}));
|
||||
rowResult.push(lines);
|
||||
}
|
||||
return rowResult;
|
||||
};
|
||||
|
||||
const wrappedHeaders = wrapAndProcessRow(styledHeaders);
|
||||
const wrappedRows = styledRows.map((row) => wrapAndProcessRow(row));
|
||||
|
||||
// Use the TIGHTEST widths that fit the wrapped content + padding
|
||||
const adjustedWidths = actualColumnWidths.map((w) => w + COLUMN_PADDING);
|
||||
|
||||
return { wrappedHeaders, wrappedRows, adjustedWidths };
|
||||
}, [styledHeaders, styledRows, terminalWidth]);
|
||||
// Helper function to render a cell with proper width
|
||||
const renderCell = (
|
||||
content: ProcessedLine,
|
||||
content: string,
|
||||
width: number,
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const contentWidth = Math.max(0, width - COLUMN_PADDING);
|
||||
// Use pre-calculated width to avoid re-parsing
|
||||
const displayWidth = content.width;
|
||||
const paddingNeeded = Math.max(0, contentWidth - displayWidth);
|
||||
const contentWidth = Math.max(0, width - 2);
|
||||
const displayWidth = getPlainTextLength(content);
|
||||
|
||||
let cellContent = content;
|
||||
if (displayWidth > contentWidth) {
|
||||
if (contentWidth <= 3) {
|
||||
// Just truncate by character count
|
||||
cellContent = content.substring(
|
||||
0,
|
||||
Math.min(content.length, contentWidth),
|
||||
);
|
||||
} else {
|
||||
// Truncate preserving markdown formatting using binary search
|
||||
let left = 0;
|
||||
let right = content.length;
|
||||
let bestTruncated = content;
|
||||
|
||||
// Binary search to find the optimal truncation point
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const candidate = content.substring(0, mid);
|
||||
const candidateWidth = getPlainTextLength(candidate);
|
||||
|
||||
if (candidateWidth <= contentWidth - 1) {
|
||||
bestTruncated = candidate;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
cellContent = bestTruncated + '…';
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate exact padding needed
|
||||
const actualDisplayWidth = getPlainTextLength(cellContent);
|
||||
const paddingNeeded = Math.max(0, contentWidth - actualDisplayWidth);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{isHeader ? (
|
||||
<Text bold color={theme.text.link}>
|
||||
<RenderInline text={content.text} />
|
||||
<RenderInline text={cellContent} />
|
||||
</Text>
|
||||
) : (
|
||||
<RenderInline text={content.text} />
|
||||
<RenderInline text={cellContent} />
|
||||
)}
|
||||
{' '.repeat(paddingNeeded)}
|
||||
</Text>
|
||||
@@ -230,14 +128,11 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
return <Text color={theme.border.default}>{border}</Text>;
|
||||
};
|
||||
|
||||
// Helper function to render a single visual line of a row
|
||||
const renderVisualRow = (
|
||||
cells: ProcessedLine[],
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
// Helper function to render a table row
|
||||
const renderRow = (cells: string[], isHeader = false): React.ReactNode => {
|
||||
const renderedCells = cells.map((cell, index) => {
|
||||
const width = adjustedWidths[index] || 0;
|
||||
return renderCell(cell, width, isHeader);
|
||||
return renderCell(cell || '', width, isHeader);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -256,46 +151,21 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Handles the wrapping logic for a logical data row
|
||||
const renderDataRow = (
|
||||
wrappedCells: ProcessedLine[][],
|
||||
rowIndex?: number,
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const key = isHeader ? 'header' : `${rowIndex}`;
|
||||
const maxHeight = Math.max(...wrappedCells.map((lines) => lines.length), 1);
|
||||
|
||||
const visualRows: React.ReactNode[] = [];
|
||||
for (let i = 0; i < maxHeight; i++) {
|
||||
const visualRowCells = wrappedCells.map(
|
||||
(lines) => lines[i] || { text: '', width: 0 },
|
||||
);
|
||||
visualRows.push(
|
||||
<React.Fragment key={`${key}-${i}`}>
|
||||
{renderVisualRow(visualRowCells, isHeader)}
|
||||
</React.Fragment>,
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment key={rowIndex}>{visualRows}</React.Fragment>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
{/* Top border */}
|
||||
{renderBorder('top')}
|
||||
|
||||
{/*
|
||||
Header row
|
||||
Keep the rowIndex as -1 to differentiate from data rows
|
||||
*/}
|
||||
{renderDataRow(wrappedHeaders, -1, true)}
|
||||
{/* Header row */}
|
||||
{renderRow(headers, true)}
|
||||
|
||||
{/* Middle border */}
|
||||
{renderBorder('middle')}
|
||||
|
||||
{/* Data rows */}
|
||||
{wrappedRows.map((row, index) => renderDataRow(row, index))}
|
||||
{rows.map((row, index) => (
|
||||
<React.Fragment key={index}>{renderRow(row)}</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Bottom border */}
|
||||
{renderBorder('bottom')}
|
||||
|
||||
@@ -1,83 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TableRenderer > 'handles non-ASCII characters (emojis …' 1`] = `
|
||||
"
|
||||
┌──────────────┬────────────┬───────────────┐
|
||||
│ Emoji 😃 │ Asian 汉字 │ Mixed 🚀 Text │
|
||||
├──────────────┼────────────┼───────────────┤
|
||||
│ Start 🌟 End │ 你好世界 │ Rocket 🚀 Man │
|
||||
│ Thumbs 👍 Up │ こんにちは │ Fire 🔥 │
|
||||
└──────────────┴────────────┴───────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders a table with mixed emojis, As…' 1`] = `
|
||||
"
|
||||
┌───────────────┬───────────────────┬────────────────┐
|
||||
│ Mixed 😃 中文 │ Complex 🚀 日本語 │ Text 📝 한국어 │
|
||||
├───────────────┼───────────────────┼────────────────┤
|
||||
│ 你好 😃 │ こんにちは 🚀 │ 안녕하세요 📝 │
|
||||
│ World 🌍 │ Code 💻 │ Pizza 🍕 │
|
||||
└───────────────┴───────────────────┴────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders a table with only Asian chara…' 1`] = `
|
||||
"
|
||||
┌──────────────┬─────────────────┬───────────────┐
|
||||
│ Chinese 中文 │ Japanese 日本語 │ Korean 한국어 │
|
||||
├──────────────┼─────────────────┼───────────────┤
|
||||
│ 你好 │ こんにちは │ 안녕하세요 │
|
||||
│ 世界 │ 世界 │ 세계 │
|
||||
└──────────────┴─────────────────┴───────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders a table with only emojis and …' 1`] = `
|
||||
"
|
||||
┌──────────┬───────────┬──────────┐
|
||||
│ Happy 😀 │ Rocket 🚀 │ Heart ❤️ │
|
||||
├──────────┼───────────┼──────────┤
|
||||
│ Smile 😃 │ Fire 🔥 │ Love 💖 │
|
||||
│ Cool 😎 │ Star ⭐ │ Blue 💙 │
|
||||
└──────────┴───────────┴──────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders correctly when headers are em…' 1`] = `
|
||||
"
|
||||
┌────────┬────────┐
|
||||
│ │ │
|
||||
├────────┼────────┤
|
||||
│ Data 1 │ Data 2 │
|
||||
└────────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders correctly when there are more…' 1`] = `
|
||||
"
|
||||
┌──────────┬──────────┬──────────┐
|
||||
│ Header 1 │ Header 2 │ Header 3 │
|
||||
├──────────┼──────────┼──────────┤
|
||||
│ Data 1 │ Data 2 │ │
|
||||
└──────────┴──────────┴──────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > handles wrapped bold headers without showing markers 1`] = `
|
||||
"
|
||||
┌─────────────┬───────┬─────────┐
|
||||
│ Very Long │ Short │ Another │
|
||||
│ Bold Header │ │ Long │
|
||||
│ That Will │ │ Header │
|
||||
│ Wrap │ │ │
|
||||
├─────────────┼───────┼─────────┤
|
||||
│ Data 1 │ Data │ Data 3 │
|
||||
│ │ 2 │ │
|
||||
└─────────────┴───────┴─────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
|
||||
"
|
||||
┌──────────────┬──────────────┬──────────────┐
|
||||
@@ -90,117 +12,14 @@ exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > renders a complex table with mixed content lengths correctly 1`] = `
|
||||
"
|
||||
┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐
|
||||
│ Comprehensive Architectural │ Implementation Details for │ Longitudinal Performance │ Strategic Security Framework │ Key │ Status │ Version │ Owner │
|
||||
│ Specification for the │ the High-Throughput │ Analysis Across │ for Mitigating Sophisticated │ │ │ │ │
|
||||
│ Distributed Infrastructure │ Asynchronous Message │ Multi-Regional Cloud │ Cross-Site Scripting │ │ │ │ │
|
||||
│ Layer │ Processing Pipeline with │ Deployment Clusters │ Vulnerabilities │ │ │ │ │
|
||||
│ │ Extended Scalability │ │ │ │ │ │ │
|
||||
│ │ Features and Redundancy │ │ │ │ │ │ │
|
||||
│ │ Protocols │ │ │ │ │ │ │
|
||||
├─────────────────────────────┼──────────────────────────────┼─────────────────────────────┼──────────────────────────────┼─────┼────────┼─────────┼───────┤
|
||||
│ The primary architecture │ Each message is processed │ Historical data indicates a │ A multi-layered defense │ INF │ Active │ v2.4 │ J. │
|
||||
│ utilizes a decoupled │ through a series of │ significant reduction in │ strategy incorporates │ │ │ │ Doe │
|
||||
│ microservices approach, │ specialized workers that │ tail latency when utilizing │ content security policies, │ │ │ │ │
|
||||
│ leveraging container │ handle data transformation, │ edge computing nodes closer │ input sanitization │ │ │ │ │
|
||||
│ orchestration for │ validation, and persistent │ to the geographic location │ libraries, and regular │ │ │ │ │
|
||||
│ scalability and fault │ storage using a persistent │ of the end-user base. │ automated penetration │ │ │ │ │
|
||||
│ tolerance in high-load │ queue. │ │ testing routines. │ │ │ │ │
|
||||
│ scenarios. │ │ Monitoring tools have │ │ │ │ │ │
|
||||
│ │ The pipeline features │ captured a steady increase │ Developers are required to │ │ │ │ │
|
||||
│ This layer provides the │ built-in retry mechanisms │ in throughput efficiency │ undergo mandatory security │ │ │ │ │
|
||||
│ fundamental building blocks │ with exponential backoff to │ since the introduction of │ training focusing on the │ │ │ │ │
|
||||
│ for service discovery, load │ ensure message delivery │ the vectorized query engine │ OWASP Top Ten to ensure that │ │ │ │ │
|
||||
│ balancing, and │ integrity even during │ in the primary data │ security is integrated into │ │ │ │ │
|
||||
│ inter-service communication │ transient network or service │ warehouse. │ the initial design phase. │ │ │ │ │
|
||||
│ via highly efficient │ failures. │ │ │ │ │ │ │
|
||||
│ protocol buffers. │ │ Resource utilization │ The implementation of a │ │ │ │ │
|
||||
│ │ Horizontal autoscaling is │ metrics demonstrate that │ robust Identity and Access │ │ │ │ │
|
||||
│ Advanced telemetry and │ triggered automatically │ the transition to │ Management system ensures │ │ │ │ │
|
||||
│ logging integrations allow │ based on the depth of the │ serverless compute for │ that the principle of least │ │ │ │ │
|
||||
│ for real-time monitoring of │ processing queue, ensuring │ intermittent tasks has │ privilege is strictly │ │ │ │ │
|
||||
│ system health and rapid │ consistent performance │ resulted in a thirty │ enforced across all │ │ │ │ │
|
||||
│ identification of │ during unexpected traffic │ percent cost optimization. │ environments. │ │ │ │ │
|
||||
│ bottlenecks within the │ spikes. │ │ │ │ │ │ │
|
||||
│ service mesh. │ │ │ │ │ │ │ │
|
||||
└─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = `
|
||||
"
|
||||
┌───────────────┬───────────────┬──────────────────┬──────────────────┐
|
||||
│ Very Long │ Very Long │ Very Long Column │ Very Long Column │
|
||||
│ Column Header │ Column Header │ Header Three │ Header Four │
|
||||
│ One │ Two │ │ │
|
||||
├───────────────┼───────────────┼──────────────────┼──────────────────┤
|
||||
│ Data 1.1 │ Data 1.2 │ Data 1.3 │ Data 1.4 │
|
||||
│ Data 2.1 │ Data 2.2 │ Data 2.3 │ Data 2.4 │
|
||||
│ Data 3.1 │ Data 3.2 │ Data 3.3 │ Data 3.4 │
|
||||
└───────────────┴───────────────┴──────────────────┴──────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > strips bold markers from headers and renders them correctly 1`] = `
|
||||
"
|
||||
┌─────────────┬───────────────┬──────────────┐
|
||||
│ Bold Header │ Normal Header │ Another Bold │
|
||||
├─────────────┼───────────────┼──────────────┤
|
||||
│ Data 1 │ Data 2 │ Data 3 │
|
||||
└─────────────┴───────────────┴──────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > wraps all long columns correctly 1`] = `
|
||||
"
|
||||
┌────────────────┬────────────────┬─────────────────┐
|
||||
│ Col 1 │ Col 2 │ Col 3 │
|
||||
├────────────────┼────────────────┼─────────────────┤
|
||||
│ This is a very │ This is also a │ And this is the │
|
||||
│ long text that │ very long text │ third long text │
|
||||
│ needs wrapping │ that needs │ that needs │
|
||||
│ in column 1 │ wrapping in │ wrapping in │
|
||||
│ │ column 2 │ column 3 │
|
||||
└────────────────┴────────────────┴─────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > wraps columns with punctuation correctly 1`] = `
|
||||
"
|
||||
┌───────────────────┬───────────────┬─────────────────┐
|
||||
│ Punctuation 1 │ Punctuation 2 │ Punctuation 3 │
|
||||
├───────────────────┼───────────────┼─────────────────┤
|
||||
│ Start. Stop. │ Semi; colon: │ At@ Hash# │
|
||||
│ Comma, separated. │ Pipe| Slash/ │ Dollar$ │
|
||||
│ Exclamation! │ Backslash\\ │ Percent% Caret^ │
|
||||
│ Question? │ │ Ampersand& │
|
||||
│ hyphen-ated │ │ Asterisk* │
|
||||
└───────────────────┴───────────────┴─────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > wraps long cell content correctly 1`] = `
|
||||
"
|
||||
┌───────┬─────────────────────────────┬───────┐
|
||||
│ Col 1 │ Col 2 │ Col 3 │
|
||||
├───────┼─────────────────────────────┼───────┤
|
||||
│ Short │ This is a very long cell │ Short │
|
||||
│ │ content that should wrap to │ │
|
||||
│ │ multiple lines │ │
|
||||
└───────┴─────────────────────────────┴───────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > wraps mixed long and short columns correctly 1`] = `
|
||||
"
|
||||
┌───────┬──────────────────────────┬────────┐
|
||||
│ Short │ Long │ Medium │
|
||||
├───────┼──────────────────────────┼────────┤
|
||||
│ Tiny │ This is a very long text │ Not so │
|
||||
│ │ that definitely needs to │ long │
|
||||
│ │ wrap to the next line │ │
|
||||
└───────┴──────────────────────────┴────────┘
|
||||
┌──────────────────┬──────────────────┬───────────────────┬──────────────────┐
|
||||
│ Very Long Colum… │ Very Long Colum… │ Very Long Column… │ Very Long Colum… │
|
||||
├──────────────────┼──────────────────┼───────────────────┼──────────────────┤
|
||||
│ Data 1.1 │ Data 1.2 │ Data 1.3 │ Data 1.4 │
|
||||
│ Data 2.1 │ Data 2.2 │ Data 2.3 │ Data 2.4 │
|
||||
│ Data 3.1 │ Data 3.2 │ Data 3.3 │ Data 3.4 │
|
||||
└──────────────────┴──────────────────┴───────────────────┴──────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -70,20 +70,12 @@ vi.mock('./activityLogger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true));
|
||||
const mockOpenBrowserSecurely = vi.hoisted(() =>
|
||||
vi.fn(() => Promise.resolve()),
|
||||
);
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
shouldLaunchBrowser: mockShouldLaunchBrowser,
|
||||
openBrowserSecurely: mockOpenBrowserSecurely,
|
||||
}));
|
||||
|
||||
vi.mock('ws', () => ({
|
||||
@@ -100,7 +92,6 @@ vi.mock('gemini-cli-devtools', () => ({
|
||||
import {
|
||||
setupInitialActivityLogger,
|
||||
startDevToolsServer,
|
||||
toggleDevToolsPanel,
|
||||
resetForTesting,
|
||||
} from './devtoolsService.js';
|
||||
|
||||
@@ -435,98 +426,4 @@ describe('devtoolsService', () => {
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleDevToolsPanel', () => {
|
||||
it('calls toggle (to close) when already open', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
const promise = toggleDevToolsPanel(config, true, toggle, setOpen);
|
||||
await promise;
|
||||
|
||||
expect(toggle).toHaveBeenCalledTimes(1);
|
||||
expect(setOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT call toggle or setOpen when browser opens successfully', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockShouldLaunchBrowser.mockReturnValue(true);
|
||||
mockOpenBrowserSecurely.mockResolvedValue(undefined);
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setOpen when browser fails to open', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockShouldLaunchBrowser.mockReturnValue(true);
|
||||
mockOpenBrowserSecurely.mockRejectedValue(new Error('no browser'));
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls setOpen when shouldLaunchBrowser returns false', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockShouldLaunchBrowser.mockReturnValue(false);
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls setOpen when DevTools server fails to start', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockDevToolsInstance.start.mockRejectedValue(new Error('fail'));
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,48 +210,6 @@ async function startDevToolsServerImpl(config: Config): Promise<string> {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the F12 key toggle for the DevTools panel.
|
||||
* Starts the DevTools server, attempts to open the browser.
|
||||
* If the panel is already open, it closes it.
|
||||
* If the panel is closed:
|
||||
* - Attempts to open the browser.
|
||||
* - If browser opening is successful, the panel remains closed.
|
||||
* - If browser opening fails or is not possible, the panel is opened.
|
||||
*/
|
||||
export async function toggleDevToolsPanel(
|
||||
config: Config,
|
||||
isOpen: boolean,
|
||||
toggle: () => void,
|
||||
setOpen: () => void,
|
||||
): Promise<void> {
|
||||
if (isOpen) {
|
||||
toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const url = await startDevToolsServer(config);
|
||||
if (shouldLaunchBrowser()) {
|
||||
try {
|
||||
await openBrowserSecurely(url);
|
||||
// Browser opened successfully, don't open drawer.
|
||||
return;
|
||||
} catch (e) {
|
||||
debugLogger.warn('Failed to open browser securely:', e);
|
||||
}
|
||||
}
|
||||
// If we can't launch browser or it failed, open drawer.
|
||||
setOpen();
|
||||
} catch (e) {
|
||||
setOpen();
|
||||
debugLogger.error('Failed to start DevTools server:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset module-level state — test only. */
|
||||
export function resetForTesting() {
|
||||
promotionAttempts = 0;
|
||||
|
||||
@@ -85,17 +85,6 @@ describe('SettingsUtils', () => {
|
||||
default: {},
|
||||
description: 'Advanced settings for power users.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
autoConfigureMemory: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Configure Max Old Space Size',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Automatically configure Node.js memory limits',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
type: 'object',
|
||||
@@ -406,15 +395,11 @@ describe('SettingsUtils', () => {
|
||||
expect(uiKeys).not.toContain('ui.theme'); // This is now marked false
|
||||
});
|
||||
|
||||
it('should include Advanced category settings', () => {
|
||||
it('should not include Advanced category settings', () => {
|
||||
const categories = getDialogSettingsByCategory();
|
||||
|
||||
// Advanced settings should now be included because of autoConfigureMemory
|
||||
expect(categories['Advanced']).toBeDefined();
|
||||
const advancedSettings = categories['Advanced'];
|
||||
expect(advancedSettings.map((s) => s.key)).toContain(
|
||||
'advanced.autoConfigureMemory',
|
||||
);
|
||||
// Advanced settings should be filtered out
|
||||
expect(categories['Advanced']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include settings with showInDialog=true', () => {
|
||||
|
||||
+15
-22
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -30,23 +30,16 @@
|
||||
"@joshua.litt/get-ripgrep": "^0.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.211.0",
|
||||
"@opentelemetry/core": "^2.5.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.211.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.211.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.211.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.211.0",
|
||||
"@opentelemetry/otlp-exporter-base": "^0.211.0",
|
||||
"@opentelemetry/resources": "^2.5.0",
|
||||
"@opentelemetry/sdk-logs": "^0.211.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.5.0",
|
||||
"@opentelemetry/sdk-node": "^0.211.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.5.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.39.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.203.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.203.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.203.0",
|
||||
"@opentelemetry/resource-detector-gcp": "^0.40.0",
|
||||
"@opentelemetry/sdk-node": "^0.203.0",
|
||||
"@types/glob": "^8.1.0",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@xterm/headless": "5.5.0",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -77,8 +70,7 @@
|
||||
"undici": "^7.10.0",
|
||||
"uuid": "^13.0.0",
|
||||
"web-tree-sitter": "^0.25.10",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
@@ -87,13 +79,14 @@
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
"node-pty": "^1.0.0",
|
||||
"keytar": "^7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/fast-levenshtein": "^0.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Config } from '../config/config.js';
|
||||
import { AgentHarness, type AgentHarnessOptions } from './harness.js';
|
||||
import { type AgentDefinition, type LocalAgentDefinition } from './types.js';
|
||||
import { MainAgentBehavior, SubagentBehavior } from './behavior.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
/**
|
||||
* Factory for creating agent executors/harnesses.
|
||||
* Respects experimental flags to determine which implementation to use.
|
||||
*/
|
||||
export class AgentFactory {
|
||||
static createHarness(
|
||||
config: Config,
|
||||
definition?: AgentDefinition,
|
||||
options: Partial<AgentHarnessOptions> = {},
|
||||
): AgentHarness {
|
||||
let behavior;
|
||||
if (definition && definition.kind === 'local') {
|
||||
const localDef: LocalAgentDefinition = definition;
|
||||
behavior = new SubagentBehavior(
|
||||
config,
|
||||
localDef,
|
||||
options.inputs,
|
||||
options.parentPromptId,
|
||||
);
|
||||
} else {
|
||||
behavior = new MainAgentBehavior(config, options.parentPromptId);
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`[AgentFactory] Creating harness for agent: ${behavior.name} (agentId: ${behavior.agentId})`,
|
||||
);
|
||||
|
||||
return new AgentHarness({
|
||||
config,
|
||||
behavior,
|
||||
isolatedTools: !!definition,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
} from '../scheduler/types.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import type { EditorType } from '../utils/editor.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
|
||||
/**
|
||||
* Options for scheduling agent tools.
|
||||
@@ -30,13 +29,6 @@ export interface AgentSchedulingOptions {
|
||||
getPreferredEditor?: () => EditorType | undefined;
|
||||
/** Optional function to be notified when the scheduler is waiting for user confirmation. */
|
||||
onWaitingForConfirmation?: (waiting: boolean) => void;
|
||||
/**
|
||||
* Optional message bus override.
|
||||
* If provided, the scheduler will broadcast to this bus.
|
||||
* If explicitly null, broadcasting is disabled.
|
||||
* If omitted, the global config message bus is used.
|
||||
*/
|
||||
messageBus?: MessageBus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +51,6 @@ export async function scheduleAgentTools(
|
||||
signal,
|
||||
getPreferredEditor,
|
||||
onWaitingForConfirmation,
|
||||
messageBus,
|
||||
} = options;
|
||||
|
||||
// Create a proxy/override of the config to provide the agent-specific tool registry.
|
||||
@@ -68,10 +59,7 @@ export async function scheduleAgentTools(
|
||||
|
||||
const scheduler = new Scheduler({
|
||||
config: agentConfig,
|
||||
messageBus:
|
||||
messageBus === undefined
|
||||
? config.getMessageBus()
|
||||
: (messageBus ?? undefined),
|
||||
messageBus: config.getMessageBus(),
|
||||
getPreferredEditor: getPreferredEditor ?? (() => undefined),
|
||||
schedulerId,
|
||||
parentCallId,
|
||||
|
||||
@@ -363,171 +363,4 @@ Hidden`,
|
||||
expect(result.errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote agent auth configuration', () => {
|
||||
it('should parse remote agent with apiKey auth', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: api-key-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
in: header
|
||||
name: X-Custom-Key
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'api-key-agent',
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
key: '$MY_API_KEY',
|
||||
in: 'header',
|
||||
name: 'X-Custom-Key',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse remote agent with http Bearer auth', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: bearer-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Bearer
|
||||
token: $BEARER_TOKEN
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'bearer-agent',
|
||||
auth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: '$BEARER_TOKEN',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse remote agent with http Basic auth', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: basic-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Basic
|
||||
username: $AUTH_USER
|
||||
password: $AUTH_PASS
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'basic-agent',
|
||||
auth: {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: '$AUTH_USER',
|
||||
password: '$AUTH_PASS',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for Bearer auth without token', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-bearer
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Bearer
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/Bearer scheme requires "token"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for Basic auth without credentials', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-basic
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Basic
|
||||
username: user
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/Basic scheme requires "username" and "password"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for apiKey auth without key', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-apikey
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: apiKey
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/auth\.key.*Required/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert auth config in markdownToAgentDefinition', () => {
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
name: 'auth-agent',
|
||||
agent_card_url: 'https://example.com/card',
|
||||
auth: {
|
||||
type: 'apiKey' as const,
|
||||
key: '$API_KEY',
|
||||
in: 'header' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const result = markdownToAgentDefinition(markdown);
|
||||
expect(result).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'auth-agent',
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
key: '$API_KEY',
|
||||
location: 'header',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse auth with agent_card_requires_auth flag', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: protected-card-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
agent_card_requires_auth: true
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result[0]).toMatchObject({
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
agent_card_requires_auth: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -40,29 +39,11 @@ interface FrontmatterLocalAgentDefinition
|
||||
timeout_mins?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication configuration for remote agents in frontmatter format.
|
||||
*/
|
||||
interface FrontmatterAuthConfig {
|
||||
type: 'apiKey' | 'http';
|
||||
agent_card_requires_auth?: boolean;
|
||||
// API Key
|
||||
key?: string;
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
name?: string;
|
||||
// HTTP
|
||||
scheme?: 'Bearer' | 'Basic';
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterRemoteAgentDefinition
|
||||
extends FrontmatterBaseAgentDefinition {
|
||||
kind: 'remote';
|
||||
description?: string;
|
||||
agent_card_url: string;
|
||||
auth?: FrontmatterAuthConfig;
|
||||
}
|
||||
|
||||
type FrontmatterAgentDefinition =
|
||||
@@ -114,66 +95,6 @@ const localAgentSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Base fields shared by all auth configs.
|
||||
*/
|
||||
const baseAuthFields = {
|
||||
agent_card_requires_auth: z.boolean().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* API Key auth schema.
|
||||
* Supports sending key in header, query parameter, or cookie.
|
||||
*/
|
||||
const apiKeyAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('apiKey'),
|
||||
key: z.string().min(1, 'API key is required'),
|
||||
in: z.enum(['header', 'query', 'cookie']).optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* HTTP auth schema (Bearer or Basic).
|
||||
* Note: Validation for scheme-specific fields is applied in authConfigSchema
|
||||
* since discriminatedUnion doesn't support refined schemas directly.
|
||||
*/
|
||||
const httpAuthSchemaBase = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('http'),
|
||||
scheme: z.enum(['Bearer', 'Basic']),
|
||||
token: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Combined auth schema - discriminated union of all auth types.
|
||||
* Note: We use the base schema for discriminatedUnion, then apply refinements
|
||||
* via superRefine since discriminatedUnion doesn't support refined schemas directly.
|
||||
*/
|
||||
const authConfigSchema = z
|
||||
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase])
|
||||
.superRefine((data, ctx) => {
|
||||
// Apply HTTP auth validation after union parsing
|
||||
if (data.type === 'http') {
|
||||
if (data.scheme === 'Bearer' && !data.token) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Bearer scheme requires "token"',
|
||||
path: ['token'],
|
||||
});
|
||||
}
|
||||
if (data.scheme === 'Basic' && (!data.username || !data.password)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Basic scheme requires "username" and "password"',
|
||||
path: data.username ? ['password'] : ['username'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const remoteAgentSchema = z
|
||||
.object({
|
||||
kind: z.literal('remote').optional().default('remote'),
|
||||
@@ -181,7 +102,6 @@ const remoteAgentSchema = z
|
||||
description: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
agent_card_url: z.string().url(),
|
||||
auth: authConfigSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -318,76 +238,6 @@ export async function parseAgentMarkdown(
|
||||
return [agentDef];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts frontmatter auth config to the internal A2AAuthConfig type.
|
||||
* This handles the mapping from snake_case YAML to the internal type structure.
|
||||
*/
|
||||
function convertFrontmatterAuthToConfig(
|
||||
frontmatter: FrontmatterAuthConfig,
|
||||
): A2AAuthConfig {
|
||||
const base = {
|
||||
agent_card_requires_auth: frontmatter.agent_card_requires_auth,
|
||||
};
|
||||
|
||||
switch (frontmatter.type) {
|
||||
case 'apiKey':
|
||||
if (!frontmatter.key) {
|
||||
throw new Error('Internal error: API key missing after validation.');
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'apiKey',
|
||||
key: frontmatter.key,
|
||||
location: frontmatter.in,
|
||||
name: frontmatter.name,
|
||||
};
|
||||
|
||||
case 'http': {
|
||||
if (!frontmatter.scheme) {
|
||||
throw new Error(
|
||||
'Internal error: HTTP scheme missing after validation.',
|
||||
);
|
||||
}
|
||||
switch (frontmatter.scheme) {
|
||||
case 'Bearer':
|
||||
if (!frontmatter.token) {
|
||||
throw new Error(
|
||||
'Internal error: Bearer token missing after validation.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: frontmatter.token,
|
||||
};
|
||||
case 'Basic':
|
||||
if (!frontmatter.username || !frontmatter.password) {
|
||||
throw new Error(
|
||||
'Internal error: Basic auth credentials missing after validation.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: frontmatter.username,
|
||||
password: frontmatter.password,
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = frontmatter.scheme;
|
||||
throw new Error(`Unknown HTTP scheme: ${exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustive: never = frontmatter.type;
|
||||
throw new Error(`Unknown auth type: ${exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
|
||||
*
|
||||
@@ -420,9 +270,6 @@ export function markdownToAgentDefinition(
|
||||
description: markdown.description || '(Loading description...)',
|
||||
displayName: markdown.display_name,
|
||||
agentCardUrl: markdown.agent_card_url,
|
||||
auth: markdown.auth
|
||||
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||
: undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -9,33 +9,17 @@ import type { A2AAuthProvider, A2AAuthProviderType } from './types.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for A2A authentication providers.
|
||||
* Provides default implementations for optional methods.
|
||||
*/
|
||||
export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
/**
|
||||
* The type of authentication provider.
|
||||
*/
|
||||
abstract readonly type: A2AAuthProviderType;
|
||||
|
||||
/**
|
||||
* Get the HTTP headers to include in requests.
|
||||
* Subclasses must implement this method.
|
||||
*/
|
||||
abstract headers(): Promise<HttpHeaders>;
|
||||
|
||||
private static readonly MAX_AUTH_RETRIES = 2;
|
||||
private authRetryCount = 0;
|
||||
|
||||
/**
|
||||
* Check if a request should be retried with new headers.
|
||||
*
|
||||
* The default implementation checks for 401/403 status codes and
|
||||
* returns fresh headers for retry. Subclasses can override for
|
||||
* custom retry logic.
|
||||
*
|
||||
* @param _req The original request init
|
||||
* @param res The response from the server
|
||||
* @returns New headers for retry, or undefined if no retry should be made
|
||||
* Default: retry on 401/403 with fresh headers.
|
||||
* Subclasses with cached tokens must override to force-refresh to avoid infinite retries.
|
||||
*/
|
||||
async shouldRetryWithHeaders(
|
||||
_req: RequestInit,
|
||||
@@ -48,15 +32,10 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
this.authRetryCount++;
|
||||
return this.headers();
|
||||
}
|
||||
// Reset count if not an auth error
|
||||
// Reset on success
|
||||
this.authRetryCount = 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the provider. Override in subclasses that need async setup.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Default: no-op
|
||||
}
|
||||
async initialize(): Promise<void> {}
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
resolveAuthValue,
|
||||
needsResolution,
|
||||
maskSensitiveValue,
|
||||
} from './value-resolver.js';
|
||||
|
||||
describe('value-resolver', () => {
|
||||
describe('resolveAuthValue', () => {
|
||||
describe('environment variables', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should resolve environment variable with $ prefix', async () => {
|
||||
vi.stubEnv('TEST_API_KEY', 'secret-key-123');
|
||||
const result = await resolveAuthValue('$TEST_API_KEY');
|
||||
expect(result).toBe('secret-key-123');
|
||||
});
|
||||
|
||||
it('should throw error for unset environment variable', async () => {
|
||||
await expect(resolveAuthValue('$UNSET_VAR_12345')).rejects.toThrow(
|
||||
"Environment variable 'UNSET_VAR_12345' is not set or is empty",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty environment variable', async () => {
|
||||
vi.stubEnv('EMPTY_VAR', '');
|
||||
await expect(resolveAuthValue('$EMPTY_VAR')).rejects.toThrow(
|
||||
"Environment variable 'EMPTY_VAR' is not set or is empty",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell commands', () => {
|
||||
it('should execute shell command with ! prefix', async () => {
|
||||
const result = await resolveAuthValue('!echo hello');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('should trim whitespace from command output', async () => {
|
||||
const result = await resolveAuthValue('!echo " hello "');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('should throw error for empty command', async () => {
|
||||
await expect(resolveAuthValue('!')).rejects.toThrow(
|
||||
'Empty command in auth value',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for command that returns empty output', async () => {
|
||||
await expect(resolveAuthValue('!echo -n ""')).rejects.toThrow(
|
||||
'returned empty output',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for failed command', async () => {
|
||||
await expect(
|
||||
resolveAuthValue('!nonexistent-command-12345'),
|
||||
).rejects.toThrow(/Command.*failed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('literal values', () => {
|
||||
it('should return literal value as-is', async () => {
|
||||
const result = await resolveAuthValue('literal-api-key');
|
||||
expect(result).toBe('literal-api-key');
|
||||
});
|
||||
|
||||
it('should return empty string as-is', async () => {
|
||||
const result = await resolveAuthValue('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should not treat values starting with other characters as special', async () => {
|
||||
const result = await resolveAuthValue('api-key-123');
|
||||
expect(result).toBe('api-key-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaped literals', () => {
|
||||
it('should return $ literal when value starts with $$', async () => {
|
||||
const result = await resolveAuthValue('$$LITERAL');
|
||||
expect(result).toBe('$LITERAL');
|
||||
});
|
||||
|
||||
it('should return ! literal when value starts with !!', async () => {
|
||||
const result = await resolveAuthValue('!!not-a-command');
|
||||
expect(result).toBe('!not-a-command');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsResolution', () => {
|
||||
it('should return true for environment variable reference', () => {
|
||||
expect(needsResolution('$ENV_VAR')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for command reference', () => {
|
||||
expect(needsResolution('!command')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for literal value', () => {
|
||||
expect(needsResolution('literal')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(needsResolution('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskSensitiveValue', () => {
|
||||
it('should mask value longer than 12 characters', () => {
|
||||
expect(maskSensitiveValue('1234567890abcd')).toBe('12****cd');
|
||||
});
|
||||
|
||||
it('should return **** for short values', () => {
|
||||
expect(maskSensitiveValue('short')).toBe('****');
|
||||
});
|
||||
|
||||
it('should return **** for exactly 12 characters', () => {
|
||||
expect(maskSensitiveValue('123456789012')).toBe('****');
|
||||
});
|
||||
|
||||
it('should return **** for empty string', () => {
|
||||
expect(maskSensitiveValue('')).toBe('****');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { getShellConfiguration, spawnAsync } from '../../utils/shell-utils.js';
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Resolves a value that may be an environment variable reference,
|
||||
* a shell command, or a literal value.
|
||||
*
|
||||
* Supported formats:
|
||||
* - `$ENV_VAR`: Read from environment variable
|
||||
* - `!command`: Execute shell command and use output (trimmed)
|
||||
* - `$$` or `!!`: Escape prefix, returns rest as literal
|
||||
* - Any other string: Use as literal value
|
||||
*
|
||||
* @param value The value to resolve
|
||||
* @returns The resolved value
|
||||
* @throws Error if environment variable is not set or command fails
|
||||
*/
|
||||
export async function resolveAuthValue(value: string): Promise<string> {
|
||||
// Support escaping with double prefix (e.g. $$ or !!).
|
||||
// Strips one prefix char: $$FOO → $FOO, !!cmd → !cmd (literal, not resolved).
|
||||
if (value.startsWith('$$') || value.startsWith('!!')) {
|
||||
return value.slice(1);
|
||||
}
|
||||
|
||||
// Environment variable: $MY_VAR
|
||||
if (value.startsWith('$')) {
|
||||
const envVar = value.slice(1);
|
||||
const resolved = process.env[envVar];
|
||||
if (resolved === undefined || resolved === '') {
|
||||
throw new Error(
|
||||
`Environment variable '${envVar}' is not set or is empty. ` +
|
||||
`Please set it before using this agent.`,
|
||||
);
|
||||
}
|
||||
debugLogger.debug(
|
||||
`[AgentHarness] [AuthValueResolver] Resolved env var: ${envVar}`,
|
||||
);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Shell command: !command arg1 arg2
|
||||
if (value.startsWith('!')) {
|
||||
const command = value.slice(1).trim();
|
||||
if (!command) {
|
||||
throw new Error('Empty command in auth value. Expected format: !command');
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`[AgentHarness] [AuthValueResolver] Executing command for auth value`,
|
||||
);
|
||||
|
||||
const shellConfig = getShellConfiguration();
|
||||
try {
|
||||
const { stdout } = await spawnAsync(
|
||||
shellConfig.executable,
|
||||
[...shellConfig.argsPrefix, command],
|
||||
{
|
||||
signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS),
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Command '${command}' returned empty output`);
|
||||
}
|
||||
return trimmed;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(
|
||||
`Command '${command}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Literal value - return as-is
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value needs resolution (is an env var or command reference).
|
||||
*/
|
||||
export function needsResolution(value: string): boolean {
|
||||
return value.startsWith('$') || value.startsWith('!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a sensitive value for logging purposes.
|
||||
* Shows the first and last 2 characters with asterisks in between.
|
||||
*/
|
||||
export function maskSensitiveValue(value: string): string {
|
||||
if (value.length <= 12) {
|
||||
return '****';
|
||||
}
|
||||
return `${value.slice(0, 2)}****${value.slice(-2)}`;
|
||||
}
|
||||
@@ -1,611 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Content,
|
||||
type Part,
|
||||
type FunctionDeclaration,
|
||||
Type,
|
||||
} from '@google/genai';
|
||||
import { type Config } from '../config/config.js';
|
||||
import {
|
||||
type Turn,
|
||||
type ServerGeminiStreamEvent,
|
||||
GeminiEventType,
|
||||
} from '../core/turn.js';
|
||||
import {
|
||||
AgentTerminateMode,
|
||||
type LocalAgentDefinition,
|
||||
type AgentInputs,
|
||||
} from './types.js';
|
||||
import { getCoreSystemPrompt } from '../core/prompts.js';
|
||||
import {
|
||||
getInitialChatHistory,
|
||||
getDirectoryContextString,
|
||||
} from '../utils/environmentContext.js';
|
||||
import { templateString } from './utils.js';
|
||||
import { getVersion } from '../utils/version.js';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import type { Schema } from '@google/genai';
|
||||
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
||||
import { partToString } from '../utils/partUtils.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import { type IdeContext } from '../ide/types.js';
|
||||
import { promptIdContext } from '../utils/promptIdContext.js';
|
||||
import { logRecoveryAttempt } from '../telemetry/loggers.js';
|
||||
import { RecoveryAttemptEvent } from '../telemetry/types.js';
|
||||
import { DeadlineTimer } from '../utils/deadlineTimer.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import type { ToolCallResponseInfo } from '../scheduler/types.js';
|
||||
|
||||
const TASK_COMPLETE_TOOL_NAME = 'complete_task';
|
||||
const GRACE_PERIOD_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* Defines the extension points for the unified ReAct loop in AgentHarness.
|
||||
*/
|
||||
export interface AgentBehavior {
|
||||
/** The unique ID for this agent instance. */
|
||||
readonly agentId: string;
|
||||
|
||||
/** The human-readable name of the agent. */
|
||||
readonly name: string;
|
||||
|
||||
/** The definition of the agent, if applicable. */
|
||||
readonly definition?: LocalAgentDefinition;
|
||||
|
||||
/** Initializes any state needed for the agent. */
|
||||
initialize(toolRegistry: ToolRegistry): Promise<void>;
|
||||
|
||||
/** Returns the system instruction for the chat. */
|
||||
|
||||
getSystemInstruction(): Promise<string | undefined>;
|
||||
|
||||
/** Returns the initial chat history. */
|
||||
getInitialHistory(): Promise<Content[]>;
|
||||
|
||||
/**
|
||||
* Prepares the tools list for the current turn.
|
||||
* @param baseTools The tools from the tool registry.
|
||||
*/
|
||||
prepareTools(baseTools: FunctionDeclaration[]): FunctionDeclaration[];
|
||||
|
||||
/**
|
||||
* Performs any environment synchronization (e.g., IDE context) before a turn.
|
||||
*/
|
||||
syncEnvironment(history: Content[]): Promise<{ additionalParts?: Part[] }>;
|
||||
|
||||
/**
|
||||
* Fires the "Before Agent" hooks if applicable.
|
||||
*/
|
||||
fireBeforeAgent(request: Part[]): Promise<{
|
||||
stop?: boolean;
|
||||
reason?: string;
|
||||
systemMessage?: string;
|
||||
additionalContext?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Fires the "After Agent" hooks if applicable.
|
||||
*/
|
||||
fireAfterAgent(
|
||||
request: Part[],
|
||||
response: string,
|
||||
turn: Turn,
|
||||
): Promise<{
|
||||
stop?: boolean;
|
||||
reason?: string;
|
||||
systemMessage?: string;
|
||||
contextCleared?: boolean;
|
||||
shouldContinue?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Transforms the initial request if needed (e.g. subagent 'Start' templating).
|
||||
*/
|
||||
transformRequest(request: Part[]): Promise<Part[]>;
|
||||
|
||||
/**
|
||||
* Determines if the current tool results signify that the agent's goal is met.
|
||||
* (e.g., Subagents checking for 'complete_task')
|
||||
*/
|
||||
isGoalReached(
|
||||
toolResults: Array<{
|
||||
name: string;
|
||||
part: Part;
|
||||
result: ToolCallResponseInfo;
|
||||
}>,
|
||||
): boolean;
|
||||
|
||||
/**
|
||||
* Checks if the agent should continue executing after a model turn with no tool calls.
|
||||
* (e.g., Main agent running next_speaker check)
|
||||
*/
|
||||
getContinuationRequest(
|
||||
turn: Turn,
|
||||
signal: AbortSignal,
|
||||
): Promise<Part[] | null>;
|
||||
|
||||
/**
|
||||
* Attempts to recover from a termination state (e.g., Subagent "Final Warning").
|
||||
* Returns a stream of events if recovery is attempted.
|
||||
*/
|
||||
executeRecovery(
|
||||
turn: Turn,
|
||||
reason: AgentTerminateMode,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ServerGeminiStreamEvent, boolean>;
|
||||
|
||||
/**
|
||||
* Returns a final failure message for a given termination reason.
|
||||
*/
|
||||
getFinalFailureMessage(
|
||||
reason: AgentTerminateMode,
|
||||
maxTurns: number,
|
||||
maxTime: number,
|
||||
): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior for the main CLI agent.
|
||||
*/
|
||||
export class MainAgentBehavior implements AgentBehavior {
|
||||
readonly agentId: string;
|
||||
readonly name = 'main';
|
||||
private lastSentIdeContext: IdeContext | undefined;
|
||||
private forceFullIdeContext = true;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
parentPromptId?: string,
|
||||
) {
|
||||
const randomIdPart = Math.random().toString(36).slice(2, 8);
|
||||
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
|
||||
this.agentId = `${parentPrefix}main-${randomIdPart}`;
|
||||
}
|
||||
|
||||
async initialize(_toolRegistry: ToolRegistry) {
|
||||
debugLogger.debug(
|
||||
`[AgentHarness] [${this.name}:${this.agentId}] Initialized`,
|
||||
);
|
||||
}
|
||||
|
||||
async getSystemInstruction() {
|
||||
const systemMemory = this.config.getUserMemory();
|
||||
return getCoreSystemPrompt(this.config, systemMemory);
|
||||
}
|
||||
|
||||
async getInitialHistory() {
|
||||
return getInitialChatHistory(this.config);
|
||||
}
|
||||
|
||||
prepareTools(baseTools: FunctionDeclaration[]) {
|
||||
return baseTools;
|
||||
}
|
||||
|
||||
async syncEnvironment(history: Content[]) {
|
||||
if (!this.config.getIdeMode()) return {};
|
||||
|
||||
const lastMessage =
|
||||
history.length > 0 ? history[history.length - 1] : undefined;
|
||||
const hasPendingToolCall =
|
||||
!!lastMessage &&
|
||||
lastMessage.role === 'model' &&
|
||||
(lastMessage.parts?.some((p) => 'functionCall' in p) || false);
|
||||
|
||||
if (hasPendingToolCall) return {};
|
||||
|
||||
const currentIdeContext = ideContextStore.get();
|
||||
if (!currentIdeContext) return {};
|
||||
|
||||
let contextParts: string[] = [];
|
||||
if (
|
||||
this.forceFullIdeContext ||
|
||||
this.lastSentIdeContext === undefined ||
|
||||
history.length === 0
|
||||
) {
|
||||
contextParts = this.getFullIdeContextParts(currentIdeContext);
|
||||
} else {
|
||||
contextParts = this.getDeltaIdeContextParts(
|
||||
currentIdeContext,
|
||||
this.lastSentIdeContext,
|
||||
);
|
||||
}
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
this.lastSentIdeContext = currentIdeContext;
|
||||
this.forceFullIdeContext = false;
|
||||
return { additionalParts: [{ text: contextParts.join('\n') }] };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private getFullIdeContextParts(context: IdeContext): string[] {
|
||||
const openFiles = context.workspaceState?.openFiles || [];
|
||||
const activeFile = openFiles.find((f) => f.isActive);
|
||||
const otherOpenFiles = openFiles
|
||||
.filter((f) => !f.isActive)
|
||||
.map((f) => f.path);
|
||||
|
||||
const contextData: Record<string, unknown> = {};
|
||||
if (activeFile) {
|
||||
contextData['activeFile'] = {
|
||||
path: activeFile.path,
|
||||
cursor: activeFile.cursor,
|
||||
selectedText: activeFile.selectedText || undefined,
|
||||
};
|
||||
}
|
||||
if (otherOpenFiles.length > 0)
|
||||
contextData['otherOpenFiles'] = otherOpenFiles;
|
||||
|
||||
if (Object.keys(contextData).length === 0) return [];
|
||||
|
||||
return [
|
||||
"Here is the user's editor context as a JSON object. This is for your information only.",
|
||||
'```json',
|
||||
JSON.stringify(contextData, null, 2),
|
||||
'```',
|
||||
];
|
||||
}
|
||||
|
||||
private getDeltaIdeContextParts(
|
||||
_current: IdeContext,
|
||||
_last: IdeContext,
|
||||
): string[] {
|
||||
// Simplified delta logic for now, similar to GeminiClient
|
||||
const changes: Record<string, unknown> = {};
|
||||
// ... delta logic ...
|
||||
if (Object.keys(changes).length === 0) return [];
|
||||
|
||||
return [
|
||||
"Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.",
|
||||
'```json',
|
||||
JSON.stringify({ changes }, null, 2),
|
||||
'```',
|
||||
];
|
||||
}
|
||||
|
||||
async fireBeforeAgent(request: Part[]) {
|
||||
if (!this.config.getEnableHooks()) return {};
|
||||
const hookOutput = await this.config
|
||||
.getHookSystem()
|
||||
?.fireBeforeAgentEvent(partToString(request));
|
||||
if (!hookOutput) return {};
|
||||
|
||||
return {
|
||||
stop: hookOutput.shouldStopExecution() || hookOutput.isBlockingDecision(),
|
||||
reason: hookOutput.getEffectiveReason(),
|
||||
systemMessage: hookOutput.systemMessage,
|
||||
additionalContext: hookOutput.getAdditionalContext(),
|
||||
};
|
||||
}
|
||||
|
||||
async fireAfterAgent(request: Part[], response: string, turn: Turn) {
|
||||
if (!this.config.getEnableHooks()) return {};
|
||||
if (turn.pendingToolCalls.length > 0) return {};
|
||||
|
||||
const hookOutput = await this.config
|
||||
.getHookSystem()
|
||||
?.fireAfterAgentEvent(partToString(request), response);
|
||||
if (!hookOutput) return {};
|
||||
|
||||
return {
|
||||
stop: hookOutput.shouldStopExecution(),
|
||||
shouldContinue: hookOutput.isBlockingDecision(),
|
||||
reason: hookOutput.getEffectiveReason(),
|
||||
systemMessage: hookOutput.systemMessage,
|
||||
contextCleared: hookOutput.shouldClearContext(),
|
||||
};
|
||||
}
|
||||
|
||||
async transformRequest(request: Part[]) {
|
||||
return request;
|
||||
}
|
||||
|
||||
isGoalReached() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getContinuationRequest(turn: Turn, signal: AbortSignal) {
|
||||
const nextSpeaker = await checkNextSpeaker(
|
||||
turn.chat,
|
||||
this.config.getBaseLlmClient(),
|
||||
signal,
|
||||
this.agentId,
|
||||
);
|
||||
if (nextSpeaker?.next_speaker === 'model') {
|
||||
return [{ text: 'Please continue.' }];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async *executeRecovery(): AsyncGenerator<ServerGeminiStreamEvent, boolean> {
|
||||
if (this.agentId === 'never') yield { type: GeminiEventType.Retry };
|
||||
return false;
|
||||
}
|
||||
|
||||
getFinalFailureMessage() {
|
||||
return 'Execution terminated.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior for subagents.
|
||||
*/
|
||||
export class SubagentBehavior implements AgentBehavior {
|
||||
readonly agentId: string;
|
||||
readonly name: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
readonly definition: LocalAgentDefinition,
|
||||
private readonly inputs?: AgentInputs,
|
||||
parentPromptId?: string,
|
||||
) {
|
||||
this.name = definition.name;
|
||||
const randomIdPart = Math.random().toString(36).slice(2, 8);
|
||||
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
|
||||
this.agentId = `${parentPrefix}${this.name}-${randomIdPart}`;
|
||||
}
|
||||
|
||||
async initialize(toolRegistry: ToolRegistry) {
|
||||
debugLogger.debug(
|
||||
`[AgentHarness] [${this.name}:${this.agentId}] Initializing tool registry`,
|
||||
);
|
||||
const parentToolRegistry = this.config.getToolRegistry();
|
||||
if (this.definition.toolConfig) {
|
||||
for (const toolRef of this.definition.toolConfig.tools) {
|
||||
if (typeof toolRef === 'string') {
|
||||
const tool = parentToolRegistry.getTool(toolRef);
|
||||
if (tool) toolRegistry.registerTool(tool);
|
||||
} else if (typeof toolRef === 'object' && 'build' in toolRef) {
|
||||
toolRegistry.registerTool(toolRef);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const toolName of parentToolRegistry.getAllToolNames()) {
|
||||
const tool = parentToolRegistry.getTool(toolName);
|
||||
if (tool) toolRegistry.registerTool(tool);
|
||||
}
|
||||
}
|
||||
toolRegistry.sortTools();
|
||||
}
|
||||
|
||||
async getSystemInstruction() {
|
||||
const augmentedInputs = {
|
||||
...this.inputs,
|
||||
cliVersion: await getVersion(),
|
||||
activeModel: this.config.getActiveModel(),
|
||||
today: new Date().toLocaleDateString(),
|
||||
};
|
||||
let prompt = templateString(
|
||||
this.definition.promptConfig.systemPrompt || '',
|
||||
augmentedInputs,
|
||||
);
|
||||
const dirContext = await getDirectoryContextString(this.config);
|
||||
prompt += `\n\n# Environment Context\n${dirContext}`;
|
||||
prompt += `\n\nImportant Rules:\n* You are running in a non-interactive mode. You CANNOT ask the user for input or clarification.\n* Work systematically using available tools to complete your task.\n* Always use absolute paths for file operations.`;
|
||||
|
||||
const hasOutput = !!this.definition.outputConfig;
|
||||
prompt += `\n* When you have completed your task, you MUST call the \`${TASK_COMPLETE_TOOL_NAME}\` tool${hasOutput ? ' with your structured output' : ''}.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async getInitialHistory() {
|
||||
const initialMessages = this.definition.promptConfig.initialMessages ?? [];
|
||||
if (this.inputs) {
|
||||
return initialMessages.map((content) => ({
|
||||
...content,
|
||||
parts: (content.parts ?? []).map((part) =>
|
||||
'text' in part && part.text
|
||||
? { text: templateString(part.text, this.inputs!) }
|
||||
: part,
|
||||
),
|
||||
}));
|
||||
}
|
||||
return initialMessages;
|
||||
}
|
||||
|
||||
prepareTools(baseTools: FunctionDeclaration[]) {
|
||||
const completeTool: FunctionDeclaration = {
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
description:
|
||||
'Call this tool to submit your final answer and complete the task.',
|
||||
parameters: { type: Type.OBJECT, properties: {}, required: [] },
|
||||
};
|
||||
|
||||
if (this.definition.outputConfig) {
|
||||
const schema = zodToJsonSchema(this.definition.outputConfig.schema);
|
||||
const {
|
||||
$schema: _,
|
||||
definitions: __,
|
||||
...cleanSchema
|
||||
} = schema as Record<string, unknown>;
|
||||
completeTool.parameters!.properties![
|
||||
this.definition.outputConfig.outputName
|
||||
] = cleanSchema as Schema;
|
||||
completeTool.parameters!.required!.push(
|
||||
this.definition.outputConfig.outputName,
|
||||
);
|
||||
} else {
|
||||
completeTool.parameters!.properties!['result'] = {
|
||||
type: Type.STRING,
|
||||
description: 'Your final results or findings.',
|
||||
};
|
||||
completeTool.parameters!.required!.push('result');
|
||||
}
|
||||
|
||||
return [...baseTools, completeTool];
|
||||
}
|
||||
|
||||
async syncEnvironment() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async fireBeforeAgent() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async fireAfterAgent() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async transformRequest(request: Part[]): Promise<Part[]> {
|
||||
if (
|
||||
request.length === 1 &&
|
||||
'text' in request[0] &&
|
||||
request[0].text === 'Start'
|
||||
) {
|
||||
return [
|
||||
{
|
||||
text: this.definition.promptConfig.query
|
||||
? templateString(
|
||||
this.definition.promptConfig.query,
|
||||
this.inputs || {},
|
||||
)
|
||||
: 'Get Started!',
|
||||
},
|
||||
];
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
isGoalReached(
|
||||
toolResults: Array<{
|
||||
name: string;
|
||||
part: Part;
|
||||
result: ToolCallResponseInfo;
|
||||
}>,
|
||||
) {
|
||||
const completeCall = toolResults.find(
|
||||
(r) => r.name === TASK_COMPLETE_TOOL_NAME,
|
||||
);
|
||||
if (completeCall) {
|
||||
// If there's an error in the call, we don't treat it as reached (model should retry)
|
||||
return !completeCall.part.functionResponse?.response?.['error'];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async getContinuationRequest() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async *executeRecovery(
|
||||
turn: Turn,
|
||||
reason: AgentTerminateMode,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ServerGeminiStreamEvent, boolean> {
|
||||
debugLogger.debug(
|
||||
`[AgentHarness] [${this.name}:${this.agentId}] Entering recovery mode. Reason: ${reason}`,
|
||||
);
|
||||
const recoveryStartTime = Date.now();
|
||||
let success = false;
|
||||
const graceTimeoutController = new DeadlineTimer(
|
||||
GRACE_PERIOD_MS,
|
||||
'Grace period timed out.',
|
||||
);
|
||||
const combinedSignal = AbortSignal.any([
|
||||
signal,
|
||||
graceTimeoutController.signal,
|
||||
]);
|
||||
|
||||
try {
|
||||
const recoveryMessage: Part[] = [
|
||||
{ text: this.getFinalWarningMessage(reason) },
|
||||
];
|
||||
const promptId = `${this.agentId}#recovery`;
|
||||
const recoveryStream = promptIdContext.run(promptId, () =>
|
||||
turn.run(
|
||||
{ model: this.config.getActiveModel() },
|
||||
recoveryMessage,
|
||||
combinedSignal,
|
||||
),
|
||||
);
|
||||
|
||||
for await (const event of recoveryStream) {
|
||||
yield event;
|
||||
}
|
||||
|
||||
// Check if they called complete_task in the recovery turn
|
||||
const completeCall = turn.pendingToolCalls.find(
|
||||
(c) => c.name === TASK_COMPLETE_TOOL_NAME,
|
||||
);
|
||||
if (completeCall) {
|
||||
success = true;
|
||||
|
||||
// Capture the result in the turn object explicitly
|
||||
const outputName = this.definition.outputConfig?.outputName || 'result';
|
||||
const rawFindings =
|
||||
completeCall.args[outputName] || completeCall.args['result'];
|
||||
|
||||
if (rawFindings) {
|
||||
turn.submittedOutput =
|
||||
typeof rawFindings === 'object'
|
||||
? JSON.stringify(rawFindings, null, 2)
|
||||
: String(rawFindings);
|
||||
|
||||
debugLogger.debug(
|
||||
`[AgentHarness] [${this.name}:${this.agentId}] Captured findings from recovery complete_task. Length: ${String(turn.submittedOutput).length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
graceTimeoutController.abort();
|
||||
logRecoveryAttempt(
|
||||
this.config,
|
||||
new RecoveryAttemptEvent(
|
||||
this.agentId,
|
||||
this.name,
|
||||
reason,
|
||||
Date.now() - recoveryStartTime,
|
||||
success,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
private getFinalWarningMessage(reason: AgentTerminateMode): string {
|
||||
let explanation = '';
|
||||
switch (reason) {
|
||||
case AgentTerminateMode.TIMEOUT:
|
||||
explanation = 'You have exceeded the time limit.';
|
||||
break;
|
||||
case AgentTerminateMode.MAX_TURNS:
|
||||
explanation = 'You have exceeded the maximum number of turns.';
|
||||
break;
|
||||
case AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL:
|
||||
explanation = 'You have stopped calling tools without finishing.';
|
||||
break;
|
||||
default:
|
||||
explanation = 'Execution was interrupted.';
|
||||
}
|
||||
return `${explanation} You have one final chance to provide your findings. You MUST call \`${TASK_COMPLETE_TOOL_NAME}\` immediately with your best synthesis and conclusion for the main agent. Do not call any other tools.`;
|
||||
}
|
||||
|
||||
getFinalFailureMessage(
|
||||
reason: AgentTerminateMode,
|
||||
maxTurns: number,
|
||||
maxTime: number,
|
||||
) {
|
||||
switch (reason) {
|
||||
case AgentTerminateMode.TIMEOUT:
|
||||
return `Agent timed out after ${maxTime} minutes.`;
|
||||
case AgentTerminateMode.MAX_TURNS:
|
||||
return `Agent reached max turns limit (${maxTurns}).`;
|
||||
case AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL:
|
||||
return `Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}'.`;
|
||||
default:
|
||||
return 'Agent execution was terminated before completion.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,8 +109,8 @@ export const CodebaseInvestigatorAgent = (
|
||||
},
|
||||
|
||||
runConfig: {
|
||||
maxTimeMinutes: 10,
|
||||
maxTurns: 50,
|
||||
maxTimeMinutes: 3,
|
||||
maxTurns: 10,
|
||||
},
|
||||
|
||||
toolConfig: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user