Compare commits

..

1 Commits

Author SHA1 Message Date
Christian Gunderman 443d046069 fix(ci): require nudge label before closing old PRs 2026-05-04 16:12:48 -07:00
94 changed files with 500 additions and 1278 deletions
@@ -1,23 +0,0 @@
name: 'Download Mac Binaries'
description: 'Downloads the unsigned macOS binaries (x64 and arm64)'
inputs:
path:
description: 'The base path to download the binaries to'
required: true
default: 'dist'
runs:
using: 'composite'
steps:
- name: 'Download macOS arm64 binary'
uses: 'actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806' # ratchet:actions/download-artifact@v4
continue-on-error: true
with:
name: 'gemini-darwin-arm64-unsigned'
path: '${{ inputs.path }}/darwin-arm64'
- name: 'Download macOS x64 binary'
uses: 'actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806' # ratchet:actions/download-artifact@v4
continue-on-error: true
with:
name: 'gemini-darwin-x64-unsigned'
path: '${{ inputs.path }}/darwin-x64'
+1 -14
View File
@@ -308,21 +308,8 @@ runs:
fi
rm -rf test-bundle
RELEASE_ASSETS=("gemini-cli-bundle.zip")
# Check for and prepare macOS binaries if they exist
if [[ -f "dist/darwin-arm64/gemini" ]]; then
zip -j gemini-darwin-arm64-unsigned.zip dist/darwin-arm64/gemini
RELEASE_ASSETS+=("gemini-darwin-arm64-unsigned.zip")
fi
if [[ -f "dist/darwin-x64/gemini" ]]; then
zip -j gemini-darwin-x64-unsigned.zip dist/darwin-x64/gemini
RELEASE_ASSETS+=("gemini-darwin-x64-unsigned.zip")
fi
gh release create "${INPUTS_RELEASE_TAG}" \
"${RELEASE_ASSETS[@]}" \
gemini-cli-bundle.zip \
--target "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" \
--title "Release ${INPUTS_RELEASE_TAG}" \
--notes-start-tag "${INPUTS_PREVIOUS_TAG}" \
+2 -6
View File
@@ -176,17 +176,13 @@ module.exports = async ({ github, context, core }) => {
// 4. Handle PR Contribution Policy (Nudge at 7d, Close at 14d)
const PR_NUDGE_DAYS = 7;
const PR_CLOSE_DAYS = 14;
const nudgeThreshold = new Date(
now.getTime() - PR_NUDGE_DAYS * 24 * 60 * 60 * 1000,
);
const prCloseThreshold = new Date(
now.getTime() - PR_CLOSE_DAYS * 24 * 60 * 60 * 1000,
);
// Nudge
await processItems(
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" -label:"status/pr-nudge-sent" created:${prCloseThreshold.toISOString()}..${nudgeThreshold.toISOString()}`,
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" -label:"status/pr-nudge-sent" created:<${nudgeThreshold.toISOString()}`,
async (pr) => {
if (
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
@@ -214,7 +210,7 @@ module.exports = async ({ github, context, core }) => {
// Close
await processItems(
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" created:<${prCloseThreshold.toISOString()}`,
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" label:"status/pr-nudge-sent" updated:<${nudgeThreshold.toISOString()}`,
async (pr) => {
if (
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
@@ -2,12 +2,6 @@ name: 'Build Unsigned Mac Binaries'
on:
workflow_dispatch:
workflow_call:
inputs:
ref:
description: 'The branch, tag, or SHA to build from.'
required: true
type: 'string'
permissions:
contents: 'read'
@@ -28,8 +22,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
with:
ref: '${{ inputs.ref || github.ref }}'
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
@@ -60,5 +52,5 @@ jobs:
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'gemini-darwin-${{ matrix.arch }}-unsigned'
path: 'dist/darwin-${{ matrix.arch }}/gemini'
retention-days: 14
path: 'dist/darwin-${{ matrix.arch }}/'
retention-days: 5
-12
View File
@@ -46,15 +46,8 @@ on:
default: 'prod'
jobs:
build-mac:
if: "github.repository == 'google-gemini/gemini-cli'"
uses: './.github/workflows/build-unsigned-mac-binaries.yml'
with:
ref: '${{ github.event.inputs.ref }}'
release:
if: "github.repository == 'google-gemini/gemini-cli'"
needs: ['build-mac']
runs-on: 'ubuntu-latest'
environment: "${{ github.event.inputs.environment || 'prod' }}"
permissions:
@@ -90,11 +83,6 @@ jobs:
working-directory: './release'
run: 'npm ci'
- name: 'Download macOS Binaries'
uses: './.github/actions/download-mac-binaries'
with:
path: 'release/dist'
- name: 'Prepare Release Info'
id: 'release_info'
working-directory: './release'
-12
View File
@@ -30,15 +30,8 @@ on:
default: 'prod'
jobs:
build-mac:
if: "github.repository == 'google-gemini/gemini-cli'"
uses: './.github/workflows/build-unsigned-mac-binaries.yml'
with:
ref: '${{ github.event.inputs.ref }}'
release:
if: "github.repository == 'google-gemini/gemini-cli'"
needs: ['build-mac']
environment: "${{ github.event.inputs.environment || 'prod' }}"
runs-on: 'ubuntu-latest'
permissions:
@@ -69,11 +62,6 @@ jobs:
working-directory: './release'
run: 'npm ci'
- name: 'Download macOS Binaries'
uses: './.github/actions/download-mac-binaries'
with:
path: 'release/dist'
- name: 'Print Inputs'
shell: 'bash'
env:
+2 -18
View File
@@ -197,15 +197,9 @@ jobs:
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
working-directory: './release'
build-mac:
if: "github.repository == 'google-gemini/gemini-cli'"
uses: './.github/workflows/build-unsigned-mac-binaries.yml'
with:
ref: '${{ github.event.inputs.ref }}'
publish-preview:
name: 'Publish preview'
needs: ['calculate-versions', 'test', 'build-mac']
needs: ['calculate-versions', 'test']
runs-on: 'ubuntu-latest'
environment: "${{ github.event.inputs.environment || 'prod' }}"
permissions:
@@ -235,11 +229,6 @@ jobs:
working-directory: './release'
run: 'npm ci'
- name: 'Download macOS Binaries'
uses: './.github/actions/download-mac-binaries'
with:
path: 'release/dist'
- name: 'Publish Release'
uses: './.github/actions/publish-release'
with:
@@ -277,7 +266,7 @@ jobs:
publish-stable:
name: 'Publish stable'
needs: ['calculate-versions', 'test', 'publish-preview', 'build-mac']
needs: ['calculate-versions', 'test', 'publish-preview']
runs-on: 'ubuntu-latest'
environment: "${{ github.event.inputs.environment || 'prod' }}"
permissions:
@@ -307,11 +296,6 @@ jobs:
working-directory: './release'
run: 'npm ci'
- name: 'Download macOS Binaries'
uses: './.github/actions/download-mac-binaries'
with:
path: 'release/dist'
- name: 'Publish Release'
uses: './.github/actions/publish-release'
with:
+3 -7
View File
@@ -1,6 +1,6 @@
# Preview release: v0.42.0-preview.1
# Preview release: v0.41.0-preview.0
Released: May 05, 2026
Released: April 28, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -28,10 +28,6 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick 3627f47 to release/v0.42.0-preview.0-pr-26542 to patch
version v0.42.0-preview.0 and create version 0.42.0-preview.1 by
@gemini-cli-robot in
[#26544](https://github.com/google-gemini/gemini-cli/pull/26544)
- chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 by
@gemini-cli-robot in
[#25847](https://github.com/google-gemini/gemini-cli/pull/25847)
@@ -122,4 +118,4 @@ npm install -g @google/gemini-cli@preview
[#26078](https://github.com/google-gemini/gemini-cli/pull/26078)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.40.0-preview.5...v0.42.0-preview.1
https://github.com/google-gemini/gemini-cli/compare/v0.40.0-preview.5...v0.41.0-preview.0
+37 -59
View File
@@ -1,10 +1,9 @@
# Auto Memory
Auto Memory is an experimental feature that mines your past Gemini CLI sessions
in the background and proposes durable memory updates and reusable
[Agent Skills](./skills.md). You review each candidate before it becomes
available to future sessions: apply memory updates, promote skills, or discard
anything you do not want.
in the background and turns recurring workflows into reusable
[Agent Skills](./skills.md). You review, accept, or discard each extracted skill
before it becomes available to future sessions.
<!-- prettier-ignore -->
> [!NOTE]
@@ -13,33 +12,28 @@ anything you do not want.
## Overview
Every session you run with Gemini CLI is recorded locally as a transcript. Auto
Memory scans those transcripts for durable facts, preferences, workflow
constraints, and procedural patterns that recur across sessions. It can draft
memory updates as unified diff `.patch` files and draft reusable procedures as
`SKILL.md` files. All candidates are held in a project-local inbox until you
approve or discard them.
Memory scans those transcripts for procedural patterns that recur across
sessions, then drafts each pattern as a `SKILL.md` file in a project-local
inbox. You inspect the draft, decide whether it captures real expertise, and
promote it to your global or workspace skills directory if you want it.
You'll use Auto Memory when you want to:
- **Capture team workflows** that you find yourself walking the agent through
more than once.
- **Preserve durable project context** such as repeated verification commands,
local constraints, or personal project notes.
- **Codify hard-won fixes** for project-specific landmines so future sessions
avoid them.
- **Bootstrap a skills library** without writing every `SKILL.md` by hand.
Auto Memory complements—but does not replace—the
[`save_memory` tool](../tools/memory.md), which captures single facts into
`GEMINI.md` when the agent explicitly calls it. Auto Memory infers candidates
from past sessions, writes reviewable patches or skill drafts, and never applies
them without your approval.
`GEMINI.md`. Auto Memory captures multi-step procedures into skills.
## Prerequisites
- Gemini CLI installed and authenticated.
- At least one idle project session with 10 or more user messages. Auto Memory
ignores active, trivial, and sub-agent sessions.
- At least 10 user messages across recent, idle sessions in the project. Auto
Memory ignores active or trivial sessions.
## How to enable Auto Memory
@@ -72,45 +66,36 @@ UI, consume your interactive turns, or surface tool prompts.
been idle for at least three hours and contain at least 10 user messages.
2. **Lock acquisition.** A lock file in the project's memory directory
coordinates across multiple CLI instances so extraction runs at most once at
a time. A state file records processed session versions, and extraction is
throttled so short back-to-back CLI launches do not repeatedly scan history.
3. **Candidate extraction.** A background extraction agent reviews the session
index, reads any sessions that look like they contain durable memory or
repeated procedural workflows, and drafts candidates. It defaults to
creating no artifacts unless the evidence is strong, so many runs produce no
inbox items.
4. **Safety boundaries.** Auto Memory writes candidates to a review inbox. It
cannot directly edit active memory files, settings, credentials, or project
`GEMINI.md` files.
5. **Patch validation.** Skill update patches are parsed and dry-run before
they are surfaced. Memory patches are parsed, target-allowlisted, and
applied atomically only when you approve them from the inbox.
6. **Notification.** When a run produces new candidates, Gemini CLI surfaces an
inline message telling you how many items are waiting.
a time.
3. **Sub-agent extraction.** A specialized sub-agent (named `confucius`)
reviews the session index, reads any sessions that look like they contain
repeated procedural workflows, and drafts new `SKILL.md` files. Its
instructions tell it to default to creating zero skills unless the evidence
is strong, so most runs produce no inbox items.
4. **Patch validation.** If the sub-agent proposes edits to skills outside the
inbox (for example, an existing global skill), it writes a unified diff
`.patch` file. Auto Memory dry-runs each patch and discards any that do not
apply cleanly.
5. **Notification.** When a run produces new skills or patches, Gemini CLI
surfaces an inline message telling you how many items are waiting.
## How to review extracted items
## How to review extracted skills
Use the `/memory inbox` slash command to open the inbox dialog at any time:
**Command:** `/memory inbox`
The dialog groups pending items into new skills, skill updates, and memory
updates. From there you can:
The dialog lists each draft skill with its name, description, and source
sessions. From there you can:
- **Read** the full `SKILL.md` body before deciding.
- **Promote** a skill to your user (`~/.gemini/skills/`) or workspace
(`.gemini/skills/`) directory.
- **Discard** a skill you do not want.
- **Apply** or reject a `.patch` proposal against an existing skill.
- **Review** memory diffs before they touch active files.
- **Apply** or dismiss private and global memory patches. Private patches target
the project memory directory; global patches target only your personal
`~/.gemini/GEMINI.md` file.
Promoted skills become discoverable in the next session and follow the standard
[skill discovery precedence](./skills.md#skill-discovery-tiers). Applied memory
patches update the underlying memory files and reload memory for the current
session.
[skill discovery precedence](./skills.md#skill-discovery-tiers).
## How to disable Auto Memory
@@ -132,26 +117,19 @@ start. Existing inbox items remain on disk; you can either drain them with
## Data and privacy
- Auto Memory only reads session files that already exist locally on your
machine.
- Auto Memory uses model calls to analyze selected local transcript content
during extraction. No candidates are applied automatically, but transcript
excerpts may be sent to the configured model as part of those calls.
- The extraction agent is instructed to redact secrets, tokens, and credentials
it encounters and to never copy large tool outputs verbatim.
- Drafted skills and memory patches live in your project's memory directory
until you promote, apply, dismiss, or discard them. They are not automatically
loaded into any session.
machine. Nothing is uploaded to Gemini outside the normal API calls the
extraction sub-agent makes during its run.
- The sub-agent is instructed to redact secrets, tokens, and credentials it
encounters and to never copy large tool outputs verbatim.
- Drafted skills live in your project's memory directory until you promote or
discard them. They are not automatically loaded into any session.
## Limitations
- The extraction agent runs on a preview Gemini Flash model. Extraction quality
depends on the model's ability to recognize durable patterns versus one-off
incidents.
- Auto Memory does not extract memory or skills from the current session. It
only considers sessions that have been idle for three hours or more.
- Project or workspace shared instructions in project `GEMINI.md` files are not
auto-extractable. Auto Memory can propose private project memory, global
personal memory, and skills.
- The sub-agent runs on a preview Gemini Flash model. Extraction quality depends
on the model's ability to recognize durable patterns versus one-off incidents.
- Auto Memory does not extract skills from the current session. It only
considers sessions that have been idle for three hours or more.
- Inbox items are stored per project. Skills extracted in one workspace are not
visible from another until you promote them to the user-scope skills
directory.
@@ -160,6 +138,6 @@ start. Existing inbox items remain on disk; you can either drain them with
- Learn how skills are discovered and activated in [Agent Skills](./skills.md).
- Explore the [memory management tutorial](./tutorials/memory-management.md) for
the complementary explicit-memory and `GEMINI.md` workflows.
the complementary `save_memory` and `GEMINI.md` workflows.
- Review the experimental settings catalog in
[Settings](./settings.md#experimental).
+1 -1
View File
@@ -125,4 +125,4 @@ immediately. Force a reload with:
`/memory` options.
- Read the technical spec for [Project context](../../cli/gemini-md.md).
- Try the experimental [Auto Memory](../auto-memory.md) feature to extract
memory updates and reusable skills from your past sessions automatically.
reusable skills from your past sessions automatically.
@@ -56,7 +56,6 @@ creating a "discovery file."
}
}
```
- `port` (number, required): The port of the MCP server.
- `workspacePath` (string, required): A list of all open workspace root paths,
delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for
+6 -4
View File
@@ -329,8 +329,9 @@ async function expectSeedSessionEligible(
fixture: Fixture,
sessionId: string,
): Promise<void> {
const { buildSessionIndex } =
await import('../packages/core/src/services/memoryService.js');
const { buildSessionIndex } = await import(
'../packages/core/src/services/memoryService.js'
);
const { newSessionIds } = await buildSessionIndex(
path.join(fixture.projectTempDir, 'chats'),
{ runs: [] },
@@ -385,8 +386,9 @@ describe('Auto Memory inbox routing', () => {
autoMemoryEval(
'every memory patch lands in .inbox/<kind>/ for review and active files stay untouched',
async () => {
const { startMemoryService } =
await import('../packages/core/src/services/memoryService.js');
const { startMemoryService } = await import(
'../packages/core/src/services/memoryService.js'
);
const fixture = await createFixture();
evalState.sessionFilePath = await seedSession(
fixture,
@@ -66,7 +66,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
expect(mockEventBus.publish).toHaveBeenCalledWith(
@@ -107,7 +106,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Simulate A2A client confirmation
@@ -150,11 +148,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Simulate Rejection (Cancel)
const handled = await (
@@ -180,11 +174,7 @@ describe('Task Event-Driven Scheduler', () => {
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall2],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall2] });
// Simulate ModifyWithEditor
const handled2 = await (
@@ -225,11 +215,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Simulate ProceedOnce for MCP
const handled = await (
@@ -269,11 +255,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
@@ -312,11 +294,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
@@ -355,11 +333,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
@@ -402,11 +376,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
@@ -449,7 +419,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Should publish artifact update for output
@@ -484,11 +453,7 @@ describe('Task Event-Driven Scheduler', () => {
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// The tool should be complete and registered appropriately, eventually
// triggering the toolCompletionPromise resolution when all clear.
@@ -568,7 +533,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
schedulerId: 'task-id',
});
// Confirm first tool call
@@ -636,7 +600,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
schedulerId: 'task-id',
});
// Should NOT transition to input-required yet
@@ -658,7 +621,6 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1Complete, toolCall2],
schedulerId: 'task-id',
});
// Now it should transition
-203
View File
@@ -12,9 +12,6 @@ import {
type ToolCallRequestInfo,
type GitService,
type CompletedToolCall,
type ToolCall,
type ToolCallsUpdateMessage,
MessageBusType,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
@@ -463,204 +460,4 @@ describe('Task', () => {
expect(task.currentPromptId).toBe(expectedPromptId2);
});
});
describe('Race Condition Fix', () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should NOT transition to input-required if a tool is still validating', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
// Manually register two tool calls
task['_registerToolCall']('tool-1', 'awaiting_approval');
task['_registerToolCall']('tool-2', 'validating');
// Call checkInputRequiredState (private)
task['checkInputRequiredState']();
// Verify task state did NOT change to input-required
expect(task.taskState).not.toBe('input-required');
expect(mockEventBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({ state: 'input-required' }),
}),
);
});
it('should transition to input-required if all active tools are awaiting approval', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
// Transition from submitted to working first to simulate normal flow
task.taskState = 'working';
// Manually register tool calls
task['_registerToolCall']('tool-1', 'awaiting_approval');
// Call checkInputRequiredState
task['checkInputRequiredState']();
// Verify task state changed to input-required
expect(task.taskState).toBe('input-required');
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({ state: 'input-required' }),
}),
);
});
it('handleEventDrivenToolCallsUpdate should ignore events for other schedulers', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const handleEventDrivenToolCallSpy = vi.spyOn(
task as unknown as {
handleEventDrivenToolCall: Task['handleEventDrivenToolCall'];
},
'handleEventDrivenToolCall',
);
const otherEvent: ToolCallsUpdateMessage = {
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
],
schedulerId: 'other-task-id',
};
task['handleEventDrivenToolCallsUpdate'](otherEvent);
expect(handleEventDrivenToolCallSpy).not.toHaveBeenCalled();
const ownEvent: ToolCallsUpdateMessage = {
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
],
schedulerId: 'task-id',
};
task['handleEventDrivenToolCallsUpdate'](ownEvent);
expect(handleEventDrivenToolCallSpy).toHaveBeenCalled();
});
});
describe('Serialization and Mapping', () => {
it('should map internal "validating" status to "scheduled" for the client and include outcome', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const mockToolCall = {
request: { callId: 'tool-1' },
status: 'validating',
outcome: 'accepted',
tool: { name: 'test-tool' },
};
const message = task['toolStatusMessage'](
mockToolCall as unknown as ToolCall,
'task-id',
'context-id',
);
const serialized = (
message.parts![0] as {
data: { status: string; outcome: string };
}
).data;
expect(serialized.status).toBe('scheduled');
expect(serialized.outcome).toBe('accepted');
});
it('should correctly detect changes when status or outcome changes', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const toolCall1 = {
request: { callId: 'tool-1' },
status: 'awaiting_approval',
};
// First update - should trigger change
const changed1 = task['handleEventDrivenToolCall'](
toolCall1 as unknown as ToolCall,
);
expect(changed1).toBe(true);
// Second update with same status - should NOT trigger change
const changed2 = task['handleEventDrivenToolCall'](
toolCall1 as unknown as ToolCall,
);
expect(changed2).toBe(false);
// Update with new outcome - SHOULD trigger change
const toolCall2 = {
request: { callId: 'tool-1' },
status: 'awaiting_approval',
outcome: 'accepted',
};
const changed3 = task['handleEventDrivenToolCall'](
toolCall2 as unknown as ToolCall,
);
expect(changed3).toBe(true);
});
});
});
+9 -31
View File
@@ -11,7 +11,6 @@ import {
GeminiEventType,
ToolConfirmationOutcome,
ApprovalMode,
CoreToolCallStatus,
getAllMCPServerStatuses,
MCPServerStatus,
isNodeError,
@@ -96,8 +95,6 @@ export class Task {
// For tool waiting logic
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
private pendingOutcomes: Map<string, ToolConfirmationOutcome | undefined> =
new Map(); // toolCallId --> outcome
private toolsAlreadyConfirmed: Set<string> = new Set();
private toolCompletionPromise?: Promise<void>;
private toolCompletionNotifier?: {
@@ -416,10 +413,7 @@ export class Task {
private handleEventDrivenToolCallsUpdate(
event: ToolCallsUpdateMessage,
): void {
if (
event.type !== MessageBusType.TOOL_CALLS_UPDATE ||
event.schedulerId !== this.id
) {
if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) {
return;
}
@@ -432,7 +426,7 @@ export class Task {
this.checkInputRequiredState();
}
private handleEventDrivenToolCall(tc: ToolCall): boolean {
private handleEventDrivenToolCall(tc: ToolCall): void {
const callId = tc.request.callId;
// Do not process events for tools that have already been finalized.
@@ -442,16 +436,11 @@ export class Task {
this.processedToolCallIds.has(callId) ||
this.completedToolCalls.some((c) => c.request.callId === callId)
) {
return false;
return;
}
const previousStatus = this.pendingToolCalls.get(callId);
const previousOutcome = this.pendingOutcomes.get(callId);
const hasChanged =
previousStatus !== tc.status || previousOutcome !== tc.outcome;
// Update outcome tracking
this.pendingOutcomes.set(callId, tc.outcome);
const hasChanged = previousStatus !== tc.status;
// 1. Handle Output
if (tc.status === 'executing' && tc.liveOutput) {
@@ -465,7 +454,6 @@ export class Task {
tc.status === 'cancelled'
) {
this.toolsAlreadyConfirmed.delete(callId);
this.pendingOutcomes.delete(callId);
if (hasChanged) {
logger.info(
`[Task] Tool call ${callId} completed with status: ${tc.status}`,
@@ -508,8 +496,6 @@ export class Task {
);
this.eventBus?.publish(statusUpdate);
}
return hasChanged;
}
private checkInputRequiredState(): void {
@@ -522,14 +508,12 @@ export class Task {
let isExecuting = false;
for (const [callId, status] of this.pendingToolCalls.entries()) {
if (
status === CoreToolCallStatus.Executing ||
status === CoreToolCallStatus.Scheduled ||
status === CoreToolCallStatus.Validating ||
this.toolsAlreadyConfirmed.has(callId)
) {
if (status === 'executing' || status === 'scheduled') {
isExecuting = true;
} else if (status === CoreToolCallStatus.AwaitingApproval) {
} else if (
status === 'awaiting_approval' &&
!this.toolsAlreadyConfirmed.has(callId)
) {
isAwaitingApproval = true;
}
}
@@ -590,14 +574,8 @@ export class Task {
'confirmationDetails',
'liveOutput',
'response',
'outcome',
);
// Map internal 'validating' status to 'scheduled' for the client
if (serializableToolCall.status === CoreToolCallStatus.Validating) {
serializableToolCall.status = CoreToolCallStatus.Scheduled;
}
if (tc.tool) {
const toolFields = this._pickFields(
tc.tool,
+37 -79
View File
@@ -228,7 +228,7 @@ describe('E2E Tests', () => {
expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
status: 'validating',
request: { callId: 'test-call-id' },
},
},
@@ -330,7 +330,7 @@ describe('E2E Tests', () => {
expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
status: 'validating',
request: { callId: 'test-call-id-1' },
},
},
@@ -352,7 +352,7 @@ describe('E2E Tests', () => {
kind: 'state-change',
});
// 4. Tool 1 is scheduled.
// 4. Tool 1 is validating.
const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent;
expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
@@ -361,12 +361,12 @@ describe('E2E Tests', () => {
{
data: {
request: { callId: 'test-call-id-1' },
status: 'scheduled',
status: 'validating',
},
},
]);
// 5. Tool 2 is scheduled.
// 5. Tool 2 is validating.
const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent;
expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
@@ -375,17 +375,17 @@ describe('E2E Tests', () => {
{
data: {
request: { callId: 'test-call-id-2' },
status: 'scheduled',
status: 'validating',
},
},
]);
// 6. Tool 1 is awaiting approval.
const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent;
expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({
const toolCallAwaitEvent = events[5].result as TaskStatusUpdateEvent;
expect(toolCallAwaitEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-confirmation',
});
expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([
expect(toolCallAwaitEvent.status.message?.parts).toMatchObject([
{
data: {
request: { callId: 'test-call-id-1' },
@@ -394,28 +394,14 @@ describe('E2E Tests', () => {
},
]);
// 7. Tool 2 is awaiting approval.
const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent;
expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-confirmation',
});
expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([
{
data: {
request: { callId: 'test-call-id-2' },
status: 'awaiting_approval',
},
},
]);
// 8. The final event is "input-required".
const finalEvent = events[7].result as TaskStatusUpdateEvent;
// 7. The final event is "input-required".
const finalEvent = events[6].result as TaskStatusUpdateEvent;
expect(finalEvent.final).toBe(true);
expect(finalEvent.status.state).toBe('input-required');
// The scheduler now waits for approval, so no more events are sent.
assertUniqueFinalEventIsLast(events);
expect(events.length).toBe(8);
expect(events.length).toBe(7);
});
it('should handle multiple tool calls sequentially in YOLO mode', async () => {
@@ -513,7 +499,7 @@ describe('E2E Tests', () => {
// Tool 1 Lifecycle
{
kind: 'tool-call-update',
status: 'scheduled',
status: 'validating',
callId: 'test-call-id-1',
},
{
@@ -534,7 +520,7 @@ describe('E2E Tests', () => {
// Tool 2 Lifecycle
{
kind: 'tool-call-update',
status: 'scheduled',
status: 'validating',
callId: 'test-call-id-2',
},
{
@@ -617,40 +603,26 @@ describe('E2E Tests', () => {
expect(workingEvent2.kind).toBe('status-update');
expect(workingEvent2.status.state).toBe('working');
// Status update: tool-call-update (scheduled)
const scheduledEvent1 = events[3].result as TaskStatusUpdateEvent;
expect(scheduledEvent1.metadata?.['coderAgent']).toMatchObject({
// Status update: tool-call-update (validating)
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent1.status.message?.parts).toMatchObject([
expect(validatingEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
status: 'validating',
request: { callId: 'test-call-id-no-approval' },
},
},
]);
// Status update: tool-call-update (scheduled)
const scheduledEvent2 = events[4].result as TaskStatusUpdateEvent;
expect(scheduledEvent2.metadata?.['coderAgent']).toMatchObject({
const scheduledEvent = events[4].result as TaskStatusUpdateEvent;
expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent2.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
request: { callId: 'test-call-id-no-approval' },
},
},
]);
// Status update: tool-call-update (scheduled)
const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent3.status.message?.parts).toMatchObject([
expect(scheduledEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
@@ -660,7 +632,7 @@ describe('E2E Tests', () => {
]);
// Status update: tool-call-update (executing)
const executingEvent = events[6].result as TaskStatusUpdateEvent;
const executingEvent = events[5].result as TaskStatusUpdateEvent;
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
@@ -674,7 +646,7 @@ describe('E2E Tests', () => {
]);
// Status update: tool-call-update (success)
const successEvent = events[7].result as TaskStatusUpdateEvent;
const successEvent = events[6].result as TaskStatusUpdateEvent;
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
@@ -688,12 +660,12 @@ describe('E2E Tests', () => {
]);
// Status update: working (before sending tool result to LLM)
const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
const workingEvent3 = events[7].result as TaskStatusUpdateEvent;
expect(workingEvent3.kind).toBe('status-update');
expect(workingEvent3.status.state).toBe('working');
// Status update: text-content (final LLM response)
const textContentEvent = events[9].result as TaskStatusUpdateEvent;
const textContentEvent = events[8].result as TaskStatusUpdateEvent;
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'text-content',
});
@@ -702,7 +674,7 @@ describe('E2E Tests', () => {
]);
assertUniqueFinalEventIsLast(events);
expect(events.length).toBe(11);
expect(events.length).toBe(10);
});
it('should bypass tool approval in YOLO mode', async () => {
@@ -762,15 +734,15 @@ describe('E2E Tests', () => {
expect(workingEvent2.kind).toBe('status-update');
expect(workingEvent2.status.state).toBe('working');
// Status update: tool-call-update (scheduled)
const scheduledEvent = events[3].result as TaskStatusUpdateEvent;
expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
// Status update: tool-call-update (validating)
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent.status.message?.parts).toMatchObject([
expect(validatingEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
status: 'validating',
request: { callId: 'test-call-id-yolo' },
},
},
@@ -790,22 +762,8 @@ describe('E2E Tests', () => {
},
]);
// Status update: tool-call-update (scheduled)
const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(scheduledEvent3.status.message?.parts).toMatchObject([
{
data: {
status: 'scheduled',
request: { callId: 'test-call-id-yolo' },
},
},
]);
// Status update: tool-call-update (executing)
const executingEvent = events[6].result as TaskStatusUpdateEvent;
const executingEvent = events[5].result as TaskStatusUpdateEvent;
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
@@ -819,7 +777,7 @@ describe('E2E Tests', () => {
]);
// Status update: tool-call-update (success)
const successEvent = events[7].result as TaskStatusUpdateEvent;
const successEvent = events[6].result as TaskStatusUpdateEvent;
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
@@ -833,12 +791,12 @@ describe('E2E Tests', () => {
]);
// Status update: working (before sending tool result to LLM)
const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
const workingEvent3 = events[7].result as TaskStatusUpdateEvent;
expect(workingEvent3.kind).toBe('status-update');
expect(workingEvent3.status.state).toBe('working');
// Status update: text-content (final LLM response)
const textContentEvent = events[9].result as TaskStatusUpdateEvent;
const textContentEvent = events[8].result as TaskStatusUpdateEvent;
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'text-content',
});
@@ -847,7 +805,7 @@ describe('E2E Tests', () => {
]);
assertUniqueFinalEventIsLast(events);
expect(events.length).toBe(11);
expect(events.length).toBe(10);
});
it('should include traceId in status updates when available', async () => {
+3 -2
View File
@@ -141,8 +141,9 @@ async function run() {
// --- Heavy Child Process ---
// Now we can safely import everything.
const { main } = await import('./src/gemini.js');
const { FatalError, writeToStderr } =
await import('@google/gemini-cli-core');
const { FatalError, writeToStderr } = await import(
'@google/gemini-cli-core'
);
const { runExitCleanup } = await import('./src/utils/cleanup.js');
main().catch(async (error: unknown) => {
+3 -2
View File
@@ -566,8 +566,9 @@ describe('Session', () => {
});
it('should send sessionUpdate when approval mode changes', async () => {
const { coreEvents, CoreEvent, ApprovalMode } =
await import('@google/gemini-cli-core');
const { coreEvents, CoreEvent, ApprovalMode } = await import(
'@google/gemini-cli-core'
);
coreEvents.emit(CoreEvent.ApprovalModeChanged, {
sessionId: 'session-1',
@@ -20,8 +20,9 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const mocked = mockCoreDebugLogger(actual, { stripAnsi: true });
@@ -11,8 +11,9 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const mocked = mockCoreDebugLogger(actual, { stripAnsi: false });
+3 -2
View File
@@ -16,8 +16,9 @@ import { getLogFilePath } from './constants.js';
import { logsCommand, readLastLines } from './logs.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return mockCoreDebugLogger(
await importOriginal<typeof import('@google/gemini-cli-core')>(),
{
+3 -2
View File
@@ -16,8 +16,9 @@ const mockReadServerProcessInfo = vi.hoisted(() => vi.fn());
const mockResolveGemmaConfig = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return mockCoreDebugLogger(
await importOriginal<typeof import('@google/gemini-cli-core')>(),
{
@@ -14,8 +14,9 @@ import {
} from '../../config/settings.js';
const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => {
const { createMockDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { createMockDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return createMockDebugLogger({ stripAnsi: true });
});
@@ -13,8 +13,9 @@ import {
} from '../../config/settings.js';
const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => {
const { createMockDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { createMockDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return createMockDebugLogger({ stripAnsi: true });
});
@@ -20,8 +20,9 @@ vi.mock('../../config/extensions/consent.js', () => ({
}));
const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
const { createMockDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { createMockDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return createMockDebugLogger({ stripAnsi: true });
});
@@ -16,8 +16,9 @@ vi.mock('../../utils/skillUtils.js', () => ({
}));
const { debugLogger } = await vi.hoisted(async () => {
const { createMockDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { createMockDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return createMockDebugLogger({ stripAnsi: false });
});
@@ -20,8 +20,9 @@ import { loadCliConfig } from '../../config/config.js';
import chalk from 'chalk';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return mockCoreDebugLogger(
await importOriginal<typeof import('@google/gemini-cli-core')>(),
{
@@ -13,8 +13,9 @@ vi.mock('../../utils/skillUtils.js', () => ({
}));
const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
const { createMockDebugLogger } =
await import('../../test-utils/mockDebugLogger.js');
const { createMockDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return createMockDebugLogger({ stripAnsi: true });
});
@@ -300,8 +300,9 @@ System using model: \${MODEL_NAME}
});
expect(extension.skills![0].body).toContain('Value is: first');
const { updateSetting, ExtensionSettingScope } =
await import('./extensions/extensionSettings.js');
const { updateSetting, ExtensionSettingScope } = await import(
'./extensions/extensionSettings.js'
);
const extensionConfig =
await extensionManager.loadExtensionConfig(extensionPath);
+2 -2
View File
@@ -334,8 +334,8 @@ Would you like to attempt to install via "git clone" instead?`,
const previousSkills = previous?.skills ?? [];
const isMigrating = Boolean(
previous &&
previous.installMetadata &&
previous.installMetadata.source !== installMetadata.source,
previous.installMetadata &&
previous.installMetadata.source !== installMetadata.source,
);
await maybeRequestConsentOrFail(
+3 -2
View File
@@ -938,8 +938,9 @@ describe('gemini.tsx main function kitty protocol', () => {
});
it.skip('should log error when cleanupExpiredSessions fails', async () => {
const { cleanupExpiredSessions } =
await import('./utils/sessionCleanup.js');
const { cleanupExpiredSessions } = await import(
'./utils/sessionCleanup.js'
);
vi.mocked(cleanupExpiredSessions).mockRejectedValue(
new Error('Cleanup failed'),
);
+3 -2
View File
@@ -552,8 +552,9 @@ export async function main() {
adminControlsListner.setConfig(config);
if (config.isInteractive() && settings.merged.general.devtools) {
const { setupInitialActivityLogger } =
await import('./utils/devtoolsService.js');
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
setupInitialActivityLogger(config);
}
+12 -8
View File
@@ -201,8 +201,9 @@ describe('gemini.tsx main function cleanup', () => {
});
it.skip('should log error when cleanupExpiredSessions fails', async () => {
const { loadCliConfig, parseArguments } =
await import('./config/config.js');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
cleanupMockState.shouldThrow = true;
cleanupMockState.called = false;
@@ -271,8 +272,9 @@ describe('gemini.tsx main function cleanup', () => {
});
it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
const { loadCliConfig, parseArguments } =
await import('./config/config.js');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { registerCleanup } = await import('./utils/cleanup.js');
const mockHookSystem = {
@@ -308,8 +310,9 @@ describe('gemini.tsx main function cleanup', () => {
it('should not register ConsolePatcher cleanup in ACP mode', async () => {
const { registerCleanup } = await import('./utils/cleanup.js');
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
const { loadCliConfig, parseArguments } =
await import('./config/config.js');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
vi.mocked(parseArguments).mockResolvedValue({
@@ -361,8 +364,9 @@ describe('gemini.tsx main function cleanup', () => {
it('should register ConsolePatcher cleanup in non-ACP mode', async () => {
const { registerCleanup } = await import('./utils/cleanup.js');
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
const { loadCliConfig, parseArguments } =
await import('./config/config.js');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
vi.mocked(parseArguments).mockResolvedValue({
+15 -10
View File
@@ -215,8 +215,9 @@ describe('runNonInteractive', () => {
computeMergedSettings: vi.fn(),
} as unknown as LoadedSettings;
const { handleAtCommand } =
await import('./ui/hooks/atCommandProcessor.js');
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
processedQuery: [{ text: query }],
}));
@@ -635,8 +636,9 @@ describe('runNonInteractive', () => {
it('should preprocess @include commands before sending to the model', async () => {
// 1. Mock the imported atCommandProcessor
const { handleAtCommand } =
await import('./ui/hooks/atCommandProcessor.js');
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
const mockHandleAtCommand = vi.mocked(handleAtCommand);
// 2. Define the raw input and the expected processed output
@@ -989,8 +991,9 @@ describe('runNonInteractive', () => {
});
it('should handle slash commands', async () => {
const nonInteractiveCliCommands =
await import('./nonInteractiveCliCommands.js');
const nonInteractiveCliCommands = await import(
'./nonInteractiveCliCommands.js'
);
const handleSlashCommandSpy = vi.spyOn(
nonInteractiveCliCommands,
'handleSlashCommand',
@@ -1268,11 +1271,13 @@ describe('runNonInteractive', () => {
it('should instantiate CommandService with correct loaders for slash commands', async () => {
// This test indirectly checks that handleSlashCommand is using the right loaders.
const { FileCommandLoader } =
await import('./services/FileCommandLoader.js');
const { FileCommandLoader } = await import(
'./services/FileCommandLoader.js'
);
const { McpPromptLoader } = await import('./services/McpPromptLoader.js');
const { BuiltinCommandLoader } =
await import('./services/BuiltinCommandLoader.js');
const { BuiltinCommandLoader } = await import(
'./services/BuiltinCommandLoader.js'
);
mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Acknowledged' },
+3 -2
View File
@@ -84,8 +84,9 @@ export async function runNonInteractive(
});
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
const { setupInitialActivityLogger } =
await import('./utils/devtoolsService.js');
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
setupInitialActivityLogger(config);
}
@@ -221,8 +221,9 @@ describe('runNonInteractive', () => {
computeMergedSettings: vi.fn(),
} as unknown as LoadedSettings;
const { handleAtCommand } =
await import('./ui/hooks/atCommandProcessor.js');
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
processedQuery: [{ text: query }],
}));
@@ -689,8 +690,9 @@ describe('runNonInteractive', () => {
it('should preprocess @include commands before sending to the model', async () => {
// 1. Mock the imported atCommandProcessor
const { handleAtCommand } =
await import('./ui/hooks/atCommandProcessor.js');
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
const mockHandleAtCommand = vi.mocked(handleAtCommand);
// 2. Define the raw input and the expected processed output
@@ -1116,8 +1118,9 @@ describe('runNonInteractive', () => {
});
it('should handle slash commands', async () => {
const nonInteractiveCliCommands =
await import('./nonInteractiveCliCommands.js');
const nonInteractiveCliCommands = await import(
'./nonInteractiveCliCommands.js'
);
const handleSlashCommandSpy = vi.spyOn(
nonInteractiveCliCommands,
'handleSlashCommand',
@@ -1437,11 +1440,13 @@ describe('runNonInteractive', () => {
it('should instantiate CommandService with correct loaders for slash commands', async () => {
// This test indirectly checks that handleSlashCommand is using the right loaders.
const { FileCommandLoader } =
await import('./services/FileCommandLoader.js');
const { FileCommandLoader } = await import(
'./services/FileCommandLoader.js'
);
const { McpPromptLoader } = await import('./services/McpPromptLoader.js');
const { BuiltinCommandLoader } =
await import('./services/BuiltinCommandLoader.js');
const { BuiltinCommandLoader } = await import(
'./services/BuiltinCommandLoader.js'
);
mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Acknowledged' },
@@ -84,8 +84,9 @@ export async function runNonInteractive({
});
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
const { setupInitialActivityLogger } =
await import('./utils/devtoolsService.js');
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
setupInitialActivityLogger(config);
}
+3 -2
View File
@@ -91,8 +91,9 @@ vi.mock('../ui/contexts/StreamingContext.js', async (importOriginal) => {
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const { MockShellExecutionService: MockService } =
await import('./MockShellExecutionService.js');
const { MockShellExecutionService: MockService } = await import(
'./MockShellExecutionService.js'
);
// Register the real execution logic so MockShellExecutionService can fall back to it
MockService.setOriginalImplementation(original.ShellExecutionService.execute);
+13 -69
View File
@@ -100,7 +100,7 @@ import { type LoadedSettings } from '../config/settings.js';
import { createMockSettings } from '../test-utils/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { StreamingState, MessageType } from './types.js';
import { StreamingState } from './types.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import {
UIActionsContext,
@@ -3134,8 +3134,9 @@ describe('AppContainer State Management', () => {
describe('Submission Handling', () => {
it('resets expansion state on submission when not in alternate buffer', async () => {
const { checkPermissions } =
await import('./hooks/atCommandProcessor.js');
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
const { unmount } = await act(async () =>
@@ -3163,8 +3164,9 @@ describe('AppContainer State Management', () => {
});
it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => {
const { checkPermissions } =
await import('./hooks/atCommandProcessor.js');
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
@@ -3446,8 +3448,9 @@ describe('AppContainer State Management', () => {
describe('Permission Handling', () => {
it('shows permission dialog when checkPermissions returns paths', async () => {
const { checkPermissions } =
await import('./hooks/atCommandProcessor.js');
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
const { unmount } = await act(async () => renderAppContainer());
@@ -3468,8 +3471,9 @@ describe('AppContainer State Management', () => {
it.each([true, false])(
'handles permissions when allowed is %s',
async (allowed) => {
const { checkPermissions } =
await import('./hooks/atCommandProcessor.js');
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
const addReadOnlyPathSpy = vi.spyOn(
mockConfig.getWorkspaceContext(),
@@ -3572,64 +3576,4 @@ describe('AppContainer State Management', () => {
unmount();
});
});
describe('Compression Queuing', () => {
beforeEach(async () => {
const { checkPermissions } =
await import('./hooks/atCommandProcessor.js');
vi.mocked(checkPermissions).mockResolvedValue([]);
vi.spyOn(mockConfig, 'isModelSteeringEnabled').mockReturnValue(true);
const actual = await vi.importActual('./hooks/useMessageQueue.js');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { useMessageQueue: realUseMessageQueue } = actual as any;
mockedUseMessageQueue.mockImplementation(realUseMessageQueue);
// Start compression by mocking pendingHistoryItems to include a pending compression
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
compressionStatus: null,
},
},
],
}));
});
it('queues messages during compression instead of handling as steering hints', async () => {
const { unmount } = await act(async () => renderAppContainer());
// Verify state isolation
expect(capturedUIState.streamingState).toBe(StreamingState.Idle);
// Submit a message
await act(async () =>
capturedUIActions.handleFinalSubmit('follow up message'),
);
// Verify it was queued, not submitted as steering hint
expect(capturedUIState.messageQueue).toContain('follow up message');
unmount();
});
it('executes slash commands immediately during compression', async () => {
const { unmount } = await act(async () => renderAppContainer());
// Submit a slash command
await act(async () => capturedUIActions.handleFinalSubmit('/help'));
// Verify it was NOT queued
expect(capturedUIState.messageQueue).not.toContain('/help');
unmount();
});
});
});
+8 -26
View File
@@ -1310,15 +1310,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { isMcpReady } = useMcpStatus(config);
const isCompressing = useMemo(
() =>
pendingHistoryItems.some(
(item) =>
item.type === MessageType.COMPRESSION && item.compression.isPending,
),
[pendingHistoryItems],
);
const {
messageQueue,
addMessage,
@@ -1330,7 +1321,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
streamingState,
submitQuery,
isMcpReady,
isCompressing,
});
cancelHandlerRef.current = useCallback(
@@ -1425,10 +1415,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
if (
(isSlash && isConfigInitialized) ||
(!isCompressing && isIdle && isMcpOrConfigReady)
) {
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
@@ -1451,12 +1438,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
if (
isIdle &&
!isCompressing &&
!isMcpOrConfigReady &&
messageQueue.length === 0
) {
if (isIdle && !isMcpOrConfigReady && messageQueue.length === 0) {
coreEvents.emitFeedback(
'info',
!isConfigInitialized
@@ -1476,7 +1458,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
slashCommands,
isMcpReady,
streamingState,
isCompressing,
messageQueue.length,
pendingHistoryItems,
config,
@@ -1644,9 +1625,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, []);
const shouldShowIdePrompt = Boolean(
currentIDE &&
!config.getIdeMode() &&
!settings.merged.ide.hasSeenNudge &&
!idePromptAnswered,
!config.getIdeMode() &&
!settings.merged.ide.hasSeenNudge &&
!idePromptAnswered,
);
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
@@ -1927,8 +1908,9 @@ 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');
const { toggleDevToolsPanel } = await import(
'../utils/devtoolsService.js'
);
await toggleDevToolsPanel(
config,
showErrorDetails,
@@ -78,8 +78,9 @@ describe('authCommand', () => {
const logoutCommand = authCommand.subCommands?.[1];
expect(logoutCommand?.name).toBe('signout');
const { clearCachedCredentialFile } =
await import('@google/gemini-cli-core');
const { clearCachedCredentialFile } = await import(
'@google/gemini-cli-core'
);
await logoutCommand!.action!(mockContext, '');
@@ -42,7 +42,6 @@ describe('compressCommand', () => {
},
};
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
@@ -63,7 +62,6 @@ describe('compressCommand', () => {
mockTryCompressChat.mockResolvedValue(compressedResult);
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(1, {
type: MessageType.COMPRESSION,
@@ -100,7 +98,6 @@ describe('compressCommand', () => {
mockTryCompressChat.mockResolvedValue(null);
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -117,7 +114,6 @@ describe('compressCommand', () => {
mockTryCompressChat.mockRejectedValue(error);
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -132,7 +128,6 @@ describe('compressCommand', () => {
it('should clear the pending item in a finally block', async () => {
mockTryCompressChat.mockRejectedValue(new Error('some error'));
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
});
+35 -38
View File
@@ -36,51 +36,48 @@ export const compressCommand: SlashCommand = {
},
};
ui.setPendingItem(pendingMessage);
void (async () => {
try {
const promptId = `compress-${Date.now()}`;
const compressed =
await context.services.agentContext?.geminiClient?.tryCompressChat(
promptId,
true,
);
if (compressed) {
ui.addItem(
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
},
} as HistoryItemCompression,
Date.now(),
);
} else {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Failed to compress chat history.',
try {
ui.setPendingItem(pendingMessage);
const promptId = `compress-${Date.now()}`;
const compressed =
await context.services.agentContext?.geminiClient?.tryCompressChat(
promptId,
true,
);
if (compressed) {
ui.addItem(
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
},
Date.now(),
);
}
} catch (e) {
} as HistoryItemCompression,
Date.now(),
);
} else {
ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to compress chat history: ${
e instanceof Error ? e.message : String(e)
}`,
text: 'Failed to compress chat history.',
},
Date.now(),
);
} finally {
ui.setPendingItem(null);
}
})();
} catch (e) {
ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to compress chat history: ${
e instanceof Error ? e.message : String(e)
}`,
},
Date.now(),
);
} finally {
ui.setPendingItem(null);
}
},
};
@@ -1139,8 +1139,9 @@ describe('extensionsCommand', () => {
const prompts = (await import('prompts')).default;
vi.mocked(prompts).mockResolvedValue({ overwrite: true });
const { getScopedEnvContents } =
await import('../../config/extensions/extensionSettings.js');
const { getScopedEnvContents } = await import(
'../../config/extensions/extensionSettings.js'
);
vi.mocked(getScopedEnvContents).mockResolvedValue({});
});
@@ -635,8 +635,9 @@ describe('MainContent', () => {
});
it('renders a ToolConfirmationQueue without an extra line when preceded by hidden tools', async () => {
const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } =
await import('@google/gemini-cli-core');
const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import(
'@google/gemini-cli-core'
);
const hiddenToolCalls = [
{
callId: 'tool-hidden',
@@ -712,8 +713,9 @@ describe('MainContent', () => {
});
it('renders a spurious line when a tool group has only hidden tools and borderBottom true', async () => {
const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } =
await import('@google/gemini-cli-core');
const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import(
'@google/gemini-cli-core'
);
const uiState = {
...defaultMockUiState,
history: [{ id: 1, type: 'user', text: 'Apply plan' }],
@@ -326,36 +326,6 @@ describe('SettingsDialog', () => {
});
unmount();
});
it('should render the bottom border correctly when height is constrained', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const constrainedHeight = 15;
const renderResult = await renderDialog(settings, onSelect, {
availableTerminalHeight: constrainedHeight,
});
await renderResult.waitUntilReady();
await waitFor(() => {
const output = renderResult.lastFrame();
const lines = output.trim().split('\n');
// Verify height constraint
expect(lines.length).toBeLessThanOrEqual(constrainedHeight);
// Verify bottom border existence in the last line of the output
const lastLine = lines[lines.length - 1];
// 'round' border characters: ─, ╰, ╯
expect(lastLine).toMatch(/[─╰╯]/);
});
// SVG snapshot ensures visual layout and border rendering are preserved
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
describe('Setting Descriptions', () => {
@@ -1,63 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="275" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#878787" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="19" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs" font-weight="bold">&gt; Settings </text>
<text x="891" y="36" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="53" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#d7ffd7" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="891" y="70" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="87" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">╰─</text>
<rect x="36" y="85" width="9" height="17" fill="#ffffff" />
<text x="36" y="87" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">S</text>
<text x="45" y="87" fill="#afafaf" textLength="135" lengthAdjust="spacingAndGlyphs">earch to filter</text>
<text x="180" y="87" fill="#d7ffd7" textLength="702" lengthAdjust="spacingAndGlyphs">─────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="87" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="104" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="104" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="119" width="9" height="17" fill="#005f00" />
<text x="27" y="121" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="119" width="9" height="17" fill="#005f00" />
<rect x="45" y="119" width="72" height="17" fill="#005f00" />
<text x="45" y="121" fill="#d7ffd7" textLength="72" lengthAdjust="spacingAndGlyphs">Vim Mode</text>
<rect x="117" y="119" width="711" height="17" fill="#005f00" />
<rect x="828" y="119" width="45" height="17" fill="#005f00" />
<text x="828" y="121" fill="#d7ffd7" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="121" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="138" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="136" width="198" height="17" fill="#005f00" />
<text x="45" y="138" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">Enable Vim keybindings</text>
<text x="891" y="138" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="155" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="172" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> Apply To </text>
<text x="891" y="172" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="187" width="9" height="17" fill="#005f00" />
<text x="27" y="189" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="187" width="9" height="17" fill="#005f00" />
<rect x="45" y="187" width="117" height="17" fill="#005f00" />
<text x="45" y="189" fill="#d7ffd7" textLength="117" lengthAdjust="spacingAndGlyphs">User Settings</text>
<rect x="162" y="187" width="711" height="17" fill="#005f00" />
<text x="891" y="189" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="206" fill="#afafaf" textLength="657" lengthAdjust="spacingAndGlyphs">(Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)</text>
<text x="891" y="206" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="223" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#878787" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

@@ -46,24 +46,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Initial Rendering > should render the bottom border correctly when height is constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ ╰─Search to filter─────────────────────────────────────────────────────────────────────────────╯ │
│ ▲ │
│ ● Vim Mode false │
│ ▼ Enable Vim keybindings │
│ │
│ Apply To │
│ ● User Settings │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -425,7 +425,7 @@ export function BaseSettingsDialog({
flexDirection="row"
padding={1}
width="100%"
maxHeight={availableHeight}
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
{/* Title */}
@@ -11,10 +11,8 @@ import { MaxSizedBox, type MaxSizedBoxProps } from './MaxSizedBox.js';
// outputs that will get truncated further MaxSizedBox anyway.
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000;
export interface SlicingMaxSizedBoxProps<T> extends Omit<
MaxSizedBoxProps,
'children'
> {
export interface SlicingMaxSizedBoxProps<T>
extends Omit<MaxSizedBoxProps, 'children'> {
data: T;
maxLines?: number;
isAlternateBuffer?: boolean;
@@ -29,7 +29,6 @@ describe('useMessageQueue', () => {
streamingState: StreamingState;
submitQuery: (query: string) => void;
isMcpReady: boolean;
isCompressing?: boolean;
}) => {
let hookResult: ReturnType<typeof useMessageQueue>;
function TestComponent(props: typeof initialProps) {
@@ -403,52 +402,4 @@ describe('useMessageQueue', () => {
expect(result.current.messageQueue).toEqual([]);
});
});
describe('isCompressing logic', () => {
it('should not auto-submit when isCompressing is true, even if streamingState is Idle', async () => {
const { result } = await renderMessageQueueHook({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: true,
isCompressing: true,
});
// Add messages
act(() => {
result.current.addMessage('Compression message');
});
expect(mockSubmitQuery).not.toHaveBeenCalled();
expect(result.current.messageQueue).toEqual(['Compression message']);
});
it('should auto-submit queued messages when isCompressing becomes false', async () => {
const { result, rerender } = await renderMessageQueueHook({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: true,
isCompressing: true,
});
// Add messages
act(() => {
result.current.addMessage('Pending compression message 1');
result.current.addMessage('Pending compression message 2');
});
expect(mockSubmitQuery).not.toHaveBeenCalled();
// Transition isCompressing to false
rerender({ isCompressing: false });
await waitFor(() => {
expect(mockSubmitQuery).toHaveBeenCalledWith(
'Pending compression message 1\n\nPending compression message 2',
);
expect(result.current.messageQueue).toEqual([]);
});
});
});
});
@@ -12,7 +12,6 @@ export interface UseMessageQueueOptions {
streamingState: StreamingState;
submitQuery: (query: string) => void;
isMcpReady: boolean;
isCompressing?: boolean;
}
export interface UseMessageQueueReturn {
@@ -33,7 +32,6 @@ export function useMessageQueue({
streamingState,
submitQuery,
isMcpReady,
isCompressing = false,
}: UseMessageQueueOptions): UseMessageQueueReturn {
const [messageQueue, setMessageQueue] = useState<string[]>([]);
@@ -71,7 +69,6 @@ export function useMessageQueue({
if (
isConfigInitialized &&
streamingState === StreamingState.Idle &&
!isCompressing &&
isMcpReady &&
messageQueue.length > 0
) {
@@ -87,7 +84,6 @@ export function useMessageQueue({
isMcpReady,
messageQueue,
submitQuery,
isCompressing,
]);
return {
+4 -4
View File
@@ -153,15 +153,15 @@ const inScreen = (): boolean =>
const isSSH = (): boolean =>
Boolean(
process.env['SSH_TTY'] ||
process.env['SSH_CONNECTION'] ||
process.env['SSH_CLIENT'],
process.env['SSH_CONNECTION'] ||
process.env['SSH_CLIENT'],
);
const isWSL = (): boolean =>
Boolean(
process.env['WSL_DISTRO_NAME'] ||
process.env['WSLENV'] ||
process.env['WSL_INTEROP'],
process.env['WSLENV'] ||
process.env['WSL_INTEROP'],
);
const isWindowsTerminal = (): boolean =>
+15 -10
View File
@@ -220,8 +220,9 @@ describe('rewindFileOps', () => {
});
it('reverts exact match', async () => {
const { getFileDiffFromResultDisplay } =
await import('@google/gemini-cli-core');
const { getFileDiffFromResultDisplay } = await import(
'@google/gemini-cli-core'
);
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
filePath: '/abs/path/test.ts',
fileName: 'test.ts',
@@ -269,8 +270,9 @@ describe('rewindFileOps', () => {
});
it('deletes new file on revert', async () => {
const { getFileDiffFromResultDisplay } =
await import('@google/gemini-cli-core');
const { getFileDiffFromResultDisplay } = await import(
'@google/gemini-cli-core'
);
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
filePath: '/abs/path/new.ts',
fileName: 'new.ts',
@@ -315,8 +317,9 @@ describe('rewindFileOps', () => {
});
it('handles smart revert (patching) successfully', async () => {
const { getFileDiffFromResultDisplay } =
await import('@google/gemini-cli-core');
const { getFileDiffFromResultDisplay } = await import(
'@google/gemini-cli-core'
);
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
filePath: '/abs/path/test.ts',
fileName: 'test.ts',
@@ -366,8 +369,9 @@ describe('rewindFileOps', () => {
});
it('emits warning on smart revert failure', async () => {
const { getFileDiffFromResultDisplay } =
await import('@google/gemini-cli-core');
const { getFileDiffFromResultDisplay } = await import(
'@google/gemini-cli-core'
);
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
filePath: '/abs/path/test.ts',
fileName: 'test.ts',
@@ -417,8 +421,9 @@ describe('rewindFileOps', () => {
});
it('emits error if fs.readFile fails with a generic error', async () => {
const { getFileDiffFromResultDisplay } =
await import('@google/gemini-cli-core');
const { getFileDiffFromResultDisplay } = await import(
'@google/gemini-cli-core'
);
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
filePath: '/abs/path/test.ts',
fileName: 'test.ts',
+2 -4
View File
@@ -17,10 +17,8 @@ export type AgentActionStatus = 'success' | 'no-op' | 'error';
/**
* Metadata representing the result of an agent settings operation.
*/
export interface AgentActionResult extends Omit<
FeatureActionResult,
'featureName'
> {
export interface AgentActionResult
extends Omit<FeatureActionResult, 'featureName'> {
agentName: string;
}
+3 -2
View File
@@ -207,8 +207,9 @@ export async function toggleDevToolsPanel(
}
try {
const { openBrowserSecurely, shouldLaunchBrowser } =
await import('@google/gemini-cli-core');
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
'@google/gemini-cli-core'
);
const url = await startDevToolsServer(config);
if (shouldLaunchBrowser()) {
try {
+2 -4
View File
@@ -20,10 +20,8 @@ export type SkillActionStatus = 'success' | 'no-op' | 'error';
/**
* Metadata representing the result of a skill settings operation.
*/
export interface SkillActionResult extends Omit<
FeatureActionResult,
'featureName'
> {
export interface SkillActionResult
extends Omit<FeatureActionResult, 'featureName'> {
skillName: string;
}
@@ -19,11 +19,11 @@ import {
} from '@google/gemini-cli-core';
// Mock os.homedir to control the home directory in tests
vi.mock('node:os', async (importOriginal) => {
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
return {
...actualOs,
homedir: vi.fn(() => actualOs.homedir()),
homedir: vi.fn(),
};
});
@@ -32,6 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
homedir: () => os.homedir(),
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
isHeadlessMode: vi.fn().mockReturnValue(false),
WarningPriority: {
@@ -65,7 +66,6 @@ describe('getUserStartupWarnings', () => {
afterEach(async () => {
await fs.rm(testRootDir, { recursive: true, force: true });
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -98,54 +98,6 @@ describe('getUserStartupWarnings', () => {
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should not return a warning when running in a subdirectory of home', async () => {
const subDir = path.join(homeDir, 'projects', 'my-app');
await fs.mkdir(subDir, { recursive: true });
const warnings = await getUserStartupWarnings({}, subDir);
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should not return a warning when home directory is a symlink and running in a subdirectory', async () => {
const realHome = path.join(testRootDir, 'real-home');
await fs.mkdir(realHome, { recursive: true });
const symlinkedHome = path.join(testRootDir, 'symlinked-home');
await fs.symlink(realHome, symlinkedHome);
vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
const subDir = path.join(symlinkedHome, 'projects');
await fs.mkdir(subDir, { recursive: true });
const warnings = await getUserStartupWarnings({}, subDir);
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should return a warning when home directory is a symlink and running in it', async () => {
const realHome = path.join(testRootDir, 'real-home2');
await fs.mkdir(realHome, { recursive: true });
const symlinkedHome = path.join(testRootDir, 'symlinked-home2');
await fs.symlink(realHome, symlinkedHome);
vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
const warnings = await getUserStartupWarnings({}, symlinkedHome);
expect(warnings).toContainEqual(
expect.objectContaining({
id: 'home-directory',
message: expect.stringContaining(
'Warning you are running Gemini CLI in your home directory',
),
priority: WarningPriority.Low,
}),
);
});
it('should not return a warning when GEMINI_CLI_HOME differs from os.homedir', async () => {
const projectDir = path.join(testRootDir, 'project');
await fs.mkdir(projectDir, { recursive: true });
vi.stubEnv('GEMINI_CLI_HOME', projectDir);
const warnings = await getUserStartupWarnings({}, projectDir);
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should not return a warning when folder trust is enabled and workspace is trusted', async () => {
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
@@ -194,8 +146,9 @@ describe('getUserStartupWarnings', () => {
describe('folder trust check', () => {
it('should throw FatalUntrustedWorkspaceError when untrusted in headless mode', async () => {
const { isHeadlessMode, FatalUntrustedWorkspaceError } =
await import('@google/gemini-cli-core');
const { isHeadlessMode, FatalUntrustedWorkspaceError } = await import(
'@google/gemini-cli-core'
);
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockImplementation(() => {
throw new FatalUntrustedWorkspaceError(
@@ -5,10 +5,10 @@
*/
import fs from 'node:fs/promises';
import { homedir as osHomedir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import {
homedir,
getCompatibilityWarnings,
WarningPriority,
type StartupWarning,
@@ -39,10 +39,10 @@ const homeDirectoryCheck: WarningCheck = {
try {
const [workspaceRealPath, homeRealPath] = await Promise.all([
fs.realpath(workspaceRoot),
fs.realpath(osHomedir()),
fs.realpath(homedir()),
]);
if (path.resolve(workspaceRealPath) === path.resolve(homeRealPath)) {
if (workspaceRealPath === homeRealPath) {
// If folder trust is enabled and the user trusts the home directory, don't show the warning.
if (
isFolderTrustEnabled(settings) &&
+1 -1
View File
@@ -281,7 +281,7 @@ export type ElicitationResponse = {
export interface ErrorData {
// One of https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
status: // 400
| 'INVALID_ARGUMENT'
| 'INVALID_ARGUMENT'
| 'FAILED_PRECONDITION'
| 'OUT_OF_RANGE'
// 401
@@ -84,8 +84,9 @@ vi.mock('../../utils/debugLogger.js', () => ({
}));
// Re-import mocked modules for assertions.
const { MCPOAuthTokenStorage } =
await import('../../mcp/oauth-token-storage.js');
const { MCPOAuthTokenStorage } = await import(
'../../mcp/oauth-token-storage.js'
);
const {
refreshAccessToken,
exchangeCodeForToken,
@@ -379,8 +379,9 @@ describe('browserAgentFactory', () => {
describe('resetBrowserSession', () => {
it('should delegate to BrowserManager.resetAll', async () => {
const { BrowserManager: MockBrowserManager } =
await import('./browserManager.js');
const { BrowserManager: MockBrowserManager } = await import(
'./browserManager.js'
);
await resetBrowserSession();
expect(
(
+9 -6
View File
@@ -1627,8 +1627,9 @@ describe('oauth2', () => {
});
it('should save credentials using OAuthCredentialStorage during web login', async () => {
const { OAuthCredentialStorage } =
await import('./oauth-credential-storage.js');
const { OAuthCredentialStorage } = await import(
'./oauth-credential-storage.js'
);
const mockAuthUrl = 'https://example.com/auth';
const mockCode = 'test-code';
const mockState = 'test-state';
@@ -1728,8 +1729,9 @@ describe('oauth2', () => {
});
it('should load credentials using OAuthCredentialStorage and not from file', async () => {
const { OAuthCredentialStorage } =
await import('./oauth-credential-storage.js');
const { OAuthCredentialStorage } = await import(
'./oauth-credential-storage.js'
);
const cachedCreds = { refresh_token: 'cached-encrypted-token' };
vi.mocked(OAuthCredentialStorage.loadCredentials).mockResolvedValue(
cachedCreds,
@@ -1765,8 +1767,9 @@ describe('oauth2', () => {
});
it('should clear credentials using OAuthCredentialStorage', async () => {
const { OAuthCredentialStorage } =
await import('./oauth-credential-storage.js');
const { OAuthCredentialStorage } = await import(
'./oauth-credential-storage.js'
);
// Create a dummy unencrypted credential file. It should not be deleted.
const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json');
@@ -8,7 +8,6 @@ import {
ProjectIdRequiredError,
setupUser,
ValidationCancelledError,
InvalidNumericProjectIdError,
resetUserDataCacheForTesting,
} from './setup.js';
import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
@@ -219,20 +218,6 @@ describe('setupUser', () => {
ProjectIdRequiredError,
);
});
it('should throw InvalidNumericProjectIdError when GOOGLE_CLOUD_PROJECT is numeric', async () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '1234567890');
await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
InvalidNumericProjectIdError,
);
});
it('should throw InvalidNumericProjectIdError when GOOGLE_CLOUD_PROJECT_ID is numeric', async () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT_ID', '1234567890');
await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
InvalidNumericProjectIdError,
);
});
});
describe('new user', () => {
-13
View File
@@ -36,15 +36,6 @@ export class ProjectIdRequiredError extends Error {
}
}
export class InvalidNumericProjectIdError extends Error {
constructor(projectId: string) {
super(
`Invalid Google Cloud Project ID: "${projectId}". The GOOGLE_CLOUD_PROJECT (or GOOGLE_CLOUD_PROJECT_ID) environment variable must be set to your string-based Project ID (e.g., "my-project-123"), not your numeric Project Number. Please update your environment variables.`,
);
this.name = 'InvalidNumericProjectIdError';
}
}
/**
* Error thrown when user cancels the validation process.
* This is a non-recoverable error that should result in auth failure.
@@ -131,10 +122,6 @@ export async function setupUser(
process.env['GOOGLE_CLOUD_PROJECT_ID'] ||
undefined;
if (projectId && /^\d+$/.test(projectId)) {
throw new InvalidNumericProjectIdError(projectId);
}
const projectCache = userDataCache.getOrCreate(client, () =>
createCache<string | undefined, Promise<UserData>>({
storage: 'map',
+6 -4
View File
@@ -436,8 +436,9 @@ describe('Server Config (config.ts)', () => {
// interactive defaults to false
});
const { McpClientManager } =
await import('../tools/mcp-client-manager.js');
const { McpClientManager } = await import(
'../tools/mcp-client-manager.js'
);
let mcpStarted = false;
vi.mocked(McpClientManager).mockImplementation(
@@ -465,8 +466,9 @@ describe('Server Config (config.ts)', () => {
interactive: true,
});
const { McpClientManager } =
await import('../tools/mcp-client-manager.js');
const { McpClientManager } = await import(
'../tools/mcp-client-manager.js'
);
let mcpStarted = false;
let resolveMcp: (value: unknown) => void;
const mcpPromise = new Promise((resolve) => {
+3 -2
View File
@@ -2453,8 +2453,9 @@ export class Config implements McpContext, AgentLoopContext {
if (this.experimentalJitContext && this.memoryContextManager) {
await this.memoryContextManager.refresh();
} else {
const { refreshServerHierarchicalMemory } =
await import('../utils/memoryDiscovery.js');
const { refreshServerHierarchicalMemory } = await import(
'../utils/memoryDiscovery.js'
);
await refreshServerHierarchicalMemory(this);
}
if (this._geminiClient?.isInitialized()) {
+2 -3
View File
@@ -78,7 +78,6 @@ export const generalistProfile: ContextProfile = {
budget: {
retainedTokens: 65000,
maxTokens: 150000,
coalescingThresholdTokens: 5000,
},
},
@@ -118,14 +117,14 @@ export const generalistProfile: ContextProfile = {
'NodeDistillation',
env,
resolveProcessorOptions(config, 'NodeDistillation', {
nodeThresholdTokens: 3000,
nodeThresholdTokens: 1000,
}),
),
createNodeTruncationProcessor(
'NodeTruncation',
env,
resolveProcessorOptions(config, 'NodeTruncation', {
maxTokensPerNode: 4000,
maxTokensPerNode: 1200,
}),
),
],
@@ -42,11 +42,6 @@ export function getContextManagementConfigSchema(
description:
'The absolute maximum token count allowed before synchronous truncation kicks in.',
},
coalescingThresholdTokens: {
type: 'number',
description:
'Only trigger background consolidation (snapshots) when at least this many tokens have aged out. Prevents "turn-by-turn" utility model churn.',
},
},
},
processorOptions: {
@@ -29,11 +29,6 @@ export interface AsyncPipelineDef {
export interface ContextBudget {
retainedTokens: number;
maxTokens: number;
/**
* Only trigger background consolidation (snapshots) when at least this many
* tokens have aged out. Prevents "turn-by-turn" utility model churn.
*/
coalescingThresholdTokens?: number;
}
/**
+9 -17
View File
@@ -141,23 +141,15 @@ export class ContextManager {
}
if (agedOutNodes.size > 0) {
const targetDeficit =
currentTokens - this.sidecar.config.budget.retainedTokens;
// Respect coalescing threshold for background work
const threshold =
this.sidecar.config.budget.coalescingThresholdTokens || 0;
if (targetDeficit >= threshold) {
this.env.tokenCalculator.garbageCollectCache(
new Set(this.buffer.nodes.map((n) => n.id)),
);
this.eventBus.emitConsolidationNeeded({
nodes: this.buffer.nodes,
targetDeficit,
targetNodeIds: agedOutNodes,
});
}
this.env.tokenCalculator.garbageCollectCache(
new Set(this.buffer.nodes.map((n) => n.id)),
);
this.eventBus.emitConsolidationNeeded({
nodes: this.buffer.nodes,
targetDeficit:
currentTokens - this.sidecar.config.budget.retainedTokens,
targetNodeIds: agedOutNodes,
});
}
}
}
@@ -17,9 +17,9 @@ export class SnapshotGenerator {
const systemPrompt =
systemInstruction ??
`You are an expert Context Memory Manager. You will be provided with a raw transcript of older conversation turns between a user and an AI assistant.
Your task is to synthesize these turns into a single, dense, factual snapshot that preserves all critical context, preferences, active tasks, and factual knowledge.
Your task is to synthesize these turns into a single, dense, factual snapshot that preserves all critical context, preferences, active tasks, and factual knowledge, but discards conversational filler, pleasantries, and redundant back-and-forth iterations.
Discard conversational filler, pleasantries, and redundant back-and-forth iterations. Output ONLY the raw factual snapshot, formatted compactly. Do not include markdown wrappers, prefixes like "Here is the snapshot", or conversational elements.`;
Output ONLY the raw factual snapshot, formatted compactly. Do not include markdown wrappers, prefixes like "Here is the snapshot", or conversational elements.`;
let userPromptText = 'TRANSCRIPT TO SNAPSHOT:\n\n';
for (const node of nodes) {
@@ -26,7 +26,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -206,7 +206,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -507,7 +507,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -687,7 +687,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -868,7 +868,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -1001,7 +1001,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -1616,7 +1616,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -1793,7 +1793,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -1961,7 +1961,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -2129,7 +2129,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -2293,7 +2293,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -2457,7 +2457,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -2615,7 +2615,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -2747,7 +2747,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -3039,7 +3039,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -3461,7 +3461,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -3625,7 +3625,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -3903,7 +3903,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -4067,7 +4067,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like grep_search to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like read_file and grep_search.
- replace fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- read_file fails if old_string is ambiguous, causing extra turns. Take care to read enough with read_file and grep_search to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
+18 -13
View File
@@ -289,7 +289,6 @@ describe('Gemini Client (client.ts)', () => {
resetTurn: vi.fn(),
isAutoDistillationEnabled: vi.fn().mockReturnValue(false),
isContextManagementEnabled: vi.fn().mockReturnValue(false),
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
getModelAvailabilityService: vi
.fn()
@@ -1305,8 +1304,9 @@ ${JSON.stringify(
it('should stop infinite loop after MAX_TURNS when nextSpeaker always returns model', async () => {
// Get the mocked checkNextSpeaker function and configure it to trigger infinite loop
const { checkNextSpeaker } =
await import('../utils/nextSpeakerChecker.js');
const { checkNextSpeaker } = await import(
'../utils/nextSpeakerChecker.js'
);
const mockCheckNextSpeaker = vi.mocked(checkNextSpeaker);
mockCheckNextSpeaker.mockResolvedValue({
next_speaker: 'model',
@@ -1428,8 +1428,9 @@ ${JSON.stringify(
// someone tries to bypass it by calling with a very large turns value
// Get the mocked checkNextSpeaker function and configure it to trigger infinite loop
const { checkNextSpeaker } =
await import('../utils/nextSpeakerChecker.js');
const { checkNextSpeaker } = await import(
'../utils/nextSpeakerChecker.js'
);
const mockCheckNextSpeaker = vi.mocked(checkNextSpeaker);
mockCheckNextSpeaker.mockResolvedValue({
next_speaker: 'model',
@@ -2835,8 +2836,9 @@ ${JSON.stringify(
it('should not call checkNextSpeaker when turn.run() yields an error', async () => {
// Arrange
const { checkNextSpeaker } =
await import('../utils/nextSpeakerChecker.js');
const { checkNextSpeaker } = await import(
'../utils/nextSpeakerChecker.js'
);
const mockCheckNextSpeaker = vi.mocked(checkNextSpeaker);
const mockStream = (async function* () {
@@ -2871,8 +2873,9 @@ ${JSON.stringify(
it('should not call checkNextSpeaker when turn.run() yields a value then an error', async () => {
// Arrange
const { checkNextSpeaker } =
await import('../utils/nextSpeakerChecker.js');
const { checkNextSpeaker } = await import(
'../utils/nextSpeakerChecker.js'
);
const mockCheckNextSpeaker = vi.mocked(checkNextSpeaker);
const mockStream = (async function* () {
@@ -3250,8 +3253,9 @@ ${JSON.stringify(
});
it('should fire BeforeAgent once and AfterAgent once even with recursion', async () => {
const { checkNextSpeaker } =
await import('../utils/nextSpeakerChecker.js');
const { checkNextSpeaker } = await import(
'../utils/nextSpeakerChecker.js'
);
vi.mocked(checkNextSpeaker)
.mockResolvedValueOnce({ next_speaker: 'model', reasoning: 'more' })
.mockResolvedValueOnce(null);
@@ -3290,8 +3294,9 @@ ${JSON.stringify(
});
it('should use original request in AfterAgent hook even when continuation happened', async () => {
const { checkNextSpeaker } =
await import('../utils/nextSpeakerChecker.js');
const { checkNextSpeaker } = await import(
'../utils/nextSpeakerChecker.js'
);
vi.mocked(checkNextSpeaker)
.mockResolvedValueOnce({ next_speaker: 'model', reasoning: 'more' })
.mockResolvedValueOnce(null);
+1 -4
View File
@@ -827,10 +827,7 @@ export class GeminiChat {
const history = curated
? extractCuratedHistory([...this.agentHistory.get()])
: this.agentHistory.get();
return this.context.config.isContextManagementEnabled()
? scrubHistory([...history])
: [...history];
return [...history];
}
/**
@@ -587,66 +587,4 @@ describe('GeminiChat Network Retries', () => {
}),
);
});
it('should retry on premature stream closure (ERR_STREAM_PREMATURE_CLOSE)', async () => {
mockConfig.getRetryFetchErrors = vi.fn().mockReturnValue(true);
const prematureCloseError = new Error('Premature close');
Object.defineProperty(prematureCloseError, 'code', {
value: 'ERR_STREAM_PREMATURE_CLOSE',
});
vi.mocked(mockContentGenerator.generateContentStream)
.mockResolvedValueOnce(
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Incomplete part' }] } }],
} as unknown as GenerateContentResponse;
throw prematureCloseError;
})(),
)
.mockResolvedValueOnce(
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Complete response after retry' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
{ model: 'test-model' },
'test message',
'prompt-id-premature-close',
new AbortController().signal,
LlmRole.MAIN,
);
const events: StreamEvent[] = [];
for await (const event of stream) {
events.push(event);
}
const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
expect(retryEvent).toBeDefined();
const successChunk = events.find(
(e) =>
e.type === StreamEventType.CHUNK &&
e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
'Complete response after retry',
);
expect(successChunk).toBeDefined();
expect(mockLogNetworkRetryAttempt).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
error_type: 'ERR_STREAM_PREMATURE_CLOSE',
}),
);
});
});
@@ -699,8 +699,9 @@ describe('ide-connection-utils', () => {
describe('createProxyAwareFetch', () => {
it('should return a proxy-aware fetcher function', async () => {
const { createProxyAwareFetch } =
await import('./ide-connection-utils.js');
const { createProxyAwareFetch } = await import(
'./ide-connection-utils.js'
);
const fetcher = await createProxyAwareFetch('127.0.0.1');
expect(typeof fetcher).toBe('function');
});
@@ -1898,30 +1898,6 @@ describe('PolicyEngine', () => {
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should NOT downgrade to ASK_USER for redirected commands in YOLO mode even without sandbox', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: 10,
},
];
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
sandboxManager: new NoopSandboxManager(),
});
const command = 'npm test 2>&1 | tail -80';
const { decision } = await engine.check(
{ name: 'run_shell_command', args: { command } },
undefined,
);
expect(decision).toBe(PolicyDecision.ALLOW);
});
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
+5 -4
View File
@@ -288,11 +288,12 @@ export class PolicyEngine {
if (allowRedirection) return false;
if (!hasRedirection(command)) return false;
// Do not downgrade (do not ask user) if in AUTO_EDIT or YOLO mode.
// These modes trust the agent's actions (YOLO) or specific task (AUTO_EDIT).
// Do not downgrade (do not ask user) if sandboxing is enabled and in AUTO_EDIT or YOLO
const sandboxEnabled = !(this.sandboxManager instanceof NoopSandboxManager);
if (
this.approvalMode === ApprovalMode.AUTO_EDIT ||
this.approvalMode === ApprovalMode.YOLO
sandboxEnabled &&
(this.approvalMode === ApprovalMode.AUTO_EDIT ||
this.approvalMode === ApprovalMode.YOLO)
) {
return false;
}
+1 -1
View File
@@ -242,7 +242,7 @@ Use the following guidelines to optimize your search and read patterns.
- Prefer using tools like ${GREP_TOOL_NAME} to identify points of interest instead of reading lots of files individually.
- If you need to read multiple ranges in a file, do so parallel, in as few turns as possible.
- It is more important to reduce extra turns, but please also try to minimize unnecessarily large file reads and search results, when doing so doesn't result in extra turns. Do this by always providing conservative limits and scopes to tools like ${READ_FILE_TOOL_NAME} and ${GREP_TOOL_NAME}.
- ${EDIT_TOOL_NAME} fails if ${EDIT_PARAM_OLD_STRING} is ambiguous, causing extra turns. Take care to read enough with ${READ_FILE_TOOL_NAME} and ${GREP_TOOL_NAME} to make the edit unambiguous.
- ${READ_FILE_TOOL_NAME} fails if ${EDIT_PARAM_OLD_STRING} is ambiguous, causing extra turns. Take care to read enough with ${READ_FILE_TOOL_NAME} and ${GREP_TOOL_NAME} to make the edit unambiguous.
- You can compensate for the risk of missing results with scoped or limited searches by doing multiple searches in parallel.
- Your primary goal is still to do your best quality work. Efficiency is an important, but secondary concern.
</guidelines>
@@ -334,8 +334,9 @@ describe('memoryService', () => {
});
it('writes state atomically via temp file + rename', async () => {
const { writeExtractionState, readExtractionState } =
await import('./memoryService.js');
const { writeExtractionState, readExtractionState } = await import(
'./memoryService.js'
);
const statePath = path.join(tmpDir, '.extraction-state.json');
const state: ExtractionState = {
@@ -363,8 +364,9 @@ describe('memoryService', () => {
describe('startMemoryService', () => {
it('skips when lock is held by another instance', async () => {
const { startMemoryService } = await import('./memoryService.js');
const { LocalAgentExecutor } =
await import('../agents/local-executor.js');
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
const memoryDir = path.join(tmpDir, 'memory');
const skillsDir = path.join(tmpDir, 'skills');
@@ -402,8 +404,9 @@ describe('memoryService', () => {
it('skips when no unprocessed sessions exist', async () => {
const { startMemoryService } = await import('./memoryService.js');
const { LocalAgentExecutor } =
await import('../agents/local-executor.js');
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
const memoryDir = path.join(tmpDir, 'memory2');
const skillsDir = path.join(tmpDir, 'skills2');
@@ -436,10 +439,12 @@ describe('memoryService', () => {
it('releases lock on error', async () => {
const { startMemoryService } = await import('./memoryService.js');
const { LocalAgentExecutor } =
await import('../agents/local-executor.js');
const { ExecutionLifecycleService } =
await import('./executionLifecycleService.js');
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
const { ExecutionLifecycleService } = await import(
'./executionLifecycleService.js'
);
const memoryDir = path.join(tmpDir, 'memory3');
const skillsDir = path.join(tmpDir, 'skills3');
@@ -493,8 +498,9 @@ describe('memoryService', () => {
it('emits feedback when new skills are created during extraction', async () => {
const { startMemoryService } = await import('./memoryService.js');
const { LocalAgentExecutor } =
await import('../agents/local-executor.js');
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
// Reset mocks that may carry state from prior tests
vi.mocked(coreEvents.emitFeedback).mockClear();
@@ -562,10 +568,12 @@ describe('memoryService', () => {
});
it('records inbox patches as memoryCandidatesCreated without applying them', async () => {
const { startMemoryService, readExtractionState } =
await import('./memoryService.js');
const { LocalAgentExecutor } =
await import('../agents/local-executor.js');
const { startMemoryService, readExtractionState } = await import(
'./memoryService.js'
);
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
vi.mocked(coreEvents.emitFeedback).mockClear();
vi.mocked(LocalAgentExecutor.create).mockReset();
@@ -663,10 +671,12 @@ describe('memoryService', () => {
});
it('records only sessions whose read_file completed successfully as processed', async () => {
const { startMemoryService, readExtractionState } =
await import('./memoryService.js');
const { LocalAgentExecutor } =
await import('../agents/local-executor.js');
const { startMemoryService, readExtractionState } = await import(
'./memoryService.js'
);
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
vi.mocked(LocalAgentExecutor.create).mockReset();
@@ -1623,8 +1633,9 @@ describe('memoryService', () => {
});
it('writeExtractionState + readExtractionState roundtrips runs correctly', async () => {
const { writeExtractionState, readExtractionState } =
await import('./memoryService.js');
const { writeExtractionState, readExtractionState } = await import(
'./memoryService.js'
);
const statePath = path.join(tmpDir, 'roundtrip-state.json');
const runs: ExtractionRun[] = [
@@ -1970,8 +1981,9 @@ describe('memoryService', () => {
describe('startMemoryService feedback for patch-only runs', () => {
it('emits feedback when extraction produces only patch suggestions', async () => {
const { startMemoryService } = await import('./memoryService.js');
const { LocalAgentExecutor } =
await import('../agents/local-executor.js');
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
vi.mocked(coreEvents.emitFeedback).mockClear();
vi.mocked(LocalAgentExecutor.create).mockReset();
@@ -2053,8 +2065,9 @@ describe('memoryService', () => {
it('does not emit feedback for old inbox patches when this run creates none', async () => {
const { startMemoryService } = await import('./memoryService.js');
const { LocalAgentExecutor } =
await import('../agents/local-executor.js');
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
vi.mocked(coreEvents.emitFeedback).mockClear();
vi.mocked(LocalAgentExecutor.create).mockReset();
@@ -143,8 +143,9 @@ describe('sessionSummaryUtils', () => {
mockGenerateSummary = vi.fn().mockResolvedValue('Add dark mode to the app');
const { SessionSummaryService } =
await import('./sessionSummaryService.js');
const { SessionSummaryService } = await import(
'./sessionSummaryService.js'
);
(
SessionSummaryService as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => ({
@@ -1885,8 +1885,9 @@ describe('ShellExecutionService environment variables', () => {
vi.stubEnv('GEMINI_CLI_TEST_VAR', 'test-value'); // A test var that should be kept
vi.resetModules();
const { ShellExecutionService } =
await import('./shellExecutionService.js');
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);
// Test pty path
await ShellExecutionService.execute(
@@ -1944,8 +1945,9 @@ describe('ShellExecutionService environment variables', () => {
vi.stubEnv('GEMINI_CLI_TEST_VAR', 'test-value'); // A test var that should be kept
vi.resetModules();
const { ShellExecutionService } =
await import('./shellExecutionService.js');
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);
// Test pty path
await ShellExecutionService.execute(
@@ -2000,8 +2002,9 @@ describe('ShellExecutionService environment variables', () => {
vi.stubEnv('GITHUB_SHA', '');
vi.stubEnv('SURFACE', '');
vi.resetModules();
const { ShellExecutionService } =
await import('./shellExecutionService.js');
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);
// Test pty path
await ShellExecutionService.execute(
@@ -2107,8 +2110,9 @@ describe('ShellExecutionService environment variables', () => {
vi.stubEnv('GIT_CONFIG_KEY_1', 'pull.rebase');
vi.stubEnv('GIT_CONFIG_VALUE_1', 'true');
const { ShellExecutionService } =
await import('./shellExecutionService.js');
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);
mockGetPty.mockResolvedValue(null); // Force child_process fallback
await ShellExecutionService.execute(
@@ -2158,8 +2162,9 @@ describe('ShellExecutionService environment variables', () => {
vi.stubEnv('GCM_INTERACTIVE', undefined);
vi.stubEnv('GIT_CONFIG_COUNT', undefined);
const { ShellExecutionService } =
await import('./shellExecutionService.js');
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);
mockGetPty.mockResolvedValue(null); // Force child_process fallback
await ShellExecutionService.execute(
@@ -1333,7 +1333,7 @@ Use this tool when the user's query implies needing the content of several files
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: replace 1`] = `
{
"description": "Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool is preferred for surgical edits to existing files as it minimizes token usage, simplifies code reviews, and avoids accidental deletions. This tool requires providing significant context around the change to ensure precise targeting.
"description": "Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting.
The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.",
"name": "replace",
"parametersJsonSchema": {
@@ -1496,7 +1496,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: write_file 1`] = `
{
"description": "Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use 'replace' for targeted edits to large files to minimize token usage and simplify reviews.",
"description": "Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use 'replace' for targeted edits to large files.",
"name": "write_file",
"parametersJsonSchema": {
"properties": {
@@ -120,7 +120,7 @@ export const GEMINI_3_SET: CoreToolSet = {
write_file: {
name: WRITE_FILE_TOOL_NAME,
description: `Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use '${EDIT_TOOL_NAME}' for targeted edits to large files to minimize token usage and simplify reviews.`,
description: `Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use '${EDIT_TOOL_NAME}' for targeted edits to large files.`,
parametersJsonSchema: {
type: 'object',
properties: {
@@ -355,7 +355,7 @@ export const GEMINI_3_SET: CoreToolSet = {
replace: {
name: EDIT_TOOL_NAME,
description: `Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool is preferred for surgical edits to existing files as it minimizes token usage, simplifies code reviews, and avoids accidental deletions. This tool requires providing significant context around the change to ensure precise targeting.
description: `Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting.
The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.`,
parametersJsonSchema: {
type: 'object',
+6 -4
View File
@@ -1261,8 +1261,9 @@ function doIt() {
describe('JIT context discovery', () => {
it('should append JIT context to output when enabled and context is found', async () => {
const { discoverJitContext, appendJitContext } =
await import('./jit-context.js');
const { discoverJitContext, appendJitContext } = await import(
'./jit-context.js'
);
vi.mocked(discoverJitContext).mockResolvedValue('Use the useAuth hook.');
vi.mocked(appendJitContext).mockImplementation((content, context) => {
if (!context) return content;
@@ -1291,8 +1292,9 @@ function doIt() {
});
it('should not append JIT context when disabled', async () => {
const { discoverJitContext, appendJitContext } =
await import('./jit-context.js');
const { discoverJitContext, appendJitContext } = await import(
'./jit-context.js'
);
vi.mocked(discoverJitContext).mockResolvedValue('');
vi.mocked(appendJitContext).mockImplementation((content, context) => {
if (!context) return content;
+2 -3
View File
@@ -21,9 +21,8 @@ import { debugLogger } from '../utils/debugLogger.js';
/**
* A declarative tool that supports a modify operation.
*/
export interface ModifiableDeclarativeTool<
TParams extends object,
> extends DeclarativeTool<TParams, ToolResult> {
export interface ModifiableDeclarativeTool<TParams extends object>
extends DeclarativeTool<TParams, ToolResult> {
getModifyContext(abortSignal: AbortSignal): ModifyContext<TParams>;
}
+4 -2
View File
@@ -157,7 +157,8 @@ export interface PolicyUpdateOptions {
export abstract class BaseToolInvocation<
TParams extends object,
TResult extends ToolResult,
> implements ToolInvocation<TParams, TResult> {
> implements ToolInvocation<TParams, TResult>
{
constructor(
readonly params: TParams,
protected readonly messageBus: MessageBus,
@@ -461,7 +462,8 @@ export interface ToolParameterSchema {
export abstract class DeclarativeTool<
TParams extends object,
TResult extends ToolResult,
> implements ToolBuilder<TParams, TResult> {
> implements ToolBuilder<TParams, TResult>
{
constructor(
readonly name: string,
readonly displayName: string,
+1 -2
View File
@@ -978,8 +978,7 @@ describe('WriteFileTool', () => {
const content = 'test content';
let existsSyncSpy: // eslint-disable-next-line @typescript-eslint/no-explicit-any
ReturnType<typeof vi.spyOn<any, 'existsSync'>> | undefined =
undefined;
ReturnType<typeof vi.spyOn<any, 'existsSync'>> | undefined = undefined;
try {
if (mockFsExistsSync) {
+10 -3
View File
@@ -280,9 +280,16 @@ function parseResponseData(error: GaxiosError): ResponseData | undefined {
export function isAuthenticationError(error: unknown): boolean {
// Check for MCP SDK errors with code property
// (SseError and StreamableHTTPError both have numeric 'code' property)
if (error && typeof error === 'object' && 'code' in error) {
const errorCode: unknown = (error as Record<string, unknown>)['code'];
if (typeof errorCode === 'number' && errorCode === 401) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
typeof (error as { code: unknown }).code === 'number'
) {
// Safe access after check
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const errorCode = (error as { code: number }).code;
if (errorCode === 401) {
return true;
}
}
+14 -24
View File
@@ -16,33 +16,23 @@ export interface ApiError {
}
export function isApiError(error: unknown): error is ApiError {
if (typeof error !== 'object' || error === null || !('error' in error)) {
return false;
}
const errorProp = (error as { error: unknown }).error;
if (typeof errorProp !== 'object' || errorProp === null) {
return false;
}
return (
'code' in errorProp &&
typeof errorProp.code === 'number' &&
'message' in errorProp &&
typeof errorProp.message === 'string' &&
'status' in errorProp &&
typeof errorProp.status === 'string'
typeof error === 'object' &&
error !== null &&
'error' in error &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
typeof (error as ApiError).error === 'object' &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
'message' in (error as ApiError).error
);
}
export function isStructuredError(error: unknown): error is StructuredError {
if (typeof error !== 'object' || error === null || !('message' in error)) {
return false;
}
if (typeof error.message !== 'string') {
return false;
}
if ('status' in error && typeof error.status !== 'number') {
return false;
}
return true;
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
typeof (error as StructuredError).message === 'string'
);
}
-1
View File
@@ -58,7 +58,6 @@ const RETRYABLE_NETWORK_CODES = [
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_BODY_TIMEOUT',
'UND_ERR_CONNECT_TIMEOUT',
'ERR_STREAM_PREMATURE_CLOSE',
];
// Node.js builds SSL error codes by prepending ERR_SSL_ to the uppercased
@@ -20,7 +20,8 @@ export interface TranscriptionEvents {
/**
* Common interface for all transcription backends (Cloud or Local).
*/
export interface TranscriptionProvider extends EventEmitter<TranscriptionEvents> {
export interface TranscriptionProvider
extends EventEmitter<TranscriptionEvents> {
/** Establish connection to the transcription service. */
connect(): Promise<void>;
/** Send a chunk of raw audio data to the service. */
+3 -3
View File
@@ -56,11 +56,11 @@ SOFTWARE.
============================================================
ajv@6.14.0
(No repository found)
(https://github.com/ajv-validator/ajv.git)
The MIT License (MIT)
Copyright (c) 2015-2021 Evgeny Poberezkin
Copyright (c) 2015-2017 Evgeny Poberezkin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -492,7 +492,7 @@ eventsource-parser@3.0.3
MIT License
Copyright (c) 2026 Espen Hovlandsdal <espen@hovlandsdal.com>
Copyright (c) 2025 Espen Hovlandsdal <espen@hovlandsdal.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal