Compare commits

...

16 Commits

Author SHA1 Message Date
gemini-cli-robot 157e64b490 chore(release): v0.55.0-preview.1 2026-08-06 01:21:02 +00:00
luisfelipe-alt 761f604c16 fix(core): unwrap and parse nested gaxios streaming errors from cause message (#28689) 2026-08-05 22:38:37 +00:00
Mpider-San 63c5b74770 fix(core): preserve functionCall thoughtSignature when stripping thought parts (#28607)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-08-05 21:55:32 +00:00
Adam Weidman 348fc35f17 fix(core,cli): repair /compress session reload and quota-fallback tool response loss (#28672)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-08-05 18:53:41 +00:00
Adam Weidman 56f9688b30 fix(core): stop a new user message fusing into an unanswered tool response (#28700)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-08-05 18:07:50 +00:00
David Pierce 6863148728 fix(release): handle npm dist-tag deletion failures on registries that forbid it (#28694) 2026-08-05 18:07:15 +00:00
joneba-google bde504f250 feat(pr-generator-infra): configure Cloud Run job, Workflows definition, and Dockerfile (#28431) 2026-08-05 16:04:44 +00:00
joneba-google b6b41f79eb feat(pr-generator-orchestrator): implement iterative bug-fixing state machine and container worker entrypoint (#28433) 2026-08-05 15:27:33 +00:00
joneba-google 8b60087673 feat(pr-generator-core): add environment config parser, command executor, GitHub R… (#28435) 2026-08-05 15:18:34 +00:00
amelidev ac42fb0a24 fix(cli): fall back to embedded macOS seatbelt profiles if missing (#28551) 2026-08-03 19:31:48 +00:00
David Pierce f47d6c6f7a fix(core,cli): propagate InvalidStreamError details to UI for specific empty response guidance (#28566)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-31 17:55:10 +00:00
luisfelipe-alt d55e366f6a fix(core): classify capacity exhaustion as terminal to prevent retry hangs (#28599) 2026-07-30 17:59:01 +00:00
gemini-cli-robot dc859e8e48 chore/release: bump version to 0.55.0-nightly.20260729.g3499c84f7 (#28573) 2026-07-29 18:55:47 +00:00
gemini-cli-robot 4bb7e93c45 Changelog for v0.53.0 (#28568)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-07-29 18:54:53 +00:00
gemini-cli-robot 55a31ef909 Changelog for v0.54.0-preview.0 (#28567)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-07-29 18:54:50 +00:00
gemini-cli-robot 3499c84f7b chore(release): bump version to 0.55.0-nightly.20260728.gd29268d36 (#28569) 2026-07-28 22:10:39 +00:00
78 changed files with 6408 additions and 361 deletions
+5 -5
View File
@@ -172,7 +172,7 @@ runs:
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
--tag staging-tmp
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
fi
- name: '🔗 Install latest core package'
@@ -251,7 +251,7 @@ runs:
${PUBLISH_TARGET} \
--tag staging-tmp
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
fi
- name: 'Get a2a-server Token'
@@ -278,9 +278,9 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
--tag staging-tmp
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp
fi
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
fi
- name: '🏷️ Tag release'
uses: './.github/actions/tag-npm-release'
+17
View File
@@ -18,6 +18,23 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.53.0 - 2026-07-28
- **Caretaker Triage Orchestration:** Implemented an LLM triage orchestrator and
container build setup
([#28345](https://github.com/google-gemini/gemini-cli/pull/28345) by
@chadd28).
- **Eval Coverage Reporting:** Introduced a new command for generating
evaluation coverage reports
([#28169](https://github.com/google-gemini/gemini-cli/pull/28169) by @ved015).
- **Security & Loop Mitigations:** Enforced workspace trust and task isolation
in the A2A server, aligned macOS Seatbelt profiles with the deny-default
model, and mitigated infinite ReAct/prompt injection loops
([#28470](https://github.com/google-gemini/gemini-cli/pull/28470) by
@luisfelipe-alt,
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424) by
@ompatel-aiml).
## Announcements: v0.52.0 - 2026-07-22
- **Caretaker Triage & Egress Services:** Implemented the core triage worker
+31 -52
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.52.0
# Latest stable release: v0.53.0
Released: July 22, 2026
Released: July 28, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,59 +11,38 @@ npm install -g @google/gemini-cli
## Highlights
- **Caretaker Services:** Introduced a new caretaker triage worker including
core foundational modules, main worker execution loops, egress action
publishers, and octokit GitHub Action handlers.
- **Robust File Editing:** Core tools like `write_file` and `replace` now bypass
LLM corrections for JSON and IPYNB files to ensure accurate and direct file
modifications.
- **Plan Mode Improvements:** Simplified plan mode write policies to natively
support writing to relative paths, enhancing project directory navigation.
- **Enhanced Account Visibility:** Improved clear user-facing messages when the
user account does not have a Code Assist tier, and enriched shared project
quota limit errors with setup instructions.
- **Caretaker Triage Orchestrator:** Implemented an LLM triage orchestrator and
container build setup to manage automated caretakers.
- **Eval Coverage Reporting:** Introduced a new command for generating
evaluation coverage reports to track agent decision logic and testing.
- **Security and Sandboxing:** Aligned macOS permissive Seatbelt profiles with
the deny-default model, and enforced workspace trust with task isolation in
the A2A server.
- **Robust Conversation Loops:** Coalesced consecutive message roles and grouped
cancelled tool responses to avoid Bad Request errors, and mitigated infinite
ReAct loops and prompt injection vulnerabilities.
## What's Changed
- Refactor: exclude transient CI configuration files from workspace context by
@DavidAPierce in
[#28216](https://github.com/google-gemini/gemini-cli/pull/28216)
- feat(caretaker-triage): add triage worker core foundational modules by
@chadd28 in [#28163](https://github.com/google-gemini/gemini-cli/pull/28163)
- feat(caretaker-egress): implement octokit github action handler for egress
service by @chadd28 in
[#28303](https://github.com/google-gemini/gemini-cli/pull/28303)
- chore(release): bump version to 0.52.0-nightly.20260707.g27a3da3e8 by
@gemini-cli-robot in
[#28323](https://github.com/google-gemini/gemini-cli/pull/28323)
- Changelog for v0.51.0-preview.0 by @gemini-cli-robot in
[#28320](https://github.com/google-gemini/gemini-cli/pull/28320)
- Changelog for v0.50.0 by @gemini-cli-robot in
[#28322](https://github.com/google-gemini/gemini-cli/pull/28322)
- fix(core-tools): bypass LLM correction for JSON and IPYNB files in write_file
and replace by @amelidev in
[#28223](https://github.com/google-gemini/gemini-cli/pull/28223)
- fix(core): use unambiguous previous intent label in fallback summary by
@amelidev in [#28343](https://github.com/google-gemini/gemini-cli/pull/28343)
- feat(caretaker-triage): implement main worker execution loop and egress action
publisher by @chadd28 in
[#28306](https://github.com/google-gemini/gemini-cli/pull/28306)
- fix(privacy): show a clear message when the account has no Code Assist tier by
@ompatel-aiml in
[#28304](https://github.com/google-gemini/gemini-cli/pull/28304)
- fix(core): enrich shared project quota limit errors with setup hint by
@amelidev in [#28391](https://github.com/google-gemini/gemini-cli/pull/28391)
- fix(a2a-server): ensure task cancellation aborts execution loop by
- fix(core,a2a): group cancelled tool responses and coalesce consecutive roles
to prevent 400 Bad Request by @luisfelipe-alt in
[#28407](https://github.com/google-gemini/gemini-cli/pull/28407)
- feat(caretaker-triage): implement LLM triage orchestrator and container build
by @chadd28 in
[#28345](https://github.com/google-gemini/gemini-cli/pull/28345)
- refactor(cli): align macOS permissive Seatbelt profiles with deny-default
model by @ompatel-aiml in
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424)
- fix(core): mitigate infinite ReAct loops and prompt injection loops by
@amelidev in [#28429](https://github.com/google-gemini/gemini-cli/pull/28429)
- fix(a2a-server): enforce workspace trust and task isolation to prevent RCE by
@luisfelipe-alt in
[#28316](https://github.com/google-gemini/gemini-cli/pull/28316)
- fix(core): simplify plan mode write policy to support relative paths by
@DavidAPierce in
[#28398](https://github.com/google-gemini/gemini-cli/pull/28398)
- feat(core): Bump node google-auth-library version to 10.9.0 by @jerrylin3321
in [#28385](https://github.com/google-gemini/gemini-cli/pull/28385)
- chore/release: bump version to 0.52.0-nightly.20260715.gfa975395b by
@gemini-cli-robot in
[#28402](https://github.com/google-gemini/gemini-cli/pull/28402)
[#28470](https://github.com/google-gemini/gemini-cli/pull/28470)
- fix(core): sequentially verify cached credentials and restore
GOOGLE_APPLICATION_CREDENTIALS fallback by @luisfelipe-alt in
[#28472](https://github.com/google-gemini/gemini-cli/pull/28472)
- feat(evals): add eval coverage report command by @ved015 in
[#28169](https://github.com/google-gemini/gemini-cli/pull/28169)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.51.0...v0.52.0
https://github.com/google-gemini/gemini-cli/compare/v0.52.0...v0.53.0
+52 -35
View File
@@ -1,6 +1,6 @@
# Preview release: v0.53.0-preview.0
# Preview release: v0.54.0-preview.0
Released: July 22, 2026
Released: July 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).
@@ -13,42 +13,59 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Caretaker LLM Triage Orchestrator**: Implemented the LLM triage orchestrator
and container build configuration to support caretaker triage workflows.
- **Enhanced Workspace Trust & Sandbox Hardening**: Aligned macOS permissive
Seatbelt profiles with the deny-default model and enforced workspace trust and
task isolation in the Agent-to-Agent (A2A) server to prevent remote code
execution (RCE).
- **Core Robustness & API Protections**: Mitigated infinite ReAct and prompt
injection loops, and prevented 400 Bad Request errors by grouping cancelled
tool responses and coalescing consecutive roles.
- **Robust Credentials & Fallbacks**: Restored the
`GOOGLE_APPLICATION_CREDENTIALS` environment variable fallback and
sequentially verified cached credentials.
- **Evaluation Coverage Reporting**: Added a new command to generate
comprehensive eval coverage reports.
- **Antigravity Agent & PR Generator:** Integrated the Antigravity agent runner,
Firestore dual-locking for concurrency, prompt templates, and ingestion
testing utilities.
- **Caretaker Triage & Issue Management:** Enhanced the issue triage workflow by
automatically posting a comment before closing issues, and sanitizing and
wrapping issue titles in `untrusted_context`.
- **Core API & Session Stability:** Enforced HTTPS for
GoogleCredentialsAuthProvider to prevent cleartext leakage, rotated session
IDs on model fallback to prevent stateful API errors, and refined chat history
by filtering out thought parts when context management is disabled.
## What's Changed
- fix(core,a2a): group cancelled tool responses and coalesce consecutive roles
to prevent 400 Bad Request by @luisfelipe-alt in
[#28407](https://github.com/google-gemini/gemini-cli/pull/28407)
- feat(caretaker-triage): implement LLM triage orchestrator and container build
by @chadd28 in
[#28345](https://github.com/google-gemini/gemini-cli/pull/28345)
- refactor(cli): align macOS permissive Seatbelt profiles with deny-default
model by @ompatel-aiml in
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424)
- fix(core): mitigate infinite ReAct loops and prompt injection loops by
@amelidev in [#28429](https://github.com/google-gemini/gemini-cli/pull/28429)
- fix(a2a-server): enforce workspace trust and task isolation to prevent RCE by
- Changelog for v0.53.0-preview.0 by @gemini-cli-robot in
[#28507](https://github.com/google-gemini/gemini-cli/pull/28507)
- Changelog for v0.52.0 by @gemini-cli-robot in
[#28508](https://github.com/google-gemini/gemini-cli/pull/28508)
- chore(release): bump version to 0.54.0-nightly.20260722.gf743ab579 by
@gemini-cli-robot in
[#28510](https://github.com/google-gemini/gemini-cli/pull/28510)
- fix(caretaker): sanitize and wrap issue title in untrusted_context by @chadd28
in [#28352](https://github.com/google-gemini/gemini-cli/pull/28352)
- chore(caretaker): update vitest to v3.2.4 and add package-lock.json files by
@chadd28 in [#28409](https://github.com/google-gemini/gemini-cli/pull/28409)
- fix(core): rotate session ID on model fallback to prevent stateful API errors
by @amelidev in
[#28469](https://github.com/google-gemini/gemini-cli/pull/28469)
- feat(caretaker-triage): post comment before auto-closing issues by @chadd28 in
[#28411](https://github.com/google-gemini/gemini-cli/pull/28411)
- fix(core): enforce HTTPS for GoogleCredentialsAuthProvider to prevent
cleartext leakage by @amelidev in
[#28517](https://github.com/google-gemini/gemini-cli/pull/28517)
- fix(core): filter out thought parts from getHistoryTurns when context
management is disabled by @DavidAPierce in
[#28509](https://github.com/google-gemini/gemini-cli/pull/28509)
- fix(a2a-server): normalize CRLF line endings to LF in getProposedContent by
@luisfelipe-alt in
[#28470](https://github.com/google-gemini/gemini-cli/pull/28470)
- fix(core): sequentially verify cached credentials and restore
GOOGLE_APPLICATION_CREDENTIALS fallback by @luisfelipe-alt in
[#28472](https://github.com/google-gemini/gemini-cli/pull/28472)
- feat(evals): add eval coverage report command by @ved015 in
[#28169](https://github.com/google-gemini/gemini-cli/pull/28169)
[#28531](https://github.com/google-gemini/gemini-cli/pull/28531)
- fix(core): enforce explicit tag length and validation in file keychain by
@luisfelipe-alt in
[#28523](https://github.com/google-gemini/gemini-cli/pull/28523)
- chore/release: bump version to 0.54.0-nightly.20260728.gbef611950 by
@gemini-cli-robot in
[#28552](https://github.com/google-gemini/gemini-cli/pull/28552)
- feat(pr-generator-db): implement Firestore concurrency dual-locking and test
ingestion utilities by @joneba-google in
[#28432](https://github.com/google-gemini/gemini-cli/pull/28432)
- feat(pr-generator-agent): implement Antigravity agent runner and prompt
templates … by @joneba-google in
[#28434](https://github.com/google-gemini/gemini-cli/pull/28434)
- fix(core): skip merged function-response turns when finding the active loop by
@adamfweidman in
[#28565](https://github.com/google-gemini/gemini-cli/pull/28565)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.52.0-preview.0...v0.53.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.53.0-preview.0...v0.54.0-preview.0
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"workspaces": [
"packages/*"
],
@@ -17782,7 +17782,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "7.19.0",
@@ -18242,7 +18242,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
@@ -18458,7 +18458,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -19131,7 +19131,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"license": "Apache-2.0",
"dependencies": {
"ws": "8.16.0"
@@ -19167,7 +19167,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -19506,7 +19506,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -19524,7 +19524,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.0-nightly.20260728.gbef611950"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.55.0-preview.1"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -3
View File
@@ -131,9 +131,9 @@ export class Task {
this.autoExecute = autoExecute;
this.config.setFallbackModelHandler(
// For a2a-server, we want to automatically switch to the fallback model
// for future requests without retrying the current one. The 'stop'
// intent achieves this.
async () => 'stop',
// and retry the current request seamlessly. The 'retry_always' intent
// achieves this, ensuring a smooth fallback experience for the user.
async () => 'retry_always',
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.0-nightly.20260728.gbef611950"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.55.0-preview.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
+35
View File
@@ -319,6 +319,41 @@ describe('Session', () => {
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it.each([
{ type: 'MAX_TOKENS_EXCEEDED', reason: 'MAX_TOKENS' },
{ type: 'SAFETY_BLOCKED', reason: 'SAFETY' },
{ type: 'RECITATION_BLOCKED', reason: 'RECITATION' },
{ type: 'OTHER_BLOCKED', reason: 'OTHER' },
{ type: 'THINKING_ONLY_RESPONSE', reason: 'STOP' },
])(
'should gracefully handle InvalidStreamError with type $type in ACP session',
async ({ type, reason }) => {
const error = new InvalidStreamError(
`Stream failed with ${reason}`,
type as InvalidStreamError['type'],
);
mockSendMessageStream.mockImplementation(() => {
async function* errorGen(): AsyncGenerator<
ServerGeminiStreamEvent,
void,
unknown
> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
yield* [] as any;
throw error;
}
return errorGen();
});
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
},
);
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
+6 -1
View File
@@ -510,7 +510,12 @@ export class Session {
(error.type === 'NO_RESPONSE_TEXT' ||
error.type === 'NO_FINISH_REASON' ||
error.type === 'MALFORMED_FUNCTION_CALL' ||
error.type === 'UNEXPECTED_TOOL_CALL'))
error.type === 'UNEXPECTED_TOOL_CALL' ||
error.type === 'MAX_TOKENS_EXCEEDED' ||
error.type === 'SAFETY_BLOCKED' ||
error.type === 'RECITATION_BLOCKED' ||
error.type === 'OTHER_BLOCKED' ||
error.type === 'THINKING_ONLY_RESPONSE'))
) {
// The stream ended with an empty response or malformed tool call.
// Treat this as a graceful end to the model's turn rather than a crash.
@@ -103,7 +103,10 @@ vi.mock('../utils.js', () => ({
describe('extensions install command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([]).command(installCommand).fail(false);
const validationParser = yargs([])
.locale('en')
.command(installCommand)
.fail(false);
expect(() => validationParser.parse('install')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
@@ -27,7 +27,10 @@ vi.mock('../utils.js', () => ({
describe('extensions validate command', () => {
it('should fail if no path is provided', () => {
const validationParser = yargs([]).command(validateCommand).fail(false);
const validationParser = yargs([])
.locale('en')
.command(validateCommand)
.fail(false);
expect(() => validationParser.parse('validate')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
+1 -1
View File
@@ -17,7 +17,7 @@ describe('mcp command', () => {
});
it('should show help when no subcommand is provided', async () => {
const yargsInstance = yargs();
const yargsInstance = yargs().locale('en');
(mcpCommand.builder as (y: Argv) => Argv)(yargsInstance);
const parser = yargsInstance.command(mcpCommand).help();
+55 -10
View File
@@ -22,6 +22,7 @@ import {
CoreEvent,
CoreToolCallStatus,
JsonStreamEventType,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
ChatRecordingService: MockChatRecordingService,
uiTelemetryService: {
getMetrics: vi.fn(),
recordSemanticValidationError: vi.fn(),
},
coreEvents: mockCoreEvents,
createWorkingStdio: vi.fn(() => ({
@@ -110,6 +112,7 @@ describe('runNonInteractive', () => {
sendMessageStream: Mock;
resumeChat: Mock;
getChatRecordingService: Mock;
getCurrentSequenceModel: Mock;
};
const MOCK_SESSION_METRICS: SessionMetrics = {
models: {},
@@ -165,6 +168,7 @@ describe('runNonInteractive', () => {
recordMessageTokens: vi.fn(),
recordToolCalls: vi.fn(),
})),
getCurrentSequenceModel: vi.fn().mockReturnValue('gemini-2.5-flash'),
};
mockConfig = {
@@ -193,6 +197,7 @@ describe('runNonInteractive', () => {
getRawOutput: vi.fn().mockReturnValue(false),
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockSettings = {
@@ -1820,7 +1825,6 @@ describe('runNonInteractive', () => {
};
// @ts-expect-error - Mocking internal structure
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
// @ts-expect-error - Mocking internal structure
mockGeminiClient.getCurrentSequenceModel = vi
.fn()
.mockReturnValue('model-1');
@@ -2298,7 +2302,13 @@ describe('runNonInteractive', () => {
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
@@ -2312,7 +2322,7 @@ describe('runNonInteractive', () => {
});
expect(processStderrSpy).toHaveBeenCalledWith(
'[ERROR] Invalid stream: The model returned an empty response or malformed tool call.\n',
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
@@ -2325,7 +2335,13 @@ describe('runNonInteractive', () => {
OutputFormat.STREAM_JSON,
);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
@@ -2341,9 +2357,7 @@ describe('runNonInteractive', () => {
const output = getWrittenOutput();
expect(output).toContain('"type":"error"');
expect(output).toContain('"severity":"error"');
expect(output).toContain(
'Invalid stream: The model returned an empty response or malformed tool call.',
);
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
@@ -2355,7 +2369,13 @@ describe('runNonInteractive', () => {
OutputFormat.JSON,
);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
@@ -2371,8 +2391,33 @@ describe('runNonInteractive', () => {
const output = getWrittenOutput();
expect(output).toContain('"error": {');
expect(output).toContain('"type": "INVALID_STREAM"');
expect(output).toContain(
'Invalid stream: The model returned an empty response or malformed tool call.',
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'MALFORMED_FUNCTION_CALL',
message: 'Custom malformed function call message',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream malformed',
prompt_id: 'prompt-id-invalid-malformed',
});
expect(processStderrSpy).toHaveBeenCalledWith(
'[ERROR] Custom malformed function call message\n',
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
+31 -2
View File
@@ -30,6 +30,12 @@ import {
ToolErrorType,
Scheduler,
ROOT_SCHEDULER_ID,
THINKING_ONLY_COMPRESS_SUGGESTION,
MAX_TOKENS_EXCEEDED_SUGGESTION,
SAFETY_BLOCKED_MESSAGE,
RECITATION_BLOCKED_MESSAGE,
OTHER_BLOCKED_MESSAGE,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Content, Part } from '@google/genai';
@@ -433,8 +439,31 @@ export async function runNonInteractive(
}
warnings.push(blockMessage);
} else if (event.type === GeminiEventType.InvalidStream) {
invalidStreamError =
'Invalid stream: The model returned an empty response or malformed tool call.';
const eventValue = event.value;
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
invalidStreamError = TRUE_EMPTY_RESPONSE_MESSAGE;
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
invalidStreamError = THINKING_ONLY_COMPRESS_SUGGESTION;
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
invalidStreamError = MAX_TOKENS_EXCEEDED_SUGGESTION;
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
invalidStreamError = SAFETY_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
invalidStreamError = RECITATION_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'OTHER_BLOCKED') {
invalidStreamError = OTHER_BLOCKED_MESSAGE;
} else {
invalidStreamError =
eventValue?.message?.trim() ||
'Invalid stream: The model returned an empty response or malformed tool call.';
}
// Log semantic error telemetry without double-counting requests
uiTelemetryService.recordSemanticValidationError(
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
eventValue?.type || 'INVALID_STREAM',
);
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
@@ -22,6 +22,7 @@ import {
CoreEvent,
CoreToolCallStatus,
JsonStreamEventType,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
ChatRecordingService: MockChatRecordingService,
uiTelemetryService: {
getMetrics: vi.fn(),
recordSemanticValidationError: vi.fn(),
},
LegacyAgentSession: original.LegacyAgentSession,
geminiPartsToContentParts: original.geminiPartsToContentParts,
@@ -199,6 +201,7 @@ describe('runNonInteractive', () => {
getRawOutput: vi.fn().mockReturnValue(false),
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockSettings = {
@@ -2457,6 +2460,126 @@ describe('runNonInteractive', () => {
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toBeUndefined();
});
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
expect(processStderrSpy).toHaveBeenCalledWith(
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => {
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
OutputFormat.STREAM_JSON,
);
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
const output = getWrittenOutput();
expect(output).toContain('"type":"error"');
expect(output).toContain('"severity":"error"');
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle InvalidStream event gracefully in JSON mode', async () => {
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
OutputFormat.JSON,
);
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
const output = getWrittenOutput();
expect(output).toContain('"error": {');
expect(output).toContain('"type": "INVALID_STREAM"');
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'MALFORMED_FUNCTION_CALL',
message: 'Malformed call',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
expect(processStderrSpy).toHaveBeenCalledWith('[ERROR] Malformed call\n');
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
});
describe('Output Sanitization', () => {
@@ -39,6 +39,12 @@ import {
geminiPartsToContentParts,
displayContentToString,
debugLogger,
THINKING_ONLY_COMPRESS_SUGGESTION,
MAX_TOKENS_EXCEEDED_SUGGESTION,
SAFETY_BLOCKED_MESSAGE,
RECITATION_BLOCKED_MESSAGE,
OTHER_BLOCKED_MESSAGE,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
@@ -332,14 +338,17 @@ export async function runNonInteractive({
return text ? text : undefined;
};
const emitFinalSuccessResult = (): void => {
const emitFinalResult = (errorPayload?: {
type: string;
message: string;
}): void => {
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
status: errorPayload ? 'error' : 'success',
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
@@ -350,7 +359,7 @@ export async function runNonInteractive({
config.getSessionId(),
responseText,
stats,
undefined,
errorPayload,
warnings,
),
);
@@ -545,6 +554,52 @@ export async function runNonInteractive({
break;
}
case 'error': {
if (event._meta?.['code'] === 'INVALID_STREAM') {
const errorTypeVal = event._meta?.['errorType'];
const errorType =
typeof errorTypeVal === 'string' ? errorTypeVal : undefined;
let errorMessage = event.message;
if (errorType === 'NO_RESPONSE_TEXT') {
errorMessage = TRUE_EMPTY_RESPONSE_MESSAGE;
} else if (errorType === 'THINKING_ONLY_RESPONSE') {
errorMessage = THINKING_ONLY_COMPRESS_SUGGESTION;
} else if (errorType === 'MAX_TOKENS_EXCEEDED') {
errorMessage = MAX_TOKENS_EXCEEDED_SUGGESTION;
} else if (errorType === 'SAFETY_BLOCKED') {
errorMessage = SAFETY_BLOCKED_MESSAGE;
} else if (errorType === 'RECITATION_BLOCKED') {
errorMessage = RECITATION_BLOCKED_MESSAGE;
} else if (errorType === 'OTHER_BLOCKED') {
errorMessage = OTHER_BLOCKED_MESSAGE;
}
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'error',
message: errorMessage,
});
} else if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`[ERROR] ${errorMessage}\n`);
}
// Log semantic error telemetry without double-counting requests
uiTelemetryService.recordSemanticValidationError(
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
errorType || 'INVALID_STREAM',
);
// If it's a fatal stream error, we should terminate and output final results
emitFinalResult({
type: 'INVALID_STREAM',
message: errorMessage,
});
streamEnded = true;
break;
}
if (event.fatal) {
throw reconstructFatalError(event);
}
@@ -613,7 +668,7 @@ export async function runNonInteractive({
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
}
emitFinalSuccessResult();
emitFinalResult();
streamEnded = true;
break;
}
@@ -36,6 +36,18 @@ function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
) {
return false;
}
const errorsA = a.api.errorsByType || {};
const errorsB = b.api.errorsByType || {};
const keysA = Object.keys(errorsA);
const keysB = Object.keys(errorsB);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (errorsA[key] !== errorsB[key]) {
return false;
}
}
if (
a.tokens.input !== b.tokens.input ||
a.tokens.prompt !== b.tokens.prompt ||
@@ -54,6 +54,7 @@ import {
GeminiCliOperation,
getPlanModeExitMessage,
UPDATE_TOPIC_TOOL_NAME,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -1045,6 +1046,107 @@ describe('useGeminiStream', () => {
});
});
it('should record tool responses in history when the model was switched due to a quota error', async () => {
// Regression test: returning early on a quota-triggered model switch
// without recording the responses leaves the already-recorded
// functionCall unpaired, which corrupts all subsequent requests.
const responseParts: Part[] = [
{
functionResponse: {
name: 'testTool',
id: 'call1',
response: { output: 'tool result' },
},
},
];
const completedToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'testTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-quota',
},
status: CoreToolCallStatus.Success,
responseSubmittedToGemini: false,
response: {
callId: 'call1',
responseParts,
errorType: undefined,
},
tool: { displayName: 'MockTool' },
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as TrackedCompletedToolCall,
];
const client = new MockedGeminiClientClass(mockConfig);
const mockConsumeUserHint = vi.fn(() => 'switch to the nprd database');
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
});
await renderHookWithProviders(() =>
useGeminiStream(
client,
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
true, // modelSwitchedFromQuotaError
() => {},
() => {},
() => {},
80,
24,
false,
mockConsumeUserHint,
),
);
await act(async () => {
if (capturedOnComplete) {
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(completedToolCalls);
}
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['call1']);
// The tool response must be paired with its functionCall in history,
// with no steering-hint text ahead of it...
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: responseParts,
});
// ...the turn must NOT auto-continue on the fallback model...
expect(mockSendMessageStream).not.toHaveBeenCalled();
// ...and the pending hint is left for the next real submit.
expect(mockConsumeUserHint).not.toHaveBeenCalled();
});
});
it('should NOT stop responding when only update_topic is called', async () => {
const topicToolCalls: TrackedToolCall[] = [
{
@@ -1772,6 +1874,120 @@ describe('useGeminiStream', () => {
expect(mockCancelAllToolCalls).toHaveBeenCalled();
});
it('should transition to Idle state when cancelled while a tool call is in progress and completes', async () => {
const toolCalls: TrackedToolCall[] = [
{
request: { callId: 'call1', name: 'tool1', args: {} },
status: CoreToolCallStatus.Executing,
responseSubmittedToGemini: false,
tool: {
name: 'tool1',
description: 'desc1',
build: vi.fn().mockImplementation((_) => ({
getDescription: () => `Mock description`,
})),
} as any,
invocation: {
getDescription: () => `Mock description`,
},
startTime: Date.now(),
liveOutput: '...',
} as TrackedExecutingToolCall,
];
const { result } = await renderTestHook(toolCalls);
// State is `Responding` because a tool is running
expect(result.current.streamingState).toBe(StreamingState.Responding);
// Try to cancel
simulateEscapeKeyPress();
// Trigger the onComplete callback with the cancelled tool call
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([
{
...toolCalls[0],
status: CoreToolCallStatus.Cancelled,
response: {
callId: 'call1',
responseParts: [],
},
} as any,
]);
}
});
// The final state should be idle because the cancelled tool call was marked as submitted
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should append cancelled tool responses to history when cancelled while a tool call is in progress and completes with response parts', async () => {
const toolCalls: TrackedToolCall[] = [
{
request: { callId: 'call1', name: 'tool1', args: {} },
status: CoreToolCallStatus.Executing,
responseSubmittedToGemini: false,
tool: {
name: 'tool1',
description: 'desc1',
build: vi.fn().mockImplementation((_) => ({
getDescription: () => `Mock description`,
})),
} as any,
invocation: {
getDescription: () => `Mock description`,
},
startTime: Date.now(),
liveOutput: '...',
} as TrackedExecutingToolCall,
];
const { result, client } = await renderTestHook(toolCalls);
// State is `Responding` because a tool is running
expect(result.current.streamingState).toBe(StreamingState.Responding);
// Try to cancel
simulateEscapeKeyPress();
const expectedResponseParts = [
{
functionResponse: {
name: 'tool1',
id: 'call1',
response: { error: 'cancelled' },
},
},
];
// Trigger the onComplete callback with the cancelled tool call having non-empty response parts
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([
{
...toolCalls[0],
status: CoreToolCallStatus.Cancelled,
response: {
callId: 'call1',
responseParts: expectedResponseParts,
},
} as any,
]);
}
});
// Assert that addHistory was called with the combined response parts
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: expectedResponseParts,
});
// The final state should be idle because the cancelled tool call was marked as submitted
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should cancel a request when a tool is awaiting confirmation', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const toolCalls: TrackedToolCall[] = [
@@ -2306,6 +2522,68 @@ describe('useGeminiStream', () => {
);
});
});
it('should use TRUE_EMPTY_RESPONSE_MESSAGE when receiving an invalid stream event of type NO_RESPONSE_TEXT', async () => {
mockSendMessageStream.mockClear();
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'empty response text',
},
};
})(),
);
const { result } = await renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: TRUE_EMPTY_RESPONSE_MESSAGE,
}),
expect.any(Number),
);
});
});
it('should use the event message when receiving a non-NO_RESPONSE_TEXT invalid stream event', async () => {
mockSendMessageStream.mockClear();
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.InvalidStream,
value: {
type: 'MALFORMED_FUNCTION_CALL',
message: 'Custom malformed function call message',
},
};
})(),
);
const { result } = await renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Custom malformed function call message',
}),
expect.any(Number),
);
});
});
});
describe('handleApprovalModeChange', () => {
+113 -12
View File
@@ -14,6 +14,7 @@ import {
GitService,
UnauthorizedError,
UserPromptEvent,
uiTelemetryService,
DEFAULT_GEMINI_FLASH_MODEL,
logConversationFinishedEvent,
ConversationFinishedEvent,
@@ -45,6 +46,12 @@ import {
buildToolVisibilityContext,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
THINKING_ONLY_COMPRESS_SUGGESTION,
MAX_TOKENS_EXCEEDED_SUGGESTION,
SAFETY_BLOCKED_MESSAGE,
RECITATION_BLOCKED_MESSAGE,
OTHER_BLOCKED_MESSAGE,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -54,6 +61,7 @@ import type {
ServerGeminiContentEvent as ContentEvent,
ServerGeminiFinishedEvent,
ServerGeminiStreamEvent as GeminiEvent,
ServerGeminiInvalidStreamEvent,
ThoughtSummary,
ToolCallRequestInfo,
ToolCallResponseInfo,
@@ -1229,6 +1237,61 @@ export const useGeminiStream = (
],
);
const handleInvalidStreamEvent = useCallback(
(
eventValue: ServerGeminiInvalidStreamEvent['value'],
userMessageTimestamp: number,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
let text =
eventValue?.message?.trim() || 'Invalid stream received from model';
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
text = TRUE_EMPTY_RESPONSE_MESSAGE;
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
text = THINKING_ONLY_COMPRESS_SUGGESTION;
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
text = MAX_TOKENS_EXCEEDED_SUGGESTION;
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
text = SAFETY_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
text = RECITATION_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'OTHER_BLOCKED') {
text = OTHER_BLOCKED_MESSAGE;
}
// Log semantic error telemetry without double-counting requests
uiTelemetryService.recordSemanticValidationError(
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
eventValue?.type || 'INVALID_STREAM',
);
addItem(
{
type: MessageType.ERROR,
text,
},
userMessageTimestamp,
);
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
setThought(null); // Reset thought when there's an error
},
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
config,
geminiClient,
],
);
const handleCitationEvent = useCallback(
(text: string, userMessageTimestamp: number) => {
if (!showCitations(settings)) {
@@ -1541,8 +1604,10 @@ export const useGeminiStream = (
loopDetectedRef.current = true;
break;
case ServerGeminiEventType.Retry:
// Handled transparently by the backend stream retries.
break;
case ServerGeminiEventType.InvalidStream:
// Will add the missing logic later
handleInvalidStreamEvent(event.value, userMessageTimestamp);
break;
default: {
// enforces exhaustive switch-case
@@ -1575,6 +1640,7 @@ export const useGeminiStream = (
handleChatModelEvent,
handleAgentExecutionStoppedEvent,
handleAgentExecutionBlockedEvent,
handleInvalidStreamEvent,
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
@@ -1886,6 +1952,30 @@ export const useGeminiStream = (
},
);
if (turnCancelledRef.current) {
setIsResponding(false);
const geminiTools = completedAndReadyToSubmitTools.filter(
(t) => !t.request.isClientInitiated,
);
if (geminiClient && geminiTools.length > 0) {
const combinedParts = geminiTools.flatMap(
(toolCall) => toolCall.response.responseParts,
);
if (combinedParts.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
geminiClient.addHistory({
role: 'user',
parts: combinedParts,
});
}
}
const callIdsToMarkAsSubmitted = toolCalls.map(
(toolCall) => toolCall.request.callId,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
return;
}
// Finalize any client-initiated tools as soon as they are done.
const clientTools = completedAndReadyToSubmitTools.filter(
(t) => t.request.isClientInitiated,
@@ -2020,6 +2110,27 @@ export const useGeminiStream = (
(toolCall) => toolCall.response.responseParts,
);
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
// Don't continue if model was switched due to quota error, but still
// record the responses: the matching functionCall is already in history,
// and leaving it unpaired corrupts every subsequent request. Any pending
// steering hint is deliberately left unconsumed so it rides along with
// the next query the user actually submits.
if (modelSwitchedFromQuotaError) {
if (geminiClient && responsesToSend.length > 0) {
await geminiClient.addHistory({
role: 'user',
parts: responsesToSend,
});
}
return;
}
if (consumeUserHint) {
const userHint = consumeUserHint();
if (userHint && userHint.trim().length > 0) {
@@ -2030,21 +2141,10 @@ export const useGeminiStream = (
}
}
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
);
const prompt_ids = geminiTools.map(
(toolCall) => toolCall.request.prompt_id,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
// Don't continue if model was switched due to quota error
if (modelSwitchedFromQuotaError) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
submitQuery(
responsesToSend,
@@ -2066,6 +2166,7 @@ export const useGeminiStream = (
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
setIsResponding,
toolCalls,
],
);
+134
View File
@@ -292,6 +292,140 @@ describe('sandbox', () => {
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
});
it('should fall back to embedded profile if the .sb file is missing on disk', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p).includes(
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
),
);
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
});
const onSpy = vi.spyOn(process, 'on');
const offSpy = vi.spyOn(process, 'off');
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config, [], undefined, ['arg1']);
setTimeout(() => {
mockSpawnProcess.emit('close', 0);
}, 10);
await expect(promise).resolves.toBe(0);
// Verify fs.writeFileSync was called with the temp profile file, content, and 0o600 permissions
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining(
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
),
expect.stringContaining('deny default'),
expect.objectContaining({
encoding: 'utf8',
mode: 0o600,
}),
);
// Verify spawn was called with the temp profile file
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.arrayContaining([
'-f',
expect.stringContaining(
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
),
]),
expect.objectContaining({ stdio: 'inherit' }),
);
// Verify process on/off hooks were called for exit, SIGINT, and SIGTERM cleanups
expect(onSpy).toHaveBeenCalledWith('exit', expect.any(Function));
expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
expect(offSpy).toHaveBeenCalledWith('exit', expect.any(Function));
expect(offSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
expect(offSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
// Verify fs.unlinkSync was called to clean up the temp file
expect(fs.unlinkSync).toHaveBeenCalledWith(
expect.stringContaining(
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
),
);
});
it.each([
'permissive-open',
'permissive-closed',
'permissive-proxied',
'restrictive-open',
'restrictive-closed',
'restrictive-proxied',
'strict-open',
'strict-proxied',
])(
'should fall back to embedded content successfully for profile "%s"',
async (profile) => {
vi.mocked(os.platform).mockReturnValue('darwin');
// Mock existsSync to return false for the profile file but true for temp directories
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p).includes('gemini-sandbox-macos-'),
);
vi.stubEnv('SEATBELT_PROFILE', profile);
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
});
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config, [], undefined, ['arg1']);
setTimeout(() => {
mockSpawnProcess.emit('close', 0);
}, 10);
await expect(promise).resolves.toBe(0);
// Verify fs.writeFileSync was called with the correct file mode and content for the profile
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining(`gemini-sandbox-macos-${profile}-`),
expect.stringContaining('deny default'),
expect.objectContaining({
encoding: 'utf8',
mode: 0o600,
}),
);
vi.unstubAllEnvs();
},
);
it('should handle Docker execution', async () => {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
+212 -149
View File
@@ -39,6 +39,7 @@ import {
SANDBOX_PROXY_NAME,
BUILTIN_SEATBELT_PROFILES,
} from './sandboxUtils.js';
import { BUILTIN_SEATBELT_PROFILE_CONTENTS } from './sandboxBuiltinProfiles.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -56,6 +57,41 @@ export async function start_sandbox(
patcher.patch();
let stopProxy: (() => void) | undefined = undefined;
let tempProfileFile: string | null = null;
const cleanup = () => {
if (tempProfileFile && fs.existsSync(tempProfileFile)) {
try {
fs.unlinkSync(tempProfileFile);
} catch {
// ignore
}
tempProfileFile = null;
}
if (stopProxy) {
try {
stopProxy();
} catch {
// ignore
}
}
};
const sigintHandler = () => {
cleanup();
process.off('SIGINT', sigintHandler);
process.kill(process.pid, 'SIGINT');
};
const sigtermHandler = () => {
cleanup();
process.off('SIGTERM', sigtermHandler);
process.kill(process.pid, 'SIGTERM');
};
process.on('exit', cleanup);
process.on('SIGINT', sigintHandler);
process.on('SIGTERM', sigtermHandler);
try {
if (config.command === 'sandbox-exec') {
@@ -81,161 +117,193 @@ export async function start_sandbox(
profileFile = fs.existsSync(userProfileFile)
? userProfileFile
: projectProfileFile;
}
if (!fs.existsSync(profileFile)) {
throw new FatalSandboxError(
`Missing macos seatbelt profile file '${profileFile}'`,
);
}
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
const nodeOptions = [
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
...nodeArgs,
].join(' ');
const args = [
'-D',
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
'-D',
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
'-D',
`HOME_DIR=${fs.realpathSync(homedir())}`,
'-D',
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
];
// Add included directories from the workspace context
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
const MAX_INCLUDE_DIRS = 5;
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
const includedDirs: string[] = [];
if (cliConfig) {
const workspaceContext = cliConfig.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
// Filter out TARGET_DIR
for (const dir of directories) {
const realDir = fs.realpathSync(dir);
if (realDir !== targetDir) {
includedDirs.push(realDir);
} else {
// For builtin profiles, if the file doesn't exist on disk (e.g. bundled or bazel environments),
// write the embedded profile content to a temporary file.
if (!fs.existsSync(profileFile)) {
const content = BUILTIN_SEATBELT_PROFILE_CONTENTS[profile];
if (content) {
try {
const tempDir = fs.realpathSync(os.tmpdir());
const rand = randomBytes(8).toString('hex');
tempProfileFile = path.join(
tempDir,
`gemini-sandbox-macos-${profile}-${rand}.sb`,
);
fs.writeFileSync(tempProfileFile, content, {
encoding: 'utf8',
mode: 0o600,
});
profileFile = tempProfileFile;
} catch (err) {
debugLogger.warn(
`Failed to write temporary seatbelt profile: ${err}`,
);
}
}
}
}
// Add custom allowed paths from config
if (config.allowedPaths) {
for (const hostPath of config.allowedPaths) {
if (
hostPath &&
path.isAbsolute(hostPath) &&
fs.existsSync(hostPath)
) {
const realDir = fs.realpathSync(hostPath);
if (!includedDirs.includes(realDir) && realDir !== targetDir) {
try {
if (!fs.existsSync(profileFile)) {
throw new FatalSandboxError(
`Missing macos seatbelt profile file '${profileFile}'`,
);
}
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
const nodeOptions = [
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
...nodeArgs,
].join(' ');
const args = [
'-D',
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
'-D',
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
'-D',
`HOME_DIR=${fs.realpathSync(homedir())}`,
'-D',
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
];
// Add included directories from the workspace context
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
const MAX_INCLUDE_DIRS = 5;
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
const includedDirs: string[] = [];
if (cliConfig) {
const workspaceContext = cliConfig.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
// Filter out TARGET_DIR
for (const dir of directories) {
const realDir = fs.realpathSync(dir);
if (realDir !== targetDir) {
includedDirs.push(realDir);
}
}
}
}
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
if (i < includedDirs.length) {
dirPath = includedDirs[i];
}
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
}
const finalArgv = cliArgs;
args.push(
'-f',
profileFile,
'sh',
'-c',
[
`SANDBOX=sandbox-exec`,
`NODE_OPTIONS="${nodeOptions}"`,
...finalArgv.map((arg) => quote([arg])),
].join(' '),
);
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND'];
let proxyProcess: ChildProcess | undefined = undefined;
let sandboxProcess: ChildProcess | undefined = undefined;
const sandboxEnv = { ...process.env };
if (proxyCommand) {
const proxy =
process.env['HTTPS_PROXY'] ||
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||
process.env['http_proxy'] ||
'http://localhost:8877';
sandboxEnv['HTTPS_PROXY'] = proxy;
sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl
sandboxEnv['HTTP_PROXY'] = proxy;
sandboxEnv['http_proxy'] = proxy;
const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];
if (noProxy) {
sandboxEnv['NO_PROXY'] = noProxy;
sandboxEnv['no_proxy'] = noProxy;
}
proxyProcess = spawn(proxyCommand, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
detached: true,
});
// install handlers to stop proxy on exit/signal
stopProxy = () => {
debugLogger.log('stopping proxy ...');
if (proxyProcess?.pid) {
try {
process.kill(-proxyProcess.pid, 'SIGTERM');
} catch {
// ignore
// Add custom allowed paths from config
if (config.allowedPaths) {
for (const hostPath of config.allowedPaths) {
if (
hostPath &&
path.isAbsolute(hostPath) &&
fs.existsSync(hostPath)
) {
const realDir = fs.realpathSync(hostPath);
if (!includedDirs.includes(realDir) && realDir !== targetDir) {
includedDirs.push(realDir);
}
}
}
};
process.on('exit', stopProxy);
process.on('SIGINT', stopProxy);
process.on('SIGTERM', stopProxy);
}
// commented out as it disrupts ink rendering
// proxyProcess.stdout?.on('data', (data) => {
// console.info(data.toString());
// });
proxyProcess.stderr?.on('data', (data) => {
debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
});
proxyProcess.on('close', (code, signal) => {
if (sandboxProcess?.pid) {
process.kill(-sandboxProcess.pid, 'SIGTERM');
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
if (i < includedDirs.length) {
dirPath = includedDirs[i];
}
throw new FatalSandboxError(
`Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,
);
});
debugLogger.log('waiting for proxy to start ...');
await execAsync(
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
}
const finalArgv = cliArgs;
args.push(
'-f',
profileFile,
'sh',
'-c',
[
`SANDBOX=sandbox-exec`,
'NODE_OPTIONS=' + quote([nodeOptions]),
...finalArgv.map((arg) => quote([arg])),
].join(' '),
);
}
// spawn child and let it inherit stdio
process.stdin.pause();
sandboxProcess = spawn(config.command, args, {
stdio: 'inherit',
});
return await new Promise((resolve, reject) => {
sandboxProcess?.on('error', reject);
sandboxProcess?.on('close', (code) => {
process.stdin.resume();
resolve(code ?? 1);
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND'];
let proxyProcess: ChildProcess | undefined = undefined;
let sandboxProcess: ChildProcess | undefined = undefined;
const sandboxEnv = { ...process.env };
if (proxyCommand) {
const proxy =
process.env['HTTPS_PROXY'] ||
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||
process.env['http_proxy'] ||
'http://localhost:8877';
sandboxEnv['HTTPS_PROXY'] = proxy;
sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl
sandboxEnv['HTTP_PROXY'] = proxy;
sandboxEnv['http_proxy'] = proxy;
const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];
if (noProxy) {
sandboxEnv['NO_PROXY'] = noProxy;
sandboxEnv['no_proxy'] = noProxy;
}
proxyProcess = spawn(proxyCommand, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
detached: true,
});
// install handlers to stop proxy on exit/signal
stopProxy = () => {
debugLogger.log('stopping proxy ...');
if (proxyProcess?.pid) {
try {
process.kill(-proxyProcess.pid, 'SIGTERM');
} catch {
// ignore
}
}
};
// commented out as it disrupts ink rendering
// proxyProcess.stdout?.on('data', (data) => {
// console.info(data.toString());
// });
proxyProcess.stderr?.on('data', (data) => {
debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
});
proxyProcess.on('close', (code, signal) => {
if (sandboxProcess?.pid) {
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
throw new FatalSandboxError(
`Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,
);
});
debugLogger.log('waiting for proxy to start ...');
await execAsync(
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
);
}
// spawn child and let it inherit stdio
process.stdin.pause();
sandboxProcess = spawn(config.command, args, {
stdio: 'inherit',
});
});
return await new Promise((resolve, reject) => {
sandboxProcess?.on('error', (err) => {
cleanup();
reject(err);
});
sandboxProcess?.on('close', (code) => {
process.stdin.resume();
cleanup();
resolve(code ?? 1);
});
});
} catch (err) {
cleanup();
throw err;
}
}
if (config.command === 'lxc') {
@@ -768,9 +836,6 @@ export async function start_sandbox(
// ignore
}
};
process.on('exit', stopProxy);
process.on('SIGINT', stopProxy);
process.on('SIGTERM', stopProxy);
// commented out as it disrupts ink rendering
// proxyProcess.stdout?.on('data', (data) => {
@@ -821,12 +886,10 @@ export async function start_sandbox(
});
});
} finally {
if (stopProxy) {
stopProxy();
process.off('exit', stopProxy);
process.off('SIGINT', stopProxy);
process.off('SIGTERM', stopProxy);
}
process.off('exit', cleanup);
process.off('SIGINT', sigintHandler);
process.off('SIGTERM', sigtermHandler);
cleanup();
patcher.cleanup();
}
}
@@ -0,0 +1,555 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const BUILTIN_SEATBELT_PROFILE_CONTENTS: Record<string, string> = {
'permissive-open': `(version 1)
(deny default)
(allow file-read*)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
(literal "/dev/ptmx")
(regex #"^/dev/ttys[0-9]*$")
)
(allow mach-lookup
(global-name "com.apple.sysmond")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.mDNSResponder")
(global-name "com.apple.mDNSResponderHelper")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "*:*"))
(allow network-bind (local ip "*:*"))
(allow network-outbound)`,
'permissive-proxied': `(version 1)
(deny default)
(allow file-read*)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
(literal "/dev/ptmx")
(regex #"^/dev/ttys[0-9]*$")
)
(allow mach-lookup
(global-name "com.apple.sysmond")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.mDNSResponder")
(global-name "com.apple.mDNSResponderHelper")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-bind (local ip "*:*"))
(allow network-outbound (remote tcp "localhost:8877"))`,
'restrictive-open': `(version 1)
(deny default)
(allow file-read*)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
)
(allow mach-lookup (global-name "com.apple.sysmond"))
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-outbound)`,
'restrictive-proxied': `(version 1)
(deny default)
(allow file-read*)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
)
(allow mach-lookup (global-name "com.apple.sysmond"))
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-outbound (remote tcp "localhost:8877"))`,
'strict-open': `(version 1)
(deny default)
(allow file-read*
(literal "/")
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
(subpath (string-append (param "HOME_DIR") "/.nvm"))
(subpath (string-append (param "HOME_DIR") "/.fnm"))
(subpath (string-append (param "HOME_DIR") "/.node"))
(subpath (string-append (param "HOME_DIR") "/.config"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(subpath "/usr")
(subpath "/bin")
(subpath "/sbin")
(subpath "/Library")
(subpath "/System")
(subpath "/private")
(subpath "/dev")
(subpath "/etc")
(subpath "/opt")
(subpath "/Applications")
)
(allow file-read-metadata)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
)
(allow mach-lookup (global-name "com.apple.sysmond"))
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-outbound)`,
'strict-proxied': `(version 1)
(deny default)
(allow file-read*
(literal "/")
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
(subpath (string-append (param "HOME_DIR") "/.nvm"))
(subpath (string-append (param "HOME_DIR") "/.fnm"))
(subpath (string-append (param "HOME_DIR") "/.node"))
(subpath (string-append (param "HOME_DIR") "/.config"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(subpath "/usr")
(subpath "/bin")
(subpath "/sbin")
(subpath "/Library")
(subpath "/System")
(subpath "/private")
(subpath "/dev")
(subpath "/etc")
(subpath "/opt")
(subpath "/Applications")
)
(allow file-read-metadata)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
)
(allow mach-lookup (global-name "com.apple.sysmond"))
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-outbound (remote tcp "localhost:8877"))`,
};
// Map standard 'closed' profiles to their strict counterparts for backward compatibility and fallback support
BUILTIN_SEATBELT_PROFILE_CONTENTS['permissive-closed'] =
BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-open'];
BUILTIN_SEATBELT_PROFILE_CONTENTS['restrictive-closed'] =
BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-proxied'];
+2
View File
@@ -15,8 +15,10 @@ export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
export const BUILTIN_SEATBELT_PROFILES = [
'permissive-open',
'permissive-closed',
'permissive-proxied',
'restrictive-open',
'restrictive-closed',
'restrictive-proxied',
'strict-open',
'strict-proxied',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -516,10 +516,34 @@ describe('translateEvent', () => {
});
describe('InvalidStream events', () => {
it('emits fatal error', () => {
it('emits fatal error with specific message from event', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('INTERNAL');
expect(err.message).toBe('Empty response');
expect(err.fatal).toBe(true);
expect(err._meta?.['code']).toBe('INVALID_STREAM');
expect(err._meta?.['errorType']).toBe('NO_RESPONSE_TEXT');
expect(err._meta?.['rawMessage']).toBe('Empty response');
});
it('falls back to default message when message is missing', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: '',
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
+8 -1
View File
@@ -222,8 +222,15 @@ export function translateEvent(
out.push(
makeEvent('error', state, {
status: 'INTERNAL',
message: 'Invalid stream received from model',
message:
event.value?.message?.trim() ||
'Invalid stream received from model',
fatal: true,
_meta: {
code: 'INVALID_STREAM',
errorType: event.value?.type,
rawMessage: event.value?.message,
},
}),
);
break;
@@ -10,7 +10,7 @@
*/
import { GeminiEventType } from '../core/turn.js';
import type { Part } from '@google/genai';
import type { Part, FinishReason } from '@google/genai';
import type { GeminiClient } from '../core/client.js';
import type { Config } from '../config/config.js';
import type { ToolCallRequestInfo } from '../scheduler/types.js';
@@ -192,6 +192,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
}
const toolCallRequests: ToolCallRequestInfo[] = [];
let finishedReason: FinishReason | undefined = undefined;
const responseStream = this._client.sendMessageStream(
currentParts,
this._abortController.signal,
@@ -220,10 +221,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
this._finishStream('failed');
return;
case GeminiEventType.Finished:
if (toolCallRequests.length === 0) {
this._finishStream(mapFinishReason(event.value.reason));
return;
}
finishedReason = event.value.reason;
break;
case GeminiEventType.AgentExecutionStopped:
case GeminiEventType.UserCancelled:
@@ -241,7 +239,11 @@ export class LegacyAgentProtocol implements AgentProtocol {
}
if (toolCallRequests.length === 0) {
this._finishStream('completed');
if (finishedReason !== undefined) {
this._finishStream(mapFinishReason(finishedReason));
} else {
this._finishStream('completed');
}
return;
}
@@ -165,6 +165,16 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -361,6 +371,16 @@ An approved plan is available for this task at \`../plans/feature-x.md\`.
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -671,6 +691,16 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -845,6 +875,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -1005,6 +1045,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -1148,6 +1198,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -1837,6 +1897,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2011,6 +2081,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2189,6 +2269,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2367,6 +2457,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2541,6 +2641,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2709,6 +2819,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -2851,6 +2971,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -3025,6 +3155,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -3345,6 +3485,16 @@ You are operating with a persistent file-based task tracking system located at \
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -3774,6 +3924,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -3948,6 +4108,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -4241,6 +4411,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -4415,6 +4595,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
describe('AgentChatHistory', () => {
const dummyTurns: HistoryTurn[] = [
{
id: 'turn-1',
content: { role: 'user', parts: [{ text: 'Hello' }] },
},
{
id: 'turn-2',
content: { role: 'model', parts: [{ text: 'Hi there' }] },
},
{
id: 'turn-3',
content: { role: 'user', parts: [{ text: 'How are you?' }] },
},
];
it('should initialize with empty history by default', () => {
const history = new AgentChatHistory();
expect(history.length).toBe(0);
expect(history.get()).toEqual([]);
});
it('should initialize with provided turns', () => {
const history = new AgentChatHistory(dummyTurns);
expect(history.length).toBe(3);
expect(history.get()).toEqual(dummyTurns);
});
it('should push new turns', () => {
const history = new AgentChatHistory();
history.push(dummyTurns[0]);
expect(history.length).toBe(1);
expect(history.get()[0]).toEqual(dummyTurns[0]);
});
it('should set and overwrite history turns', () => {
const history = new AgentChatHistory(dummyTurns.slice(0, 1));
expect(history.length).toBe(1);
history.set(dummyTurns);
expect(history.length).toBe(3);
expect(history.get()).toEqual(dummyTurns);
});
it('should clear history', () => {
const history = new AgentChatHistory(dummyTurns);
expect(history.length).toBe(3);
history.clear();
expect(history.length).toBe(0);
expect(history.get()).toEqual([]);
});
describe('rollback', () => {
it('should roll back history to a specified length', () => {
const history = new AgentChatHistory(dummyTurns);
history.rollback(1);
expect(history.length).toBe(1);
expect(history.get()).toEqual([dummyTurns[0]]);
});
it('should roll back to 0', () => {
const history = new AgentChatHistory(dummyTurns);
history.rollback(0);
expect(history.length).toBe(0);
expect(history.get()).toEqual([]);
});
it('should do nothing if rollback length is out of bounds (negative)', () => {
const history = new AgentChatHistory(dummyTurns);
history.rollback(-1);
expect(history.length).toBe(3);
expect(history.get()).toEqual(dummyTurns);
});
it('should do nothing if rollback length is out of bounds (greater than current history length)', () => {
const history = new AgentChatHistory(dummyTurns);
history.rollback(5);
expect(history.length).toBe(3);
expect(history.get()).toEqual(dummyTurns);
});
});
it('should return raw Content array via getContents()', () => {
const history = new AgentChatHistory(dummyTurns);
expect(history.getContents()).toEqual(
dummyTurns.map((turn) => turn.content),
);
});
it('should support mapping and flatMapping operations', () => {
const history = new AgentChatHistory(dummyTurns);
const mappedIds = history.map((turn) => turn.id);
expect(mappedIds).toEqual(['turn-1', 'turn-2', 'turn-3']);
const flatMappedParts = history.flatMap((turn) => turn.content.parts || []);
expect(flatMappedParts).toEqual([
{ text: 'Hello' },
{ text: 'Hi there' },
{ text: 'How are you?' },
]);
});
});
@@ -46,6 +46,16 @@ export class AgentChatHistory {
this.history = [];
}
/**
* Rolls back the history to a specified length.
* Useful when a stream fails and we need to remove the un-responded turn(s).
*/
rollback(length: number) {
if (length >= 0 && length <= this.history.length) {
this.history = this.history.slice(0, length);
}
}
get(): readonly HistoryTurn[] {
return this.history;
}
File diff suppressed because it is too large Load Diff
+215 -38
View File
@@ -108,6 +108,13 @@ const MID_STREAM_RETRY_OPTIONS: MidStreamRetryOptions = {
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
/**
* Stands in for a model turn that never arrived because the stream failed
* after a tool response was already committed to history.
*/
export const INTERRUPTED_RESPONSE_PLACEHOLDER =
'[The previous response was interrupted before it completed.]';
/**
* Internal interface for parts that carry the magic 'callIndex' property
* used during model response consolidation.
@@ -225,7 +232,12 @@ export class InvalidStreamError extends Error {
| 'NO_FINISH_REASON'
| 'NO_RESPONSE_TEXT'
| 'MALFORMED_FUNCTION_CALL'
| 'UNEXPECTED_TOOL_CALL';
| 'UNEXPECTED_TOOL_CALL'
| 'MAX_TOKENS_EXCEEDED'
| 'SAFETY_BLOCKED'
| 'RECITATION_BLOCKED'
| 'OTHER_BLOCKED'
| 'THINKING_ONLY_RESPONSE';
constructor(
message: string,
@@ -233,7 +245,12 @@ export class InvalidStreamError extends Error {
| 'NO_FINISH_REASON'
| 'NO_RESPONSE_TEXT'
| 'MALFORMED_FUNCTION_CALL'
| 'UNEXPECTED_TOOL_CALL',
| 'UNEXPECTED_TOOL_CALL'
| 'MAX_TOKENS_EXCEEDED'
| 'SAFETY_BLOCKED'
| 'RECITATION_BLOCKED'
| 'OTHER_BLOCKED'
| 'THINKING_ONLY_RESPONSE',
) {
super(message);
this.name = 'InvalidStreamError';
@@ -387,6 +404,9 @@ export class GeminiChat {
): Promise<AsyncGenerator<StreamEvent>> {
await this.sendPromise;
const historyLengthBefore = this.agentHistory.length;
const baselinePromptTokenCount = this.lastPromptTokenCount;
let streamDoneResolver: () => void;
const streamDonePromise = new Promise<void>((resolve) => {
streamDoneResolver = resolve;
@@ -394,6 +414,17 @@ export class GeminiChat {
this.sendPromise = streamDonePromise;
let userContent = createUserContent(message);
const isOriginalFunctionResponse = isFunctionResponse(userContent);
// A turn can end leaving history on an unanswered tool response: a stream
// error after the response was committed, or a cancelled tool call. Close
// it before recording a genuinely new user message, otherwise the two user
// turns are coalesced into one and the model continues the trailing text
// instead of answering it.
if (!isOriginalFunctionResponse) {
this.closeUnansweredToolResponseTurn();
}
const { model } =
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
@@ -402,7 +433,7 @@ export class GeminiChat {
// Record user input - capture complete message with all parts (text, files, images, etc.)
// but skip recording function responses (tool call results) as they should be stored in tool call records
if (!isFunctionResponse(userContent)) {
if (!isOriginalFunctionResponse) {
const userMessageParts = userContent.parts || [];
const userMessageContent = partListUnionToString(userMessageParts);
@@ -519,6 +550,7 @@ export class GeminiChat {
): AsyncGenerator<StreamEvent, void, void> {
try {
const maxAttempts = this.context.config.getMaxAttempts();
let lastStreamError: unknown = undefined;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let isConnectionPhase = true;
@@ -530,7 +562,7 @@ export class GeminiChat {
// If this is a retry, update the key with the new context.
const currentConfigKey =
attempt > 0
? { ...modelConfigKey, isRetry: true }
? { ...modelConfigKey, isRetry: true, lastStreamError }
: modelConfigKey;
isConnectionPhase = true;
@@ -549,6 +581,10 @@ export class GeminiChat {
return;
} catch (error) {
if (error instanceof InvalidStreamError) {
lastStreamError = error;
}
if (error instanceof AgentExecutionStoppedError) {
yield {
type: StreamEventType.AGENT_EXECUTION_STOPPED,
@@ -585,8 +621,7 @@ export class GeminiChat {
);
const isContentError = error instanceof InvalidStreamError;
const isRetryableContentError =
isContentError && error.type !== 'NO_RESPONSE_TEXT';
const isRetryableContentError = isContentError;
const errorType = isContentError
? error.type
: getRetryErrorType(error);
@@ -648,6 +683,15 @@ export class GeminiChat {
throw error;
}
}
} catch (error) {
if (!isOriginalFunctionResponse) {
this.agentHistory.rollback(historyLengthBefore);
this.chatRecordingService.updateMessagesFromHistory(
this.agentHistory.get(),
);
this.lastPromptTokenCount = baselinePromptTokenCount;
}
throw error;
} finally {
streamDoneResolver!();
}
@@ -656,6 +700,28 @@ export class GeminiChat {
return streamWithRetries.call(this);
}
/**
* Appends a closing model turn when history ends with an unanswered tool
* response, so the next user message stays a turn of its own.
*/
private closeUnansweredToolResponseTurn(): void {
const turns = this.agentHistory.get();
const last = turns[turns.length - 1];
if (
last?.content.role !== 'user' ||
!last.content.parts?.some((part) => !!part.functionResponse)
) {
return;
}
this.agentHistory.push({
id: randomUUID(),
content: {
role: 'model',
parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
},
});
}
private extractBinaryInjections(
parts: Part[] | undefined,
): Part[] | undefined {
@@ -770,6 +836,30 @@ export class GeminiChat {
abortSignal,
};
// Apply Context-Aware Retries (On-Retry Nudging) to guide the model out of silent loops
if (
modelConfigKey.isRetry &&
modelConfigKey.lastStreamError instanceof InvalidStreamError
) {
const lastError = modelConfigKey.lastStreamError;
let nudgeMessage = '';
if (lastError.type === 'THINKING_ONLY_RESPONSE') {
nudgeMessage =
'\n[System: You previously generated thoughts but failed to provide a final user-facing response. Please ensure you provide your final answer or call a tool now.]';
} else if (lastError.type === 'NO_RESPONSE_TEXT') {
nudgeMessage =
'\n[System: You previously returned an empty response with no text or thoughts. Please ensure you provide your final answer or call a tool now.]';
}
if (nudgeMessage) {
if (typeof config.systemInstruction === 'string') {
config.systemInstruction += nudgeMessage;
} else if (config.systemInstruction === undefined) {
config.systemInstruction = nudgeMessage;
}
}
}
let contentsToUse: Content[] =
supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse)
? [...contentsForPreviewModel]
@@ -1143,6 +1233,13 @@ export class GeminiChat {
let hasThoughts = false;
let finishReason: FinishReason | undefined;
// Buffers to prevent failed stream attempts from polluting telemetry and logs
const bufferedThoughts: Array<{ subject: string; description: string }> =
[];
let bufferedUsageMetadata:
| GenerateContentResponse['usageMetadata']
| undefined = undefined;
// The SDK provides fully assembled FunctionCall objects in chunk.functionCalls
// We use a Map to ensure we only keep the latest version of each call (by ID)
const finalFunctionCallsMap = new Map<string, FunctionCall>();
@@ -1198,7 +1295,10 @@ export class GeminiChat {
if (content.parts.some((part) => part.thought)) {
// Record thoughts
hasThoughts = true;
this.recordThoughtFromContent(content);
const thought = this.extractThoughtFromContent(content);
if (thought) {
bufferedThoughts.push(thought);
}
}
if (content.parts.some((part) => part.functionCall)) {
hasToolCall = true;
@@ -1226,12 +1326,9 @@ export class GeminiChat {
}
}
// Record token usage if this chunk has usageMetadata
// Buffer token usage if this chunk has usageMetadata
if (chunk.usageMetadata) {
this.chatRecordingService.recordMessageTokens(chunk.usageMetadata);
if (chunk.usageMetadata.promptTokenCount !== undefined) {
this.lastPromptTokenCount = chunk.usageMetadata.promptTokenCount;
}
bufferedUsageMetadata = chunk.usageMetadata;
}
const hookSystem = this.context.config.getHookSystem();
@@ -1318,29 +1415,22 @@ export class GeminiChat {
}
}
const responseText = consolidatedParts
const rawResponseText = consolidatedParts
.filter((part) => part.text)
.map((part) => part.text)
.join('')
.trim();
.join('');
let id: string;
// Record model response text from the collected parts.
// Also flush when there are thoughts or a tool call (even with no text)
// so that BeforeTool hooks always see the latest transcript state.
if (responseText || hasThoughts || hasToolCall) {
id = this.chatRecordingService.recordMessage({
model,
type: 'gemini',
content: responseText,
});
} else {
// Still need a durable ID even if response is empty (e.g. only tool calls)
id = this.chatRecordingService.recordSyntheticMessage(
'gemini',
consolidatedParts,
);
}
// Clean zero-width/invisible characters and HTML comments to determine actual printable/visible content
let responseText = rawResponseText.replace(
/[\u200B-\u200D\uFEFF\u200E\u200F]/g,
'',
);
let previous: string;
do {
previous = responseText;
responseText = responseText.replace(/<!--[\s\S]*?-->/g, '');
} while (responseText !== previous);
responseText = responseText.trim();
// Stream validation logic: A stream is considered successful if:
// 1. There's a tool call OR
@@ -1370,6 +1460,36 @@ export class GeminiChat {
);
}
if (!responseText) {
if (finishReason === FinishReason.MAX_TOKENS) {
throw new InvalidStreamError(
'Model stream ended due to token limit exhaustion (MAX_TOKENS) with empty response text.',
'MAX_TOKENS_EXCEEDED',
);
}
if (finishReason === FinishReason.SAFETY) {
throw new InvalidStreamError(
'Model stream ended due to safety settings (SAFETY) with empty response text.',
'SAFETY_BLOCKED',
);
}
if (finishReason === FinishReason.RECITATION) {
throw new InvalidStreamError(
'Model stream ended due to recitation settings (RECITATION) with empty response text.',
'RECITATION_BLOCKED',
);
}
if (finishReason === FinishReason.OTHER) {
throw new InvalidStreamError(
'Model stream ended due to other settings (OTHER) with empty response text.',
'OTHER_BLOCKED',
);
}
if (hasThoughts) {
throw new InvalidStreamError(
'Model stream ended with empty response text but contained reasoning thoughts.',
'THINKING_ONLY_RESPONSE',
);
}
throw new InvalidStreamError(
'Model stream ended with empty response text.',
'NO_RESPONSE_TEXT',
@@ -1377,6 +1497,37 @@ export class GeminiChat {
}
}
// Flush buffered thoughts from the successful attempt
for (const thought of bufferedThoughts) {
this.chatRecordingService.recordThought(thought);
}
// Flush buffered usage metadata and token counts from the successful attempt
if (bufferedUsageMetadata) {
this.chatRecordingService.recordMessageTokens(bufferedUsageMetadata);
if (bufferedUsageMetadata.promptTokenCount !== undefined) {
this.lastPromptTokenCount = bufferedUsageMetadata.promptTokenCount;
}
}
let id: string;
// Record model response text from the collected parts.
// Also flush when there are thoughts or a tool call (even with no text)
// so that BeforeTool hooks always see the latest transcript state.
if (responseText || hasThoughts || hasToolCall) {
id = this.chatRecordingService.recordMessage({
model,
type: 'gemini',
content: responseText,
});
} else {
// Still need a durable ID even if response is empty (e.g. only tool calls)
id = this.chatRecordingService.recordSyntheticMessage(
'gemini',
consolidatedParts,
);
}
this.agentHistory.push({
id,
content: { role: 'model', parts: consolidatedParts },
@@ -1431,11 +1582,13 @@ export class GeminiChat {
}
/**
* Extracts and records thought from thought content.
* Extracts thought from thought content.
*/
private recordThoughtFromContent(content: Content): void {
private extractThoughtFromContent(
content: Content,
): { subject: string; description: string } | undefined {
if (!content.parts || content.parts.length === 0) {
return;
return undefined;
}
const thoughtPart = content.parts[0];
@@ -1448,11 +1601,12 @@ export class GeminiChat {
: '';
const description = rawText.replace(/\*\*(.*?)\*\*/s, '').trim();
this.chatRecordingService.recordThought({
return {
subject,
description,
});
};
}
return undefined;
}
}
@@ -1533,11 +1687,34 @@ export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] {
if (!hasThought) return turn;
const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought);
// The thoughtSignature the API requires on the first functionCall of a
// model turn is sometimes only carried by the thought part we just
// removed, not by the functionCall part itself. Without it, replaying
// this turn in a later request gets rejected with a 400 "missing
// thought_signature" error, so inject a synthetic one if needed.
let patchedFirstCall = false;
const finalParts =
turn.content.role === 'model'
? nonThoughtParts.map((p) => {
if (!patchedFirstCall && p.functionCall) {
patchedFirstCall = true;
if (!p.thoughtSignature) {
return {
...p,
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
};
}
}
return p;
})
: nonThoughtParts;
return {
...turn,
content: {
...turn.content,
parts: nonThoughtParts,
parts: finalParts,
},
};
})
+9 -1
View File
@@ -254,7 +254,15 @@ describe('Turn', () => {
events.push(event);
}
expect(events).toEqual([{ type: GeminiEventType.InvalidStream }]);
expect(events).toEqual([
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_FINISH_REASON',
message: 'Test invalid stream',
},
},
]);
expect(turn.getDebugResponses().length).toBe(0);
expect(reportError).not.toHaveBeenCalled(); // Should not report as error
});
+20 -1
View File
@@ -105,6 +105,19 @@ export type ServerGeminiContextWindowWillOverflowEvent = {
export type ServerGeminiInvalidStreamEvent = {
type: GeminiEventType.InvalidStream;
value: {
type:
| 'NO_FINISH_REASON'
| 'NO_RESPONSE_TEXT'
| 'MALFORMED_FUNCTION_CALL'
| 'UNEXPECTED_TOOL_CALL'
| 'MAX_TOKENS_EXCEEDED'
| 'SAFETY_BLOCKED'
| 'RECITATION_BLOCKED'
| 'OTHER_BLOCKED'
| 'THINKING_ONLY_RESPONSE';
message: string;
};
};
export type ServerGeminiModelInfoEvent = {
@@ -408,7 +421,13 @@ export class Turn {
}
if (e instanceof InvalidStreamError) {
yield { type: GeminiEventType.InvalidStream };
yield {
type: GeminiEventType.InvalidStream,
value: {
type: e.type,
message: e.message,
},
};
return;
}
+10
View File
@@ -413,6 +413,16 @@ export function renderOperationalGuidelines(
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Tool Execution Response Rules:**
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
a) Call another tool to proceed with the task.
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
2. You MUST NEVER return an empty response with no text and no tool calls.
- **Post-Edit Response Rules:**
1. After an edit tool execution (e.g. ${formatToolName(EDIT_TOOL_NAME)}, ${formatToolName(WRITE_FILE_TOOL_NAME)}), you MUST ALWAYS generate a user-facing text response summarizing:
- What changes were made to the file.
- Your verification plan or next steps (e.g. running tests).
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the ${formatToolName(EDIT_TOOL_NAME)} tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive(
@@ -630,8 +630,8 @@ describe('Scheduler (Orchestrator)', () => {
CoreToolCallStatus.Cancelled,
'Operation cancelled by user',
);
// finalizeCall is handled by the processing loop, not synchronously by cancelAll
// expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
// finalizeCall is called synchronously by cancelAll to ensure completedBatch is populated and isActive is updated immediately
expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
expect(mockStateManager.cancelAllQueued).toHaveBeenCalledWith(
'Operation cancelled by user',
);
+9
View File
@@ -278,6 +278,7 @@ export class Scheduler {
CoreToolCallStatus.Cancelled,
'Operation cancelled by user',
);
this.state.finalizeCall(activeCall.request.callId);
}
}
@@ -438,6 +439,14 @@ export class Scheduler {
*/
private async _processNextItem(signal: AbortSignal): Promise<boolean> {
if (signal.aborted || this.isCancelling) {
// Finalize active calls that are terminal
const activeCalls = this.state.allActiveCalls;
for (const call of activeCalls) {
if (this.isTerminal(call.status)) {
this.state.finalizeCall(call.request.callId);
}
}
this.state.cancelAllQueued('Operation cancelled');
return false;
}
@@ -308,6 +308,125 @@ describe('ChatRecordingService', () => {
)) as ConversationRecord;
expect(conversation.sessionId).toBe('old-session-id');
});
it('should fall back to the in-memory conversation when the file cannot be reloaded', async () => {
// Regression test for the `/compress` "Failed to load resumed session
// data from file" bug: when resuming with a filePath that cannot be
// loaded from disk, initialize must NOT throw. It should adopt the
// in-memory conversation it was handed and rewrite a clean file.
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const missingFile = path.join(chatsDir, 'missing-session.jsonl');
expect(fs.existsSync(missingFile)).toBe(false);
const inMemoryConversation = {
sessionId: 'resumed-session-id',
projectHash: 'resumed-project-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [
{
id: 'msg-1',
type: 'user',
timestamp: new Date().toISOString(),
content: 'hello from memory',
},
],
} as unknown as ConversationRecord;
await expect(
chatRecordingService.initialize({
filePath: missingFile,
conversation: inMemoryConversation,
}),
).resolves.not.toThrow();
// The in-memory conversation is adopted.
expect(chatRecordingService.getConversation()?.sessionId).toBe(
'resumed-session-id',
);
// A clean, loadable file is rewritten from the in-memory copy so future
// loads and appends succeed.
const reloaded = (await loadConversationRecord(
missingFile,
)) as ConversationRecord;
expect(reloaded).not.toBeNull();
expect(reloaded.sessionId).toBe('resumed-session-id');
expect(reloaded.projectHash).toBe('resumed-project-hash');
expect(reloaded.messages).toHaveLength(1);
});
it('should preserve an unreadable session file instead of destroying it', async () => {
// The reload may have failed only transiently, so the original bytes
// must survive the recovery rewrite.
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const sessionFile = path.join(chatsDir, 'unreadable.jsonl');
// No usable metadata line => loadConversationRecord() returns null.
const originalBytes = '{"not":"a valid metadata line"}\n';
fs.writeFileSync(sessionFile, originalBytes);
await chatRecordingService.initialize({
filePath: sessionFile,
conversation: {
sessionId: 'recovered-session-id',
projectHash: 'recovered-project-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
} as unknown as ConversationRecord,
});
// The rewritten file is loadable again...
const reloaded = (await loadConversationRecord(
sessionFile,
)) as ConversationRecord;
expect(reloaded.sessionId).toBe('recovered-session-id');
// ...and the original bytes were kept alongside it.
const preserved = fs
.readdirSync(chatsDir)
.filter((f) => f.startsWith('unreadable.jsonl.unreadable-'));
expect(preserved).toHaveLength(1);
expect(fs.readFileSync(path.join(chatsDir, preserved[0]), 'utf-8')).toBe(
originalBytes,
);
});
it('should not leave a temp file behind when the rewrite fails', async () => {
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const sessionFile = path.join(chatsDir, 'rewrite-fails.jsonl');
// Fail the rename that publishes the temp file, leaving it orphaned.
const realRename = fs.renameSync;
vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => {
if (String(from).includes('.tmp-')) {
throw new Error('simulated rename failure');
}
return realRename(from, to);
});
await expect(
chatRecordingService.initialize({
filePath: sessionFile,
conversation: {
sessionId: 'temp-cleanup-session',
projectHash: 'temp-cleanup-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
} as unknown as ConversationRecord,
}),
).rejects.toThrow('simulated rename failure');
const leftovers = fs
.readdirSync(chatsDir)
.filter((f) => f.includes('.tmp-'));
expect(leftovers).toEqual([]);
});
});
describe('recordMessage', () => {
@@ -462,7 +462,16 @@ export class ChatRecordingService {
// Update the session ID in the existing file
this.updateMetadata({ sessionId: this.sessionId });
} else {
throw new Error('Failed to load resumed session data from file');
// The file could not be reloaded (missing, corrupt metadata, or an
// I/O error). Fall back to the in-memory conversation we were handed
// rather than failing the caller, and rewrite a clean file from it.
debugLogger.warn(
'Failed to reload resumed session data from file; falling back ' +
'to the in-memory conversation.',
);
this.cachedConversation = resumedSessionData.conversation;
this.projectHash = this.cachedConversation.projectHash;
this.rewriteConversationFile(this.cachedConversation);
}
} else {
// Create new session
@@ -563,6 +572,73 @@ export class ChatRecordingService {
}
}
/**
* Rewrites the session file from an in-memory record. Any existing
* (unreadable) file is preserved alongside rather than destroyed, and the
* new file is written atomically (temp file + rename).
*/
private rewriteConversationFile(conversation: ConversationRecord): void {
if (!this.conversationFile) return;
// Normalize legacy `.json` paths to the `.jsonl` format we write.
if (this.conversationFile.endsWith('.json')) {
this.conversationFile = this.conversationFile + 'l';
}
const { messages, memoryScratchpad, ...metadata } = conversation;
const lines: string[] = [JSON.stringify(metadata)];
for (const msg of messages) {
lines.push(JSON.stringify(msg));
}
if (memoryScratchpad) {
lines.push(JSON.stringify({ $set: { memoryScratchpad } }));
}
const content = lines.join('\n') + '\n';
try {
fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true });
// The existing file was unreadable, but it may have been only
// transiently so (a lock or I/O blip) rather than truly corrupt. Keep
// its bytes rather than destroying them.
if (fs.existsSync(this.conversationFile)) {
const backup = `${this.conversationFile}.unreadable-${Date.now()}`;
try {
fs.renameSync(this.conversationFile, backup);
debugLogger.warn(
`Preserved the unreadable session file at ${backup}.`,
);
} catch (backupError) {
debugLogger.error(
'Failed to preserve the unreadable session file.',
backupError,
);
}
}
const tempFile = `${this.conversationFile}.tmp-${process.pid}`;
try {
fs.writeFileSync(tempFile, content);
fs.renameSync(tempFile, this.conversationFile);
} catch (error) {
// The rename did not complete, so the temp file would be left behind.
try {
fs.unlinkSync(tempFile);
} catch {
// Ignore cleanup errors so the original failure still surfaces.
}
throw error;
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOSPC') {
this.conversationFile = null;
debugLogger.warn(ENOSPC_WARNING_MESSAGE);
} else {
throw error;
}
}
}
private updateMetadata(updates: Partial<ConversationRecord>): void {
if (!this.cachedConversation) return;
Object.assign(this.cachedConversation, updates);
@@ -37,6 +37,9 @@ export interface ModelConfigKey {
// Indicates whether this request originates from the primary interactive chat model.
// Enables the default fallback configuration to `chat-base` when unknown.
isChatModel?: boolean;
// The last stream error that triggered this retry attempt, if any.
lastStreamError?: unknown;
}
export interface ModelConfig {
@@ -173,6 +173,7 @@ describe('UiTelemetryService', () => {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 500,
errorsByType: {},
},
tokens: {
input: 5,
@@ -229,6 +230,7 @@ describe('UiTelemetryService', () => {
totalRequests: 2,
totalErrors: 0,
totalLatencyMs: 1100,
errorsByType: {},
},
tokens: {
input: 10,
@@ -305,6 +307,9 @@ describe('UiTelemetryService', () => {
totalRequests: 1,
totalErrors: 1,
totalLatencyMs: 300,
errorsByType: {
UNKNOWN: 1,
},
},
tokens: {
input: 0,
@@ -319,6 +324,42 @@ describe('UiTelemetryService', () => {
});
});
it('should track errors by error_type distinctly', () => {
const event1 = {
'event.name': EVENT_API_ERROR,
model: 'gemini-2.5-pro',
duration_ms: 200,
error: 'Empty response',
error_type: 'NO_RESPONSE_TEXT',
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
const event2 = {
'event.name': EVENT_API_ERROR,
model: 'gemini-2.5-pro',
duration_ms: 250,
error: 'Malformed JSON',
error_type: 'MALFORMED_FUNCTION_CALL',
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
const event3 = {
'event.name': EVENT_API_ERROR,
model: 'gemini-2.5-pro',
duration_ms: 100,
error: 'Another empty response',
error_type: 'NO_RESPONSE_TEXT',
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
service.addEvent(event1);
service.addEvent(event2);
service.addEvent(event3);
const metrics = service.getMetrics();
expect(metrics.models['gemini-2.5-pro'].api.errorsByType).toEqual({
NO_RESPONSE_TEXT: 2,
MALFORMED_FUNCTION_CALL: 1,
});
});
it('should aggregate ApiErrorEvents and ApiResponseEvents', () => {
const responseEvent = {
'event.name': EVENT_API_RESPONSE,
@@ -351,6 +392,9 @@ describe('UiTelemetryService', () => {
totalRequests: 2,
totalErrors: 1,
totalLatencyMs: 800,
errorsByType: {
UNKNOWN: 1,
},
},
tokens: {
input: 5,
@@ -56,6 +56,7 @@ export interface ModelMetrics {
totalRequests: number;
totalErrors: number;
totalLatencyMs: number;
errorsByType?: Record<string, number>;
};
tokens: {
input: number;
@@ -110,6 +111,7 @@ const createInitialModelMetrics = (): ModelMetrics => ({
totalRequests: 0,
totalErrors: 0,
totalLatencyMs: 0,
errorsByType: {},
},
tokens: {
input: 0,
@@ -170,6 +172,23 @@ export class UiTelemetryService extends EventEmitter {
});
}
recordSemanticValidationError(model: string, errorType: string): void {
const modelMetrics = this.getOrCreateModelMetrics(model);
modelMetrics.api.totalErrors++;
if (!modelMetrics.api.errorsByType) {
modelMetrics.api.errorsByType = {};
}
const type = errorType || 'INVALID_STREAM';
modelMetrics.api.errorsByType[type] =
(modelMetrics.api.errorsByType[type] || 0) + 1;
this.emit('update', {
metrics: this.#metrics,
lastPromptTokenCount: this.#lastPromptTokenCount,
});
}
getMetrics(): SessionMetrics {
return this.#metrics;
}
@@ -326,6 +345,13 @@ export class UiTelemetryService extends EventEmitter {
modelMetrics.api.totalErrors++;
modelMetrics.api.totalLatencyMs += event.duration_ms;
if (!modelMetrics.api.errorsByType) {
modelMetrics.api.errorsByType = {};
}
const errorType = event.error_type || 'UNKNOWN';
modelMetrics.api.errorsByType[errorType] =
(modelMetrics.api.errorsByType[errorType] || 0) + 1;
if (event.role) {
if (!modelMetrics.roles[event.role]) {
modelMetrics.roles[event.role] = createInitialRoleMetrics();
+21
View File
@@ -10,3 +10,24 @@ export const REFERENCE_CONTENT_END = '--- End of content ---';
export const DEFAULT_MAX_LINES_TEXT_FILE = 2000;
export const MAX_LINE_LENGTH_TEXT_FILE = 2000;
export const MAX_FILE_SIZE_MB = 20;
export const EMPTY_RESPONSE_COMPRESS_SUGGESTION =
'The model returned an empty text response. If your context window is near capacity, try using /compress.';
export const THINKING_ONLY_COMPRESS_SUGGESTION =
'The model returned reasoning thoughts but no final response text. If your context window is near capacity, try using /compress.';
export const MAX_TOKENS_EXCEEDED_SUGGESTION =
'Model response was truncated because it exceeded the token limit. Try using /compress to free up context space.';
export const SAFETY_BLOCKED_MESSAGE =
'The model response was blocked due to safety settings.';
export const RECITATION_BLOCKED_MESSAGE =
'The model response was blocked due to recitation/copyright filters.';
export const OTHER_BLOCKED_MESSAGE =
'The model response was blocked due to other policy settings.';
export const TRUE_EMPTY_RESPONSE_MESSAGE =
'The model returned an empty response with no text or thoughts. This may be a transient API issue; please try again.';
@@ -109,6 +109,16 @@ describe('parseAndFormatApiError', () => {
expect(result).toContain(vertexMessage);
});
it('should format a StructuredError with status: undefined', () => {
const error: StructuredError = {
message: 'Rate limit exceeded (simulated 429 error, limit: 0)',
status: undefined,
};
const expected =
'[API Error: Rate limit exceeded (simulated 429 error, limit: 0)]';
expect(parseAndFormatApiError(error)).toBe(expected);
});
it('should handle an unknown error type', () => {
const error = 12345;
const expected = '[API Error: An unknown error occurred.]';
@@ -445,4 +445,113 @@ describe('parseGoogleApiError', () => {
expect(parsed?.code).toBe(429);
expect(parsed?.message).toBe('Quota exceeded');
});
it('should parse an error wrapped inside cause.message by gaxios', () => {
const mockError = {
code: 429,
status: 429,
cause: {
message: JSON.stringify([
{
error: {
code: 429,
message:
'No capacity available for model gemini-3.1-pro-preview on the server',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'MODEL_CAPACITY_EXHAUSTED',
domain: 'cloudcode-pa.googleapis.com',
metadata: { model: 'gemini-3.1-pro-preview' },
},
],
},
},
]),
code: 429,
status: 'Too Many Requests',
},
};
const parsed = parseGoogleApiError(mockError);
expect(parsed).not.toBeNull();
expect(parsed?.code).toBe(429);
expect(parsed?.message).toBe(
'No capacity available for model gemini-3.1-pro-preview on the server',
);
expect(parsed?.details).toHaveLength(1);
expect(parsed?.details[0]['@type']).toBe(
'type.googleapis.com/google.rpc.ErrorInfo',
);
});
it('should parse an error where cause is a plain ErrorShape and propagate outer code', () => {
const mockError = {
code: 429,
cause: {
message: 'Quota exceeded on the server',
},
};
const parsed = parseGoogleApiError(mockError);
expect(parsed).not.toBeNull();
expect(parsed?.code).toBe(429);
expect(parsed?.message).toBe('Quota exceeded on the server');
});
it('should parse an error where cause is a standard Error object and propagate outer status', () => {
const mockError = {
status: 503,
cause: new Error('Service Unavailable'),
};
const parsed = parseGoogleApiError(mockError);
expect(parsed).not.toBeNull();
expect(parsed?.code).toBe(503);
expect(parsed?.message).toBe('Service Unavailable');
});
it('should defensively parse numeric string status codes from outer error', () => {
const mockError = {
status: '503',
cause: new Error('Service Unavailable'),
};
const parsed = parseGoogleApiError(mockError);
expect(parsed).not.toBeNull();
expect(parsed?.code).toBe(503);
expect(parsed?.message).toBe('Service Unavailable');
});
it('should return null for non-numeric string status codes from outer error', () => {
const mockError = {
status: 'Too Many Requests',
cause: new Error('Quota exceeded'),
};
const parsed = parseGoogleApiError(mockError);
expect(parsed).toBeNull();
});
it('should return null for empty or whitespace-only string status codes from outer error', () => {
const mockError = {
status: ' ',
cause: new Error('Quota exceeded'),
};
const parsed = parseGoogleApiError(mockError);
expect(parsed).toBeNull();
});
it('should parse an error where cause is a plain string and propagate outer status', () => {
const mockError = {
status: 429,
cause: 'Quota exceeded on the server',
};
const parsed = parseGoogleApiError(mockError);
expect(parsed).not.toBeNull();
expect(parsed?.code).toBe(429);
expect(parsed?.message).toBe('Quota exceeded on the server');
});
});
+82 -1
View File
@@ -153,6 +153,18 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
return null;
}
// Skip parsing if the error is already a classified quota error
if (
typeof error === 'object' &&
error !== null &&
'name' in error &&
(error.name === 'TerminalQuotaError' ||
error.name === 'RetryableQuotaError' ||
error.name === 'ValidationRequiredError')
) {
return null;
}
let errorObj: unknown = error;
// If error is a string, try to parse it.
@@ -174,7 +186,9 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
}
let currentError: ErrorShape | undefined =
fromGaxiosError(errorObj) ?? fromApiError(errorObj);
fromGaxiosError(errorObj) ??
fromApiError(errorObj) ??
fromCauseError(errorObj);
let depth = 0;
const maxDepth = 10;
@@ -371,3 +385,70 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
}
return outerError;
}
function fromCauseError(errorObj: object): ErrorShape | undefined {
const err = errorObj as {
code?: unknown;
status?: unknown;
cause?: unknown;
};
if (!err.cause) return undefined;
const rawCode = err.code ?? err.status;
const fallbackCode =
typeof rawCode === 'number'
? rawCode
: typeof rawCode === 'string' &&
rawCode.trim() !== '' &&
!isNaN(Number(rawCode))
? Number(rawCode)
: undefined;
const resolveError = (
resolved: ErrorShape | undefined,
): ErrorShape | undefined => {
if (!resolved) return undefined;
const message = resolved.message;
const details = resolved.details;
const code = resolved.code ?? fallbackCode;
return {
...(message !== undefined ? { message } : {}),
...(details !== undefined ? { details } : {}),
...(code !== undefined ? { code } : {}),
};
};
if (typeof err.cause === 'object' && err.cause !== null) {
if (
'error' in err.cause &&
err.cause.error &&
isErrorShape(err.cause.error)
) {
return resolveError(err.cause.error);
}
if ('message' in err.cause && err.cause.message) {
if (typeof err.cause.message === 'string') {
const parsed = fromApiError({ message: err.cause.message });
if (parsed) return resolveError(parsed);
} else if (
typeof err.cause.message === 'object' &&
err.cause.message !== null
) {
const msgObj = err.cause.message as { error?: unknown };
if (msgObj.error && isErrorShape(msgObj.error)) {
return resolveError(msgObj.error);
}
}
}
if (isErrorShape(err.cause)) {
return resolveError(err.cause);
}
}
if (typeof err.cause === 'string' && err.cause.trim() !== '') {
const parsed = fromApiError({ message: err.cause }) ?? {
message: err.cause,
};
return resolveError(parsed);
}
return undefined;
}
@@ -107,6 +107,63 @@ describe('classifyGoogleError', () => {
expect((result as RetryableQuotaError).retryDelayMs).toBe(9000);
});
it('should return TerminalQuotaError for MODEL_CAPACITY_EXHAUSTED when no retry delay is specified', () => {
const apiError: GoogleApiError = {
code: 429,
message:
'No capacity available for model gemini-3.1-pro-preview on the server',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'MODEL_CAPACITY_EXHAUSTED',
domain: 'cloudcode-pa.googleapis.com',
metadata: { model: 'gemini-3.1-pro-preview' },
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(TerminalQuotaError);
});
it('should return TerminalQuotaError for MODEL_CAPACITY_EXHAUSTED even when the domain is not a Cloud Code domain (domain-agnostic)', () => {
const apiError: GoogleApiError = {
code: 429,
message:
'No capacity available for model gemini-3.1-pro-preview on the server',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'MODEL_CAPACITY_EXHAUSTED',
domain: 'other.googleapis.com',
metadata: { model: 'gemini-3.1-pro-preview' },
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(TerminalQuotaError);
});
it('should return TerminalQuotaError for MODEL_CAPACITY_EXCEEDED when no retry delay is specified', () => {
const apiError: GoogleApiError = {
code: 429,
message:
'No capacity available for model gemini-3.1-pro-preview on the server',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'MODEL_CAPACITY_EXCEEDED',
domain: 'cloudcode-pa.googleapis.com',
metadata: { model: 'gemini-3.1-pro-preview' },
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(TerminalQuotaError);
});
it('should return original error if code is not 429, 499 or 503', () => {
const apiError: GoogleApiError = {
code: 500,
+37 -2
View File
@@ -28,15 +28,17 @@ enum GoogleApiType {
export class TerminalQuotaError extends Error {
retryDelayMs?: number;
reason?: string;
status?: number;
constructor(
message: string,
override readonly cause: GoogleApiError,
override readonly cause?: GoogleApiError,
retryDelaySeconds?: number,
reason?: string,
) {
super(message);
this.name = 'TerminalQuotaError';
this.status = cause?.code;
this.retryDelayMs = retryDelaySeconds
? retryDelaySeconds * 1000
: undefined;
@@ -53,14 +55,16 @@ export class TerminalQuotaError extends Error {
*/
export class RetryableQuotaError extends Error {
retryDelayMs?: number;
status?: number;
constructor(
message: string,
override readonly cause: GoogleApiError,
override readonly cause?: GoogleApiError,
retryDelaySeconds?: number,
) {
super(message);
this.name = 'RetryableQuotaError';
this.status = cause?.code;
this.retryDelayMs = retryDelaySeconds
? retryDelaySeconds * 1000
: undefined;
@@ -217,6 +221,20 @@ function classifyValidationRequiredError(
* @returns A classified error or the original `unknown` error.
*/
export function classifyGoogleError(error: unknown): unknown {
if (
error instanceof TerminalQuotaError ||
error instanceof RetryableQuotaError ||
error instanceof ValidationRequiredError ||
(typeof error === 'object' &&
error !== null &&
'name' in error &&
(error.name === 'TerminalQuotaError' ||
error.name === 'RetryableQuotaError' ||
error.name === 'ValidationRequiredError'))
) {
return error;
}
const googleApiError = parseGoogleApiError(error);
const status = googleApiError?.code ?? getErrorStatus(error);
const errorMessage = googleApiError?.message || extractErrorMessage(error);
@@ -330,6 +348,23 @@ export function classifyGoogleError(error: unknown): unknown {
);
}
if (
errorInfo.reason === 'MODEL_CAPACITY_EXHAUSTED' ||
errorInfo.reason === 'MODEL_CAPACITY_EXCEEDED'
) {
// If no server backoff delay is specified, treat capacity exhaustion as a terminal error
// to trigger immediate model fallback without retrying on the same exhausted model.
if (delaySeconds === undefined) {
return new TerminalQuotaError(
googleApiError.message,
googleApiError,
delaySeconds,
errorInfo.reason,
);
}
// Otherwise, fall through to RetryableQuotaError to honor the server's requested delay.
}
// New Cloud Code API quota handling
if (errorInfo.domain) {
if (isCloudCodeDomain(errorInfo.domain)) {
@@ -0,0 +1,167 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { isFunctionResponse, isFunctionCall } from './messageInspectors.js';
describe('messageInspectors', () => {
describe('isFunctionResponse', () => {
it('should return false if content role is not user', () => {
const content = {
role: 'model',
parts: [
{
functionResponse: {
name: 'test_tool',
response: { success: true },
},
},
],
};
expect(isFunctionResponse(content)).toBe(false);
});
it('should return false if content has no parts', () => {
const content = {
role: 'user',
};
expect(isFunctionResponse(content)).toBe(false);
});
it('should return false if parts are empty', () => {
const content = {
role: 'user',
parts: [],
};
expect(isFunctionResponse(content)).toBe(false);
});
it('should return false if none of the parts is a functionResponse', () => {
const content = {
role: 'user',
parts: [
{
text: 'Hello world',
},
{
fileData: {
mimeType: 'image/png',
fileUri: 'https://example.com/image.png',
},
},
],
};
expect(isFunctionResponse(content)).toBe(false);
});
it('should return true if all parts are functionResponses', () => {
const content = {
role: 'user',
parts: [
{
functionResponse: {
name: 'test_tool_1',
response: { success: true },
},
},
{
functionResponse: {
name: 'test_tool_2',
response: { value: 42 },
},
},
],
};
expect(isFunctionResponse(content)).toBe(true);
});
it('should return true if content is a mixed multimodal tool response containing functionResponse and sibling parts', () => {
const content = {
role: 'user',
parts: [
{
functionResponse: {
name: 'test_tool',
response: { success: true },
},
},
{
fileData: {
mimeType: 'image/png',
fileUri: 'https://example.com/image.png',
},
},
],
};
expect(isFunctionResponse(content)).toBe(true);
});
});
describe('isFunctionCall', () => {
it('should return false if content role is not model', () => {
const content = {
role: 'user',
parts: [
{
functionCall: {
name: 'test_tool',
args: {},
},
},
],
};
expect(isFunctionCall(content)).toBe(false);
});
it('should return false if content has no parts', () => {
const content = {
role: 'model',
};
expect(isFunctionCall(content)).toBe(false);
});
it('should return false if parts are empty', () => {
const content = {
role: 'model',
parts: [],
};
expect(isFunctionCall(content)).toBe(false);
});
it('should return false if none of the parts is a functionCall', () => {
const content = {
role: 'model',
parts: [
{
text: 'I am thinking...',
},
],
};
expect(isFunctionCall(content)).toBe(false);
});
it('should return true if all parts are functionCalls', () => {
const content = {
role: 'model',
parts: [
{
functionCall: {
name: 'test_tool_1',
args: {},
},
},
{
functionCall: {
name: 'test_tool_2',
args: { query: 'foo' },
},
},
],
};
expect(isFunctionCall(content)).toBe(true);
});
});
});
+2 -1
View File
@@ -10,7 +10,7 @@ export function isFunctionResponse(content: Content): boolean {
return (
content.role === 'user' &&
!!content.parts &&
content.parts.every((part) => !!part.functionResponse)
content.parts.some((part) => !!part.functionResponse)
);
}
@@ -18,6 +18,7 @@ export function isFunctionCall(content: Content): boolean {
return (
content.role === 'model' &&
!!content.parts &&
content.parts.length > 0 &&
content.parts.every((part) => !!part.functionCall)
);
}
@@ -41,7 +41,11 @@ export function isStructuredError(error: unknown): error is StructuredError {
if (typeof error.message !== 'string') {
return false;
}
if ('status' in error && typeof error.status !== 'number') {
if (
'status' in error &&
error.status !== undefined &&
typeof error.status !== 'number'
) {
return false;
}
return true;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.54.0-nightly.20260728.gbef611950",
"version": "0.55.0-preview.1",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
@@ -0,0 +1,38 @@
# Dockerfile for Jetski/Antigravity Worker Job using the Python SDK
# This container runs the manager script (workflow/worker.py) which orchestrates
# the code generation and evaluation in-process using google-antigravity and Firestore synchronization.
FROM python:3.11-slim
# 1. Install system utilities (git is required for repository operations)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy Node.js 20 and npm directly from the official node:20-slim image
COPY --from=node:20-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:20-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
# 2. Create non-root system user and establish working directory
RUN useradd -m -u 1000 appuser
WORKDIR /app
# 3. Install Python dependencies using requirements.txt for optimized build layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# 4. Copy the pipeline orchestration script and prompts with non-root ownership
COPY --chown=appuser:appuser workflow/ /app/workflow/
COPY --chown=appuser:appuser agent_prompts/ /app/agent_prompts/
# 5. Switch to unprivileged user
USER appuser
# 6. Execute script directly
ENTRYPOINT ["python", "/app/workflow/worker.py"]
@@ -0,0 +1,40 @@
apiVersion: 'run.googleapis.com/v1'
kind: 'Job'
metadata:
labels:
cloud.googleapis.com/location: 'us-central1'
name: 'pr-gen-job'
spec:
template:
metadata:
annotations:
run.googleapis.com/client-name: 'gcloud'
run.googleapis.com/client-version: '575.0.1'
run.googleapis.com/execution-environment: 'gen2'
spec:
taskCount: 1
template:
spec:
containers:
- env:
- name: 'GOOGLE_CLOUD_LOCATION'
value: 'global'
- name: 'MODEL_NAME'
value: 'gemini-3.5-flash'
- name: 'FIRESTORE_DATABASE'
value: 'gcli-db'
- name: 'FIRESTORE_COLLECTION'
value: 'issues'
- name: 'GIT_TOKEN'
valueFrom:
secretKeyRef:
key: 'latest'
name: 'PR_GEN_GITHUB_PUSH_KEY'
image: 'us-central1-docker.pkg.dev/gcli-intern-project-2026/pr-gen-repo/jetski-worker:latest'
resources:
limits:
cpu: '2'
memory: '8Gi'
maxRetries: 2
serviceAccountName: 'code-gen-job-execution-sa@gcli-intern-project-2026.iam.gserviceaccount.com'
timeoutSeconds: '3600'
@@ -0,0 +1,13 @@
[pytest]
minversion = 8.0
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
addopts =
-v
--strict-markers
--tb=short
--cov=workflow
--cov-report=term-missing
@@ -0,0 +1,6 @@
google-antigravity>=0.1.0
protobuf>=7.35.0
pydantic
google-cloud-firestore>=2.15.0, <3.0.0
google-cloud-storage>=2.14.0
google-genai>=2.0.0
@@ -0,0 +1,4 @@
# Copyright 2026 Google LLC
# Apache-2.0 License
"""Tests package for SSR Code Generator workflow modules."""
@@ -0,0 +1,26 @@
# Copyright 2026 Google LLC
# Apache-2.0 License
"""Shared Pytest Fixtures for Workflow Module Tests."""
import os
import sys
import pytest
# Ensure workflow directory is in sys.path
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
WORKFLOW_DIR = os.path.join(BASE_DIR, "workflow")
if WORKFLOW_DIR not in sys.path:
sys.path.insert(0, WORKFLOW_DIR)
@pytest.fixture(autouse=True)
def reset_env(monkeypatch):
"""Ensures environment variables are clean and isolated for each test."""
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project-2026")
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
monkeypatch.setenv("MODEL_NAME", "gemini-3.5-flash")
monkeypatch.setenv("MAX_ATTEMPTS", "5")
monkeypatch.setenv("REPO_URL", "https://github.com/test-owner/test-repo.git")
monkeypatch.setenv("GIT_TOKEN", "test-github-token-12345")
yield
@@ -0,0 +1,170 @@
# Copyright 2026 Google LLC
# Apache-2.0 License
"""Unit tests for workflow/command_executor.py."""
import os
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from command_executor import (
CommandExecutionError,
CommandExecutor,
sanitize_identifier,
sanitize_relative_path,
)
# --- Sanitization Unit Tests ---
def test_sanitize_relative_path_valid():
"""Tests that valid relative paths are normalized cleanly."""
assert sanitize_relative_path("src/utils/file.ts") == "src/utils/file.ts"
assert sanitize_relative_path("a/b/../c/file.ts") == "a/c/file.ts"
def test_sanitize_relative_path_traversal():
"""Tests that path traversal attempts returning '..' are rejected."""
assert sanitize_relative_path("../secret/passwords.txt") is None
assert sanitize_relative_path("a/../../etc/passwd") is None
def test_sanitize_relative_path_absolute():
"""Tests that absolute paths are rejected."""
assert sanitize_relative_path("/etc/passwd") is None
assert sanitize_relative_path("/usr/local/bin") is None
def test_sanitize_relative_path_null_bytes_and_empty():
"""Tests stripping of null bytes and handling of empty inputs."""
assert sanitize_relative_path("src/utils\x00/file.ts") == "src/utils/file.ts"
assert sanitize_relative_path(" \x00 ") is None
assert sanitize_relative_path("") is None
assert sanitize_relative_path(None) is None
def test_sanitize_identifier_valid():
"""Tests sanitization of alphanumeric identifiers with hyphens/underscores."""
assert sanitize_identifier("feature-branch_123") == "feature-branch_123"
assert sanitize_identifier("v1.0.0") == "v1.0.0"
def test_sanitize_identifier_injection_stripping():
"""Tests that special command injection characters are removed."""
assert sanitize_identifier("branch; rm -rf /") == "branchrm-rf"
assert sanitize_identifier("issue#190$(whoami)") == "issue190whoami"
def test_sanitize_identifier_empty_fallback():
"""Tests that empty or invalid inputs fall back to 'default'."""
assert sanitize_identifier("") == "default"
assert sanitize_identifier(None) == "default"
assert sanitize_identifier("!!!") == "default"
# --- CommandExecutionError Tests ---
def test_command_execution_error_attributes():
"""Tests that CommandExecutionError formats exception message and stores attributes."""
err = CommandExecutionError(
cmd=["git", "status"],
returncode=128,
stdout="out_data",
stderr="err_data",
)
assert "git status" in str(err)
assert "exit code 128" in str(err)
assert err.cmd == "git status"
assert err.returncode == 128
assert err.stdout == "out_data"
assert err.stderr == "err_data"
# --- CommandExecutor.run Unit Tests ---
@patch("subprocess.run")
def test_run_list_args_success(mock_subprocess_run):
"""Tests successful command execution with a list of argument tokens."""
mock_subprocess_run.return_value = MagicMock(
returncode=0, stdout="hello world\n", stderr=""
)
output = CommandExecutor.run(["echo", "hello", "world"])
assert output == "hello world"
mock_subprocess_run.assert_called_once()
args, kwargs = mock_subprocess_run.call_args
assert args[0] == ["echo", "hello", "world"]
assert kwargs["check"] is False
@patch("subprocess.run")
def test_run_string_command_shlex_split(mock_subprocess_run):
"""Tests that string commands are tokenized using shlex without shell=True."""
mock_subprocess_run.return_value = MagicMock(
returncode=0, stdout="diff output\n", stderr=""
)
output = CommandExecutor.run("git diff --stat origin/main")
assert output == "diff output"
mock_subprocess_run.assert_called_once()
args, kwargs = mock_subprocess_run.call_args
assert args[0] == ["git", "diff", "--stat", "origin/main"]
@patch("subprocess.run")
def test_run_inline_env_parsing(mock_subprocess_run):
"""Tests parsing of inline KEY=VALUE env prefixes in string commands."""
mock_subprocess_run.return_value = MagicMock(
returncode=0, stdout="installed\n", stderr=""
)
output = CommandExecutor.run('NODE_OPTIONS="--max-old-space-size=4096" npm ci')
assert output == "installed"
mock_subprocess_run.assert_called_once()
args, kwargs = mock_subprocess_run.call_args
assert args[0] == ["npm", "ci"]
assert kwargs["env"].get("NODE_OPTIONS") == "--max-old-space-size=4096"
@patch("subprocess.run")
def test_run_custom_cwd_and_env(mock_subprocess_run):
"""Tests custom CWD and environment variable dict propagation."""
mock_subprocess_run.return_value = MagicMock(
returncode=0, stdout="ok\n", stderr=""
)
custom_env = {"MY_VAR": "custom_val"}
output = CommandExecutor.run(["pwd"], cwd="/tmp/pr", env=custom_env)
assert output == "ok"
mock_subprocess_run.assert_called_once()
args, kwargs = mock_subprocess_run.call_args
assert kwargs["cwd"] == "/tmp/pr"
assert kwargs["env"].get("MY_VAR") == "custom_val"
@patch("subprocess.run")
def test_run_non_zero_exit_code_raises_error(mock_subprocess_run):
"""Tests that a non-zero exit code raises CommandExecutionError."""
mock_subprocess_run.return_value = MagicMock(
returncode=1, stdout="some stdout", stderr="fatal error"
)
with pytest.raises(CommandExecutionError) as exc_info:
CommandExecutor.run(["git", "checkout", "nonexistent"])
err = exc_info.value
assert err.returncode == 1
assert err.stdout == "some stdout"
assert err.stderr == "fatal error"
@patch("subprocess.run")
def test_run_timeout_expired(mock_subprocess_run):
"""Tests that subprocess timeout exceptions propagate cleanly."""
mock_subprocess_run.side_effect = subprocess.TimeoutExpired(
cmd="long_task", timeout=10.0
)
with pytest.raises(subprocess.TimeoutExpired):
CommandExecutor.run(["sleep", "100"], timeout=10.0)
@@ -0,0 +1,99 @@
# Copyright 2026 Google LLC
# Apache-2.0 License
"""Unit tests for workflow/config.py."""
import json
import os
import pytest
from config import Config, ConfigurationError
def test_config_defaults(monkeypatch):
"""Tests default configuration fallback values when environment variables are unset."""
monkeypatch.delenv("REPO_URL", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
monkeypatch.delenv("MODEL_NAME", raising=False)
monkeypatch.delenv("MAX_ATTEMPTS", raising=False)
cfg = Config()
assert cfg.repo_url == "https://github.com/joneba-google/gemini-cli-clone"
assert cfg.project_id == "gcli-intern-project-2026"
assert cfg.location == "global"
assert cfg.model_name == "gemini-3.5-flash"
assert cfg.max_attempts == 5
assert cfg.repo_name == "gemini-cli-clone"
def test_config_max_attempts_valid(monkeypatch):
"""Tests custom MAX_ATTEMPTS environment variable parsing."""
monkeypatch.setenv("MAX_ATTEMPTS", "12")
cfg = Config()
assert cfg.max_attempts == 12
def test_config_max_attempts_invalid_string(monkeypatch):
"""Tests that invalid MAX_ATTEMPTS strings fall back cleanly to 5."""
monkeypatch.setenv("MAX_ATTEMPTS", "invalid_string")
cfg = Config()
assert cfg.max_attempts == 5
def test_config_max_attempts_zero_or_negative(monkeypatch):
"""Tests lower bound enforcement (max(val, 1)) for zero or negative values."""
monkeypatch.setenv("MAX_ATTEMPTS", "0")
cfg1 = Config()
assert cfg1.max_attempts == 1
monkeypatch.setenv("MAX_ATTEMPTS", "-5")
cfg2 = Config()
assert cfg2.max_attempts == 1
def test_config_repo_name_derivation(monkeypatch):
"""Tests repository name parsing from REPO_URL with trailing slashes and .git suffixes."""
monkeypatch.setenv("REPO_URL", "https://github.com/my-org/my-custom-repo.git/")
cfg = Config()
assert cfg.repo_name == "my-custom-repo"
assert cfg.pr_repo_path == os.path.join("/tmp/pr", "my-custom-repo")
assert cfg.eval_repo_path == os.path.join("/tmp/eval", "my-custom-repo")
def test_load_and_validate_firestore_doc_valid(monkeypatch):
"""Tests loading and validating a valid JSON FIRESTORE_DOC string."""
valid_doc = {"workable_spec": {"issue_id": "190"}, "status": "PENDING"}
monkeypatch.setenv("FIRESTORE_DOC", json.dumps(valid_doc))
cfg = Config()
parsed = cfg.load_and_validate_firestore_doc()
assert parsed["workable_spec"]["issue_id"] == "190"
assert parsed["status"] == "PENDING"
def test_load_and_validate_firestore_doc_missing(monkeypatch):
"""Tests error handling when FIRESTORE_DOC environment variable is missing."""
monkeypatch.delenv("FIRESTORE_DOC", raising=False)
cfg = Config()
with pytest.raises(ConfigurationError) as exc_info:
cfg.load_and_validate_firestore_doc()
assert "Environment variable 'FIRESTORE_DOC' is required" in str(exc_info.value)
def test_load_and_validate_firestore_doc_invalid_json(monkeypatch):
"""Tests error handling when FIRESTORE_DOC is not valid JSON."""
monkeypatch.setenv("FIRESTORE_DOC", "{invalid json string")
cfg = Config()
with pytest.raises(ConfigurationError) as exc_info:
cfg.load_and_validate_firestore_doc()
assert "Failed to parse 'FIRESTORE_DOC' as JSON" in str(exc_info.value)
def test_load_and_validate_firestore_doc_non_dict(monkeypatch):
"""Tests error handling when FIRESTORE_DOC parses to a non-dict JSON structure."""
monkeypatch.setenv("FIRESTORE_DOC", '["array_item1", "array_item2"]')
cfg = Config()
with pytest.raises(ConfigurationError) as exc_info:
cfg.load_and_validate_firestore_doc()
assert "Firestore document specification must be a JSON object" in str(exc_info.value)
@@ -0,0 +1,105 @@
# Copyright 2026 Google LLC
# Apache-2.0 License
"""Unit tests for workflow/github_client.py."""
import io
import json
import urllib.error
from unittest.mock import MagicMock, patch
import pytest
from github_client import GitHubClient, GitHubClientError
def test_github_client_init():
"""Tests GitHubClient initialization and URL construction."""
client = GitHubClient(owner="my-owner", repo="my-repo", token="secret-token")
assert client.owner == "my-owner"
assert client.repo == "my-repo"
assert client._token == "secret-token"
assert client._base_url == "https://api.github.com/repos/my-owner/my-repo/pulls"
def test_create_pull_request_missing_token():
"""Tests that create_pull_request raises GitHubClientError when token is missing."""
client = GitHubClient(owner="my-owner", repo="my-repo", token=None)
with pytest.raises(GitHubClientError) as exc_info:
client.create_pull_request("feature-branch", "Fix bug", "PR description")
assert "GitHub token is missing" in str(exc_info.value)
@patch("urllib.request.urlopen")
def test_create_pull_request_success(mock_urlopen):
"""Tests successful pull request creation, verifying headers, payload, and PR number return."""
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
{"number": 28, "html_url": "https://github.com/my-owner/my-repo/pull/28"}
).encode("utf-8")
mock_urlopen.return_value.__enter__.return_value = mock_response
client = GitHubClient(owner="my-owner", repo="my-repo", token="valid-token")
pr_num = client.create_pull_request("feature-branch", "Fix bug", "PR description")
assert pr_num == "28"
mock_urlopen.assert_called_once()
req = mock_urlopen.call_args[0][0]
assert req.headers["Authorization"] == "Bearer valid-token"
assert req.headers["Accept"] == "application/vnd.github+json"
assert req.headers["Content-type"] == "application/json"
data = json.loads(req.data.decode("utf-8"))
assert data["title"] == "Fix bug"
assert data["body"] == "PR description"
assert data["head"] == "feature-branch"
assert data["base"] == "main"
@patch("urllib.request.urlopen")
def test_create_pull_request_http_error(mock_urlopen):
"""Tests HTTPError handling, verifying status code and response body preservation."""
error_body = json.dumps({"message": "Validation Failed", "errors": ["Branch already exists"]})
mock_fp = io.BytesIO(error_body.encode("utf-8"))
http_err = urllib.error.HTTPError(
url="https://api.github.com/...",
code=422,
msg="Unprocessable Entity",
hdrs={},
fp=mock_fp,
)
mock_urlopen.side_effect = http_err
client = GitHubClient(owner="my-owner", repo="my-repo", token="valid-token")
with pytest.raises(GitHubClientError) as exc_info:
client.create_pull_request("feature-branch", "Fix bug", "PR description")
err_str = str(exc_info.value)
assert "HTTP 422" in err_str
assert "Validation Failed" in err_str
@patch("urllib.request.urlopen")
def test_create_pull_request_url_error(mock_urlopen):
"""Tests network URLError handling (e.g. DNS failure or connection refused)."""
url_err = urllib.error.URLError(reason="Connection refused")
mock_urlopen.side_effect = url_err
client = GitHubClient(owner="my-owner", repo="my-repo", token="valid-token")
with pytest.raises(GitHubClientError) as exc_info:
client.create_pull_request("feature-branch", "Fix bug", "PR description")
err_str = str(exc_info.value)
assert "Network Error: Connection refused" in err_str
@patch("urllib.request.urlopen")
def test_create_pull_request_unexpected_exception(mock_urlopen):
"""Tests unexpected runtime exception handling."""
mock_urlopen.side_effect = RuntimeError("System socket crash")
client = GitHubClient(owner="my-owner", repo="my-repo", token="valid-token")
with pytest.raises(GitHubClientError) as exc_info:
client.create_pull_request("feature-branch", "Fix bug", "PR description")
assert "Unexpected API client error" in str(exc_info.value)
@@ -0,0 +1,12 @@
# Copyright 2026 Google LLC
# Apache-2.0 License
"""Unit tests for workflow/__init__.py."""
import workflow
def test_package_docstring():
"""Tests that the workflow package contains a valid docstring."""
assert workflow.__doc__ is not None
assert "GCLI Orchestrator Package" in workflow.__doc__
@@ -0,0 +1,187 @@
# Copyright 2026 Google LLC
# Apache-2.0 License
"""Unit tests for workflow/orchestrator.py."""
import json
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from orchestrator import Orchestrator, OrchestrationError
from command_executor import CommandExecutionError
@pytest.fixture
def mock_config():
"""Returns a mock Config instance for Orchestrator tests."""
config = MagicMock()
config.repo_url = "https://github.com/test-owner/test-repo"
config.repo_name = "test-repo"
config.git_token = "secret-token"
config.pr_dir = "/tmp/pr"
config.eval_dir = "/tmp/eval"
config.pr_repo_path = "/tmp/pr/test-repo"
config.eval_repo_path = "/tmp/eval/test-repo"
config.max_attempts = 2
config.model_name = "gemini-3.5-flash"
config.load_and_validate_firestore_doc.return_value = {
"github_metadata": {"owner": "test-owner", "repo": "test-repo", "issue_number": 190},
"workable_spec": {
"issue_id": "190",
"title": "Fix issue 190",
"description": "Issue description content",
},
}
return config
def test_orchestrator_init(mock_config):
"""Tests Orchestrator initialization and component setup."""
orc = Orchestrator(mock_config)
assert orc.config == mock_config
assert hasattr(orc, "agent_runner")
@patch("shutil.rmtree")
@patch("os.makedirs")
def test_setup_workspace(mock_makedirs, mock_rmtree, mock_config):
"""Tests workspace directory setup and cleanup."""
orc = Orchestrator(mock_config)
orc._setup_workspace()
assert mock_makedirs.call_count >= 2
@patch("command_executor.CommandExecutor.run")
def test_sync_or_clone_repository(mock_cmd_run, mock_config):
"""Tests repository cloning and syncing."""
mock_cmd_run.return_value = "git output"
orc = Orchestrator(mock_config)
with patch("os.path.exists", return_value=False):
orc._sync_or_clone_repository()
assert mock_cmd_run.call_count >= 1
@pytest.mark.asyncio
@patch("command_executor.CommandExecutor.run")
async def test_run_regression_checks_pass(mock_cmd_run, mock_config):
"""Tests _run_regression_checks when npm clean and npm ci succeed."""
mock_cmd_run.return_value = "clean ok"
orc = Orchestrator(mock_config)
result = await orc._run_regression_checks()
assert result is True
@pytest.mark.asyncio
@patch("preflight_filter.PreflightFilter.should_ignore_preflight_failure")
@patch("command_executor.CommandExecutor.run")
async def test_run_regression_checks_bypassed_failure(mock_cmd_run, mock_preflight_filter, mock_config):
"""Tests bypassing regression failures when preflight filter approves."""
mock_cmd_run.side_effect = CommandExecutionError(
cmd="npm run test:ci", returncode=1, stdout="FAIL src/utils/sessionCleanup.test.ts", stderr=""
)
mock_preflight_filter.return_value = True
orc = Orchestrator(mock_config)
result = await orc._run_regression_checks()
assert result is True
@pytest.mark.asyncio
@patch("preflight_filter.PreflightFilter.should_ignore_preflight_failure")
@patch("command_executor.CommandExecutor.run")
async def test_run_regression_checks_unapproved_failure(mock_cmd_run, mock_preflight_filter, mock_config):
"""Tests handling of unapproved regression failures."""
mock_cmd_run.side_effect = CommandExecutionError(
cmd="npm run test:ci", returncode=1, stdout="FAIL src/auth/login.test.ts", stderr=""
)
mock_preflight_filter.return_value = False
orc = Orchestrator(mock_config)
with patch("builtins.open", MagicMock()):
result = await orc._run_regression_checks()
assert result is False
@patch("shutil.copyfile")
@patch("os.path.exists")
def test_save_feedback_to_coding_workspace(mock_exists, mock_copyfile, mock_config):
"""Tests copying pr_feedback.md from eval workspace to PR workspace."""
mock_exists.return_value = True
orc = Orchestrator(mock_config)
orc._save_feedback_to_coding_workspace()
mock_copyfile.assert_called_once_with(
os.path.join(mock_config.eval_repo_path, "pr_feedback.md"),
os.path.join(mock_config.pr_repo_path, "pr_feedback.md"),
)
@pytest.mark.asyncio
@patch("orchestrator.acquire_lock", return_value="CLAIMED")
@patch("orchestrator.release_lock", return_value=True)
@patch("command_executor.CommandExecutor.run")
@patch("orchestrator.Orchestrator._setup_workspace")
@patch("orchestrator.Orchestrator._sync_or_clone_repository")
@patch("orchestrator.Orchestrator._run_regression_checks")
@patch("github_client.GitHubClient.create_pull_request")
async def test_run_loop_success_pr_created(
mock_create_pr, mock_regression, mock_sync, mock_setup, mock_cmd_run, mock_release_lock, mock_acquire_lock, mock_config
):
"""Tests complete successful orchestrator run loop resulting in PR creation."""
mock_regression.return_value = True
mock_create_pr.return_value = "28"
def cmd_side_effect(cmd, *args, **kwargs):
cmd_str = str(cmd)
if "diff --stat" in cmd_str:
return "1 file changed, 5 insertions(+), 5 deletions(-)"
if "diff" in cmd_str:
return "diff --git a/file.py b/file.py\n+new line"
if "status" in cmd_str:
return "modified: file.py"
return "ok"
mock_cmd_run.side_effect = cmd_side_effect
orc = Orchestrator(mock_config)
orc.agent_runner.run_agent = AsyncMock()
orc.agent_runner.run_agent.return_value = ("Coding agent completed code changes.", [])
with patch("os.path.exists", return_value=True), \
patch("builtins.open", MagicMock()):
with patch.object(orc, "_run_evaluation", AsyncMock(return_value="APPROVED")):
await orc.run()
mock_create_pr.assert_called_once()
@pytest.mark.asyncio
@patch("orchestrator.acquire_lock", return_value="CLAIMED")
@patch("orchestrator.release_lock", return_value=True)
@patch("command_executor.CommandExecutor.run")
@patch("orchestrator.Orchestrator._setup_workspace")
@patch("orchestrator.Orchestrator._sync_or_clone_repository")
async def test_run_loop_max_attempts_exceeded(mock_sync, mock_setup, mock_cmd_run, mock_release_lock, mock_acquire_lock, mock_config):
"""Tests that run loop finishes and releases lock when max repair attempts are exhausted."""
def cmd_side_effect(cmd, *args, **kwargs):
cmd_str = str(cmd)
if "diff" in cmd_str:
return "diff --git a/file.py b/file.py\n+new line"
return "ok"
mock_cmd_run.side_effect = cmd_side_effect
orc = Orchestrator(mock_config)
orc.agent_runner.run_agent = AsyncMock(
return_value=("Code generation output", [])
)
with patch("os.path.exists", return_value=False), \
patch.object(orc, "_run_evaluation", AsyncMock(return_value="REJECTED")):
await orc.run()
mock_release_lock.assert_called_once()
@@ -0,0 +1,64 @@
# Copyright 2026 Google LLC
# Apache-2.0 License
"""Unit tests for workflow/preflight_filter.py."""
from preflight_filter import (
ALLOWED_SANDBOX_FAILURES,
PreflightFilter,
is_preflight_failure_allowed,
strip_ansi,
)
def test_strip_ansi_color_codes():
"""Tests that strip_ansi cleanly removes ANSI terminal escape codes."""
colored_text = "\x1b[31mFAIL\x1b[0m \x1b[1msrc/utils/test.ts\x1b[0m"
assert strip_ansi(colored_text) == "FAIL src/utils/test.ts"
assert PreflightFilter.strip_ansi(colored_text) == "FAIL src/utils/test.ts"
def test_is_preflight_failure_allowed_single_approved_file():
"""Tests approval of a single known allowed test file failure."""
output = "FAIL src/utils/sessionCleanup.test.ts"
assert is_preflight_failure_allowed(output) is True
def test_is_preflight_failure_allowed_multiple_approved_files():
"""Tests approval when multiple known allowed test files fail."""
output = (
"FAIL src/utils/sessionCleanup.test.ts\n"
"FAIL src/config/extension-manager-permissions.test.ts"
)
assert is_preflight_failure_allowed(output) is True
def test_is_preflight_failure_allowed_generic_keyword():
"""Tests approval when failure matches generic container/sandbox exception keywords."""
output = "FAILED root-privilege-check in sandbox"
assert is_preflight_failure_allowed(output) is True
def test_is_preflight_failure_allowed_unapproved_failure():
"""Tests rejection when an unapproved test file fails."""
output = (
"FAIL src/utils/sessionCleanup.test.ts\n"
"FAIL src/auth/loginService.test.ts"
)
assert is_preflight_failure_allowed(output) is False
def test_is_preflight_failure_allowed_no_failures():
"""Tests that output with no 'FAIL' or 'FAILED' lines returns False."""
output = "PASS src/utils/sessionCleanup.test.ts\nTests: 12 passed, 12 total"
assert is_preflight_failure_allowed(output) is False
def test_should_ignore_preflight_failure_concatenates_streams():
"""Tests PreflightFilter.should_ignore_preflight_failure stream concatenation."""
stdout = "FAIL src/utils/sessionCleanup.test.ts"
stderr = ""
assert PreflightFilter.should_ignore_preflight_failure(stdout, stderr) is True
unapproved_stdout = "FAIL src/core/main.test.ts"
assert PreflightFilter.should_ignore_preflight_failure(unapproved_stdout, stderr) is False
@@ -0,0 +1,89 @@
# Copyright 2026 Google LLC
# Apache-2.0 License
"""Unit tests for workflow/worker.py."""
import logging
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from worker import IgnoreRawWsMsgFilter, main, setup_logging
from orchestrator import OrchestrationError
def test_ignore_raw_ws_msg_filter():
"""Tests that IgnoreRawWsMsgFilter filters out RAW WS MSG log records."""
msg_filter = IgnoreRawWsMsgFilter()
record_ws = logging.LogRecord(
name="test", level=logging.INFO, pathname="", lineno=0,
msg="RAW WS MSG: websocket data packet", args=(), exc_info=None
)
assert msg_filter.filter(record_ws) is False
record_normal = logging.LogRecord(
name="test", level=logging.INFO, pathname="", lineno=0,
msg="Normal execution status log", args=(), exc_info=None
)
assert msg_filter.filter(record_normal) is True
@patch("logging.basicConfig")
def test_setup_logging(mock_basic_config):
"""Tests that setup_logging configures root logger handlers correctly."""
setup_logging()
mock_basic_config.assert_called_once()
kwargs = mock_basic_config.call_args[1]
assert kwargs["level"] == logging.INFO
assert len(kwargs["handlers"]) == 1
assert isinstance(kwargs["handlers"][0], logging.StreamHandler)
@pytest.mark.asyncio
@patch("worker.Config")
@patch("worker.Orchestrator")
async def test_worker_main_success(mock_orchestrator_cls, mock_config_cls):
"""Tests successful worker execution lifecycle."""
mock_config = MagicMock()
mock_config_cls.return_value = mock_config
mock_orchestrator = MagicMock()
mock_orchestrator.run = AsyncMock(return_value="PR_CREATED")
mock_orchestrator_cls.return_value = mock_orchestrator
await main()
mock_config_cls.assert_called_once()
mock_orchestrator_cls.assert_called_once_with(mock_config)
mock_orchestrator.run.assert_called_once()
@pytest.mark.asyncio
@patch("worker.Config")
@patch("worker.Orchestrator")
async def test_worker_main_orchestration_error(mock_orchestrator_cls, mock_config_cls):
"""Tests that OrchestrationError results in sys.exit(1)."""
mock_orchestrator = MagicMock()
mock_orchestrator.run = AsyncMock(side_effect=OrchestrationError("Fatal loop limit"))
mock_orchestrator_cls.return_value = mock_orchestrator
with pytest.raises(SystemExit) as exc_info:
await main()
assert exc_info.value.code == 1
@pytest.mark.asyncio
@patch("worker.Config")
@patch("worker.Orchestrator")
async def test_worker_main_unexpected_exception(mock_orchestrator_cls, mock_config_cls):
"""Tests that unhandled exceptions result in sys.exit(4)."""
mock_orchestrator = MagicMock()
mock_orchestrator.run = AsyncMock(side_effect=RuntimeError("System crash"))
mock_orchestrator_cls.return_value = mock_orchestrator
with pytest.raises(SystemExit) as exc_info:
await main()
assert exc_info.value.code == 4
@@ -0,0 +1,103 @@
# Google Cloud Workflow that triggers Cloud Run Job and updates Firestore on failure.
main:
params: ['event']
steps:
- init:
assign:
- project_id: '${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}'
- database_id: '${default(sys.get_env("FIRESTORE_DATABASE"), "gcli-db")}'
- collection_name: '${default(sys.get_env("FIRESTORE_COLLECTION"), "issues")}'
- job_name: 'pr-gen-job' # The Cloud Run Job name
- job_location: 'us-central1'
- workflow_execution_id: '${sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID")}'
# Decode Pub/Sub message
- pubsub_message_bytes: '${base64.decode(event.data.message.data)}'
- firestore_doc_str: '${text.decode(pubsub_message_bytes)}'
- firestore_doc: '${json.decode(firestore_doc_str)}'
- doc_id: '${"github_" + firestore_doc.github_metadata.owner + "_" + firestore_doc.github_metadata.repo + "_" + string(firestore_doc.github_metadata.issue_number)}'
- repo_url: '${"https://github.com/" + firestore_doc.github_metadata.owner + "/" + firestore_doc.github_metadata.repo}'
- status: 'STARTED'
- error_details: null
- validate_doc_id:
switch:
- condition: '${not text.match_regex(doc_id, "^[a-zA-Z0-9_.-]+$")}'
raise: '${"Security Exception: Invalid or unsafe firestore_id format: " + string(doc_id)}'
- run_job:
try:
call: 'googleapis.run.v1.namespaces.jobs.run'
args:
name: '${"namespaces/" + project_id + "/jobs/" + job_name}'
location: '${job_location}'
body:
overrides:
containerOverrides:
- env:
- name: 'FIRESTORE_DOC'
value: '${firestore_doc_str}'
- name: 'REPO_URL'
value: '${repo_url}'
- name: 'USE_ADC'
value: 'true'
- name: 'EXECUTION_ID'
value: '${sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID")}'
- name: 'GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID'
value: '${sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID")}'
- name: 'FIRESTORE_ID'
value: '${doc_id}'
connector_params:
timeout: 7200
result: 'job_result'
except:
as: 'e'
steps:
- handle_job_error:
assign:
- status: 'NEEDS_HUMAN'
- error_details: '${e}'
- update_firestore_on_failure:
try:
call: 'googleapis.firestore.v1.projects.databases.documents.patch'
args:
name: '${"projects/" + project_id + "/databases/" + database_id + "/documents/" + collection_name + "/" + doc_id}'
updateMask:
fieldPaths:
- 'status'
- 'error'
- 'lock.holder'
- 'lock.expires_at'
body:
fields:
status:
stringValue: 'NEEDS_HUMAN'
error:
stringValue: '${"Workflow Execution " + sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID") + " failed: " + json.encode_to_string(error_details)}'
lock:
mapValue:
fields:
holder:
nullValue: 'NULL_VALUE'
expires_at:
nullValue: 'NULL_VALUE'
except:
as: 'fs_err'
steps:
- log_firestore_error:
assign:
- firestore_error: '${fs_err}'
next: 'handle_failure'
next: 'mark_completed'
- handle_failure:
return: '${"Failed: " + json.encode_to_string(error_details)}'
- mark_completed:
assign:
- status: 'COMPLETED'
next: 'finish'
- finish:
return: '${status}'
@@ -0,0 +1,10 @@
"""GCLI Orchestrator Package.
This package contains all components of the SSR Agent Orchestrator:
- config: Configuration loading and validation.
- command_executor: Subprocess execution utility.
- github_client: GitHub v3 REST API client.
- agent_runner: Google Antigravity SDK wrapper.
- preflight_filter: Preflight test verification filtering.
- orchestrator: Orchestration state machine coordinating code generation and evaluation.
"""
@@ -0,0 +1,154 @@
"""Command execution and input sanitization module.
Provides safe subprocess execution utilities, path traversal guards,
and input sanitizers to prevent injection attacks and capture process output cleanly.
"""
import logging
import os
import re
import shlex
import subprocess
def sanitize_relative_path(path: str | os.PathLike) -> str | None:
"""Sanitizes an untrusted relative file path to prevent Path Traversal.
Strips null bytes, normalizes path separators, and ensures the path does not
escape the workspace or refer to an absolute root path.
Args:
path: Untrusted file path string or PathLike object.
Returns:
The normalized safe relative path string, or None if malicious/invalid.
"""
if not path:
return None
raw_str = str(path).replace("\x00", "").strip()
if not raw_str:
return None
clean_path = os.path.normpath(raw_str)
if clean_path.startswith("..") or os.path.isabs(clean_path):
logging.warning("Path traversal attempt or absolute path detected: %s", path)
return None
return clean_path
def sanitize_identifier(value: str) -> str:
"""Sanitizes an untrusted string for use in branch names, tags, or CLI identifiers.
Strips null bytes and removes any character not in [a-zA-Z0-9._-].
Args:
value: Untrusted identifier string.
Returns:
A sanitized alphanumeric identifier string (defaults to 'default' if empty).
"""
if not value:
return "default"
raw_str = str(value).replace("\x00", "")
sanitized = re.sub(r"[^a-zA-Z0-9._-]", "", raw_str)
return sanitized or "default"
class CommandExecutionError(Exception):
"""Raised when a subprocess fails to run or returns a non-zero exit code."""
def __init__(
self, cmd: str | list[str], returncode: int, stdout: str, stderr: str
) -> None:
"""Initializes the error with command results."""
cmd_str = " ".join(cmd) if isinstance(cmd, list) else cmd
super().__init__(f"Command '{cmd_str}' failed with exit code {returncode}")
self.cmd = cmd_str
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class CommandExecutor:
"""Utility class to execute system-level commands and handle failures."""
@staticmethod
def run(
cmd: str | list[str],
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: float = 3600.0,
) -> str:
"""Executes a command safely using direct argument lists without shell invocation.
Args:
cmd: The command string or list of argument tokens to execute.
cwd: The directory path in which to run the command. Defaults to CWD.
env: Custom environment variable dictionary to pass to the process.
timeout: Maximum allowed duration in seconds. Defaults to 3600.0s.
Returns:
The trimmed stdout string from the command process.
Raises:
CommandExecutionError: If the process exits with a non-zero status.
"""
active_cwd = cwd or os.getcwd()
exec_env = os.environ.copy()
if env:
exec_env.update(env)
# Convert string commands into argument tokens, parsing inline KEY=VAL env prefixes
if isinstance(cmd, str):
tokens = shlex.split(cmd)
args: list[str] = []
for token in tokens:
if "=" in token and not args:
k, v = token.split("=", 1)
exec_env[k] = v
else:
args.append(token)
else:
args = list(cmd)
cmd_str = " ".join(args)
logging.info("Executing command: %s (CWD: %s)", cmd_str, active_cwd)
try:
result = subprocess.run(
args,
cwd=active_cwd,
env=exec_env,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
stdout_str = result.stdout.strip() if result.stdout else ""
stderr_str = result.stderr.strip() if result.stderr else ""
if result.returncode != 0:
logging.error(
"Command execution failed: %s (Exit Code: %s)",
cmd_str,
result.returncode,
)
if stdout_str:
logging.error("Stdout:\n%s", stdout_str)
if stderr_str:
logging.error("Stderr:\n%s", stderr_str)
raise CommandExecutionError(
cmd=args,
returncode=result.returncode,
stdout=stdout_str,
stderr=stderr_str,
)
return stdout_str
except Exception as e:
if not isinstance(e, CommandExecutionError):
logging.exception(
"An unexpected error occurred during command execution: %s",
cmd_str,
)
raise
@@ -0,0 +1,83 @@
"""Configuration module for the SSR Agent Orchestrator.
This module parses, validates, and holds all configuration parameters and path
constants needed by the orchestrator. It ensures fast-fail on missing or invalid
configurations.
"""
import json
import os
from typing import Any
class ConfigurationError(Exception):
"""Raised when configuration loading or validation fails."""
class Config:
"""Manages environmental inputs, paths, and limits for the orchestrator."""
def __init__(self) -> None:
"""Initializes the configuration with environment variables and defaults."""
# Target repository configuration
self.repo_url: str = os.environ.get(
"REPO_URL", "https://github.com/joneba-google/gemini-cli-clone"
)
self.git_token: str | None = os.environ.pop("GIT_TOKEN", None)
self.firestore_doc_raw: str | None = os.environ.get("FIRESTORE_DOC")
self.firestore_id: str | None = (
os.environ.get("FIRESTORE_ID") or os.environ.get("firestore_id")
)
self.execution_id: str | None = os.environ.get("EXECUTION_ID")
# Google Cloud Platform configuration
self.project_id: str = os.environ.get(
"GOOGLE_CLOUD_PROJECT", "gcli-intern-project-2026"
)
self.location: str = os.environ.get("GOOGLE_CLOUD_LOCATION", "global")
self.model_name: str = os.environ.get("MODEL_NAME", "gemini-3.5-flash")
# Global runtime settings
try:
self.max_attempts: int = max(int(os.environ.get("MAX_ATTEMPTS", "5")), 1)
except ValueError:
self.max_attempts = 5
# Workspace directory configuration
self.tmp_dir: str = "/tmp"
self.pr_dir: str = os.path.join(self.tmp_dir, "pr")
self.eval_dir: str = os.path.join(self.tmp_dir, "eval")
self.repo_name: str = (
self.repo_url.rstrip("/").split("/")[-1].replace(".git", "")
)
self.pr_repo_path: str = os.path.join(self.pr_dir, self.repo_name)
self.eval_repo_path: str = os.path.join(self.eval_dir, self.repo_name)
# Global environment variables to trust the CLI
os.environ["GEMINI_CLI_WORKSPACE_TRUSTED"] = "true"
def load_and_validate_firestore_doc(self) -> dict[str, Any]:
"""Parses and validates the Firestore JSON input specification.
Returns:
The decoded dictionary of the Firestore document.
Raises:
ConfigurationError: If the document is missing or not valid JSON.
"""
if not self.firestore_doc_raw:
raise ConfigurationError(
"Environment variable 'FIRESTORE_DOC' is required but was not set."
)
try:
doc_data = json.loads(self.firestore_doc_raw)
if not isinstance(doc_data, dict):
raise ConfigurationError(
"Firestore document specification must be a JSON object."
)
return doc_data
except json.JSONDecodeError as e:
raise ConfigurationError(
f"Failed to parse 'FIRESTORE_DOC' as JSON: {e}"
) from e
@@ -0,0 +1,97 @@
"""GitHub REST API Client module.
Handles GitHub pull request creation and branch push operations cleanly using
standard urllib to minimize container dependency footprint.
"""
import json
import logging
import urllib.error
import urllib.request
class GitHubClientError(Exception):
"""Raised when a GitHub API request fails or is rejected."""
class GitHubClient:
"""Lightweight client for communicating with the GitHub v3 REST API."""
def __init__(self, owner: str, repo: str, token: str | None = None) -> None:
"""Initializes the GitHub REST Client.
Args:
owner: Owner/organization of the repository.
repo: Name of the repository.
token: Authentication token. If missing, API calls will fail.
"""
self.owner = owner
self.repo = repo
self._token = token
self._base_url = f"https://api.github.com/repos/{owner}/{repo}/pulls"
def create_pull_request(
self, branch_name: str, title: str, body: str
) -> str:
"""Submits a POST request to GitHub to create a new Pull Request.
Args:
branch_name: The feature branch to be merged.
title: Title of the Pull Request.
body: Body description markdown of the Pull Request.
Returns:
The PR number of the successfully created Pull Request as a string.
Raises:
GitHubClientError: If the HTTP request fails or token is missing.
"""
if not self._token:
raise GitHubClientError(
"GitHub token is missing. Cannot authorize Pull Request creation."
)
data = {
"title": title,
"body": body,
"head": branch_name,
"base": "main",
}
req = urllib.request.Request(
self._base_url,
data=json.dumps(data).encode("utf-8"),
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self._token}",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
method="POST",
)
logging.info(
"Sending Pull Request creation request for branch: %s", branch_name
)
try:
with urllib.request.urlopen(req,timeout=60) as response:
response_payload = json.loads(response.read().decode("utf-8"))
pr_number: str = str(response_payload.get("number", response_payload.get("html_url", "")))
logging.info(
"Pull Request created successfully! PR Number: %s", pr_number
)
return pr_number
except urllib.error.URLError as e:
if isinstance(e, urllib.error.HTTPError):
err_body = e.read().decode("utf-8") if e.fp else "No body content"
err_msg = f"HTTP {e.code}: {err_body}"
else:
err_msg = f"Network Error: {getattr(e, 'reason', e)}"
logging.error("Failed to create Pull Request: %s", err_msg)
raise GitHubClientError(f"GitHub API Error: {err_msg}") from e
except Exception as e:
logging.exception("Encountered unexpected error during PR creation.")
raise GitHubClientError(
f"Unexpected API client error: {e}"
) from e
@@ -0,0 +1,694 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Iterative Bug-Fixing and Evaluation Orchestrator State Machine.
Coordinates repository cloning, branch setup, execution of Google Antigravity
Coding and Evaluator Agents, ESLint static analysis, and deterministic regression
preflight checks. Manages Firestore dual-lock validation and lifecycle state
transitions (COMMIT_GENERATION, PR_EVALUATION_PENDING, NEEDS_HUMAN).
"""
import base64
import json
import logging
import os
import re
import shutil
import sys
from typing import Any
from config import Config
from command_executor import (
CommandExecutor,
CommandExecutionError,
sanitize_identifier,
sanitize_relative_path,
)
from github_client import GitHubClient, GitHubClientError
from agent_runner import AgentRunner, AgentRunnerError
from db.db_interface import (
acquire_lock,
release_lock,
mark_pr_created,
mark_needs_human,
ClaimAction,
IssueStatus,
)
from preflight_filter import PreflightFilter
def _remove_readonly(func: Any, path: str, exc_info: Any) -> None:
"""Error handler to change read-only file permissions during rmtree."""
try:
os.chmod(path, 0o777)
func(path)
except Exception:
pass
class OrchestrationError(Exception):
"""Raised when the orchestration loop encounters an unrecoverable failure."""
class Orchestrator:
"""State machine running the iterative patch generation and evaluation loop."""
def __init__(self, config: Config) -> None:
"""Initializes the state machine.
Args:
config: Initialized Config instance.
"""
self.config = config
orchestrator_dir = os.path.dirname(os.path.abspath(__file__))
prompts_dir = os.path.join(os.path.dirname(orchestrator_dir), "agent_prompts")
self.agent_runner = AgentRunner(
project_id=config.project_id,
location=config.location,
model_name=config.model_name,
script_dir=prompts_dir,
)
def _setup_workspace(self) -> None:
"""Ensures that required temporary workspace structures are initialized."""
logging.info("Initializing workspace directories...")
os.makedirs(self.config.tmp_dir, exist_ok=True)
os.makedirs(self.config.pr_dir, exist_ok=True)
os.makedirs(self.config.eval_dir, exist_ok=True)
def _clean_eval_dir(self) -> None:
"""Cleans up the evaluation workspace directory to prevent state bleeding."""
logging.info("Cleaning up evaluation repository path: %s", self.config.eval_dir)
if os.path.exists(self.config.eval_dir):
try:
shutil.rmtree(self.config.eval_dir, onerror=_remove_readonly)
except OSError as e:
logging.error("Failed to remove evaluation directory: %s", e)
raise OrchestrationError(f"Failed to clean eval directory: {e}") from e
os.makedirs(self.config.eval_dir, exist_ok=True)
def _sync_or_clone_repository(self) -> None:
"""Clones the target git repo or synchronizes it back to clean main branch."""
git_dir = os.path.join(self.config.pr_repo_path, ".git")
repo_exists = os.path.exists(git_dir)
if repo_exists:
logging.info("Repository already exists locally. Syncing with remote origin/main...")
try:
CommandExecutor.run(["git", "reset", "--hard", "HEAD"], self.config.pr_repo_path)
CommandExecutor.run(["git", "clean", "-fd"], self.config.pr_repo_path)
CommandExecutor.run(["git", "checkout", "main"], self.config.pr_repo_path)
CommandExecutor.run(["git", "pull", "origin", "main"], self.config.pr_repo_path)
except CommandExecutionError as e:
logging.warning("Repository sync failed: %s. Re-cloning from scratch.", e)
try:
shutil.rmtree(self.config.pr_repo_path)
except OSError as rm_err:
logging.error("Failed to remove existing repo path: %s", rm_err)
repo_exists = False
if not repo_exists:
logging.info("Cloning repository %s into %s", self.config.repo_url, self.config.pr_dir)
try:
CommandExecutor.run(["git", "clone", self.config.repo_url, self.config.repo_name], self.config.pr_dir)
except CommandExecutionError as e:
raise OrchestrationError(f"Failed to clone repository: {e}") from e
# Establish safe bot git identity
CommandExecutor.run(["git", "config", "user.name", "Jetski Bot"], self.config.pr_repo_path)
CommandExecutor.run(["git", "config", "user.email", "jetski-bot@google.com"], self.config.pr_repo_path)
# Ignore agent/orch file writes to prevent pollution of git status
exclude_file = os.path.join(self.config.pr_repo_path, ".git", "info", "exclude")
os.makedirs(os.path.dirname(exclude_file), exist_ok=True)
try:
with open(exclude_file, "a", encoding="utf-8") as f:
f.write(
"\nfirestore_doc.json\npr_feedback.md\nfeedback.md\n"
"changes.diff\nverdict.json\npr_details.md\n"
)
except IOError as io_err:
logging.warning("Failed to configure git exclude file: %s", io_err)
async def run(self) -> None:
"""Executes the core state machine pipeline.
Raises:
OrchestrationError: If the bug fix generation or submission fails.
"""
self._setup_workspace()
# Load firestore specifications
try:
firestore_doc = self.config.load_and_validate_firestore_doc()
except Exception as e:
raise OrchestrationError(f"Failed to load / validate config: {e}") from e
issue_id = firestore_doc.get("workable_spec", {}).get("issue_id")
github_metadata = firestore_doc.get("github_metadata", {})
issue_num = github_metadata.get("issue_number")
owner = github_metadata.get("owner")
repo = github_metadata.get("repo")
doc_id = self.config.firestore_id or (
f"github_{owner}_{repo}_{issue_num}" if owner and repo and issue_num else None
)
if not issue_num:
raise OrchestrationError("Issue number is missing in Firestore metadata.")
# --- STEP 1, 2, 3: Concurrency Dual-Lock Validation & COMMIT_GENERATION State Update ---
execution_id = self.config.execution_id
logging.info("Validating concurrency dual-lock in Firestore for issue #%s...", issue_num)
claim_action = acquire_lock(
lock_holder=execution_id,
doc_id=doc_id,
owner=owner,
repo=repo,
issue_number=issue_num,
lock_duration_sec=900, # 15 minutes
target_status=IssueStatus.COMMIT_GENERATION.value,
)
if claim_action == ClaimAction.SKIP:
logging.info(
"Lock validation: another worker is working on this issue or issue is in terminal state. Exiting cleanly."
)
return
if claim_action == ClaimAction.NEEDS_HUMAN:
logging.warning(
"Generation attempts exceeded maximum allowed limit. Issue moved to NEEDS_HUMAN. Exiting cleanly."
)
return
try:
branch_name = f"ssr-agent-{sanitize_identifier(str(issue_num))}"
# Sync repository and check out target branch
self._sync_or_clone_repository()
try:
# TODO: Add logic to fetch and checkout the existing branch if responding to user feedback
CommandExecutor.run(["git", "checkout", "-B", branch_name, "origin/main"], self.config.pr_repo_path)
except CommandExecutionError as e:
raise OrchestrationError(f"Failed to checkout feature branch {branch_name}: {e}") from e
# Install project NPM dependencies inside PR workspace
logging.info("Installing node dependencies inside PR repository workspace...")
try:
npm_install_cmd = 'NODE_OPTIONS="--max-old-space-size=4096" npm ci --no-audit --no-fund --maxsockets 3'
CommandExecutor.run(npm_install_cmd, self.config.pr_repo_path)
except CommandExecutionError as e:
raise OrchestrationError(f"Failed to install NPM dependencies in PR workspace: {e}") from e
# Persist the specifications file inside the PR repo workspace
spec_pr_path = os.path.join(self.config.pr_repo_path, "firestore_doc.json")
try:
with open(spec_pr_path, "w", encoding="utf-8") as f:
json.dump(firestore_doc, f, indent=2)
except IOError as e:
raise OrchestrationError(f"Failed to save firestore_doc.json to workspace: {e}") from e
approved = False
loop_count = 0
verdict = "NEEDS_REVISION"
commit_line_count = 0
while loop_count < self.config.max_attempts and not approved:
loop_count += 1
logging.info("=== Starting Iteration %s/%s ===", loop_count, self.config.max_attempts)
# --- PHASE 1: CODE GENERATION ---
await self._run_code_generation(loop_count)
# Consolidate edits and generate diff
diff_content = self._prepare_iteration_commit(issue_num, loop_count)
if not diff_content:
# No changes detected in code generation
continue
# --- PHASE 2: EVALUATION ---
verdict = await self._run_evaluation(diff_content, firestore_doc)
if verdict in ["APPROVED", "PASS"]:
logging.info("Evaluator approved the patch. Launching deterministic regression pre-flights...")
approved = await self._run_regression_checks()
if approved:
try:
diff_stat = CommandExecutor.run("git diff --stat origin/main", self.config.pr_repo_path)
logging.info("Diff Stat summary:\n%s", diff_stat)
lines = diff_stat.strip().split("\n")
last_line = lines[-1] if lines else ""
insertions = re.search(r"(\d+)\s+insertion", last_line)
deletions = re.search(r"(\d+)\s+deletion", last_line)
if insertions:
commit_line_count += int(insertions.group(1))
if deletions:
commit_line_count += int(deletions.group(1))
logging.info("Total modifications line count: %s", commit_line_count)
except Exception as e:
logging.warning("Failed to parse modifications line count: %s", e)
# If iteration wasn't approved, synchronize feedback to the coding workspace
if not approved:
self._save_feedback_to_coding_workspace()
# --- POST LOOP RESOLUTION ---
if approved:
logging.info("=== PATCH APPROVED ===")
if commit_line_count > 500:
logging.error(
"Verdict: APPROVED but modified line size (%s) exceeds 500 limit. Moving to NEEDS_HUMAN.",
commit_line_count,
)
try:
mark_needs_human(
lock_holder=execution_id,
reason=f"Commit modifications ({commit_line_count} lines) exceed 500 lines limit.",
doc_id=doc_id,
owner=owner,
repo=repo,
issue_number=issue_num,
)
except Exception as e:
logging.error("Failed to update Firestore status to NEEDS_HUMAN: %s", e)
return
else:
pr_number = await self._submit_pull_request(issue_num, issue_id, branch_name)
try:
mark_pr_created(
lock_holder=execution_id,
pr_number=pr_number or "",
doc_id=doc_id,
owner=owner,
repo=repo,
issue_number=issue_num,
status=IssueStatus.PR_EVALUATION_PENDING.value,
)
except Exception as e:
logging.error("Failed to update Firestore status to PR_EVALUATION_PENDING: %s", e)
else:
logging.error(
"=== PR REJECTED (Exceeded max loop attempts %s) ===",
self.config.max_attempts,
)
try:
release_lock(
lock_holder=execution_id,
success=False,
doc_id=doc_id,
owner=owner,
repo=repo,
issue_number=issue_num,
status=IssueStatus.NEEDS_HUMAN.value,
error=f"PR rejected after exceeding max loop attempts ({self.config.max_attempts}).",
)
except Exception as e:
logging.error("Failed to release Firestore lock on rejection: %s", e)
return
except Exception as e:
logging.error("Orchestrator pipeline failed: %s. Releasing lock.", e)
try:
release_lock(
lock_holder=execution_id,
success=False,
doc_id=doc_id,
owner=owner,
repo=repo,
issue_number=issue_num,
error=str(e),
)
except Exception as db_err:
logging.error("Failed to release lock on error: %s", db_err)
raise
async def _run_code_generation(self, iteration: int) -> None:
"""Runs the Google Antigravity Coding Agent to fix the bug."""
logging.info("Starting Code Generation Agent...")
if iteration == 1:
prompt = (
"Fix the bug described in firestore_doc.json. "
"CRITICAL: You MUST use file editing tools (such as replace_file_content or write_file) "
"to apply the code modifications to the target files in implementation_plan.files_to_modify "
"and add the requested test assertions to testing_strategy.test_file. "
"Do NOT conclude your session after only viewing files or running baseline tests without making edits. "
"You are running in a headless sandbox environment. Execute any necessary test commands "
"using your run_command tool (e.g. npx vitest run <test_file>). Do NOT ask for permission in the chat."
)
prompt_file = "bug_fixer_prompt.md"
else:
prompt = (
"Use the feedback in pr_feedback.md to address the remaining issues in the code and tests. "
"CRITICAL: You MUST apply file modifications to the codebase using replace_file_content or write_file. "
"Original spec is at firestore_doc.json. "
"You are running in a headless sandbox environment. Execute any necessary test or build commands "
"directly using your run_command tool. Do NOT ask for permission in the chat."
)
prompt_file = "code_revision_prompt.md"
try:
await self.agent_runner.run_agent(
role="Coding Agent",
prompt=prompt,
repo_path=self.config.pr_repo_path,
system_prompt_file=prompt_file,
)
except AgentRunnerError as e:
logging.error("Coding Agent run encountered an error: %s. Transitioning to evaluation...", e)
def _prepare_iteration_commit(self, issue_num: int | str, iteration: int) -> str | None:
"""Consolidates all file edits and stages a soft commit.
Returns:
The raw diff comparison string to origin/main, or None if no changes.
"""
logging.info("Staging workspace modifications and soft-committing...")
try:
CommandExecutor.run(["git", "add", "."], self.config.pr_repo_path)
CommandExecutor.run(["git", "reset", "--soft", "origin/main"], self.config.pr_repo_path)
git_status = CommandExecutor.run(["git", "status", "--porcelain"], self.config.pr_repo_path)
if git_status:
commit_msg = f"[SSR Agent] Issue Fix: issues/{issue_num}"
CommandExecutor.run(["git", "commit", "-m", commit_msg, "--allow-empty", "--no-verify"], self.config.pr_repo_path)
else:
logging.info("No modifications staged against origin/main in this iteration.")
if iteration == 1:
logging.error("Failed to generate any code changes in the first iteration. Aborting.")
raise OrchestrationError("Failed to generate any code changes in the first iteration.")
return None
return CommandExecutor.run(["git", "diff", "origin/main"], self.config.pr_repo_path)
except CommandExecutionError as e:
logging.error("Failed to stage iteration commit or generate diff: %s", e)
return None
async def _run_evaluation(self, diff_content: str, firestore_doc: dict[str, Any]) -> str:
"""Sets up the evaluation sandbox workspace and runs the Evaluator Agent."""
logging.info("Starting Evaluation Agent phase...")
self._clean_eval_dir()
# Copy files to evaluation workspace (ignoring node_modules)
try:
shutil.copytree(
self.config.pr_repo_path,
self.config.eval_repo_path,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("node_modules"),
)
except OSError as e:
logging.error("Failed to sync code into Evaluation workspace: %s", e)
return "NEEDS_REVISION"
# Reuse existing node_modules from PR workspace via symlink to avoid redundant npm ci installs
pr_node_modules = os.path.join(self.config.pr_repo_path, "node_modules")
eval_node_modules = os.path.join(self.config.eval_repo_path, "node_modules")
if os.path.exists(pr_node_modules) and not os.path.exists(eval_node_modules):
logging.info("Symlinking node_modules from PR repository to evaluation workspace...")
try:
os.symlink(pr_node_modules, eval_node_modules)
except OSError as e:
logging.warning("Failed to symlink node_modules: %s. Falling back to npm install.", e)
# Fallback to installing node dependencies if node_modules is missing
if not os.path.exists(eval_node_modules):
logging.info("Installing node dependencies inside evaluation workspace...")
try:
npm_install_cmd = 'NODE_OPTIONS="--max-old-space-size=4096" npm ci --no-audit --no-fund --maxsockets 3'
CommandExecutor.run(npm_install_cmd, self.config.eval_repo_path)
except CommandExecutionError as e:
logging.error("Failed to install NPM packages in evaluation sandbox: %s", e)
return "NEEDS_REVISION"
# Persist changes diff file
diff_eval_path = os.path.join(self.config.eval_repo_path, "changes.diff")
try:
with open(diff_eval_path, "w", encoding="utf-8") as f:
f.write(diff_content)
except IOError as e:
logging.error("Failed to write changes.diff to evaluation workspace: %s", e)
return "NEEDS_REVISION"
# Run linter checks
self._run_eslint_static_check()
eval_prompt = (
"Evaluate the changes in changes.diff against the spec in firestore_doc.json. "
"You are running in a headless sandbox environment. "
"Do NOT run the linter yourself; the linter has already been run and its results are "
"saved in linter_output.txt. You MUST read linter_output.txt to determine if there are lint issues. "
"You MUST output verdict.json to verdict.json in the format {\"verdict\": \"APPROVED\" | \"NEEDS_REVISION\"}. "
"If approved, create pr_details.md containing the recommended commit message and PR description details "
"(explicitly writing 'fixes #<issue_number>' and including the original issue URL https://github.com/<owner>/<repo>/issues/<issue_number>). "
"If verification (linter or static inspection) fails or needs revision, output detailed feedback to pr_feedback.md."
)
try:
await self.agent_runner.run_agent(
role="Evaluator Agent",
prompt=eval_prompt,
repo_path=self.config.eval_repo_path,
system_prompt_file="code_evaluator_prompt.md",
)
except AgentRunnerError as e:
logging.error("Evaluator Agent execution crashed: %s", e)
# Parse verdict output
verdict_file = os.path.join(self.config.eval_repo_path, "verdict.json")
if os.path.exists(verdict_file):
try:
with open(verdict_file, "r", encoding="utf-8") as f:
verdict_payload = json.load(f)
return str(verdict_payload.get("verdict", "NEEDS_REVISION"))
except (json.JSONDecodeError, IOError) as e:
logging.error("Failed to decode verdict JSON file: %s", e)
else:
logging.warning("Verdict JSON file was not generated by the Evaluator Agent.")
return "NEEDS_REVISION"
def _run_eslint_static_check(self) -> None:
"""Runs ESLint dynamically over modified source files."""
logging.info("Executing ESLint code checks...")
linter_output_path = os.path.join(self.config.eval_repo_path, "linter_output.txt")
try:
git_diff_cmd = 'git diff origin/main... --name-only --diff-filter=d -- "*.ts" "*.tsx" "*.js" "*.jsx"'
changed_files_out = CommandExecutor.run(git_diff_cmd, self.config.eval_repo_path).strip()
changed_files = []
for f in changed_files_out.split("\n"):
safe_path = sanitize_relative_path(f)
if safe_path:
changed_files.append(safe_path)
except CommandExecutionError as e:
logging.warning("Failed to retrieve changed files list from git: %s", e)
changed_files = []
if changed_files:
logging.info("Targeting ESLint against modified files: %s", changed_files)
eslint_cmd = [
"npx",
"eslint",
"--max-warnings",
"0",
"--no-error-on-unmatched-pattern",
"--no-warn-ignored",
] + changed_files
eslint_env = {**os.environ, "NODE_OPTIONS": "--max-old-space-size=4096"}
try:
lint_result = CommandExecutor.run(
eslint_cmd, self.config.eval_repo_path, env=eslint_env
)
with open(linter_output_path, "w", encoding="utf-8") as f:
f.write(f"ESLint check succeeded. Output:\n{lint_result}")
logging.info("ESLint static checks passed. Stored results.")
except CommandExecutionError as lint_err:
with open(linter_output_path, "w", encoding="utf-8") as f:
f.write(f"ESLint check FAILED. Errors found:\n{lint_err.stderr or lint_err.stdout}")
logging.warning("ESLint static checks failed. Recorded details inside linter_output.txt.")
else:
try:
with open(linter_output_path, "w", encoding="utf-8") as f:
f.write("No TypeScript/JavaScript files were modified. ESLint skipped.")
logging.info("ESLint skipped because no JS/TS files were modified.")
except IOError as io_err:
logging.error("Failed to write empty ESLint output: %s", io_err)
async def _run_regression_checks(self) -> bool:
"""Runs deterministic E2E regression check pipeline.
Returns:
True if all checks pass or bypass is approved, False otherwise.
"""
logging.info("Executing E2E regression check pipeline...")
try:
CommandExecutor.run("npm run clean", self.config.eval_repo_path)
CommandExecutor.run("npm ci --no-audit --no-fund", self.config.eval_repo_path)
# Regression steps such as npm run build, npm run typecheck, and npm run test:ci are bypassed.
# To run them: CommandExecutor.run("npm run test:ci", self.config.eval_repo_path)
logging.info("Deterministic preflight regression checks bypassed.")
return True
except CommandExecutionError as preflight_error:
logging.warning("Regression checks failed: %s", preflight_error)
# Match bypass rule filter
if "test:ci" in preflight_error.cmd and PreflightFilter.should_ignore_preflight_failure(
preflight_error.stdout, preflight_error.stderr
):
logging.info("Bypassing regression failure due to privilege-bypass allowed list rules.")
return True
# If unapproved regression error, save detailed log report to evaluator feedback
eval_feedback_file = os.path.join(self.config.eval_repo_path, "pr_feedback.md")
try:
with open(eval_feedback_file, "w", encoding="utf-8") as f:
f.write("# E2E Regression Verification Failure\n\n")
f.write(
"The Evaluator Agent approved the PR, but the orchestrator's "
"deterministic regression testing suite failed.\n\n"
)
f.write("## Error Details\n")
f.write("```\n")
f.write(f"Exit Code: {preflight_error.returncode}\n")
f.write(f"Stdout:\n{preflight_error.stdout}\n")
f.write(f"Stderr:\n{preflight_error.stderr}\n")
f.write("```\n\n")
f.write("Please analyze the regression and correct the implementation or tests.\n")
except IOError as io_err:
logging.error("Failed to write feedback report file: %s", io_err)
return False
def _save_feedback_to_coding_workspace(self) -> None:
"""Copies feedback file back to coding workspace for next loop iteration."""
logging.info("Syncing feedback files into Coding workspace...")
eval_feedback = os.path.join(self.config.eval_repo_path, "pr_feedback.md")
coding_feedback = os.path.join(self.config.pr_repo_path, "pr_feedback.md")
if os.path.exists(eval_feedback):
try:
shutil.copyfile(eval_feedback, coding_feedback)
logging.info("Successfully loaded loop revision feedback:")
with open(coding_feedback, "r", encoding="utf-8") as f:
logging.info("\n%s", f.read())
except (OSError, IOError) as e:
logging.error("Failed to load feedback details: %s", e)
else:
try:
with open(coding_feedback, "w", encoding="utf-8") as f:
f.write("Evaluator rejected changes or preflight failed, but did not provide pr_feedback.md.")
logging.info("No detailed feedback found. Preloaded fallback message.")
except IOError as io_err:
logging.error("Failed to write placeholder feedback: %s", io_err)
async def _submit_pull_request(
self, issue_num: int | str, issue_id: str, branch_name: str
) -> str | None:
"""Amends commit message, pushes feature branch, and publishes a GitHub PR.
Returns:
The HTML URL of the created PR, or None if token is missing.
"""
logging.info("Proceeding with git push and pull request submission...")
pr_details_file = os.path.join(self.config.eval_repo_path, "pr_details.md")
recommended_commit_msg = None
recommended_pr_desc = None
if os.path.exists(pr_details_file):
logging.info("Parsing recommended PR details from evaluator output...")
try:
with open(pr_details_file, "r", encoding="utf-8") as f:
details_content = f.read()
# Parse recommended Commit Message (case-insensitive)
commit_match = re.search(
r"##\s*Commit\s*Message\r?\n\s*(.+?)(?=\r?\n##|$)",
details_content,
re.IGNORECASE | re.DOTALL,
)
if commit_match:
recommended_commit_msg = commit_match.group(1).strip()
logging.info("Found recommended commit message: %s", recommended_commit_msg)
# Parse recommended PR Description (case-insensitive)
desc_match = re.search(
r"##\s*PR\s*Description\r?\n\s*(.+?)(?=\r?\n##|$)",
details_content,
re.IGNORECASE | re.DOTALL,
)
if desc_match:
recommended_pr_desc = desc_match.group(1).strip()
logging.info("Found recommended PR description.")
except Exception as e:
logging.warning("Failed to parse recommended PR details: %s. Falling back to default details.", e)
# Amend current Git commit with the recommended title if available
if recommended_commit_msg:
try:
CommandExecutor.run(["git", "commit", "--amend", "-m", recommended_commit_msg, "--no-verify"], self.config.pr_repo_path)
except CommandExecutionError as e:
logging.error("Failed to amend git commit message: %s", e)
# Push branch securely using in-memory auth headers (force pushes are supported to override prior retries)
git_env = os.environ.copy()
if self.config.git_token:
auth_bytes = f"x-access-token:{self.config.git_token}".encode("utf-8")
auth_b64 = base64.b64encode(auth_bytes).decode("utf-8")
git_env["GIT_CONFIG_COUNT"] = "1"
git_env["GIT_CONFIG_KEY_0"] = "http.extraHeader"
git_env["GIT_CONFIG_VALUE_0"] = f"AUTHORIZATION: basic {auth_b64}"
try:
CommandExecutor.run(
["git", "push", "-f", "origin", f"HEAD:refs/heads/{branch_name}"],
cwd=self.config.pr_repo_path,
env=git_env,
)
logging.info("Branch push to remote succeeded.")
except CommandExecutionError as e:
logging.error("Failed to push git branch: %s", e)
raise OrchestrationError(f"Failed to push git branch: {e}") from e
# Submit Pull Request
if self.config.git_token:
repo_parts = self.config.repo_url.rstrip("/").split("/")
owner = repo_parts[-2]
repo_name = self.config.repo_name
pr_title = recommended_commit_msg if recommended_commit_msg else f"[SSR Agent] Issue Fix: issues/{issue_num}"
pr_body = recommended_pr_desc if recommended_pr_desc else (
f"This Pull Request was automatically generated by the SSR Code Generator Agent "
f"to resolve issue `{issue_id}`.\n\n"
f"### Summary of Changes:\n"
f"Applied targeted modifications to address the issue, validated with local compilation and unit tests."
)
client = GitHubClient(owner=owner, repo=repo_name, token=self.config.git_token)
try:
pr_number = client.create_pull_request(
branch_name=branch_name,
title=pr_title,
body=pr_body,
)
return pr_number
except GitHubClientError as e:
logging.error("Pull request submission failed: %s", e)
raise OrchestrationError(f"Pull request submission failed: {e}") from e
else:
logging.warning("GitHub token not configured. Skipping PR creation.")
return None
@@ -0,0 +1,66 @@
"""Preflight test and linting validation filter.
Parses CI tool and unit test terminal outputs to identify and selectively
bypass known, acceptable test failures (such as specific container/sandbox
privilege test failures).
"""
import logging
import re
_ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
ALLOWED_SANDBOX_FAILURES: set[str] = {
"src/utils/sessionCleanup.test.ts",
"src/config/extension-manager-permissions.test.ts",
"root-privilege-check",
"container-permission-test",
}
def strip_ansi(text: str) -> str:
"""Removes ANSI terminal styling escape sequences from a string."""
return _ANSI_ESCAPE_RE.sub("", text)
def is_preflight_failure_allowed(
test_output: str,
allowed_failures: set[str] = ALLOWED_SANDBOX_FAILURES,
) -> bool:
"""Checks if test failures belong strictly to approved container/sandbox exceptions."""
clean_output = strip_ansi(test_output)
lines = clean_output.splitlines()
failing_lines = [
line for line in lines if "FAIL" in line or "FAILED" in line
]
if not failing_lines:
return False
for line in failing_lines:
if not any(allowed in line for allowed in allowed_failures):
logging.warning("Unapproved preflight test failure detected: %s", line)
return False
logging.info("All detected test failure lines match approved sandbox exceptions.")
return True
class PreflightFilter:
"""Utility class to filter ANSI characters and analyze test suite results."""
@staticmethod
def strip_ansi(text: str) -> str:
"""Removes ANSI terminal styling escape sequences from a string."""
return strip_ansi(text)
@classmethod
def should_ignore_preflight_failure(
cls,
stdout: str | None,
stderr: str | None,
allowed_failures: set[str] = ALLOWED_SANDBOX_FAILURES,
) -> bool:
"""Analyzes regression test outputs to see if they can be safely bypassed."""
raw_output = (stdout or "") + "\n" + (stderr or "")
return is_preflight_failure_allowed(raw_output, allowed_failures)
@@ -0,0 +1,55 @@
"""Entrypoint orchestrator script running as a Cloud Run Job.
Loads the environment config, configures centralized logging, and executes the
iterative bug-fixing orchestrator asynchronously.
"""
import asyncio
import logging
import sys
from config import Config
from orchestrator import Orchestrator, OrchestrationError
class IgnoreRawWsMsgFilter(logging.Filter):
"""Filter to ignore raw websocket messages in the log output."""
def filter(self, record: logging.LogRecord) -> bool:
return "RAW WS MSG" not in record.getMessage()
def setup_logging() -> None:
"""Sets up the root logger with a standardized format."""
handler = logging.StreamHandler(sys.stdout)
handler.addFilter(IgnoreRawWsMsgFilter())
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[handler],
)
async def main() -> None:
"""Asynchronous process execution entrypoint."""
setup_logging()
logging.info("Starting SSR Agent Orchestration Worker...")
try:
config = Config()
orchestrator = Orchestrator(config)
await orchestrator.run()
except OrchestrationError as e:
logging.critical("Orchestrator encountered a fatal error: %s", e)
sys.exit(1)
except Exception as e:
logging.exception("An unhandled error occurred in the orchestrator.")
sys.exit(4)
if __name__ == "__main__":
try:
asyncio.run(main())
except Exception as err:
print(f"Fatal crash during startup initialization: {err}", file=sys.stderr)
sys.exit(4)