mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-29 03:00:58 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74523f82c1 | |||
| b55320926d |
@@ -1,132 +0,0 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
||||
name: 'Agent Session Drift Check'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'packages/cli/src/nonInteractiveCli.ts'
|
||||
- 'packages/cli/src/nonInteractiveCliAgentSession.ts'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-drift:
|
||||
name: 'Check Agent Session Drift'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Detect drift and comment'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v8
|
||||
with:
|
||||
script: |-
|
||||
// === Pair configuration — append here to cover more pairs ===
|
||||
const PAIRS = [
|
||||
{
|
||||
legacy: 'packages/cli/src/nonInteractiveCli.ts',
|
||||
session: 'packages/cli/src/nonInteractiveCliAgentSession.ts',
|
||||
label: 'non-interactive CLI',
|
||||
},
|
||||
// Future pairs can be added here. Remember to also add both
|
||||
// paths to the `paths:` filter at the top of this workflow.
|
||||
// Example:
|
||||
// {
|
||||
// legacy: 'packages/core/src/agents/local-invocation.ts',
|
||||
// session: 'packages/core/src/agents/local-session-invocation.ts',
|
||||
// label: 'local subagent invocation',
|
||||
// },
|
||||
];
|
||||
// ============================================================
|
||||
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Use the API to list changed files — no checkout/git diff needed.
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const changed = new Set(files.map((f) => f.filename));
|
||||
|
||||
const warnings = [];
|
||||
for (const { legacy, session, label } of PAIRS) {
|
||||
const legacyChanged = changed.has(legacy);
|
||||
const sessionChanged = changed.has(session);
|
||||
if (legacyChanged && !sessionChanged) {
|
||||
warnings.push(
|
||||
`**${label}**: \`${legacy}\` was modified but \`${session}\` was not.`,
|
||||
);
|
||||
} else if (!legacyChanged && sessionChanged) {
|
||||
warnings.push(
|
||||
`**${label}**: \`${session}\` was modified but \`${legacy}\` was not.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const MARKER = '<!-- agent-session-drift-check -->';
|
||||
|
||||
// Look up our existing drift comment (for upsert/cleanup).
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find(
|
||||
(c) => c.user?.type === 'Bot' && c.body?.includes(MARKER),
|
||||
);
|
||||
|
||||
if (warnings.length === 0) {
|
||||
core.info('No drift detected.');
|
||||
// If drift was previously flagged and is now resolved, remove the comment.
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
core.info(`Deleted stale drift comment ${existing.id}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
MARKER,
|
||||
'### ⚠️ Invocation Drift Warning',
|
||||
'',
|
||||
'The following file pairs should generally be kept in sync during the AgentSession migration:',
|
||||
'',
|
||||
...warnings.map((w) => `- ${w}`),
|
||||
'',
|
||||
'If this is intentional (e.g., a bug fix specific to one implementation), you can ignore this comment.',
|
||||
'',
|
||||
'_This check will be removed once the legacy implementations are deleted._',
|
||||
].join('\n');
|
||||
|
||||
if (existing) {
|
||||
core.info(`Updating existing drift comment ${existing.id}.`);
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
core.info('Creating new drift comment.');
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
needs:
|
||||
- 'merge_queue_skipper'
|
||||
- 'parse_run_context'
|
||||
runs-on: 'macos-latest-large'
|
||||
runs-on: 'macos-latest'
|
||||
if: |
|
||||
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
steps:
|
||||
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
|
||||
test_mac:
|
||||
name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}'
|
||||
runs-on: 'macos-latest-large'
|
||||
runs-on: 'macos-latest'
|
||||
needs:
|
||||
- 'merge_queue_skipper'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
deflake_e2e_mac:
|
||||
name: 'E2E Test (macOS)'
|
||||
runs-on: 'macos-latest-large'
|
||||
runs-on: 'macos-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
|
||||
@@ -18,27 +18,6 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.38.0 - 2026-04-14
|
||||
|
||||
- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on
|
||||
intent and tool usage for better session structure
|
||||
([#23150](https://github.com/google-gemini/gemini-cli/pull/23150) by
|
||||
@Abhijit-2592,
|
||||
[#24079](https://github.com/google-gemini/gemini-cli/pull/24079) by
|
||||
@gundermanc).
|
||||
- **Context Compression Service:** Advanced context management to efficiently
|
||||
distill conversation history
|
||||
([#24483](https://github.com/google-gemini/gemini-cli/pull/24483) by
|
||||
@joshualitt).
|
||||
- **UI Flicker & UX Enhancements:** Solved rendering flicker with "Terminal
|
||||
Buffer" mode and introduced selective topic expansion
|
||||
([#24512](https://github.com/google-gemini/gemini-cli/pull/24512) by
|
||||
@jacob314, [#24793](https://github.com/google-gemini/gemini-cli/pull/24793) by
|
||||
@Abhijit-2592).
|
||||
- **Persistent Policy Approvals:** Implemented context-aware persistent
|
||||
approvals for tool execution
|
||||
([#23257](https://github.com/google-gemini/gemini-cli/pull/23257) by @jerop).
|
||||
|
||||
## Announcements: v0.37.0 - 2026-04-08
|
||||
|
||||
- **Dynamic Sandbox Expansion:** Implemented dynamic sandbox expansion and
|
||||
|
||||
+407
-253
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.38.1
|
||||
# Latest stable release: v0.37.1
|
||||
|
||||
Released: April 15, 2026
|
||||
Released: April 09, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,261 +11,415 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Chapters Narrative Flow:** Introduced tool-based topic grouping ("Chapters")
|
||||
to provide better session structure and narrative continuity in long-running
|
||||
tasks.
|
||||
- **Context Compression Service:** Implemented a dedicated service for advanced
|
||||
context management, efficiently distilling conversation history to preserve
|
||||
focus and tokens.
|
||||
- **Enhanced UI Stability & UX:** Introduced a new "Terminal Buffer" mode to
|
||||
solve rendering flicker, along with selective topic expansion and improved
|
||||
tool confirmation layouts.
|
||||
- **Context-Aware Policy Approvals:** Users can now grant persistent,
|
||||
context-aware approvals for tools, significantly reducing manual confirmation
|
||||
overhead for trusted workflows.
|
||||
- **Background Process Monitoring:** New tools for monitoring and inspecting
|
||||
background shell processes, providing better visibility into asynchronous
|
||||
tasks.
|
||||
- **Dynamic Sandbox Expansion:** Implemented dynamic sandbox expansion and
|
||||
worktree support for both Linux and Windows, enhancing development flexibility
|
||||
in restricted environments.
|
||||
- **Tool-Based Topic Grouping (Chapters):** Introduced "Chapters" to logically
|
||||
group agent interactions based on tool usage and intent, providing a clearer
|
||||
narrative flow in long sessions.
|
||||
- **Enhanced Browser Agent:** Added persistent session management, dynamic
|
||||
read-only tool discovery, and sandbox-aware initialization for the browser
|
||||
agent.
|
||||
- **Security & Permission Hardening:** Implemented secret visibility lockdown
|
||||
for environment files and integrated integrity controls for Windows
|
||||
sandboxing.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
|
||||
v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
|
||||
[#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
|
||||
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
|
||||
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
|
||||
- Update README.md for links. by @g-samroberts in
|
||||
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
|
||||
- fix(core): ensure complete_task tool calls are recorded in chat history by
|
||||
@abhipatel12 in
|
||||
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
|
||||
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
|
||||
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
|
||||
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
|
||||
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
|
||||
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
|
||||
by @adamfweidman in
|
||||
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
|
||||
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
|
||||
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
|
||||
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
|
||||
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
|
||||
- fix(cli): ensure agent stops when all declinable tools are cancelled by
|
||||
@NTaylorMullen in
|
||||
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
|
||||
- fix(core): enhance sandbox usability and fix build error by @galz10 in
|
||||
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
|
||||
- Terminal Serializer Optimization by @jacob314 in
|
||||
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
|
||||
- Auto configure memory. by @jacob314 in
|
||||
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
|
||||
- Unused error variables in catch block are not allowed by @alisa-alisa in
|
||||
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
|
||||
- feat(core): add background memory service for skill extraction by @SandyTao520
|
||||
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
|
||||
- feat: implement high-signal PR regression check for evaluations by
|
||||
@alisa-alisa in
|
||||
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
|
||||
- Fix shell output display by @jacob314 in
|
||||
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
|
||||
- fix(ui): resolve unwanted vertical spacing around various tool output
|
||||
treatments by @jwhelangoog in
|
||||
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
|
||||
- revert(cli): bring back input box and footer visibility in copy mode by
|
||||
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
|
||||
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
|
||||
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
|
||||
- feat(cli): support default values for environment variables by @ruomengz in
|
||||
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
|
||||
- Implement background process monitoring and inspection tools by @cocosheng-g
|
||||
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
|
||||
- docs(browser-agent): update stale browser agent documentation by @gsquared94
|
||||
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
|
||||
- fix: enable browser_agent in integration tests and add localhost fixture tests
|
||||
by @gsquared94 in
|
||||
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
|
||||
- fix(browser): handle computer-use model detection for analyze_screenshot by
|
||||
@gsquared94 in
|
||||
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
|
||||
- feat(core): Land ContextCompressionService by @joshualitt in
|
||||
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
|
||||
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
|
||||
@SandyTao520 in
|
||||
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
|
||||
- Update ink version to 6.6.7 by @jacob314 in
|
||||
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
|
||||
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
|
||||
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
|
||||
- Fix crash when vim editor is not found in PATH on Windows by
|
||||
@Nagajyothi-tammisetti in
|
||||
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
|
||||
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
|
||||
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
|
||||
- Enable 'Other' option for yesno question type by @ruomengz in
|
||||
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
|
||||
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
|
||||
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
|
||||
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
|
||||
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
|
||||
- feat(core): implement context-aware persistent policy approvals by @jerop in
|
||||
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
|
||||
- docs: move agent disabling instructions and update remote agent status by
|
||||
@jackwotherspoon in
|
||||
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
|
||||
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
|
||||
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
|
||||
- fix(core): unsafe type assertions in Core File System #19712 by
|
||||
@aniketsaurav18 in
|
||||
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
|
||||
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
|
||||
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
|
||||
- Changelog for v0.36.0 by @gemini-cli-robot in
|
||||
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
|
||||
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
|
||||
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
|
||||
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
|
||||
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
|
||||
- fix(ui): fixed table styling by @devr0306 in
|
||||
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
|
||||
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
|
||||
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
|
||||
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
|
||||
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
|
||||
- docs: clarify release coordination by @scidomino in
|
||||
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
|
||||
- fix(core): remove broken PowerShell translation and fix native \_\_write in
|
||||
Windows sandbox by @scidomino in
|
||||
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
|
||||
- Add instructions for how to start react in prod and force react to prod mode
|
||||
by @jacob314 in
|
||||
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
|
||||
- feat(cli): minimalist sandbox status labels by @galz10 in
|
||||
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
|
||||
- Feat/browser agent metrics by @kunal-10-cloud in
|
||||
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
|
||||
- test: fix Windows CI execution and resolve exposed platform failures by
|
||||
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
|
||||
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
|
||||
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
|
||||
- show color by @jacob314 in
|
||||
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
|
||||
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
|
||||
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
|
||||
- fix(core): inject skill system instructions into subagent prompts if activated
|
||||
by @abhipatel12 in
|
||||
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
|
||||
- fix(core): improve windows sandbox reliability and fix integration tests by
|
||||
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
|
||||
- fix(core): ensure sandbox approvals are correctly persisted and matched for
|
||||
proactive expansions by @galz10 in
|
||||
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
|
||||
- feat(cli) Scrollbar for input prompt by @jacob314 in
|
||||
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
|
||||
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
|
||||
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
|
||||
- Fix restoration of topic headers. by @gundermanc in
|
||||
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
|
||||
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
|
||||
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
|
||||
- fix(core): ensure global temp directory is always in sandbox allowed paths by
|
||||
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
|
||||
- fix(core): detect uninitialized lines by @jacob314 in
|
||||
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
|
||||
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
|
||||
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
|
||||
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
|
||||
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
|
||||
- feat(acp): add support for `/about` command by @sripasg in
|
||||
- fix(acp): handle all InvalidStreamError types gracefully in prompt
|
||||
[#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
|
||||
- feat(acp): add support for /about command
|
||||
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
|
||||
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
|
||||
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
|
||||
- split context by @jacob314 in
|
||||
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
|
||||
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
|
||||
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
|
||||
- Fix issue where topic headers can be posted back to back by @gundermanc in
|
||||
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
|
||||
- fix(core): handle partial llm_request in BeforeModel hook override by
|
||||
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
|
||||
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
|
||||
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
|
||||
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
|
||||
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
|
||||
- fix(browser): remove premature browser cleanup after subagent invocation by
|
||||
@gsquared94 in
|
||||
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
|
||||
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
|
||||
@Abhijit-2592 in
|
||||
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
|
||||
- relax tool sandboxing overrides for plan mode to match defaults. by
|
||||
@DavidAPierce in
|
||||
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
|
||||
- fix(cli): respect global environment variable allowlist by @scidomino in
|
||||
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
|
||||
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
|
||||
by @spencer426 in
|
||||
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
|
||||
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
|
||||
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
|
||||
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
|
||||
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
|
||||
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
|
||||
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
|
||||
- feat(cli): support selective topic expansion and click-to-expand by
|
||||
@Abhijit-2592 in
|
||||
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
|
||||
- temporarily disable sandbox integration test on windows by @ehedlund in
|
||||
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
|
||||
- Remove flakey test by @scidomino in
|
||||
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
|
||||
- Alisa/approve button by @alisa-alisa in
|
||||
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
|
||||
- feat(hooks): display hook system messages in UI by @mbleigh in
|
||||
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
|
||||
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
|
||||
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
|
||||
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
|
||||
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
|
||||
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
|
||||
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
|
||||
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
|
||||
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
|
||||
- feat(acp): add /help command by @sripasg in
|
||||
- feat(acp): add /help command
|
||||
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
|
||||
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
|
||||
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
|
||||
- Improve sandbox error matching and caching by @DavidAPierce in
|
||||
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
|
||||
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
|
||||
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
|
||||
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
|
||||
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
|
||||
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
|
||||
@gundermanc in
|
||||
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
|
||||
- refactor(cli): remove duplication in interactive shell awaiting input hint by
|
||||
@JayadityaGit in
|
||||
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
|
||||
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
|
||||
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
|
||||
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
|
||||
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
|
||||
- fix(cli): always show shell command description or actual command by @jacob314
|
||||
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
|
||||
- Added flag for ept size and increased default size by @devr0306 in
|
||||
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
|
||||
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
|
||||
@Anjaligarhwal in
|
||||
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
|
||||
- fix(cli): switch default back to terminalBuffer=false and fix regressions
|
||||
introduced for that mode by @jacob314 in
|
||||
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
|
||||
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
|
||||
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
|
||||
- fix: isolate concurrent browser agent instances by @gsquared94 in
|
||||
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
|
||||
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
|
||||
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
|
||||
- feat(evals): centralize test agents into test-utils for reuse by @Samee24 in
|
||||
[#23616](https://github.com/google-gemini/gemini-cli/pull/23616)
|
||||
- revert: chore(config): disable agents by default by @abhipatel12 in
|
||||
[#23672](https://github.com/google-gemini/gemini-cli/pull/23672)
|
||||
- fix(plan): update telemetry attribute keys and add timestamp by @Adib234 in
|
||||
[#23685](https://github.com/google-gemini/gemini-cli/pull/23685)
|
||||
- fix(core): prevent premature MCP discovery completion by @jackwotherspoon in
|
||||
[#23637](https://github.com/google-gemini/gemini-cli/pull/23637)
|
||||
- feat(browser): add maxActionsPerTask for browser agent setting by
|
||||
@cynthialong0-0 in
|
||||
[#23216](https://github.com/google-gemini/gemini-cli/pull/23216)
|
||||
- fix(core): improve agent loader error formatting for empty paths by
|
||||
@adamfweidman in
|
||||
[#23690](https://github.com/google-gemini/gemini-cli/pull/23690)
|
||||
- fix(cli): only show updating spinner when auto-update is in progress by
|
||||
@scidomino in [#23709](https://github.com/google-gemini/gemini-cli/pull/23709)
|
||||
- Refine onboarding metrics to log the duration explicitly and use the tier
|
||||
name. by @yunaseoul in
|
||||
[#23678](https://github.com/google-gemini/gemini-cli/pull/23678)
|
||||
- chore(tools): add toJSON to tools and invocations to reduce logging verbosity
|
||||
by @alisa-alisa in
|
||||
[#22899](https://github.com/google-gemini/gemini-cli/pull/22899)
|
||||
- fix(cli): stabilize copy mode to prevent flickering and cursor resets by
|
||||
@mattKorwel in
|
||||
[#22584](https://github.com/google-gemini/gemini-cli/pull/22584)
|
||||
- fix(test): move flaky ctrl-c-exit test to non-blocking suite by @mattKorwel in
|
||||
[#23732](https://github.com/google-gemini/gemini-cli/pull/23732)
|
||||
- feat(skills): add ci skill for automated failure replication by @mattKorwel in
|
||||
[#23720](https://github.com/google-gemini/gemini-cli/pull/23720)
|
||||
- feat(sandbox): implement forbiddenPaths for OS-specific sandbox managers by
|
||||
@ehedlund in [#23282](https://github.com/google-gemini/gemini-cli/pull/23282)
|
||||
- fix(core): conditionally expose additional_permissions in shell tool by
|
||||
@galz10 in [#23729](https://github.com/google-gemini/gemini-cli/pull/23729)
|
||||
- refactor(core): standardize OS-specific sandbox tests and extract linux helper
|
||||
methods by @ehedlund in
|
||||
[#23715](https://github.com/google-gemini/gemini-cli/pull/23715)
|
||||
- format recently added script by @scidomino in
|
||||
[#23739](https://github.com/google-gemini/gemini-cli/pull/23739)
|
||||
- fix(ui): prevent over-eager slash subcommand completion by @keithguerin in
|
||||
[#20136](https://github.com/google-gemini/gemini-cli/pull/20136)
|
||||
- Fix dynamic model routing for gemini 3.1 pro to customtools model by
|
||||
@kevinjwang1 in
|
||||
[#23641](https://github.com/google-gemini/gemini-cli/pull/23641)
|
||||
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
|
||||
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
|
||||
- fix(cli): skip console log/info in headless mode by @cynthialong0-0 in
|
||||
[#22739](https://github.com/google-gemini/gemini-cli/pull/22739)
|
||||
- test(core): install bubblewrap on Linux CI for sandbox integration tests by
|
||||
@ehedlund in [#23583](https://github.com/google-gemini/gemini-cli/pull/23583)
|
||||
- docs(reference): split tools table into category sections by @sheikhlimon in
|
||||
[#21516](https://github.com/google-gemini/gemini-cli/pull/21516)
|
||||
- fix(browser): detect embedded URLs in query params to prevent allowedDomains
|
||||
bypass by @tony-shi in
|
||||
[#23225](https://github.com/google-gemini/gemini-cli/pull/23225)
|
||||
- fix(browser): add proxy bypass constraint to domain restriction system prompt
|
||||
by @tony-shi in
|
||||
[#23229](https://github.com/google-gemini/gemini-cli/pull/23229)
|
||||
- fix(policy): relax write_file argsPattern in plan mode to allow paths without
|
||||
session ID by @Adib234 in
|
||||
[#23695](https://github.com/google-gemini/gemini-cli/pull/23695)
|
||||
- docs: fix grammar in CONTRIBUTING and numbering in sandbox docs by
|
||||
@splint-disk-8i in
|
||||
[#23448](https://github.com/google-gemini/gemini-cli/pull/23448)
|
||||
- fix(acp): allow attachments by adding a permission prompt by @sripasg in
|
||||
[#23680](https://github.com/google-gemini/gemini-cli/pull/23680)
|
||||
- fix(core): thread AbortSignal to chat compression requests (#20405) by
|
||||
@SH20RAJ in [#20778](https://github.com/google-gemini/gemini-cli/pull/20778)
|
||||
- feat(core): implement Windows sandbox dynamic expansion Phase 1 and 2.1 by
|
||||
@scidomino in [#23691](https://github.com/google-gemini/gemini-cli/pull/23691)
|
||||
- Add note about root privileges in sandbox docs by @diodesign in
|
||||
[#23314](https://github.com/google-gemini/gemini-cli/pull/23314)
|
||||
- docs(core): document agent_card_json string literal options for remote agents
|
||||
by @adamfweidman in
|
||||
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
|
||||
- fix(cli): resolve TTY hang on headless environments by unconditionally
|
||||
resuming process.stdin before React Ink launch by @cocosheng-g in
|
||||
[#23673](https://github.com/google-gemini/gemini-cli/pull/23673)
|
||||
- fix(ui): cleanup estimated string length hacks in composer by @keithguerin in
|
||||
[#23694](https://github.com/google-gemini/gemini-cli/pull/23694)
|
||||
- feat(browser): dynamically discover read-only tools by @cynthialong0-0 in
|
||||
[#23805](https://github.com/google-gemini/gemini-cli/pull/23805)
|
||||
- docs: clarify policy requirement for `general.plan.directory` in settings
|
||||
schema by @jerop in
|
||||
[#23784](https://github.com/google-gemini/gemini-cli/pull/23784)
|
||||
- Revert "perf(cli): optimize --version startup time (#23671)" by @scidomino in
|
||||
[#23812](https://github.com/google-gemini/gemini-cli/pull/23812)
|
||||
- don't silence errors from wombat by @scidomino in
|
||||
[#23822](https://github.com/google-gemini/gemini-cli/pull/23822)
|
||||
- fix(ui): prevent escape key from cancelling requests in shell mode by
|
||||
@PrasannaPal21 in
|
||||
[#21245](https://github.com/google-gemini/gemini-cli/pull/21245)
|
||||
- Changelog for v0.36.0-preview.0 by @gemini-cli-robot in
|
||||
[#23702](https://github.com/google-gemini/gemini-cli/pull/23702)
|
||||
- feat(core,ui): Add experiment-gated support for gemini flash 3.1 lite by
|
||||
@chrstnb in [#23794](https://github.com/google-gemini/gemini-cli/pull/23794)
|
||||
- Changelog for v0.36.0-preview.3 by @gemini-cli-robot in
|
||||
[#23827](https://github.com/google-gemini/gemini-cli/pull/23827)
|
||||
- new linting check: github-actions-pinning by @alisa-alisa in
|
||||
[#23808](https://github.com/google-gemini/gemini-cli/pull/23808)
|
||||
- fix(cli): show helpful guidance when no skills are available by @Niralisj in
|
||||
[#23785](https://github.com/google-gemini/gemini-cli/pull/23785)
|
||||
- fix: Chat logs and errors handle tail tool calls correctly by @googlestrobe in
|
||||
[#22460](https://github.com/google-gemini/gemini-cli/pull/22460)
|
||||
- Don't try removing a tag from a non-existent release. by @scidomino in
|
||||
[#23830](https://github.com/google-gemini/gemini-cli/pull/23830)
|
||||
- fix(cli): allow ask question dialog to take full window height by @jacob314 in
|
||||
[#23693](https://github.com/google-gemini/gemini-cli/pull/23693)
|
||||
- fix(core): strip leading underscores from error types in telemetry by
|
||||
@yunaseoul in [#23824](https://github.com/google-gemini/gemini-cli/pull/23824)
|
||||
- Changelog for v0.35.0 by @gemini-cli-robot in
|
||||
[#23819](https://github.com/google-gemini/gemini-cli/pull/23819)
|
||||
- feat(evals): add reliability harvester and 500/503 retry support by
|
||||
@alisa-alisa in
|
||||
[#23626](https://github.com/google-gemini/gemini-cli/pull/23626)
|
||||
- feat(sandbox): dynamic Linux sandbox expansion and worktree support by @galz10
|
||||
in [#23692](https://github.com/google-gemini/gemini-cli/pull/23692)
|
||||
- Merge examples of use into quickstart documentation by @diodesign in
|
||||
[#23319](https://github.com/google-gemini/gemini-cli/pull/23319)
|
||||
- fix(cli): prioritize primary name matches in slash command search by @sehoon38
|
||||
in [#23850](https://github.com/google-gemini/gemini-cli/pull/23850)
|
||||
- Changelog for v0.35.1 by @gemini-cli-robot in
|
||||
[#23840](https://github.com/google-gemini/gemini-cli/pull/23840)
|
||||
- fix(browser): keep input blocker active across navigations by @kunal-10-cloud
|
||||
in [#22562](https://github.com/google-gemini/gemini-cli/pull/22562)
|
||||
- feat(core): new skill to look for duplicated code while reviewing PRs by
|
||||
@devr0306 in [#23704](https://github.com/google-gemini/gemini-cli/pull/23704)
|
||||
- fix(core): replace hardcoded non-interactive ASK_USER denial with explicit
|
||||
policy rules by @ruomengz in
|
||||
[#23668](https://github.com/google-gemini/gemini-cli/pull/23668)
|
||||
- fix(plan): after exiting plan mode switches model to a flash model by @Adib234
|
||||
in [#23885](https://github.com/google-gemini/gemini-cli/pull/23885)
|
||||
- feat(gcp): add development worker infrastructure by @mattKorwel in
|
||||
[#23814](https://github.com/google-gemini/gemini-cli/pull/23814)
|
||||
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||
- feat(core): define TrajectoryProvider interface by @sehoon38 in
|
||||
[#23050](https://github.com/google-gemini/gemini-cli/pull/23050)
|
||||
- Docs: Update quotas and pricing by @jkcinouye in
|
||||
[#23835](https://github.com/google-gemini/gemini-cli/pull/23835)
|
||||
- fix(core): allow disabling environment variable redaction by @galz10 in
|
||||
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
|
||||
- feat(cli): enable notifications cross-platform via terminal bell fallback by
|
||||
@genneth in [#21618](https://github.com/google-gemini/gemini-cli/pull/21618)
|
||||
- feat(sandbox): implement secret visibility lockdown for env files by
|
||||
@DavidAPierce in
|
||||
[#23712](https://github.com/google-gemini/gemini-cli/pull/23712)
|
||||
- fix(core): remove shell outputChunks buffer caching to prevent memory bloat
|
||||
and sanitize prompt input by @spencer426 in
|
||||
[#23751](https://github.com/google-gemini/gemini-cli/pull/23751)
|
||||
- feat(core): implement persistent browser session management by @kunal-10-cloud
|
||||
in [#21306](https://github.com/google-gemini/gemini-cli/pull/21306)
|
||||
- refactor(core): delegate sandbox denial parsing to SandboxManager by
|
||||
@scidomino in [#23928](https://github.com/google-gemini/gemini-cli/pull/23928)
|
||||
- dep(update) Update Ink version to 6.5.0 by @jacob314 in
|
||||
[#23843](https://github.com/google-gemini/gemini-cli/pull/23843)
|
||||
- Docs: Update 'docs-writer' skill for relative links by @jkcinouye in
|
||||
[#21463](https://github.com/google-gemini/gemini-cli/pull/21463)
|
||||
- Changelog for v0.36.0-preview.4 by @gemini-cli-robot in
|
||||
[#23935](https://github.com/google-gemini/gemini-cli/pull/23935)
|
||||
- fix(acp): Update allow approval policy flow for ACP clients to fix config
|
||||
persistence and compatible with TUI by @sripasg in
|
||||
[#23818](https://github.com/google-gemini/gemini-cli/pull/23818)
|
||||
- Changelog for v0.35.2 by @gemini-cli-robot in
|
||||
[#23960](https://github.com/google-gemini/gemini-cli/pull/23960)
|
||||
- ACP integration documents by @g-samroberts in
|
||||
[#22254](https://github.com/google-gemini/gemini-cli/pull/22254)
|
||||
- fix(core): explicitly set error names to avoid bundling renaming issues by
|
||||
@yunaseoul in [#23913](https://github.com/google-gemini/gemini-cli/pull/23913)
|
||||
- feat(core): subagent isolation and cleanup hardening by @abhipatel12 in
|
||||
[#23903](https://github.com/google-gemini/gemini-cli/pull/23903)
|
||||
- disable extension-reload test by @scidomino in
|
||||
[#24018](https://github.com/google-gemini/gemini-cli/pull/24018)
|
||||
- feat(core): add forbiddenPaths to GlobalSandboxOptions and refactor
|
||||
createSandboxManager by @ehedlund in
|
||||
[#23936](https://github.com/google-gemini/gemini-cli/pull/23936)
|
||||
- refactor(core): improve ignore resolution and fix directory-matching bug by
|
||||
@ehedlund in [#23816](https://github.com/google-gemini/gemini-cli/pull/23816)
|
||||
- revert(core): support custom base URL via env vars by @spencer426 in
|
||||
[#23976](https://github.com/google-gemini/gemini-cli/pull/23976)
|
||||
- Increase memory limited for eslint. by @jacob314 in
|
||||
[#24022](https://github.com/google-gemini/gemini-cli/pull/24022)
|
||||
- fix(acp): prevent crash on empty response in ACP mode by @sripasg in
|
||||
[#23952](https://github.com/google-gemini/gemini-cli/pull/23952)
|
||||
- feat(core): Land `AgentHistoryProvider`. by @joshualitt in
|
||||
[#23978](https://github.com/google-gemini/gemini-cli/pull/23978)
|
||||
- fix(core): switch to subshells for shell tool wrapping to fix heredocs and
|
||||
edge cases by @abhipatel12 in
|
||||
[#24024](https://github.com/google-gemini/gemini-cli/pull/24024)
|
||||
- Debug command. by @jacob314 in
|
||||
[#23851](https://github.com/google-gemini/gemini-cli/pull/23851)
|
||||
- Changelog for v0.36.0-preview.5 by @gemini-cli-robot in
|
||||
[#24046](https://github.com/google-gemini/gemini-cli/pull/24046)
|
||||
- Fix test flakes by globally mocking ink-spinner by @jacob314 in
|
||||
[#24044](https://github.com/google-gemini/gemini-cli/pull/24044)
|
||||
- Enable network access in sandbox configuration by @galz10 in
|
||||
[#24055](https://github.com/google-gemini/gemini-cli/pull/24055)
|
||||
- feat(context): add configurable memoryBoundaryMarkers setting by @SandyTao520
|
||||
in [#24020](https://github.com/google-gemini/gemini-cli/pull/24020)
|
||||
- feat(core): implement windows sandbox expansion and denial detection by
|
||||
@scidomino in [#24027](https://github.com/google-gemini/gemini-cli/pull/24027)
|
||||
- fix(core): resolve ACP Operation Aborted Errors in grep_search by @ivanporty
|
||||
in [#23821](https://github.com/google-gemini/gemini-cli/pull/23821)
|
||||
- fix(hooks): prevent SessionEnd from firing twice in non-interactive mode by
|
||||
@krishdef7 in [#22139](https://github.com/google-gemini/gemini-cli/pull/22139)
|
||||
- Re-word intro to Gemini 3 page. by @g-samroberts in
|
||||
[#24069](https://github.com/google-gemini/gemini-cli/pull/24069)
|
||||
- fix(cli): resolve layout contention and flashing loop in StatusRow by
|
||||
@keithguerin in
|
||||
[#24065](https://github.com/google-gemini/gemini-cli/pull/24065)
|
||||
- fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox
|
||||
by @galz10 in [#24057](https://github.com/google-gemini/gemini-cli/pull/24057)
|
||||
- feat(core): implement tool-based topic grouping (Chapters) by @Abhijit-2592 in
|
||||
[#23150](https://github.com/google-gemini/gemini-cli/pull/23150)
|
||||
- feat(cli): support 'tab to queue' for messages while generating by @gundermanc
|
||||
in [#24052](https://github.com/google-gemini/gemini-cli/pull/24052)
|
||||
- feat(core): agnostic background task UI with CompletionBehavior by
|
||||
@adamfweidman in
|
||||
[#22740](https://github.com/google-gemini/gemini-cli/pull/22740)
|
||||
- UX for topic narration tool by @gundermanc in
|
||||
[#24079](https://github.com/google-gemini/gemini-cli/pull/24079)
|
||||
- fix: shellcheck warnings in scripts by @scidomino in
|
||||
[#24035](https://github.com/google-gemini/gemini-cli/pull/24035)
|
||||
- test(evals): add comprehensive subagent delegation evaluations by @abhipatel12
|
||||
in [#24132](https://github.com/google-gemini/gemini-cli/pull/24132)
|
||||
- fix(a2a-server): prioritize ADC before evaluating headless constraints for
|
||||
auth initialization by @spencer426 in
|
||||
[#23614](https://github.com/google-gemini/gemini-cli/pull/23614)
|
||||
- Text can be added after /plan command by @rambleraptor in
|
||||
[#22833](https://github.com/google-gemini/gemini-cli/pull/22833)
|
||||
- fix(cli): resolve missing F12 logs via global console store by @scidomino in
|
||||
[#24235](https://github.com/google-gemini/gemini-cli/pull/24235)
|
||||
- fix broken tests by @scidomino in
|
||||
[#24279](https://github.com/google-gemini/gemini-cli/pull/24279)
|
||||
- fix(evals): add update_topic behavioral eval by @gundermanc in
|
||||
[#24223](https://github.com/google-gemini/gemini-cli/pull/24223)
|
||||
- feat(core): Unified Context Management and Tool Distillation. by @joshualitt
|
||||
in [#24157](https://github.com/google-gemini/gemini-cli/pull/24157)
|
||||
- Default enable narration for the team. by @gundermanc in
|
||||
[#24224](https://github.com/google-gemini/gemini-cli/pull/24224)
|
||||
- fix(core): ensure default agents provide tools and use model-specific schemas
|
||||
by @abhipatel12 in
|
||||
[#24268](https://github.com/google-gemini/gemini-cli/pull/24268)
|
||||
- feat(cli): show Flash Lite Preview model regardless of user tier by @sehoon38
|
||||
in [#23904](https://github.com/google-gemini/gemini-cli/pull/23904)
|
||||
- feat(cli): implement compact tool output by @jwhelangoog in
|
||||
[#20974](https://github.com/google-gemini/gemini-cli/pull/20974)
|
||||
- Add security settings for tool sandboxing by @galz10 in
|
||||
[#23923](https://github.com/google-gemini/gemini-cli/pull/23923)
|
||||
- chore(test-utils): switch integration tests to use PREVIEW_GEMINI_MODEL by
|
||||
@sehoon38 in [#24276](https://github.com/google-gemini/gemini-cli/pull/24276)
|
||||
- feat(core): enable topic update narration for legacy models by @Abhijit-2592
|
||||
in [#24241](https://github.com/google-gemini/gemini-cli/pull/24241)
|
||||
- feat(core): add project-level memory scope to save_memory tool by @SandyTao520
|
||||
in [#24161](https://github.com/google-gemini/gemini-cli/pull/24161)
|
||||
- test(integration): fix plan mode write denial test false positive by @sehoon38
|
||||
in [#24299](https://github.com/google-gemini/gemini-cli/pull/24299)
|
||||
- feat(plan): support `Plan` mode in untrusted folders by @Adib234 in
|
||||
[#17586](https://github.com/google-gemini/gemini-cli/pull/17586)
|
||||
- fix(core): enable mid-stream retries for all models and re-enable compression
|
||||
test by @sehoon38 in
|
||||
[#24302](https://github.com/google-gemini/gemini-cli/pull/24302)
|
||||
- Changelog for v0.36.0-preview.6 by @gemini-cli-robot in
|
||||
[#24082](https://github.com/google-gemini/gemini-cli/pull/24082)
|
||||
- Changelog for v0.35.3 by @gemini-cli-robot in
|
||||
[#24083](https://github.com/google-gemini/gemini-cli/pull/24083)
|
||||
- feat(cli): add auth info to footer by @sehoon38 in
|
||||
[#24042](https://github.com/google-gemini/gemini-cli/pull/24042)
|
||||
- fix(browser): reset action counter for each agent session and let it ignore
|
||||
internal actions by @cynthialong0-0 in
|
||||
[#24228](https://github.com/google-gemini/gemini-cli/pull/24228)
|
||||
- feat(plan): promote planning feature to stable by @ruomengz in
|
||||
[#24282](https://github.com/google-gemini/gemini-cli/pull/24282)
|
||||
- fix(browser): terminate subagent immediately on domain restriction violations
|
||||
by @gsquared94 in
|
||||
[#24313](https://github.com/google-gemini/gemini-cli/pull/24313)
|
||||
- feat(cli): add UI to update extensions by @ruomengz in
|
||||
[#23682](https://github.com/google-gemini/gemini-cli/pull/23682)
|
||||
- Fix(browser): terminate immediately for "browser is already running" error by
|
||||
@cynthialong0-0 in
|
||||
[#24233](https://github.com/google-gemini/gemini-cli/pull/24233)
|
||||
- docs: Add 'plan' option to approval mode in CLI reference by @YifanRuan in
|
||||
[#24134](https://github.com/google-gemini/gemini-cli/pull/24134)
|
||||
- fix(core): batch macOS seatbelt rules into a profile file to prevent ARG_MAX
|
||||
errors by @ehedlund in
|
||||
[#24255](https://github.com/google-gemini/gemini-cli/pull/24255)
|
||||
- fix(core): fix race condition between browser agent and main closing process
|
||||
by @cynthialong0-0 in
|
||||
[#24340](https://github.com/google-gemini/gemini-cli/pull/24340)
|
||||
- perf(build): optimize build scripts for parallel execution and remove
|
||||
redundant checks by @sehoon38 in
|
||||
[#24307](https://github.com/google-gemini/gemini-cli/pull/24307)
|
||||
- ci: install bubblewrap on Linux for release workflows by @ehedlund in
|
||||
[#24347](https://github.com/google-gemini/gemini-cli/pull/24347)
|
||||
- chore(release): allow bundling for all builds, including stable by @sehoon38
|
||||
in [#24305](https://github.com/google-gemini/gemini-cli/pull/24305)
|
||||
- Revert "Add security settings for tool sandboxing" by @jerop in
|
||||
[#24357](https://github.com/google-gemini/gemini-cli/pull/24357)
|
||||
- docs: update subagents docs to not be experimental by @abhipatel12 in
|
||||
[#24343](https://github.com/google-gemini/gemini-cli/pull/24343)
|
||||
- fix(core): implement **read and **write commands in sandbox managers by
|
||||
@galz10 in [#24283](https://github.com/google-gemini/gemini-cli/pull/24283)
|
||||
- don't try to remove tags in dry run by @scidomino in
|
||||
[#24356](https://github.com/google-gemini/gemini-cli/pull/24356)
|
||||
- fix(config): disable JIT context loading by default by @SandyTao520 in
|
||||
[#24364](https://github.com/google-gemini/gemini-cli/pull/24364)
|
||||
- test(sandbox): add integration test for dynamic permission expansion by
|
||||
@galz10 in [#24359](https://github.com/google-gemini/gemini-cli/pull/24359)
|
||||
- docs(policy): remove unsupported mcpName wildcard edge case by @abhipatel12 in
|
||||
[#24133](https://github.com/google-gemini/gemini-cli/pull/24133)
|
||||
- docs: fix broken GEMINI.md link in CONTRIBUTING.md by @Panchal-Tirth in
|
||||
[#24182](https://github.com/google-gemini/gemini-cli/pull/24182)
|
||||
- feat(core): infrastructure for event-driven subagent history by @abhipatel12
|
||||
in [#23914](https://github.com/google-gemini/gemini-cli/pull/23914)
|
||||
- fix(core): resolve Plan Mode deadlock during plan file creation due to sandbox
|
||||
restrictions by @DavidAPierce in
|
||||
[#24047](https://github.com/google-gemini/gemini-cli/pull/24047)
|
||||
- fix(core): fix browser agent UX issues and improve E2E test reliability by
|
||||
@gsquared94 in
|
||||
[#24312](https://github.com/google-gemini/gemini-cli/pull/24312)
|
||||
- fix(ui): wrap topic and intent fields in TopicMessage by @jwhelangoog in
|
||||
[#24386](https://github.com/google-gemini/gemini-cli/pull/24386)
|
||||
- refactor(core): Centralize context management logic into src/context by
|
||||
@joshualitt in
|
||||
[#24380](https://github.com/google-gemini/gemini-cli/pull/24380)
|
||||
- fix(core): pin AuthType.GATEWAY to use Gemini 3.1 Pro/Flash Lite by default by
|
||||
@sripasg in [#24375](https://github.com/google-gemini/gemini-cli/pull/24375)
|
||||
- feat(ui): add Tokyo Night theme by @danrneal in
|
||||
[#24054](https://github.com/google-gemini/gemini-cli/pull/24054)
|
||||
- fix(cli): refactor test config loading and mock debugLogger in test-setup by
|
||||
@mattKorwel in
|
||||
[#24389](https://github.com/google-gemini/gemini-cli/pull/24389)
|
||||
- Set memoryManager to false in settings.json by @mattKorwel in
|
||||
[#24393](https://github.com/google-gemini/gemini-cli/pull/24393)
|
||||
- ink 6.6.3 by @jacob314 in
|
||||
[#24372](https://github.com/google-gemini/gemini-cli/pull/24372)
|
||||
- fix(core): resolve subagent chat recording gaps and directory inheritance by
|
||||
@abhipatel12 in
|
||||
[#24368](https://github.com/google-gemini/gemini-cli/pull/24368)
|
||||
- fix(cli): cap shell output at 10 MB to prevent RangeError crash by @ProthamD
|
||||
in [#24168](https://github.com/google-gemini/gemini-cli/pull/24168)
|
||||
- feat(plan): conditionally add enter/exit plan mode tools based on current mode
|
||||
by @ruomengz in
|
||||
[#24378](https://github.com/google-gemini/gemini-cli/pull/24378)
|
||||
- feat(core): prioritize discussion before formal plan approval by @jerop in
|
||||
[#24423](https://github.com/google-gemini/gemini-cli/pull/24423)
|
||||
- fix(ui): add accelerated scrolling on alternate buffer mode by @devr0306 in
|
||||
[#23940](https://github.com/google-gemini/gemini-cli/pull/23940)
|
||||
- feat(core): populate sandbox forbidden paths with project ignore file contents
|
||||
by @ehedlund in
|
||||
[#24038](https://github.com/google-gemini/gemini-cli/pull/24038)
|
||||
- fix(core): ensure blue border overlay and input blocker to act correctly
|
||||
depending on browser agent activities by @cynthialong0-0 in
|
||||
[#24385](https://github.com/google-gemini/gemini-cli/pull/24385)
|
||||
- fix(ui): removed additional vertical padding for tables by @devr0306 in
|
||||
[#24381](https://github.com/google-gemini/gemini-cli/pull/24381)
|
||||
- fix(build): upload full bundle directory archive to GitHub releases by
|
||||
@sehoon38 in [#24403](https://github.com/google-gemini/gemini-cli/pull/24403)
|
||||
- fix(build): wire bundle:browser-mcp into bundle pipeline by @gsquared94 in
|
||||
[#24424](https://github.com/google-gemini/gemini-cli/pull/24424)
|
||||
- feat(browser): add sandbox-aware browser agent initialization by @gsquared94
|
||||
in [#24419](https://github.com/google-gemini/gemini-cli/pull/24419)
|
||||
- feat(core): enhance tracker task schemas for detailed titles and descriptions
|
||||
by @anj-s in [#23902](https://github.com/google-gemini/gemini-cli/pull/23902)
|
||||
- refactor(core): Unified context management settings schema by @joshualitt in
|
||||
[#24391](https://github.com/google-gemini/gemini-cli/pull/24391)
|
||||
- feat(core): update browser agent prompt to check open pages first when
|
||||
bringing up by @cynthialong0-0 in
|
||||
[#24431](https://github.com/google-gemini/gemini-cli/pull/24431)
|
||||
- fix(acp) refactor(core,cli): centralize model discovery logic in
|
||||
ModelConfigService by @sripasg in
|
||||
[#24392](https://github.com/google-gemini/gemini-cli/pull/24392)
|
||||
- Changelog for v0.36.0-preview.7 by @gemini-cli-robot in
|
||||
[#24346](https://github.com/google-gemini/gemini-cli/pull/24346)
|
||||
- fix: update task tracker storage location in system prompt by @anj-s in
|
||||
[#24034](https://github.com/google-gemini/gemini-cli/pull/24034)
|
||||
- feat(browser): supersede stale snapshots to reclaim context-window tokens by
|
||||
@gsquared94 in
|
||||
[#24440](https://github.com/google-gemini/gemini-cli/pull/24440)
|
||||
- docs(core): add subagent tool isolation draft doc by @akh64bit in
|
||||
[#23275](https://github.com/google-gemini/gemini-cli/pull/23275)
|
||||
- fix(patch): cherry-pick 64c928f to release/v0.37.0-preview.0-pr-23257 to patch
|
||||
version v0.37.0-preview.0 and create version 0.37.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#24561](https://github.com/google-gemini/gemini-cli/pull/24561)
|
||||
- fix(patch): cherry-pick cb7f7d6 to release/v0.37.0-preview.1-pr-24342 to patch
|
||||
version v0.37.0-preview.1 and create version 0.37.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.1
|
||||
|
||||
+247
-236
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.39.0-preview.0
|
||||
# Preview release: v0.38.0-preview.0
|
||||
|
||||
Released: April 14, 2026
|
||||
Released: April 08, 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,245 +13,256 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Refactored Subagents and Unified Tooling:** Consolidate subagent tools into
|
||||
a single `invoke_subagent` tool, removed legacy wrapping tools, and improved
|
||||
turn limits for codebase investigator.
|
||||
- **Advanced Memory and Skill Management:** Introduced `/memory` inbox for
|
||||
reviewing extracted skills and added skill patching support, enhancing agent
|
||||
learning and persistence.
|
||||
- **Expanded Test and Evaluation Infrastructure:** Added memory and CPU
|
||||
performance integration test harnesses and generalized evaluation
|
||||
infrastructure for better suite organization.
|
||||
- **Sandbox and Security Hardening:** Centralized sandbox paths for Linux and
|
||||
macOS, enforced read-only security for async git worktree resolution, and
|
||||
optimized Windows sandbox initialization.
|
||||
- **Enhanced CLI UX and UI Stability:** Improved scroll momentum, added a
|
||||
`debugRainbow` setting, and resolved various memory leaks and PTY exhaustion
|
||||
issues for a smoother terminal experience.
|
||||
- **Context Management:** Introduced a Context Compression Service to optimize
|
||||
context window usage and landed a background memory service for skill
|
||||
extraction.
|
||||
- **Enhanced Security:** Implemented context-aware persistent policy approvals
|
||||
for smarter tool permissions and enabled `web_fetch` in plan mode with user
|
||||
confirmation.
|
||||
- **Workflow Monitoring:** Added background process monitoring and inspection
|
||||
tools for better visibility into long-running tasks.
|
||||
- **UI/UX Refinements:** Enhanced the tool confirmation UI, selection layout,
|
||||
and added support for selective topic expansion and click-to-expand.
|
||||
- **Core Stability:** Improved sandbox reliability on Linux and Windows,
|
||||
resolved shebang compatibility issues, and fixed various crashes in the CLI
|
||||
and core services.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- refactor(plan): simplify policy priorities and consolidate read-only rules by
|
||||
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
|
||||
- feat(test-utils): add memory usage integration test harness by @sripasg in
|
||||
[#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
|
||||
- feat(memory): add /memory inbox command for reviewing extracted skills by
|
||||
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
|
||||
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
|
||||
- Update README.md for links. by @g-samroberts in
|
||||
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
|
||||
- fix(core): ensure complete_task tool calls are recorded in chat history by
|
||||
@abhipatel12 in
|
||||
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
|
||||
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
|
||||
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
|
||||
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
|
||||
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
|
||||
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
|
||||
by @adamfweidman in
|
||||
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
|
||||
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
|
||||
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
|
||||
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
|
||||
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
|
||||
- fix(cli): ensure agent stops when all declinable tools are cancelled by
|
||||
@NTaylorMullen in
|
||||
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
|
||||
- fix(core): enhance sandbox usability and fix build error by @galz10 in
|
||||
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
|
||||
- Terminal Serializer Optimization by @jacob314 in
|
||||
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
|
||||
- Auto configure memory. by @jacob314 in
|
||||
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
|
||||
- Unused error variables in catch block are not allowed by @alisa-alisa in
|
||||
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
|
||||
- feat(core): add background memory service for skill extraction by @SandyTao520
|
||||
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
|
||||
- feat: implement high-signal PR regression check for evaluations by
|
||||
@alisa-alisa in
|
||||
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
|
||||
- Fix shell output display by @jacob314 in
|
||||
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
|
||||
- fix(ui): resolve unwanted vertical spacing around various tool output
|
||||
treatments by @jwhelangoog in
|
||||
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
|
||||
- revert(cli): bring back input box and footer visibility in copy mode by
|
||||
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
|
||||
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
|
||||
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
|
||||
- feat(cli): support default values for environment variables by @ruomengz in
|
||||
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
|
||||
- Implement background process monitoring and inspection tools by @cocosheng-g
|
||||
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
|
||||
- docs(browser-agent): update stale browser agent documentation by @gsquared94
|
||||
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
|
||||
- fix: enable browser_agent in integration tests and add localhost fixture tests
|
||||
by @gsquared94 in
|
||||
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
|
||||
- fix(browser): handle computer-use model detection for analyze_screenshot by
|
||||
@gsquared94 in
|
||||
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
|
||||
- feat(core): Land ContextCompressionService by @joshualitt in
|
||||
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
|
||||
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
|
||||
@SandyTao520 in
|
||||
[#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
|
||||
- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
|
||||
@gemini-cli-robot in
|
||||
[#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
|
||||
- fix(core): ensure robust sandbox cleanup in all process execution paths by
|
||||
@ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
|
||||
- chore: update ink version to 6.6.8 by @jacob314 in
|
||||
[#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
|
||||
- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
|
||||
[#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
|
||||
- chore: ignore conductor directory by @JayadityaGit in
|
||||
[#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
|
||||
- Changelog for v0.37.0 by @gemini-cli-robot in
|
||||
[#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
|
||||
- feat(plan): require user confirmation for activate_skill in Plan Mode by
|
||||
@ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
|
||||
- feat(test-utils): add CPU performance integration test harness by @sripasg in
|
||||
[#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
|
||||
- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
|
||||
@dogukanozen in
|
||||
[#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
|
||||
- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
|
||||
[#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
|
||||
- fix(core): resolve windows symlink bypass and stabilize sandbox integration
|
||||
tests by @ehedlund in
|
||||
[#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
|
||||
- fix(cli): restore file path display in edit and write tool confirmations by
|
||||
@jwhelangoog in
|
||||
[#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
|
||||
- feat(core): refine shell tool description display logic by @jwhelangoog in
|
||||
[#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
|
||||
- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
|
||||
in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
|
||||
- Update ink version to 6.6.9 by @jacob314 in
|
||||
[#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
|
||||
- Generalize evals infra to support more types of evals, organization and
|
||||
queuing of named suites by @gundermanc in
|
||||
[#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
|
||||
- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
|
||||
[#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
|
||||
- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
|
||||
implementation by @ehedlund in
|
||||
[#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
|
||||
- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
|
||||
[#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
|
||||
- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
|
||||
[#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
|
||||
- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
|
||||
in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
|
||||
- Support ctrl+shift+g by @jacob314 in
|
||||
[#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
|
||||
- feat(core): refactor subagent tool to unified invoke_subagent tool by
|
||||
@abhipatel12 in
|
||||
[#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
|
||||
- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
|
||||
error by @mrpmohiburrahman in
|
||||
[#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
|
||||
- fix: respect hideContextPercentage when FooterConfigDialog is closed without
|
||||
changes by @chernistry in
|
||||
[#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
|
||||
- fix(cli): suppress unhandled AbortError logs during request cancellation by
|
||||
@euxaristia in
|
||||
[#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
|
||||
- Automated documentation audit by @g-samroberts in
|
||||
[#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
|
||||
- feat(cli): implement useAgentStream hook by @mbleigh in
|
||||
[#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
|
||||
- refactor(plan) Clean default plan toml by @ruomengz in
|
||||
[#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
|
||||
- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
|
||||
[#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
|
||||
- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
|
||||
[#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
|
||||
- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
|
||||
@abhipatel12 in
|
||||
[#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
|
||||
- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
|
||||
[#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
|
||||
- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
|
||||
[#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
|
||||
- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
|
||||
@spencer426 in
|
||||
[#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
|
||||
- fix(sandbox): centralize async git worktree resolution and enforce read-only
|
||||
security by @ehedlund in
|
||||
[#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
|
||||
- feat(test): add high-volume shell test and refine perf harness by @sripasg in
|
||||
[#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
|
||||
- fix(core): silently handle EPERM when listing dir structure by @scidomino in
|
||||
[#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
|
||||
- Changelog for v0.37.1 by @gemini-cli-robot in
|
||||
[#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
|
||||
- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
|
||||
@kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
|
||||
- Automated documentation audit results by @g-samroberts in
|
||||
[#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
|
||||
- debugging(ui): add optional debugRainbow setting by @jacob314 in
|
||||
[#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
|
||||
- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
|
||||
by @spencer426 in
|
||||
[#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
|
||||
- docs(cli): updates f12 description to be more precise by @JayadityaGit in
|
||||
[#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
|
||||
- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
|
||||
[#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
|
||||
- fix(core): remove buffer slice to prevent OOM on large output streams by
|
||||
@spencer426 in
|
||||
[#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
|
||||
- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
|
||||
[#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
|
||||
- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
|
||||
in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
|
||||
- refactor(core): consolidate execute() arguments into ExecuteOptions by
|
||||
@mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
|
||||
- feat(core): add Strategic Re-evaluation guidance to system prompt by
|
||||
@aishaneeshah in
|
||||
[#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
|
||||
- fix(core): preserve shell execution config fields on update by
|
||||
@jasonmatthewsuhari in
|
||||
[#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
|
||||
- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
|
||||
[#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
|
||||
- fix(cli): pass session id to interactive shell executions by
|
||||
@jasonmatthewsuhari in
|
||||
[#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
|
||||
- fix(cli): resolve text sanitization data loss due to C1 control characters by
|
||||
@euxaristia in
|
||||
[#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
|
||||
- feat(core): add large memory regression test by @cynthialong0-0 in
|
||||
[#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
|
||||
- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
|
||||
@spencer426 in
|
||||
[#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
|
||||
[#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
|
||||
- perf(sandbox): optimize Windows sandbox initialization via native ACL
|
||||
application by @ehedlund in
|
||||
[#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
|
||||
- chore: switch from keytar to @github/keytar by @cocosheng-g in
|
||||
[#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
|
||||
- fix: improve audio MIME normalization and validation in file reads by
|
||||
@junaiddshaukat in
|
||||
[#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
|
||||
- docs: Update docs-audit to include changes in PR body by @g-samroberts in
|
||||
[#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
|
||||
- docs: correct documentation for enforced authentication type by @cocosheng-g
|
||||
in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
|
||||
- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
|
||||
in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
|
||||
- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
|
||||
[#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
|
||||
- fix(core): fix quota footer for non-auto models and improve display by
|
||||
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
|
||||
- Update ink version to 6.6.7 by @jacob314 in
|
||||
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
|
||||
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
|
||||
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
|
||||
- Fix crash when vim editor is not found in PATH on Windows by
|
||||
@Nagajyothi-tammisetti in
|
||||
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
|
||||
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
|
||||
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
|
||||
- Enable 'Other' option for yesno question type by @ruomengz in
|
||||
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
|
||||
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
|
||||
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
|
||||
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
|
||||
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
|
||||
- feat(core): implement context-aware persistent policy approvals by @jerop in
|
||||
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
|
||||
- docs: move agent disabling instructions and update remote agent status by
|
||||
@jackwotherspoon in
|
||||
[#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
|
||||
- docs(contributing): clarify self-assignment policy for issues by @jmr in
|
||||
[#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
|
||||
- feat(core): add skill patching support with /memory inbox integration by
|
||||
@SandyTao520 in
|
||||
[#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
- Stop suppressing thoughts and text in model response by @gundermanc in
|
||||
[#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
|
||||
- fix(release): prefix git hash in nightly versions to prevent semver
|
||||
normalization by @SandyTao520 in
|
||||
[#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
|
||||
- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
|
||||
in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
|
||||
- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
|
||||
[#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
|
||||
- feat(ui): added enhancements to scroll momentum by @devr0306 in
|
||||
[#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
|
||||
- fix(core): replace custom binary detection with isbinaryfile to correctly
|
||||
handle UTF-8 (U+FFFD) by @Anjaligarhwal in
|
||||
[#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
|
||||
- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
|
||||
@mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
|
||||
- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
|
||||
[#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
|
||||
- feat: support auth block in MCP servers config in agents by @TanmayVartak in
|
||||
[#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
|
||||
- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
|
||||
[#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
|
||||
- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
|
||||
[#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
|
||||
- fix: correct redirect count increment in fetchJson by @KevinZhao in
|
||||
[#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
|
||||
- fix(core): prevent secondary crash in ModelRouterService finally block by
|
||||
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
|
||||
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
|
||||
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
|
||||
- fix(core): unsafe type assertions in Core File System #19712 by
|
||||
@aniketsaurav18 in
|
||||
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
|
||||
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
|
||||
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
|
||||
- Changelog for v0.36.0 by @gemini-cli-robot in
|
||||
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
|
||||
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
|
||||
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
|
||||
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
|
||||
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
|
||||
- fix(ui): fixed table styling by @devr0306 in
|
||||
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
|
||||
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
|
||||
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
|
||||
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
|
||||
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
|
||||
- docs: clarify release coordination by @scidomino in
|
||||
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
|
||||
- fix(core): remove broken PowerShell translation and fix native \_\_write in
|
||||
Windows sandbox by @scidomino in
|
||||
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
|
||||
- Add instructions for how to start react in prod and force react to prod mode
|
||||
by @jacob314 in
|
||||
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
|
||||
- feat(cli): minimalist sandbox status labels by @galz10 in
|
||||
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
|
||||
- Feat/browser agent metrics by @kunal-10-cloud in
|
||||
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
|
||||
- test: fix Windows CI execution and resolve exposed platform failures by
|
||||
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
|
||||
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
|
||||
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
|
||||
- show color by @jacob314 in
|
||||
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
|
||||
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
|
||||
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
|
||||
- fix(core): inject skill system instructions into subagent prompts if activated
|
||||
by @abhipatel12 in
|
||||
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
|
||||
- fix(core): improve windows sandbox reliability and fix integration tests by
|
||||
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
|
||||
- fix(core): ensure sandbox approvals are correctly persisted and matched for
|
||||
proactive expansions by @galz10 in
|
||||
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
|
||||
- feat(cli) Scrollbar for input prompt by @jacob314 in
|
||||
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
|
||||
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
|
||||
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
|
||||
- Fix restoration of topic headers. by @gundermanc in
|
||||
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
|
||||
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
|
||||
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
|
||||
- fix(core): ensure global temp directory is always in sandbox allowed paths by
|
||||
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
|
||||
- fix(core): detect uninitialized lines by @jacob314 in
|
||||
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
|
||||
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
|
||||
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
|
||||
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
|
||||
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
|
||||
- feat(acp): add support for `/about` command by @sripasg in
|
||||
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
|
||||
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
|
||||
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
|
||||
- split context by @jacob314 in
|
||||
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
|
||||
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
|
||||
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
|
||||
- Fix issue where topic headers can be posted back to back by @gundermanc in
|
||||
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
|
||||
- fix(core): handle partial llm_request in BeforeModel hook override by
|
||||
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
|
||||
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
|
||||
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
|
||||
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
|
||||
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
|
||||
- fix(browser): remove premature browser cleanup after subagent invocation by
|
||||
@gsquared94 in
|
||||
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
|
||||
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
|
||||
@Abhijit-2592 in
|
||||
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
|
||||
- relax tool sandboxing overrides for plan mode to match defaults. by
|
||||
@DavidAPierce in
|
||||
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
|
||||
- fix(cli): respect global environment variable allowlist by @scidomino in
|
||||
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
|
||||
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
|
||||
by @spencer426 in
|
||||
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
|
||||
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
|
||||
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
|
||||
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
|
||||
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
|
||||
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
|
||||
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
|
||||
- feat(cli): support selective topic expansion and click-to-expand by
|
||||
@Abhijit-2592 in
|
||||
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
|
||||
- temporarily disable sandbox integration test on windows by @ehedlund in
|
||||
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
|
||||
- Remove flakey test by @scidomino in
|
||||
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
|
||||
- Alisa/approve button by @alisa-alisa in
|
||||
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
|
||||
- feat(hooks): display hook system messages in UI by @mbleigh in
|
||||
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
|
||||
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
|
||||
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
|
||||
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
|
||||
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
|
||||
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
|
||||
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
|
||||
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
|
||||
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
|
||||
- feat(acp): add /help command by @sripasg in
|
||||
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
|
||||
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
|
||||
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
|
||||
- Improve sandbox error matching and caching by @DavidAPierce in
|
||||
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
|
||||
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
|
||||
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
|
||||
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
|
||||
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
|
||||
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
|
||||
@gundermanc in
|
||||
[#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
|
||||
- feat(core): introduce decoupled ContextManager and Sidecar architecture by
|
||||
@joshualitt in
|
||||
[#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
|
||||
- docs(core): update generalist agent documentation by @abhipatel12 in
|
||||
[#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
|
||||
- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
|
||||
in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
|
||||
- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
|
||||
in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
|
||||
- test(core): improve sandbox integration test coverage and fix OS-specific
|
||||
failures by @ehedlund in
|
||||
[#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
|
||||
- fix(core): use debug level for keychain fallback logging by @ehedlund in
|
||||
[#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
|
||||
- feat(test): add a performance test in asian language by @cynthialong0-0 in
|
||||
[#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
|
||||
- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
|
||||
answers by @Adib234 in
|
||||
[#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
|
||||
- fix(core): detect kmscon terminal as supporting true color by @claygeo in
|
||||
[#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
|
||||
- ci: add agent session drift check workflow by @adamfweidman in
|
||||
[#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
|
||||
- use macos-latest-large runner where applicable. by @scidomino in
|
||||
[#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
|
||||
- Changelog for v0.37.2 by @gemini-cli-robot in
|
||||
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
|
||||
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
|
||||
- refactor(cli): remove duplication in interactive shell awaiting input hint by
|
||||
@JayadityaGit in
|
||||
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
|
||||
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
|
||||
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
|
||||
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
|
||||
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
|
||||
- fix(cli): always show shell command description or actual command by @jacob314
|
||||
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
|
||||
- Added flag for ept size and increased default size by @devr0306 in
|
||||
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
|
||||
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
|
||||
@Anjaligarhwal in
|
||||
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
|
||||
- fix(cli): switch default back to terminalBuffer=false and fix regressions
|
||||
introduced for that mode by @jacob314 in
|
||||
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
|
||||
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
|
||||
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
|
||||
- fix: isolate concurrent browser agent instances by @gsquared94 in
|
||||
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
|
||||
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
|
||||
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.37.0-preview.2...v0.38.0-preview.0
|
||||
|
||||
@@ -2148,21 +2148,6 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- When set, overrides the default API version used by the SDK.
|
||||
- Example: `export GOOGLE_GENAI_API_VERSION="v1"` (Windows PowerShell:
|
||||
`$env:GOOGLE_GENAI_API_VERSION="v1"`)
|
||||
- **`GOOGLE_GEMINI_BASE_URL`**:
|
||||
- Overrides the default base URL for Gemini API requests (when using
|
||||
`gemini-api-key` authentication).
|
||||
- Must be a valid URL. For security, it must use HTTPS unless pointing to
|
||||
`localhost` (or `127.0.0.1` / `[::1]`).
|
||||
- Example: `export GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"` (Windows
|
||||
PowerShell: `$env:GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"`)
|
||||
- **`GOOGLE_VERTEX_BASE_URL`**:
|
||||
- Overrides the default base URL for Vertex AI API requests (when using
|
||||
`vertex-ai` authentication).
|
||||
- Must be a valid URL. For security, it must use HTTPS unless pointing to
|
||||
`localhost` (or `127.0.0.1` / `[::1]`).
|
||||
- Example: `export GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`
|
||||
(Windows PowerShell:
|
||||
`$env:GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`)
|
||||
- **`OTLP_GOOGLE_CLOUD_PROJECT`**:
|
||||
- Your Google Cloud Project ID for Telemetry in Google Cloud
|
||||
- Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows
|
||||
|
||||
@@ -17,17 +17,9 @@ describe('CliHelpAgent Delegation', () => {
|
||||
timeout: 60000,
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const toolCallIndex = toolLogs.findIndex((log) => {
|
||||
if (log.toolRequest.name === 'invoke_agent') {
|
||||
try {
|
||||
const args = JSON.parse(log.toolRequest.args);
|
||||
return args.agent_name === 'cli_help';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const toolCallIndex = toolLogs.findIndex(
|
||||
(log) => log.toolRequest.name === 'cli_help',
|
||||
);
|
||||
expect(toolCallIndex).toBeGreaterThan(-1);
|
||||
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
Config,
|
||||
type ConfigParameters,
|
||||
@@ -53,7 +52,6 @@ export interface ComponentEvalCase extends BaseEvalCase {
|
||||
export class ComponentRig {
|
||||
public config: Config | undefined;
|
||||
public testDir: string;
|
||||
public homeDir: string;
|
||||
public sessionId: string;
|
||||
|
||||
constructor(
|
||||
@@ -63,9 +61,6 @@ export class ComponentRig {
|
||||
this.testDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `gemini-component-rig-${uniqueId.slice(0, 8)}-`),
|
||||
);
|
||||
this.homeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `gemini-component-home-${uniqueId.slice(0, 8)}-`),
|
||||
);
|
||||
this.sessionId = `test-session-${uniqueId}`;
|
||||
}
|
||||
|
||||
@@ -94,23 +89,12 @@ export class ComponentRig {
|
||||
this.config = makeFakeConfig(configParams);
|
||||
await this.config.initialize();
|
||||
|
||||
// Refresh auth using USE_GEMINI to initialize the real BaseLlmClient.
|
||||
// This must happen BEFORE stubbing GEMINI_CLI_HOME because OAuth credential
|
||||
// lookup resolves through homedir() → GEMINI_CLI_HOME.
|
||||
// Refresh auth using USE_GEMINI to initialize the real BaseLlmClient
|
||||
await this.config.refreshAuth(AuthType.USE_GEMINI);
|
||||
|
||||
// Isolate storage paths (session files, skills, extraction state) by
|
||||
// pointing GEMINI_CLI_HOME at a per-test temp directory. Storage resolves
|
||||
// global paths through `homedir()` which reads this env var. This is set
|
||||
// after auth so credential lookup uses the real home directory.
|
||||
vi.stubEnv('GEMINI_CLI_HOME', this.homeDir);
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
await this.config?.dispose();
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(this.testDir, { recursive: true, force: true });
|
||||
fs.rmSync(this.homeDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,22 +26,11 @@ describe('generalist_agent', () => {
|
||||
prompt:
|
||||
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
|
||||
assert: async (rig) => {
|
||||
// 1) Verify the generalist agent was invoked via invoke_agent
|
||||
const foundToolCall = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'generalist';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
// 1) Verify the generalist agent was invoked
|
||||
const foundToolCall = await rig.waitForToolCall('generalist');
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find an invoke_agent tool call for generalist agent',
|
||||
'Expected to find a tool call for generalist agent',
|
||||
).toBeTruthy();
|
||||
|
||||
// 2) Verify the file was created as expected
|
||||
|
||||
+31
-63
@@ -145,30 +145,22 @@ describe('save_memory', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const savingDbSchemaLocationAsProjectMemory =
|
||||
'Agent saves workspace database schema location as project memory';
|
||||
const ignoringDbSchemaLocation =
|
||||
"Agent ignores workspace's database schema location";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingDbSchemaLocationAsProjectMemory,
|
||||
name: ignoringDbSchemaLocation,
|
||||
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'save_memory',
|
||||
undefined,
|
||||
(args) => {
|
||||
try {
|
||||
const params = JSON.parse(args);
|
||||
return params.scope === 'project';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
await rig.waitForTelemetryReady();
|
||||
const wasToolCalled = rig
|
||||
.readToolLogs()
|
||||
.some((log) => log.toolRequest.name === 'save_memory');
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected save_memory to be called with scope="project" for workspace-specific information',
|
||||
).toBe(true);
|
||||
'save_memory should not be called for workspace-specific information',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
@@ -196,59 +188,42 @@ describe('save_memory', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const savingBuildArtifactLocationAsProjectMemory =
|
||||
'Agent saves workspace build artifact location as project memory';
|
||||
const ignoringBuildArtifactLocation =
|
||||
'Agent ignores workspace build artifact location';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingBuildArtifactLocationAsProjectMemory,
|
||||
name: ignoringBuildArtifactLocation,
|
||||
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'save_memory',
|
||||
undefined,
|
||||
(args) => {
|
||||
try {
|
||||
const params = JSON.parse(args);
|
||||
return params.scope === 'project';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
await rig.waitForTelemetryReady();
|
||||
const wasToolCalled = rig
|
||||
.readToolLogs()
|
||||
.some((log) => log.toolRequest.name === 'save_memory');
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected save_memory to be called with scope="project" for workspace-specific information',
|
||||
).toBe(true);
|
||||
'save_memory should not be called for workspace-specific information',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const savingMainEntryPointAsProjectMemory =
|
||||
'Agent saves workspace main entry point as project memory';
|
||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingMainEntryPointAsProjectMemory,
|
||||
name: ignoringMainEntryPoint,
|
||||
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'save_memory',
|
||||
undefined,
|
||||
(args) => {
|
||||
try {
|
||||
const params = JSON.parse(args);
|
||||
return params.scope === 'project';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
await rig.waitForTelemetryReady();
|
||||
const wasToolCalled = rig
|
||||
.readToolLogs()
|
||||
.some((log) => log.toolRequest.name === 'save_memory');
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected save_memory to be called with scope="project" for workspace-specific information',
|
||||
).toBe(true);
|
||||
'save_memory should not be called for workspace-specific information',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
@@ -342,13 +317,13 @@ describe('save_memory', () => {
|
||||
'Please save any persistent preferences or facts about me from our conversation to memory.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
'save_memory',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args) && /vitest/i.test(args),
|
||||
(args) => /vitest/i.test(args),
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent and the Vitest preference from the conversation history',
|
||||
'Expected save_memory to be called with the Vitest preference from the conversation history',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
@@ -404,15 +379,8 @@ describe('save_memory', () => {
|
||||
],
|
||||
prompt: 'Please save the preferences I mentioned earlier to memory.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args),
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent',
|
||||
).toBe(true);
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory to be called').toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect } from 'vitest';
|
||||
import {
|
||||
type Config,
|
||||
ApprovalMode,
|
||||
SESSION_FILE_PREFIX,
|
||||
getProjectHash,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
|
||||
interface SeedSession {
|
||||
sessionId: string;
|
||||
summary: string;
|
||||
userTurns: string[];
|
||||
timestampOffsetMinutes: number;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: string;
|
||||
content: Array<{ text: string }>;
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES = {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'skill-extraction-eval',
|
||||
private: true,
|
||||
scripts: {
|
||||
build: 'echo build',
|
||||
lint: 'echo lint',
|
||||
test: 'echo test',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'README.md': `# Skill Extraction Eval
|
||||
|
||||
This workspace exists to exercise background skill extraction from prior chats.
|
||||
`,
|
||||
};
|
||||
|
||||
function buildMessages(userTurns: string[]): MessageRecord[] {
|
||||
const baseTime = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
|
||||
return userTurns.flatMap((text, index) => [
|
||||
{
|
||||
id: `u${index + 1}`,
|
||||
timestamp: baseTime,
|
||||
type: 'user',
|
||||
content: [{ text }],
|
||||
},
|
||||
{
|
||||
id: `a${index + 1}`,
|
||||
timestamp: baseTime,
|
||||
type: 'gemini',
|
||||
content: [{ text: `Acknowledged: ${index + 1}` }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function seedSessions(
|
||||
config: Config,
|
||||
sessions: SeedSession[],
|
||||
): Promise<void> {
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
await fsp.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
for (const session of sessions) {
|
||||
const timestamp = new Date(
|
||||
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace(/:/g, '-');
|
||||
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${session.sessionId.slice(0, 8)}.json`;
|
||||
const conversation = {
|
||||
sessionId: session.sessionId,
|
||||
projectHash: getProjectHash(projectRoot),
|
||||
summary: session.summary,
|
||||
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||
messages: buildMessages(session.userTurns),
|
||||
};
|
||||
|
||||
await fsp.writeFile(
|
||||
path.join(chatsDir, filename),
|
||||
JSON.stringify(conversation, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runExtractionAndReadState(config: Config): Promise<{
|
||||
state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
|
||||
skillsDir: string;
|
||||
}> {
|
||||
await startMemoryService(config);
|
||||
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
||||
const statePath = path.join(memoryDir, '.extraction-state.json');
|
||||
|
||||
const raw = await fsp.readFile(statePath, 'utf-8');
|
||||
const state = JSON.parse(raw) as {
|
||||
runs?: Array<{ sessionIds?: string[]; skillsCreated?: string[] }>;
|
||||
};
|
||||
if (!Array.isArray(state.runs) || state.runs.length === 0) {
|
||||
throw new Error('Skill extraction finished without writing any run state');
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
runs: state.runs.map((run) => ({
|
||||
sessionIds: Array.isArray(run.sessionIds) ? run.sessionIds : [],
|
||||
skillsCreated: Array.isArray(run.skillsCreated)
|
||||
? run.skillsCreated
|
||||
: [],
|
||||
})),
|
||||
},
|
||||
skillsDir,
|
||||
};
|
||||
}
|
||||
|
||||
async function readSkillBodies(skillsDir: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
|
||||
const skillDirs = entries.filter((entry) => entry.isDirectory());
|
||||
const bodies = await Promise.all(
|
||||
skillDirs.map((entry) =>
|
||||
fsp.readFile(path.join(skillsDir, entry.name, 'SKILL.md'), 'utf-8'),
|
||||
),
|
||||
);
|
||||
return bodies;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared configOverrides for all skill extraction component evals.
|
||||
* - experimentalMemoryManager: enables the memory extraction pipeline.
|
||||
* - approvalMode: YOLO auto-approves tool calls (write_file, read_file) so the
|
||||
* background agent can execute without interactive confirmation.
|
||||
*/
|
||||
const EXTRACTION_CONFIG_OVERRIDES = {
|
||||
experimentalMemoryManager: true,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
describe('Skill Extraction', () => {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'ignores one-off incidents even when session summaries look similar',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 180000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'incident-login-redirect',
|
||||
summary: 'Debug login redirect loop in staging',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'We only need a one-off fix for incident INC-4412 on branch hotfix/login-loop.',
|
||||
'The exact failing string is ERR_REDIRECT_4412 and this workaround is incident-specific.',
|
||||
'Patch packages/auth/src/redirect.ts just for this branch and do not generalize it.',
|
||||
'The thing that worked was deleting the stale staging cookie before retrying.',
|
||||
'This is not a normal workflow and should not become a reusable instruction.',
|
||||
'It only reproduced against the 2026-04-08 staging rollout.',
|
||||
'After the cookie clear, the branch-specific redirect logic passed.',
|
||||
'Do not turn this incident writeup into a standing process.',
|
||||
'Yes, the hotfix worked for this exact redirect-loop incident.',
|
||||
'Close out INC-4412 once the staging login succeeds again.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'incident-login-timeout',
|
||||
summary: 'Debug login callback timeout in staging',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'This is another one-off staging incident, this time TICKET-991 for callback timeout.',
|
||||
'The exact failing string is ERR_CALLBACK_TIMEOUT_991 and it is unrelated to the redirect loop.',
|
||||
'The temporary fix was rotating the staging secret and deleting a bad feature-flag row.',
|
||||
'Do not write a generic login-debugging playbook from this.',
|
||||
'This only applied to the callback timeout during the April rollout.',
|
||||
'The successful fix was specific to the stale secret in staging.',
|
||||
'It does not define a durable repo workflow for future tasks.',
|
||||
'After rotating the secret, the callback timeout stopped reproducing.',
|
||||
'Treat this as incident response only, not a reusable skill.',
|
||||
'Once staging passed again, we closed TICKET-991.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expect(state.runs[0].skillsCreated).toEqual([]);
|
||||
expect(skillBodies).toEqual([]);
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'extracts a repeated project-specific workflow into a skill',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 180000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'settings-docs-regen-1',
|
||||
summary: 'Update settings docs after adding a config option',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'When we add a new config option, we have to regenerate the settings docs in a specific order.',
|
||||
'The sequence that worked was npm run predocs:settings, npm run schema:settings, then npm run docs:settings.',
|
||||
'Do not hand-edit generated settings docs.',
|
||||
'If predocs is skipped, the generated schema docs miss the new defaults.',
|
||||
'Update the source first, then run that generation sequence.',
|
||||
'After regenerating, verify the schema output and docs changed together.',
|
||||
'We used this same sequence the last time we touched settings docs.',
|
||||
'That ordered workflow passed and produced the expected generated files.',
|
||||
'Please keep the exact command order because reversing it breaks the output.',
|
||||
'Yes, the generated settings docs were correct after those three commands.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'settings-docs-regen-2',
|
||||
summary: 'Regenerate settings schema docs for another new setting',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'We are touching another setting, so follow the same settings-doc regeneration workflow again.',
|
||||
'Run npm run predocs:settings before npm run schema:settings and npm run docs:settings.',
|
||||
'The project keeps generated settings docs in sync through those commands, not manual edits.',
|
||||
'Skipping predocs caused stale defaults in the generated output before.',
|
||||
'Change the source, then execute the same three commands in order.',
|
||||
'Verify both the schema artifact and docs update together after regeneration.',
|
||||
'This is the recurring workflow we use whenever a setting changes.',
|
||||
'The exact order worked again on this second settings update.',
|
||||
'Please preserve that ordering constraint for future settings changes.',
|
||||
'Confirmed: the settings docs regenerated correctly with the same command sequence.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
|
||||
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(combinedSkills).toContain('npm run predocs:settings');
|
||||
expect(combinedSkills).toContain('npm run schema:settings');
|
||||
expect(combinedSkills).toContain('npm run docs:settings');
|
||||
expect(combinedSkills).toMatch(/Verification/i);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'extracts a repeated multi-step migration workflow with ordering constraints',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 180000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'db-migration-v12',
|
||||
summary: 'Run database migration for v12 schema update',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Every time we change the database schema we follow a specific migration workflow.',
|
||||
'First run npm run db:check to verify no pending migrations conflict.',
|
||||
'Then run npm run db:migrate to apply the new migration files.',
|
||||
'After migration, always run npm run db:validate to confirm schema integrity.',
|
||||
'If db:validate fails, immediately run npm run db:rollback before anything else.',
|
||||
'Never skip db:check — last time we did, two migrations collided and corrupted the index.',
|
||||
'The ordering is critical: check, migrate, validate. Reversing migrate and validate caused silent data loss before.',
|
||||
'This v12 migration passed after following that exact sequence.',
|
||||
'We use this same three-step workflow every time the schema changes.',
|
||||
'Confirmed: db:check, db:migrate, db:validate completed successfully for v12.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'db-migration-v13',
|
||||
summary: 'Run database migration for v13 schema update',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'New schema change for v13, following the same database migration workflow as before.',
|
||||
'Start with npm run db:check to ensure no conflicting pending migrations.',
|
||||
'Then npm run db:migrate to apply the v13 migration files.',
|
||||
'Then npm run db:validate to confirm the schema is consistent.',
|
||||
'If validation fails, run npm run db:rollback immediately — do not attempt manual fixes.',
|
||||
'We learned the hard way that skipping db:check causes index corruption.',
|
||||
'The check-migrate-validate order is mandatory for every schema change.',
|
||||
'This is the same recurring workflow we used for v12 and earlier migrations.',
|
||||
'The v13 migration passed with the same three-step sequence.',
|
||||
'Confirmed: the standard db migration workflow succeeded again for v13.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
|
||||
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(combinedSkills).toContain('npm run db:check');
|
||||
expect(combinedSkills).toContain('npm run db:migrate');
|
||||
expect(combinedSkills).toContain('npm run db:validate');
|
||||
expect(combinedSkills).toMatch(/rollback/i);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import {
|
||||
TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { evalTest, TEST_AGENTS } from './test-helper.js';
|
||||
|
||||
describe('subtask delegation eval test cases', () => {
|
||||
/**
|
||||
* Checks that the main agent can correctly decompose a complex, sequential
|
||||
* task into subtasks using the task tracker and delegate each to the appropriate expert subagent.
|
||||
*
|
||||
* The task requires:
|
||||
* 1. Reading requirements (researcher)
|
||||
* 2. Implementing logic (developer)
|
||||
* 3. Documenting (doc expert)
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should delegate sequential subtasks to relevant experts using the task tracker',
|
||||
params: {
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
taskTracker: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt:
|
||||
'Please read the requirements in requirements.txt using a researcher, then implement the requested logic in src/logic.ts using a developer, and finally document the implementation in docs/logic.md using a documentation expert.',
|
||||
files: {
|
||||
'.gemini/agents/researcher.md': `---
|
||||
name: researcher
|
||||
description: Expert in reading files and extracting requirements.
|
||||
tools:
|
||||
- read_file
|
||||
---
|
||||
You are the researcher. Read the provided file and extract requirements.`,
|
||||
'.gemini/agents/developer.md': `---
|
||||
name: developer
|
||||
description: Expert in implementing logic in TypeScript.
|
||||
tools:
|
||||
- write_file
|
||||
---
|
||||
You are the developer. Implement the requested logic in the specified file.`,
|
||||
'.gemini/agents/doc-expert.md': `---
|
||||
name: doc-expert
|
||||
description: Expert in writing technical documentation.
|
||||
tools:
|
||||
- write_file
|
||||
---
|
||||
You are the doc expert. Document the provided implementation clearly.`,
|
||||
'requirements.txt':
|
||||
'Implement a function named "calculateSum" that adds two numbers.',
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
// Verify tracker tasks were created
|
||||
const wasCreateCalled = await rig.waitForToolCall(
|
||||
TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
);
|
||||
expect(wasCreateCalled).toBe(true);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const createCalls = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
);
|
||||
expect(createCalls.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await rig.expectToolCallSuccess([
|
||||
'researcher',
|
||||
'developer',
|
||||
'doc-expert',
|
||||
]);
|
||||
|
||||
const logicFile = rig.readFile('src/logic.ts');
|
||||
const docFile = rig.readFile('docs/logic.md');
|
||||
|
||||
expect(logicFile).toContain('calculateSum');
|
||||
expect(docFile).toBeTruthy();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks that the main agent can delegate a batch of independent subtasks
|
||||
* to multiple subagents in parallel using the task tracker to manage state.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should delegate independent subtasks to specialists using the task tracker',
|
||||
params: {
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
taskTracker: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt:
|
||||
'Please update the project for internationalization (i18n), audit the security of the current code, and update the CSS to use a blue theme. Use specialized experts for each task.',
|
||||
files: {
|
||||
...TEST_AGENTS.I18N_AGENT.asFile(),
|
||||
...TEST_AGENTS.SECURITY_AGENT.asFile(),
|
||||
...TEST_AGENTS.CSS_AGENT.asFile(),
|
||||
'index.ts': 'console.log("Hello World");',
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
// Verify tracker tasks were created
|
||||
const wasCreateCalled = await rig.waitForToolCall(
|
||||
TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
);
|
||||
expect(wasCreateCalled).toBe(true);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const createCalls = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
);
|
||||
expect(createCalls.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await rig.expectToolCallSuccess([
|
||||
TEST_AGENTS.I18N_AGENT.name,
|
||||
TEST_AGENTS.SECURITY_AGENT.name,
|
||||
TEST_AGENTS.CSS_AGENT.name,
|
||||
]);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
describe('run_shell_command streaming to file regression', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should stream large outputs to a file and verify full content presence', async () => {
|
||||
await rig.setup(
|
||||
'should stream large outputs to a file and verify full content presence',
|
||||
{
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
},
|
||||
);
|
||||
|
||||
const numLines = 20000;
|
||||
const testFileName = 'large_output_test.txt';
|
||||
const testFilePath = path.join(rig.testDir!, testFileName);
|
||||
|
||||
// Create a ~20MB file with unique content at start and end
|
||||
const startMarker = 'START_OF_FILE_MARKER';
|
||||
const endMarker = 'END_OF_FILE_MARKER';
|
||||
|
||||
const stream = fs.createWriteStream(testFilePath);
|
||||
stream.write(startMarker + '\n');
|
||||
for (let i = 0; i < numLines; i++) {
|
||||
stream.write(`Line ${i + 1}: ` + 'A'.repeat(1000) + '\n');
|
||||
}
|
||||
stream.write(endMarker + '\n');
|
||||
await new Promise((resolve) => stream.end(resolve));
|
||||
|
||||
const fileSize = fs.statSync(testFilePath).size;
|
||||
expect(fileSize).toBeGreaterThan(20000000);
|
||||
|
||||
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
|
||||
await rig.run({ args: prompt });
|
||||
|
||||
let savedFilePath = '';
|
||||
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||||
if (fs.existsSync(tmpdir)) {
|
||||
const findFiles = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
results = results.concat(findFiles(fullPath));
|
||||
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||||
results.push(fullPath);
|
||||
} else if (file.isFile() && file.name.endsWith('.log')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const files = findFiles(tmpdir);
|
||||
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
|
||||
for (const p of files) {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.size >= 20000000) {
|
||||
savedFilePath = p;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!savedFilePath) {
|
||||
const fileStats = files.map((p) => {
|
||||
try {
|
||||
return { p, size: fs.statSync(p).size };
|
||||
} catch {
|
||||
return { p, size: 'error' };
|
||||
}
|
||||
});
|
||||
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
savedFilePath,
|
||||
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
|
||||
expect(savedContent).toContain(startMarker);
|
||||
expect(savedContent).toContain(endMarker);
|
||||
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
|
||||
|
||||
fs.unlinkSync(savedFilePath);
|
||||
}, 120000);
|
||||
|
||||
it('should stream very large (50MB) outputs to a file and verify full content presence', async () => {
|
||||
await rig.setup(
|
||||
'should stream very large (50MB) outputs to a file and verify full content presence',
|
||||
{
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
},
|
||||
);
|
||||
|
||||
const numLines = 1000000;
|
||||
const testFileName = 'very_large_output_test.txt';
|
||||
const testFilePath = path.join(rig.testDir!, testFileName);
|
||||
|
||||
// Create a ~50MB file with unique content at start and end
|
||||
const startMarker = 'START_OF_FILE_MARKER';
|
||||
const endMarker = 'END_OF_FILE_MARKER';
|
||||
|
||||
const stream = fs.createWriteStream(testFilePath);
|
||||
stream.write(startMarker + '\n');
|
||||
for (let i = 0; i < numLines; i++) {
|
||||
stream.write(`Line ${i + 1}: ` + 'A'.repeat(40) + '\n');
|
||||
}
|
||||
stream.write(endMarker + '\n');
|
||||
await new Promise((resolve) => stream.end(resolve));
|
||||
|
||||
const fileSize = fs.statSync(testFilePath).size;
|
||||
expect(fileSize).toBeGreaterThan(45000000);
|
||||
|
||||
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
|
||||
await rig.run({ args: prompt });
|
||||
|
||||
let savedFilePath = '';
|
||||
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||||
if (fs.existsSync(tmpdir)) {
|
||||
const findFiles = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
results = results.concat(findFiles(fullPath));
|
||||
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||||
results.push(fullPath);
|
||||
} else if (file.isFile() && file.name.endsWith('.log')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const files = findFiles(tmpdir);
|
||||
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
|
||||
for (const p of files) {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.size >= 20000000) {
|
||||
savedFilePath = p;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!savedFilePath) {
|
||||
const fileStats = files.map((p) => {
|
||||
try {
|
||||
return { p, size: fs.statSync(p).size };
|
||||
} catch {
|
||||
return { p, size: 'error' };
|
||||
}
|
||||
});
|
||||
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
savedFilePath,
|
||||
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
|
||||
expect(savedContent).toContain(startMarker);
|
||||
expect(savedContent).toContain(endMarker);
|
||||
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
|
||||
|
||||
fs.unlinkSync(savedFilePath);
|
||||
}, 120000);
|
||||
|
||||
it('should produce clean output resolving carriage returns and backspaces', async () => {
|
||||
await rig.setup(
|
||||
'should produce clean output resolving carriage returns and backspaces',
|
||||
{
|
||||
settings: {
|
||||
tools: { core: ['run_shell_command'] },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const script = `
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Fill buffer to force file streaming/truncation
|
||||
# 45000 chars to be safe (default threshold is 40000)
|
||||
print('A' * 45000)
|
||||
sys.stdout.flush()
|
||||
|
||||
# Test sequence
|
||||
print('XXXXX', end='', flush=True)
|
||||
time.sleep(0.5)
|
||||
print('\\rYYYYY', end='', flush=True)
|
||||
time.sleep(0.5)
|
||||
print('\\nNext Line', end='', flush=True)
|
||||
`;
|
||||
const scriptPath = path.join(rig.testDir!, 'test_script.py');
|
||||
fs.writeFileSync(scriptPath, script);
|
||||
|
||||
const prompt = `run_shell_command python3 "${scriptPath}"`;
|
||||
await rig.run({ args: prompt });
|
||||
|
||||
let savedFilePath = '';
|
||||
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||||
if (fs.existsSync(tmpdir)) {
|
||||
const findFiles = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
results = results.concat(findFiles(fullPath));
|
||||
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const files = findFiles(tmpdir);
|
||||
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
|
||||
if (files.length > 0) {
|
||||
savedFilePath = files[0];
|
||||
}
|
||||
}
|
||||
|
||||
expect(savedFilePath, 'Output file should exist').toBeTruthy();
|
||||
const content = fs.readFileSync(savedFilePath, 'utf8');
|
||||
|
||||
// Verify it contains the large chunk
|
||||
expect(content).toContain('AAAA');
|
||||
|
||||
// Verify cleanup logic:
|
||||
// 1. The final text "YYYYY" should be present.
|
||||
expect(content).toContain('YYYYY');
|
||||
// 2. The next line should be present.
|
||||
expect(content).toContain('Next Line');
|
||||
|
||||
// 3. Verify overwrite happened.
|
||||
// In raw output, we would have "XXXXX...YYYYY".
|
||||
// In processed output, "YYYYY" overwrites "XXXXX".
|
||||
// We confirm that escape codes are stripped (processed text).
|
||||
|
||||
// 4. Check for ANSI escape codes (like \\x1b) just in case
|
||||
expect(content).not.toContain('\x1b');
|
||||
}, 60000);
|
||||
});
|
||||
Generated
+328
-15
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -1727,6 +1727,29 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@joshua.litt/get-ripgrep": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.3.tgz",
|
||||
"integrity": "sha512-rycdieAKKqXi2bsM7G2ayDiNk5CAX8ZOzsTQsirfOqUKPef04Xw40BWGGyimaOOuvPgLWYt3tPnLLG3TvPXi5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lvce-editor/verror": "^1.6.0",
|
||||
"execa": "^9.5.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"got": "^14.4.5",
|
||||
"path-exists": "^5.0.0",
|
||||
"xdg-basedir": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
|
||||
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1909,6 +1932,12 @@
|
||||
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lvce-editor/verror": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz",
|
||||
"integrity": "sha512-+LGuAEIC2L7pbvkyAQVWM2Go0dAy+UWEui28g07zNtZsCBhm+gusBK8PNwLJLV5Jay+TyUYuwLIbJdjLLzqEBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lydell/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz",
|
||||
@@ -3561,6 +3590,18 @@
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz",
|
||||
"integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
|
||||
@@ -3573,6 +3614,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
|
||||
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defer-to-connect": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@textlint/ast-node-types": {
|
||||
"version": "15.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz",
|
||||
@@ -3878,6 +3931,12 @@
|
||||
"integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
||||
"integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
@@ -5949,6 +6008,33 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-lookup": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
|
||||
"integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-request": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-12.0.1.tgz",
|
||||
"integrity": "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-cache-semantics": "^4.0.4",
|
||||
"get-stream": "^9.0.1",
|
||||
"http-cache-semantics": "^4.1.1",
|
||||
"keyv": "^4.5.4",
|
||||
"mimic-response": "^4.0.0",
|
||||
"normalize-url": "^8.0.1",
|
||||
"responselike": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
@@ -6882,6 +6968,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response/node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
@@ -6944,6 +7057,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/defer-to-connect": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
@@ -8306,9 +8428,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
|
||||
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^4.0.0",
|
||||
@@ -8828,6 +8950,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data-encoder": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz",
|
||||
"integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
@@ -9455,6 +9586,43 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/got": {
|
||||
"version": "14.4.8",
|
||||
"resolved": "https://registry.npmjs.org/got/-/got-14.4.8.tgz",
|
||||
"integrity": "sha512-vxwU4HuR0BIl+zcT1LYrgBjM+IJjNElOjCzs0aPgHorQyr/V6H6Y73Sn3r3FOlUffvWD+Q5jtRuGWaXkU8Jbhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/is": "^7.0.1",
|
||||
"@szmarczak/http-timer": "^5.0.1",
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"cacheable-request": "^12.0.1",
|
||||
"decompress-response": "^6.0.0",
|
||||
"form-data-encoder": "^4.0.2",
|
||||
"http2-wrapper": "^2.2.1",
|
||||
"lowercase-keys": "^3.0.0",
|
||||
"p-cancelable": "^4.0.1",
|
||||
"responselike": "^3.0.0",
|
||||
"type-fest": "^4.26.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/got?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/got/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -9710,6 +9878,12 @@
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -9743,6 +9917,19 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/http2-wrapper": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
|
||||
"integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quick-lru": "^5.1.1",
|
||||
"resolve-alpn": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
@@ -10853,7 +11040,6 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-parse-better-errors": {
|
||||
@@ -11049,7 +11235,6 @@
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
@@ -11514,6 +11699,18 @@
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
|
||||
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
||||
@@ -11784,6 +11981,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
|
||||
"integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
@@ -12162,6 +12371,18 @@
|
||||
"node": "^16.14.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-url": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz",
|
||||
"integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-normalize-package-bin": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
|
||||
@@ -12674,6 +12895,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
|
||||
"integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -13513,6 +13743,18 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -13953,6 +14195,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-alpn": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-dir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
|
||||
@@ -13987,6 +14235,21 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/responselike": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz",
|
||||
"integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lowercase-keys": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
|
||||
@@ -17381,6 +17644,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xdg-basedir": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz",
|
||||
"integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
||||
@@ -17589,7 +17864,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17704,7 +17979,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -17804,6 +18079,44 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/execa": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"figures": "^6.1.0",
|
||||
"get-stream": "^9.0.0",
|
||||
"human-signals": "^8.0.1",
|
||||
"is-plain-obj": "^4.1.0",
|
||||
"is-stream": "^4.0.1",
|
||||
"npm-run-path": "^6.0.0",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"strip-final-newline": "^4.0.0",
|
||||
"yoctocolors": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/is-stream": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
|
||||
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/string-width": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
|
||||
@@ -17838,7 +18151,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -17849,6 +18162,7 @@
|
||||
"@google/genai": "1.30.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.211.0",
|
||||
@@ -17876,7 +18190,6 @@
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"execa": "^9.6.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
@@ -18105,7 +18418,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18120,7 +18433,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18137,7 +18450,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18155,7 +18468,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"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.40.0-nightly.20260414.g5b1f7375a"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"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.40.0-nightly.20260414.g5b1f7375a"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { ApiAuthDialog } from './ApiAuthDialog.js';
|
||||
@@ -40,16 +40,11 @@ vi.mock('../components/shared/text-buffer.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../contexts/UIStateContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useUIState: vi.fn(() => ({
|
||||
terminalWidth: 80,
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
terminalWidth: 80,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
@@ -78,7 +73,7 @@ describe('ApiAuthDialog', () => {
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -86,7 +81,7 @@ describe('ApiAuthDialog', () => {
|
||||
});
|
||||
|
||||
it('renders with a defaultValue', async () => {
|
||||
const { unmount } = await renderWithProviders(
|
||||
const { unmount } = await render(
|
||||
<ApiAuthDialog
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
@@ -116,7 +111,7 @@ describe('ApiAuthDialog', () => {
|
||||
'calls $expectedCall.name when $keyName is pressed',
|
||||
async ({ keyName, sequence, expectedCall, args }) => {
|
||||
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
||||
const { unmount } = await renderWithProviders(
|
||||
const { unmount } = await render(
|
||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
||||
@@ -138,7 +133,7 @@ describe('ApiAuthDialog', () => {
|
||||
);
|
||||
|
||||
it('displays an error message', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ApiAuthDialog
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
@@ -151,7 +146,7 @@ describe('ApiAuthDialog', () => {
|
||||
});
|
||||
|
||||
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
|
||||
const { unmount } = await renderWithProviders(
|
||||
const { unmount } = await render(
|
||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
// Call 0 is ApiAuthDialog (isActive: true)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -53,26 +53,23 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const AnsiLineText = React.memo<{ line: AnsiLine }>(
|
||||
({ line }: { line: AnsiLine }) => (
|
||||
<Text>
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
),
|
||||
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
|
||||
<Text>
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
);
|
||||
AnsiLineText.displayName = 'AnsiLineText';
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
useReducer,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { checkExhaustive, type Question } from '@google/gemini-cli-core';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
@@ -86,24 +85,6 @@ function autoBoldIfPlain(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
const ClickableCheckbox: React.FC<{
|
||||
isChecked: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ isChecked, onClick }) => {
|
||||
const ref = useRef<DOMElement>(null);
|
||||
useMouseClick(ref, () => {
|
||||
onClick();
|
||||
});
|
||||
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
<Text color={isChecked ? theme.status.success : theme.text.secondary}>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface AskUserDialogState {
|
||||
answers: { [key: string]: string };
|
||||
isEditingCustomOption: boolean;
|
||||
@@ -938,14 +919,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
<ClickableCheckbox
|
||||
isChecked={isChecked}
|
||||
onClick={() => {
|
||||
if (!context.isSelected) {
|
||||
handleSelect(optionItem);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}> </Text>
|
||||
<TextInput
|
||||
@@ -986,14 +966,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
<ClickableCheckbox
|
||||
isChecked={isChecked}
|
||||
onClick={() => {
|
||||
if (!context.isSelected) {
|
||||
handleSelect(optionItem);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
)}
|
||||
<Text color={labelColor} bold={optionItem.type === 'done'}>
|
||||
{' '}
|
||||
|
||||
@@ -3877,9 +3877,8 @@ describe('InputPrompt', () => {
|
||||
|
||||
// 1. Verify initial placeholder
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
|
||||
// Simulate double-click to expand
|
||||
await simulateClick(5, 2);
|
||||
@@ -3887,9 +3886,8 @@ describe('InputPrompt', () => {
|
||||
|
||||
// 2. Verify expanded content is visible
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('line10');
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
|
||||
// Simulate double-click to collapse
|
||||
await simulateClick(5, 2);
|
||||
@@ -3897,9 +3895,8 @@ describe('InputPrompt', () => {
|
||||
|
||||
// 3. Verify placeholder is restored
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -156,16 +156,7 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> line1
|
||||
line2
|
||||
line3
|
||||
line4
|
||||
line5
|
||||
line6
|
||||
line7
|
||||
line8
|
||||
line9
|
||||
line10
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -11,12 +11,12 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
|
||||
│ ● 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
|
||||
│ 8. Holiday Dark │ │ │
|
||||
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Tokyo Night Dark │ │ │
|
||||
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. ANSI Light │ │ │
|
||||
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
@@ -34,14 +34,14 @@ exports[`Initial Theme Selection > should default to a light theme when terminal
|
||||
│ 2. Ayu Light │ 1 # function │ │
|
||||
│ ● 3. Default Light │ 2 def fibonacci(n): │ │
|
||||
│ 4. GitHub Light │ 3 a, b = 0, 1 │ │
|
||||
│ 5. GitHub Light Colorblind Light (Mat… │ 4 for _ in range(n): │ │
|
||||
│ 6. Google Code Light │ 5 a, b = b, a + b │ │
|
||||
│ 7. Solarized Light │ 6 return a │ │
|
||||
│ 8. Xcode Light │ │ │
|
||||
│ 9. ANSI Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Atom One Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Ayu Dark (Incompatible) │ │ │
|
||||
│ 12. Default Dark (Incompatible) └─────────────────────────────────────────────────┘ │
|
||||
│ 5. Google Code Light │ 4 for _ in range(n): │ │
|
||||
│ 6. Solarized Light │ 5 a, b = b, a + b │ │
|
||||
│ 7. Xcode Light │ 6 return a │ │
|
||||
│ 8. ANSI Dark (Incompatible) │ │ │
|
||||
│ 9. Atom One Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Ayu Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Default Dark (Incompatible) │ │ │
|
||||
│ 12. Dracula Dark (Incompatible) └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
@@ -61,12 +61,12 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
|
||||
│ 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
|
||||
│ 8. Holiday Dark │ │ │
|
||||
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Tokyo Night Dark │ │ │
|
||||
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. ANSI Light │ │ │
|
||||
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
@@ -100,12 +100,12 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
|
||||
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
|
||||
│ 8. Holiday Dark │ │ │
|
||||
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Tokyo Night Dark │ │ │
|
||||
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. ANSI Light │ │ │
|
||||
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
@@ -125,12 +125,12 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
|
||||
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
|
||||
│ 8. Holiday Dark │ │ │
|
||||
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Tokyo Night Dark │ │ │
|
||||
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. ANSI Light │ │ │
|
||||
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ╭─────────────────────────────────────────────────╮ │
|
||||
│ │ DEVELOPER TOOLS (Not visible to users) │ │
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import {
|
||||
BaseSelectionList,
|
||||
@@ -15,10 +14,8 @@ import {
|
||||
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||
import { Text } from 'ink';
|
||||
import type { theme } from '../../semantic-colors.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
vi.mock('../../hooks/useSelectionList.js');
|
||||
vi.mock('../../hooks/useMouseClick.js');
|
||||
|
||||
const mockTheme = {
|
||||
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
|
||||
@@ -38,7 +35,6 @@ describe('BaseSelectionList', () => {
|
||||
const mockOnSelect = vi.fn();
|
||||
const mockOnHighlight = vi.fn();
|
||||
const mockRenderItem = vi.fn();
|
||||
const mockSetActiveIndex = vi.fn();
|
||||
|
||||
const items = [
|
||||
{ value: 'A', label: 'Item A', key: 'A' },
|
||||
@@ -58,7 +54,7 @@ describe('BaseSelectionList', () => {
|
||||
) => {
|
||||
vi.mocked(useSelectionList).mockReturnValue({
|
||||
activeIndex,
|
||||
setActiveIndex: mockSetActiveIndex,
|
||||
setActiveIndex: vi.fn(),
|
||||
});
|
||||
|
||||
mockRenderItem.mockImplementation(
|
||||
@@ -488,79 +484,6 @@ describe('BaseSelectionList', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mouse Interaction', () => {
|
||||
it('should register mouse click handler for each item', async () => {
|
||||
const { unmount } = await renderComponent();
|
||||
|
||||
// items are A, B (disabled), C
|
||||
expect(useMouseClick).toHaveBeenCalledTimes(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should update activeIndex on first click and call onSelect on second click', async () => {
|
||||
const { unmount, waitUntilReady } = await renderComponent();
|
||||
await waitUntilReady();
|
||||
|
||||
// items[0] is 'A' (enabled)
|
||||
// items[1] is 'B' (disabled)
|
||||
// items[2] is 'C' (enabled)
|
||||
|
||||
// Get the mouse click handler for the third item (index 2)
|
||||
const mouseClickHandler = (useMouseClick as Mock).mock.calls[2][1];
|
||||
|
||||
// First click on item C
|
||||
act(() => {
|
||||
mouseClickHandler();
|
||||
});
|
||||
|
||||
expect(mockSetActiveIndex).toHaveBeenCalledWith(2);
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
|
||||
// Now simulate being on item C (isSelected = true)
|
||||
// Rerender or update mocks for the next check
|
||||
await renderComponent({}, 2);
|
||||
|
||||
// Get the updated mouse click handler for item C
|
||||
// useMouseClick is called 3 more times on rerender
|
||||
const updatedMouseClickHandler = (useMouseClick as Mock).mock.calls[5][1];
|
||||
|
||||
// Second click on item C
|
||||
act(() => {
|
||||
updatedMouseClickHandler();
|
||||
});
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('C');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not call onSelect when a disabled item is clicked', async () => {
|
||||
const { unmount, waitUntilReady } = await renderComponent();
|
||||
await waitUntilReady();
|
||||
|
||||
// items[1] is 'B' (disabled)
|
||||
const mouseClickHandler = (useMouseClick as Mock).mock.calls[1][1];
|
||||
|
||||
act(() => {
|
||||
mouseClickHandler();
|
||||
});
|
||||
|
||||
expect(mockSetActiveIndex).not.toHaveBeenCalled();
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should pass isActive: isFocused to useMouseClick', async () => {
|
||||
const { unmount } = await renderComponent({ isFocused: false });
|
||||
|
||||
expect(useMouseClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
{ isActive: false },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll Arrows (showScrollArrows)', () => {
|
||||
const longList = Array.from({ length: 10 }, (_, i) => ({
|
||||
value: `Item ${i + 1}`,
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Text, Box, type DOMElement } from 'ink';
|
||||
import { useState } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
useSelectionList,
|
||||
type SelectionListItem,
|
||||
} from '../../hooks/useSelectionList.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
export interface RenderItemContext {
|
||||
isSelected: boolean;
|
||||
@@ -39,119 +38,6 @@ export interface BaseSelectionListProps<
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface SelectionListItemRowProps<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
> {
|
||||
item: TItem;
|
||||
itemIndex: number;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
showNumbers: boolean;
|
||||
selectedIndicator: string;
|
||||
numberColumnWidth: number;
|
||||
onSelect: (value: T) => void;
|
||||
setActiveIndex: (index: number) => void;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
function SelectionListItemRow<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
>({
|
||||
item,
|
||||
itemIndex,
|
||||
isSelected,
|
||||
isFocused,
|
||||
showNumbers,
|
||||
selectedIndicator,
|
||||
numberColumnWidth,
|
||||
onSelect,
|
||||
setActiveIndex,
|
||||
renderItem,
|
||||
}: SelectionListItemRowProps<T, TItem>) {
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
useMouseClick(
|
||||
containerRef,
|
||||
() => {
|
||||
if (!item.disabled) {
|
||||
if (isSelected) {
|
||||
// Second click on the same item triggers submission
|
||||
onSelect(item.value);
|
||||
} else {
|
||||
// First click highlights the item
|
||||
setActiveIndex(itemIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
let titleColor = theme.text.primary;
|
||||
let numberColor = theme.text.primary;
|
||||
|
||||
if (isSelected) {
|
||||
titleColor = theme.ui.focus;
|
||||
numberColor = theme.ui.focus;
|
||||
} else if (item.disabled) {
|
||||
titleColor = theme.text.secondary;
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!isFocused && !item.disabled) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
key={item.key}
|
||||
alignItems="flex-start"
|
||||
backgroundColor={isSelected ? theme.background.focus : undefined}
|
||||
>
|
||||
{/* Radio button indicator */}
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? theme.ui.focus : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? selectedIndicator : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Item number */}
|
||||
{showNumbers && !item.hideNumber && (
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
aria-state={{ checked: isSelected }}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Custom content via render prop */}
|
||||
<Box flexGrow={1}>
|
||||
{renderItem(item, {
|
||||
isSelected,
|
||||
titleColor,
|
||||
numberColor,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base component for selection lists that provides common UI structure
|
||||
* and keyboard navigation logic via the useSelectionList hook.
|
||||
@@ -184,7 +70,7 @@ export function BaseSelectionList<
|
||||
selectedIndicator = '●',
|
||||
renderItem,
|
||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||
const { activeIndex, setActiveIndex } = useSelectionList({
|
||||
const { activeIndex } = useSelectionList({
|
||||
items,
|
||||
initialIndex,
|
||||
onSelect,
|
||||
@@ -221,12 +107,10 @@ export function BaseSelectionList<
|
||||
);
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
|
||||
const showArrows = showScrollArrows && items.length > maxItemsToShow;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Use conditional coloring instead of conditional rendering */}
|
||||
{showArrows && (
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset > 0
|
||||
@@ -242,24 +126,71 @@ export function BaseSelectionList<
|
||||
const itemIndex = effectiveScrollOffset + index;
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
// Determine colors based on selection and disabled state
|
||||
let titleColor = theme.text.primary;
|
||||
let numberColor = theme.text.primary;
|
||||
|
||||
if (isSelected) {
|
||||
titleColor = theme.ui.focus;
|
||||
numberColor = theme.ui.focus;
|
||||
} else if (item.disabled) {
|
||||
titleColor = theme.text.secondary;
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!isFocused && !item.disabled) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<SelectionListItemRow
|
||||
<Box
|
||||
key={item.key}
|
||||
item={item}
|
||||
itemIndex={itemIndex}
|
||||
isSelected={isSelected}
|
||||
isFocused={isFocused}
|
||||
showNumbers={showNumbers}
|
||||
selectedIndicator={selectedIndicator}
|
||||
numberColumnWidth={numberColumnWidth}
|
||||
onSelect={onSelect}
|
||||
setActiveIndex={setActiveIndex}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
alignItems="flex-start"
|
||||
backgroundColor={isSelected ? theme.background.focus : undefined}
|
||||
>
|
||||
{/* Radio button indicator */}
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? theme.ui.focus : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? selectedIndicator : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Item number */}
|
||||
{showNumbers && !item.hideNumber && (
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
aria-state={{ checked: isSelected }}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Custom content via render prop */}
|
||||
<Box flexGrow={1}>
|
||||
{renderItem(item, {
|
||||
isSelected,
|
||||
titleColor,
|
||||
numberColor,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{showArrows && (
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset + maxItemsToShow < items.length
|
||||
|
||||
@@ -11,17 +11,12 @@ import { act } from 'react';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useTextBuffer, type TextBuffer } from './text-buffer.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useMouseClick.js', () => ({
|
||||
useMouseClick: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./text-buffer.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./text-buffer.js')>();
|
||||
const mockTextBuffer = {
|
||||
@@ -74,7 +69,6 @@ vi.mock('./text-buffer.js', async (importOriginal) => {
|
||||
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
const mockedUseMouseClick = useMouseClick as Mock;
|
||||
|
||||
describe('TextInput', () => {
|
||||
const onCancel = vi.fn();
|
||||
@@ -90,7 +84,6 @@ describe('TextInput', () => {
|
||||
cursor: [0, 0],
|
||||
visualCursor: [0, 0],
|
||||
viewportVisualLines: [''],
|
||||
visualScrollRow: 0,
|
||||
pastedContent: {} as Record<string, string>,
|
||||
handleInput: vi.fn((key) => {
|
||||
if (key.sequence) {
|
||||
@@ -415,36 +408,4 @@ describe('TextInput', () => {
|
||||
expect(lastFrame()).toContain('line2');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('registers mouse click handler for free-form text input', async () => {
|
||||
const { unmount } = await render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
expect(mockedUseMouseClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ isActive: true, name: 'left-press' }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('registers mouse click handler for placeholder view', async () => {
|
||||
mockBuffer.text = '';
|
||||
const { unmount } = await render(
|
||||
<TextInput
|
||||
buffer={mockBuffer}
|
||||
placeholder="test"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockedUseMouseClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ isActive: true, name: 'left-press' }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Text, Box, type DOMElement } from 'ink';
|
||||
import { useCallback } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
@@ -14,7 +14,6 @@ import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
|
||||
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
export interface TextInputProps {
|
||||
buffer: TextBuffer;
|
||||
@@ -32,8 +31,6 @@ export function TextInput({
|
||||
focus = true,
|
||||
}: TextInputProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const {
|
||||
text,
|
||||
handleInput,
|
||||
@@ -43,17 +40,6 @@ export function TextInput({
|
||||
} = buffer;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor;
|
||||
|
||||
useMouseClick(
|
||||
containerRef,
|
||||
(_event, relativeX, relativeY) => {
|
||||
if (focus) {
|
||||
const visRowAbsolute = visualScrollRow + relativeY;
|
||||
buffer.moveToVisualPosition(visRowAbsolute, relativeX);
|
||||
}
|
||||
},
|
||||
{ isActive: focus, name: 'left-press' },
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(key: Key) => {
|
||||
if (key.name === 'escape' && onCancel) {
|
||||
@@ -78,7 +64,7 @@ export function TextInput({
|
||||
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<Box ref={containerRef}>
|
||||
<Box>
|
||||
{focus ? (
|
||||
<Text terminalCursorFocus={focus} terminalCursorPosition={0}>
|
||||
{chalk.inverse(placeholder[0] || ' ')}
|
||||
@@ -92,7 +78,7 @@ export function TextInput({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{viewportVisualLines.map((lineText, idx) => {
|
||||
const currentVisualRow = visualScrollRow + idx;
|
||||
const isCursorLine =
|
||||
|
||||
@@ -858,81 +858,11 @@ describe('useSlashCommandProcessor', () => {
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('removes the IDE status listener on unmount after async initialization', async () => {
|
||||
let resolveIdeClient:
|
||||
| ((client: {
|
||||
addStatusChangeListener: (listener: () => void) => void;
|
||||
removeStatusChangeListener: (listener: () => void) => void;
|
||||
}) => void)
|
||||
| undefined;
|
||||
const addStatusChangeListener = vi.fn();
|
||||
const removeStatusChangeListener = vi.fn();
|
||||
|
||||
mockIdeClientGetInstance.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveIdeClient = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await setupProcessorHook();
|
||||
|
||||
await act(async () => {
|
||||
resolveIdeClient?.({
|
||||
addStatusChangeListener,
|
||||
removeStatusChangeListener,
|
||||
});
|
||||
});
|
||||
|
||||
result.unmount();
|
||||
unmountHook = undefined;
|
||||
|
||||
expect(addStatusChangeListener).toHaveBeenCalledTimes(1);
|
||||
expect(removeStatusChangeListener).toHaveBeenCalledTimes(1);
|
||||
expect(removeStatusChangeListener).toHaveBeenCalledWith(
|
||||
addStatusChangeListener.mock.calls[0]?.[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('does not register an IDE status listener if unmounted before async initialization resolves', async () => {
|
||||
let resolveIdeClient:
|
||||
| ((client: {
|
||||
addStatusChangeListener: (listener: () => void) => void;
|
||||
removeStatusChangeListener: (listener: () => void) => void;
|
||||
}) => void)
|
||||
| undefined;
|
||||
const addStatusChangeListener = vi.fn();
|
||||
const removeStatusChangeListener = vi.fn();
|
||||
|
||||
mockIdeClientGetInstance.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveIdeClient = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await setupProcessorHook();
|
||||
|
||||
result.unmount();
|
||||
unmountHook = undefined;
|
||||
|
||||
await act(async () => {
|
||||
resolveIdeClient?.({
|
||||
addStatusChangeListener,
|
||||
removeStatusChangeListener,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStatusChangeListener).not.toHaveBeenCalled();
|
||||
expect(removeStatusChangeListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should abort command loading when the hook unmounts', async () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
|
||||
const { unmount } = await setupProcessorHook();
|
||||
|
||||
unmount();
|
||||
unmountHook = undefined;
|
||||
|
||||
expect(abortSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -281,16 +281,10 @@ export const useSlashCommandProcessor = (
|
||||
const listener = () => {
|
||||
reloadCommands();
|
||||
};
|
||||
let isActive = true;
|
||||
let activeIdeClient: IdeClient | undefined;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
(async () => {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
activeIdeClient = ideClient;
|
||||
ideClient.addStatusChangeListener(listener);
|
||||
})();
|
||||
|
||||
@@ -313,8 +307,11 @@ export const useSlashCommandProcessor = (
|
||||
coreEvents.on('extensionsStopping', extensionEventListener);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
activeIdeClient?.removeStatusChangeListener(listener);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
(async () => {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
ideClient.removeStatusChangeListener(listener);
|
||||
})();
|
||||
removeMCPStatusChangeListener(listener);
|
||||
coreEvents.off('extensionsStarting', extensionEventListener);
|
||||
coreEvents.off('extensionsStopping', extensionEventListener);
|
||||
|
||||
@@ -76,23 +76,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
isBinary: mockIsBinary,
|
||||
};
|
||||
});
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
const mocked = {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
createWriteStream: vi.fn(),
|
||||
promises: {
|
||||
...actual.promises,
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
return {
|
||||
...mocked,
|
||||
default: mocked,
|
||||
};
|
||||
});
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
const mocked = {
|
||||
@@ -160,10 +144,6 @@ describe('useExecutionLifecycle', () => {
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 40000,
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp/project',
|
||||
},
|
||||
} as unknown as Config;
|
||||
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
|
||||
|
||||
@@ -175,16 +155,6 @@ describe('useExecutionLifecycle', () => {
|
||||
mockIsBinary.mockReturnValue(false);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue({
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb: () => void) => {
|
||||
if (cb) cb();
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
bytesWritten: 0,
|
||||
closed: false,
|
||||
} as unknown as fs.WriteStream);
|
||||
|
||||
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
|
||||
mockShellOutputCallback = callback;
|
||||
return Promise.resolve({
|
||||
@@ -676,7 +646,7 @@ describe('useExecutionLifecycle', () => {
|
||||
});
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
|
||||
// Verify that the temporary file was cleaned up
|
||||
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -704,7 +674,7 @@ describe('useExecutionLifecycle', () => {
|
||||
expect(finalHistoryItem.tools[0].resultDisplay).toContain(
|
||||
"WARNING: shell mode is stateless; the directory change to '/test/dir/new' will not persist.",
|
||||
);
|
||||
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
});
|
||||
|
||||
it('should NOT show a warning if the directory does not change', async () => {
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
ShellExecutionService,
|
||||
ExecutionLifecycleService,
|
||||
CoreToolCallStatus,
|
||||
moveToolOutputToFile,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -41,15 +39,16 @@ export { type BackgroundTask };
|
||||
|
||||
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||
const RESTORE_VISIBILITY_DELAY_MS = 300;
|
||||
const MAX_OUTPUT_LENGTH = 10000;
|
||||
|
||||
function addShellCommandToGeminiHistory(
|
||||
geminiClient: GeminiClient,
|
||||
rawQuery: string,
|
||||
resultText: string,
|
||||
maxOutputLength: number,
|
||||
) {
|
||||
const modelContent =
|
||||
maxOutputLength > 0 && resultText.length > maxOutputLength
|
||||
? resultText.substring(0, maxOutputLength) + '\n... (truncated)'
|
||||
resultText.length > MAX_OUTPUT_LENGTH
|
||||
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
|
||||
: resultText;
|
||||
|
||||
// Escape backticks to prevent prompt injection breakouts
|
||||
@@ -425,9 +424,6 @@ export const useExecutionLifecycle = (
|
||||
let shouldUpdate = false;
|
||||
|
||||
switch (event.type) {
|
||||
case 'raw_data':
|
||||
case 'file_data':
|
||||
break;
|
||||
case 'data':
|
||||
if (isBinaryStream) break;
|
||||
if (typeof event.chunk === 'string') {
|
||||
@@ -537,24 +533,6 @@ export const useExecutionLifecycle = (
|
||||
} else {
|
||||
mainContent =
|
||||
result.output.trim() || '(Command produced no output)';
|
||||
if (result.fullOutputFilePath) {
|
||||
const { outputFile: savedPath } = await moveToolOutputToFile(
|
||||
result.fullOutputFilePath,
|
||||
SHELL_COMMAND_NAME,
|
||||
callId,
|
||||
config.storage.getProjectTempDir(),
|
||||
config.getSessionId(),
|
||||
);
|
||||
const warning = `[Full command output saved to: ${savedPath}]`;
|
||||
mainContent = mainContent.includes(
|
||||
'[GEMINI_CLI_WARNING: Output truncated.',
|
||||
)
|
||||
? mainContent.replace(
|
||||
/\[GEMINI_CLI_WARNING: Output truncated\..*?\]/,
|
||||
warning,
|
||||
)
|
||||
: `${mainContent}\n\n${warning}`;
|
||||
}
|
||||
}
|
||||
|
||||
let finalOutput: string | AnsiOutput =
|
||||
@@ -639,12 +617,7 @@ export const useExecutionLifecycle = (
|
||||
);
|
||||
}
|
||||
|
||||
addShellCommandToGeminiHistory(
|
||||
geminiClient,
|
||||
rawQuery,
|
||||
mainContent,
|
||||
config.getTruncateToolOutputThreshold(),
|
||||
);
|
||||
addShellCommandToGeminiHistory(geminiClient, rawQuery, mainContent);
|
||||
} catch (err) {
|
||||
setPendingHistoryItem(null);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -657,13 +630,8 @@ export const useExecutionLifecycle = (
|
||||
);
|
||||
} finally {
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
if (pwdFilePath) {
|
||||
fs.promises.unlink(pwdFilePath).catch((err) => {
|
||||
debugLogger.warn(
|
||||
`Failed to cleanup pwd file: ${pwdFilePath}`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||
fs.unlinkSync(pwdFilePath);
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ColorsTheme, Theme } from '../../theme.js';
|
||||
import { interpolateColor } from '../../color-utils.js';
|
||||
|
||||
const githubDarkColorblindColors: ColorsTheme = {
|
||||
type: 'dark',
|
||||
Background: '#0d1117',
|
||||
Foreground: '#e6edf3',
|
||||
LightBlue: '#a5d6ff',
|
||||
AccentBlue: '#79c0ff',
|
||||
AccentPurple: '#d2a8ff',
|
||||
AccentCyan: '#a5d6ff',
|
||||
AccentGreen: '#a5d6ff',
|
||||
AccentYellow: '#d29922',
|
||||
AccentRed: '#f0883e',
|
||||
DiffAdded: '#0d161f',
|
||||
DiffRemoved: '#1d150e',
|
||||
Comment: '#7d8590',
|
||||
Gray: '#7d8590',
|
||||
DarkGray: interpolateColor('#7d8590', '#0d1117', 0.5),
|
||||
GradientColors: ['#58a6ff', '#f0883e'],
|
||||
};
|
||||
|
||||
export const GitHubDarkColorblind: Theme = new Theme(
|
||||
'GitHub Dark Colorblind',
|
||||
'dark',
|
||||
{
|
||||
hljs: {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
padding: '0.5em',
|
||||
color: githubDarkColorblindColors.Foreground,
|
||||
background: githubDarkColorblindColors.Background,
|
||||
},
|
||||
'hljs-comment': {
|
||||
color: githubDarkColorblindColors.Comment,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: githubDarkColorblindColors.Comment,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: githubDarkColorblindColors.AccentRed,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: githubDarkColorblindColors.AccentRed,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-subst': {
|
||||
color: githubDarkColorblindColors.Foreground,
|
||||
},
|
||||
'hljs-number': {
|
||||
color: githubDarkColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: githubDarkColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: githubDarkColorblindColors.Foreground,
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: githubDarkColorblindColors.Foreground,
|
||||
},
|
||||
'hljs-tag .hljs-attr': {
|
||||
color: githubDarkColorblindColors.AccentYellow,
|
||||
},
|
||||
'hljs-string': {
|
||||
color: githubDarkColorblindColors.AccentCyan,
|
||||
},
|
||||
'hljs-doctag': {
|
||||
color: githubDarkColorblindColors.AccentCyan,
|
||||
},
|
||||
'hljs-title': {
|
||||
color: githubDarkColorblindColors.AccentPurple,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: githubDarkColorblindColors.AccentPurple,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-selector-id': {
|
||||
color: githubDarkColorblindColors.AccentPurple,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: githubDarkColorblindColors.AccentGreen,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-class .hljs-title': {
|
||||
color: githubDarkColorblindColors.AccentGreen,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-tag': {
|
||||
color: githubDarkColorblindColors.AccentGreen,
|
||||
},
|
||||
'hljs-name': {
|
||||
color: githubDarkColorblindColors.AccentGreen,
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: githubDarkColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-regexp': {
|
||||
color: githubDarkColorblindColors.AccentCyan,
|
||||
},
|
||||
'hljs-link': {
|
||||
color: githubDarkColorblindColors.AccentCyan,
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: githubDarkColorblindColors.AccentPurple,
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: githubDarkColorblindColors.AccentPurple,
|
||||
},
|
||||
'hljs-built_in': {
|
||||
color: githubDarkColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-builtin-name': {
|
||||
color: githubDarkColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: githubDarkColorblindColors.LightBlue,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
background: '#682d0f',
|
||||
color: githubDarkColorblindColors.AccentRed,
|
||||
},
|
||||
'hljs-addition': {
|
||||
background: '#0c2d6b',
|
||||
color: githubDarkColorblindColors.AccentGreen,
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-strong': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
githubDarkColorblindColors,
|
||||
);
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ColorsTheme, Theme } from '../../theme.js';
|
||||
import { interpolateColor } from '../../color-utils.js';
|
||||
|
||||
const githubLightColorblindColors: ColorsTheme = {
|
||||
type: 'light',
|
||||
Background: '#ffffff',
|
||||
Foreground: '#1f2328',
|
||||
LightBlue: '#0a3069',
|
||||
AccentBlue: '#0550ae',
|
||||
AccentPurple: '#8250df',
|
||||
AccentCyan: '#0a3069',
|
||||
AccentGreen: '#0969da',
|
||||
AccentYellow: '#9a6700',
|
||||
AccentRed: '#bc4c00',
|
||||
DiffAdded: '#ddf4ff',
|
||||
DiffRemoved: '#fff1e5',
|
||||
Comment: '#656d76',
|
||||
Gray: '#656d76',
|
||||
DarkGray: interpolateColor('#656d76', '#ffffff', 0.5),
|
||||
GradientColors: ['#0969da', '#bc4c00'],
|
||||
};
|
||||
|
||||
export const GitHubLightColorblind: Theme = new Theme(
|
||||
'GitHub Light Colorblind',
|
||||
'light',
|
||||
{
|
||||
hljs: {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
padding: '0.5em',
|
||||
color: githubLightColorblindColors.Foreground,
|
||||
background: githubLightColorblindColors.Background,
|
||||
},
|
||||
'hljs-comment': {
|
||||
color: githubLightColorblindColors.Comment,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: githubLightColorblindColors.Comment,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: githubLightColorblindColors.AccentRed,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: githubLightColorblindColors.AccentRed,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-subst': {
|
||||
color: githubLightColorblindColors.Foreground,
|
||||
},
|
||||
'hljs-number': {
|
||||
color: githubLightColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: githubLightColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: githubLightColorblindColors.Foreground,
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: githubLightColorblindColors.Foreground,
|
||||
},
|
||||
'hljs-tag .hljs-attr': {
|
||||
color: githubLightColorblindColors.AccentYellow,
|
||||
},
|
||||
'hljs-string': {
|
||||
color: githubLightColorblindColors.AccentCyan,
|
||||
},
|
||||
'hljs-doctag': {
|
||||
color: githubLightColorblindColors.AccentCyan,
|
||||
},
|
||||
'hljs-title': {
|
||||
color: githubLightColorblindColors.AccentPurple,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: githubLightColorblindColors.AccentPurple,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-selector-id': {
|
||||
color: githubLightColorblindColors.AccentPurple,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: githubLightColorblindColors.AccentGreen,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-class .hljs-title': {
|
||||
color: githubLightColorblindColors.AccentGreen,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-tag': {
|
||||
color: githubLightColorblindColors.AccentGreen,
|
||||
},
|
||||
'hljs-name': {
|
||||
color: githubLightColorblindColors.AccentGreen,
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: githubLightColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-regexp': {
|
||||
color: githubLightColorblindColors.AccentCyan,
|
||||
},
|
||||
'hljs-link': {
|
||||
color: githubLightColorblindColors.AccentCyan,
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: githubLightColorblindColors.AccentPurple,
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: githubLightColorblindColors.AccentPurple,
|
||||
},
|
||||
'hljs-built_in': {
|
||||
color: githubLightColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-builtin-name': {
|
||||
color: githubLightColorblindColors.LightBlue,
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: githubLightColorblindColors.LightBlue,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
background: '#fff1e5',
|
||||
color: githubLightColorblindColors.AccentRed,
|
||||
},
|
||||
'hljs-addition': {
|
||||
background: '#ddf4ff',
|
||||
color: githubLightColorblindColors.AccentGreen,
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-strong': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
githubLightColorblindColors,
|
||||
);
|
||||
@@ -10,8 +10,6 @@ import { AtomOneDark } from './builtin/dark/atom-one-dark.js';
|
||||
import { Dracula } from './builtin/dark/dracula-dark.js';
|
||||
import { GitHubDark } from './builtin/dark/github-dark.js';
|
||||
import { GitHubLight } from './builtin/light/github-light.js';
|
||||
import { GitHubDarkColorblind } from './builtin/dark/github-dark-colorblind.js';
|
||||
import { GitHubLightColorblind } from './builtin/light/github-light-colorblind.js';
|
||||
import { GoogleCode } from './builtin/light/googlecode-light.js';
|
||||
import { Holiday } from './builtin/dark/holiday-dark.js';
|
||||
import { DefaultLight } from './builtin/light/default-light.js';
|
||||
@@ -81,8 +79,6 @@ class ThemeManager {
|
||||
DefaultDark,
|
||||
GitHubDark,
|
||||
GitHubLight,
|
||||
GitHubDarkColorblind,
|
||||
GitHubLightColorblind,
|
||||
GoogleCode,
|
||||
Holiday,
|
||||
ShadesOfPurple,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -20,8 +20,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"vendor"
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -32,6 +31,7 @@
|
||||
"@google/genai": "1.30.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.211.0",
|
||||
@@ -59,7 +59,6 @@
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"execa": "^9.6.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SkillExtractionAgent } from './skill-extraction-agent.js';
|
||||
import {
|
||||
EDIT_TOOL_NAME,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
|
||||
describe('SkillExtractionAgent', () => {
|
||||
const skillsDir = '/tmp/skills';
|
||||
const sessionIndex =
|
||||
'[NEW] Debug login flow (12 user msgs) — /tmp/chats/session-1.json';
|
||||
const existingSkillsSummary =
|
||||
'## Workspace Skills (.gemini/skills — do NOT duplicate)\n- **existing-skill**: Existing description';
|
||||
|
||||
const agent = SkillExtractionAgent(
|
||||
skillsDir,
|
||||
sessionIndex,
|
||||
existingSkillsSummary,
|
||||
);
|
||||
|
||||
it('should expose expected metadata, model, and tools', () => {
|
||||
expect(agent.kind).toBe('local');
|
||||
expect(agent.name).toBe('confucius');
|
||||
expect(agent.displayName).toBe('Skill Extractor');
|
||||
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
expect(agent.toolConfig?.tools).toEqual(
|
||||
expect.arrayContaining([
|
||||
READ_FILE_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to no skill unless recurrence and durability are proven', () => {
|
||||
const prompt = agent.promptConfig.systemPrompt;
|
||||
|
||||
expect(prompt).toContain('Default to NO SKILL.');
|
||||
expect(prompt).toContain(
|
||||
'strong evidence this will recur for future agents in this repo/workflow',
|
||||
);
|
||||
expect(prompt).toContain('broader than a single incident');
|
||||
expect(prompt).toContain('A skill MUST meet ALL of these criteria:');
|
||||
expect(prompt).toContain(
|
||||
'Future agents in this repo/workflow are likely to need it',
|
||||
);
|
||||
});
|
||||
|
||||
it('should explicitly reject one-off incidents and single-session preferences', () => {
|
||||
const prompt = agent.promptConfig.systemPrompt;
|
||||
|
||||
expect(prompt).toContain('Single-session preferences');
|
||||
expect(prompt).toContain('One-off incidents');
|
||||
expect(prompt).toContain('Output-style preferences');
|
||||
expect(prompt).toContain('cannot survive renaming the specific');
|
||||
});
|
||||
|
||||
it('should warn that session summaries are user-intent summaries, not workflow evidence', () => {
|
||||
const query = agent.promptConfig.query ?? '';
|
||||
|
||||
expect(query).toContain(existingSkillsSummary);
|
||||
expect(query).toContain(sessionIndex);
|
||||
expect(query).toContain(
|
||||
'The summary is a user-intent summary, not a workflow summary.',
|
||||
);
|
||||
expect(query).toContain(
|
||||
'The session summaries describe user intent, not workflow details.',
|
||||
);
|
||||
expect(query).toContain(
|
||||
'Only write a skill if the evidence shows a durable, recurring workflow',
|
||||
);
|
||||
expect(query).toContain(
|
||||
'If recurrence or future reuse is unclear, create no skill and explain why.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,11 @@
|
||||
import { z } from 'zod';
|
||||
import type { LocalAgentDefinition } from './types.js';
|
||||
import {
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
@@ -38,7 +36,7 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'- solve similar tasks with fewer tool calls and fewer reasoning tokens',
|
||||
'- reuse proven workflows and verification checklists',
|
||||
'- avoid known failure modes and landmines',
|
||||
'- capture durable workflow constraints that future agents are likely to encounter again',
|
||||
'- anticipate user preferences without being reminded',
|
||||
'',
|
||||
'============================================================',
|
||||
'SAFETY AND HYGIENE (STRICT)',
|
||||
@@ -61,10 +59,6 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'1. "Is this something a competent agent would NOT already know?" If no, STOP.',
|
||||
'2. "Does an existing skill (listed below) already cover this?" If yes, STOP.',
|
||||
'3. "Can I write a concrete, step-by-step procedure?" If no, STOP.',
|
||||
'4. "Is there strong evidence this will recur for future agents in this repo/workflow?" If no, STOP.',
|
||||
'5. "Is this broader than a single incident (one bug, one ticket, one branch, one date, one exact error)?" If no, STOP.',
|
||||
'',
|
||||
'Default to NO SKILL.',
|
||||
'',
|
||||
'Do NOT create skills for:',
|
||||
'',
|
||||
@@ -73,10 +67,6 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'- **Pure Q&A**: The user asked "how does X work?" and got an answer. No procedure.',
|
||||
'- **Brainstorming/design**: Discussion of how to build something, without a validated',
|
||||
' implementation that produced a reusable procedure.',
|
||||
'- **Single-session preferences**: User-specific style/output preferences or workflow',
|
||||
' preferences mentioned only once.',
|
||||
'- **One-off incidents**: Debugging or incident response tied to a single bug, ticket,',
|
||||
' branch, date, or exact error string.',
|
||||
'- **Anything already covered by an existing skill** (global, workspace, builtin, or',
|
||||
' previously extracted). Check the "Existing Skills" section carefully.',
|
||||
'',
|
||||
@@ -84,40 +74,31 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'WHAT COUNTS AS A SKILL',
|
||||
'============================================================',
|
||||
'',
|
||||
'A skill MUST meet ALL of these criteria:',
|
||||
'A skill MUST meet BOTH of these criteria:',
|
||||
'',
|
||||
'1. **Procedural and concrete**: It can be expressed as numbered steps with specific',
|
||||
' commands, paths, or code patterns. If you can only write vague guidance, it is NOT',
|
||||
' a skill. "Be careful with X" is advice, not a skill.',
|
||||
'',
|
||||
'2. **Durable and reusable**: Future agents in this repo/workflow are likely to need it',
|
||||
' again. If it only solved one incident, it is NOT a skill.',
|
||||
'2. **Non-obvious and project-specific**: A competent agent would NOT already know this.',
|
||||
' It encodes project-specific knowledge, non-obvious ordering constraints, or',
|
||||
' hard-won failure shields that cannot be inferred from the codebase alone.',
|
||||
'',
|
||||
'3. **Evidence-backed and project-specific**: It encodes project-specific knowledge,',
|
||||
' repeated operational constraints, or hard-won failure shields supported by session',
|
||||
' evidence. Do not assume something is non-obvious just because it sounds detailed.',
|
||||
'Confidence tiers (prefer higher tiers):',
|
||||
'',
|
||||
'Confidence tiers:',
|
||||
'**High confidence** — create the skill:',
|
||||
'- The same workflow appeared in multiple sessions (cross-session repetition)',
|
||||
'- A multi-step procedure was validated (tests passed, user confirmed success)',
|
||||
'',
|
||||
'**High confidence** — create the skill only when recurrence/durability is clear:',
|
||||
'- The same workflow appeared in multiple sessions (cross-session repetition), OR it is',
|
||||
' a stable recurring repo workflow (for example setup/build/test/deploy/release) with a',
|
||||
' clear future trigger',
|
||||
'- The workflow was validated (tests passed, user confirmed success, or the same fix',
|
||||
' worked repeatedly)',
|
||||
'- The skill can be named without referencing a specific incident, bug, branch, or date',
|
||||
'',
|
||||
'**Medium confidence** — usually do NOT create the skill yet:',
|
||||
'- A project-specific procedure appeared once and seems useful, but recurrence is not yet',
|
||||
' clear',
|
||||
'- A verified fix exists, but it is still tied to one incident',
|
||||
'- A user correction changed the approach once, but durability is uncertain',
|
||||
'**Medium confidence** — create the skill if it is clearly project-specific:',
|
||||
'- A project-specific build/test/deploy/release procedure was established',
|
||||
'- A non-obvious ordering constraint or prerequisite was discovered',
|
||||
'- A failure mode was hit and a concrete fix was found and verified',
|
||||
'',
|
||||
'**Low confidence** — do NOT create the skill:',
|
||||
'- A one-off debugging session with no reusable procedure',
|
||||
'- Generic workflows any agent could figure out from the codebase',
|
||||
'- A code review or investigation with no durable takeaway',
|
||||
'- Output-style preferences that do not materially change procedure',
|
||||
'',
|
||||
'Aim for 0-2 skills per run. Quality over quantity.',
|
||||
'',
|
||||
@@ -136,10 +117,8 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'',
|
||||
'What to look for:',
|
||||
'',
|
||||
'- User corrections that change procedure in a durable way, especially when repeated',
|
||||
' across sessions',
|
||||
'- User corrections: "No, do it this way" -> preference signal',
|
||||
'- Repeated patterns across sessions: same commands, same file paths, same workflow',
|
||||
'- Stable recurring repo lifecycle workflows with clear future triggers',
|
||||
'- Failed attempts followed by successful ones -> failure shield',
|
||||
'- Multi-step procedures that were validated (tests passed, user confirmed)',
|
||||
'- User interruptions: "Stop, you need to X first" -> ordering constraint',
|
||||
@@ -150,8 +129,45 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'- Tool outputs that are just data (file contents, search results)',
|
||||
'- Speculative plans that were never executed',
|
||||
"- Temporary context (current branch name, today's date, specific error IDs)",
|
||||
'- Similar session summaries without matching workflow evidence',
|
||||
'- One-off artifact names: bug IDs, branch names, timestamps, exact incident strings',
|
||||
'',
|
||||
'============================================================',
|
||||
'SKILL FORMAT',
|
||||
'============================================================',
|
||||
'',
|
||||
'Each skill is a directory containing a SKILL.md file with YAML frontmatter',
|
||||
'and optional supporting scripts.',
|
||||
'',
|
||||
'Directory structure:',
|
||||
` ${skillsDir}/<skill-name>/`,
|
||||
' SKILL.md # Required entrypoint',
|
||||
' scripts/<tool>.* # Optional helper scripts (Python stdlib-only or shell)',
|
||||
'',
|
||||
'SKILL.md structure:',
|
||||
'',
|
||||
' ---',
|
||||
' name: <skill-name>',
|
||||
' description: <1-2 lines; include concrete triggers in user-like language>',
|
||||
' ---',
|
||||
'',
|
||||
' ## When to Use',
|
||||
' <Clear trigger conditions and non-goals>',
|
||||
'',
|
||||
' ## Procedure',
|
||||
' <Numbered steps with specific commands, paths, code patterns>',
|
||||
'',
|
||||
' ## Pitfalls and Fixes',
|
||||
' <symptom -> likely cause -> fix; only include observed failures>',
|
||||
'',
|
||||
' ## Verification',
|
||||
' <Concrete success checks>',
|
||||
'',
|
||||
'Supporting scripts (optional but recommended when applicable):',
|
||||
'- Put helper scripts in scripts/ and reference them from SKILL.md',
|
||||
'- Prefer Python (stdlib only) or small shell scripts',
|
||||
'- Make scripts safe: no destructive actions, no secrets, deterministic output',
|
||||
'- Include a usage example in SKILL.md',
|
||||
'',
|
||||
'Naming: kebab-case (e.g., fix-lint-errors, run-migrations).',
|
||||
'',
|
||||
'============================================================',
|
||||
'UPDATING EXISTING SKILLS (PATCHES)',
|
||||
@@ -198,10 +214,7 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'- Keep scopes distinct. Avoid overlapping "do-everything" skills.',
|
||||
'- Every skill MUST have: triggers, procedure, at least one pitfall or verification step.',
|
||||
'- If you cannot write a reliable procedure (too many unknowns), do NOT create the skill.',
|
||||
'- If the candidate is tied to one incident or cannot survive renaming the specific',
|
||||
' bug/ticket, do NOT create it.',
|
||||
'- Do not create skills for generic advice, output-style preferences, or ephemeral',
|
||||
' choices that any competent agent would already know or adapt to on the fly.',
|
||||
'- Do not create skills for generic advice that any competent agent would already know.',
|
||||
'- Prefer fewer, higher-quality skills. 0-2 skills per run is typical. 3+ is unusual.',
|
||||
'',
|
||||
'============================================================',
|
||||
@@ -210,32 +223,18 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'',
|
||||
`1. Use list_directory on ${skillsDir} to see existing skills.`,
|
||||
'2. If skills exist, read their SKILL.md files to understand what is already captured.',
|
||||
'3. Use activate_skill to load the "skill-creator" skill. Follow its design guidance',
|
||||
' (conciseness, progressive disclosure, frontmatter format, bundled resources) when',
|
||||
' writing SKILL.md files. You may also use its init_skill.cjs script to scaffold new',
|
||||
' skill directories and package_skill.cjs to validate finished skills.',
|
||||
' IMPORTANT: You are a background agent with no user interaction. Skip any interactive',
|
||||
' steps in the skill-creator guide (asking clarifying questions, requesting user feedback,',
|
||||
' installation prompts, iteration loops). Use only its format and quality guidance.',
|
||||
'4. Scan the session index provided in the query. Look for [NEW] sessions whose summaries',
|
||||
' hint at workflows that ALSO appear in other sessions (either [NEW] or [old]) or at a',
|
||||
' stable recurring repo workflow. Remember: summary similarity alone is NOT enough.',
|
||||
'5. Apply the minimum signal gate. If recurrence or durability is not visible, report that',
|
||||
' no skill should be created and finish.',
|
||||
'6. For promising patterns, use read_file on the session file paths to inspect the full',
|
||||
' conversation. Confirm the workflow was actually repeated and validated. Read at least',
|
||||
' two sessions unless the candidate is clearly a stable recurring repo lifecycle workflow.',
|
||||
'7. For each candidate, verify it meets ALL criteria. Before writing, make sure you can',
|
||||
' state: future trigger, evidence sessions, recurrence signal, validation signal, and',
|
||||
' why it is not generic.',
|
||||
'8. Write new SKILL.md files or update existing ones in your directory.',
|
||||
' Use run_shell_command to run init_skill.cjs for scaffolding and package_skill.cjs for validation.',
|
||||
'3. Scan the session index provided in the query. Look for [NEW] sessions whose summaries',
|
||||
' suggest workflows that ALSO appear in other sessions (either [NEW] or [old]).',
|
||||
'4. Apply the minimum signal gate. If no repeated patterns are visible, report that and finish.',
|
||||
'5. For promising patterns, use read_file on the session file paths to inspect the full',
|
||||
' conversation. Confirm the workflow was actually repeated and validated.',
|
||||
'6. For each confirmed skill, verify it meets ALL criteria (repeatable, procedural, high-leverage).',
|
||||
'7. Write new SKILL.md files or update existing ones in your directory using write_file.',
|
||||
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
|
||||
'9. Write COMPLETE files — never partially update a SKILL.md.',
|
||||
'8. Write COMPLETE files — never partially update a SKILL.md.',
|
||||
'',
|
||||
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
|
||||
'repeated pattern or a stable recurring repo workflow worth investigating. Most runs',
|
||||
'should read 0-3 sessions and create 0 skills.',
|
||||
'repeated pattern worth investigating. Most runs should read 0-3 sessions and create 0 skills.',
|
||||
'Do not explore the codebase. Work only with the session index, session files, and the skills directory.',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -245,9 +244,8 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
* writes reusable SKILL.md files to the project memory directory.
|
||||
*
|
||||
* This agent is designed to run in the background on session startup.
|
||||
* It has restricted tool access (file tools, shell, and skill activation — no
|
||||
* user interaction) and is prompted to only operate within the skills memory
|
||||
* directory.
|
||||
* It has restricted tool access (file tools only, no shell or user interaction)
|
||||
* and is prompted to only operate within the skills memory directory.
|
||||
*/
|
||||
export const SkillExtractionAgent = (
|
||||
skillsDir: string,
|
||||
@@ -281,14 +279,12 @@ export const SkillExtractionAgent = (
|
||||
},
|
||||
toolConfig: {
|
||||
tools: [
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
],
|
||||
},
|
||||
get promptConfig() {
|
||||
@@ -305,9 +301,6 @@ export const SkillExtractionAgent = (
|
||||
'Below is an index of past conversation sessions. Each line shows:',
|
||||
'[NEW] or [old] status, a 1-line summary, message count, and the file path.',
|
||||
'',
|
||||
'The summary is a user-intent summary, not a workflow summary.',
|
||||
'Matching summary text alone is never enough evidence for a reusable skill.',
|
||||
'',
|
||||
'[NEW] = not yet processed for skill extraction (focus on these)',
|
||||
'[old] = previously processed (read only if a [NEW] session hints at a repeated pattern)',
|
||||
'',
|
||||
@@ -326,7 +319,7 @@ export const SkillExtractionAgent = (
|
||||
|
||||
return {
|
||||
systemPrompt: buildSystemPrompt(skillsDir),
|
||||
query: `${initialContext}\n\nAnalyze the session index above. The session summaries describe user intent, not workflow details. Read sessions that suggest repeated workflows using read_file. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. If recurrence or future reuse is unclear, create no skill and explain why.`,
|
||||
query: `${initialContext}\n\nAnalyze the session index above. Read sessions that suggest repeated workflows using read_file. Extract reusable skills to ${skillsDir}/.`,
|
||||
};
|
||||
},
|
||||
runConfig: {
|
||||
|
||||
@@ -3552,7 +3552,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
registry.registerTool(new RipGrepTool(this, this.messageBus)),
|
||||
);
|
||||
} else {
|
||||
debugLogger.warn(`Ripgrep is not available. Falling back to GrepTool.`);
|
||||
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
|
||||
maybeRegister(GrepTool, () =>
|
||||
registry.registerTool(new GrepTool(this, this.messageBus)),
|
||||
|
||||
@@ -661,82 +661,4 @@ describe('ToolOutputMaskingService', () => {
|
||||
)['output'],
|
||||
).toContain(MASKING_INDICATOR_TAG);
|
||||
});
|
||||
|
||||
it('should use existing outputFile if available in the tool response', async () => {
|
||||
// Setup: Create a large history to trigger masking
|
||||
const largeContent = 'a'.repeat(60000);
|
||||
const existingOutputFile = path.join(testTempDir, 'truly_full_output.txt');
|
||||
await fs.promises.writeFile(existingOutputFile, 'truly full content');
|
||||
|
||||
const history: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Old turn' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'shell',
|
||||
id: 'call-1',
|
||||
response: {
|
||||
output: largeContent,
|
||||
outputFile: existingOutputFile,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Protection buffer
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'padding',
|
||||
response: { output: 'B'.repeat(60000) },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Newest turn' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => {
|
||||
const resp = parts[0].functionResponse?.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const content = (resp?.['output'] as string) ?? JSON.stringify(resp);
|
||||
if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100;
|
||||
|
||||
const name = parts[0].functionResponse?.name;
|
||||
if (name === 'shell') return 60000;
|
||||
if (name === 'padding') return 60000;
|
||||
return 10;
|
||||
});
|
||||
|
||||
// Trigger masking
|
||||
const result = await service.mask(history, mockConfig);
|
||||
|
||||
expect(result.maskedCount).toBe(2);
|
||||
const maskedPart = result.newHistory[1].parts![0];
|
||||
const maskedResponse = maskedPart.functionResponse?.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const maskedOutput = maskedResponse['output'] as string;
|
||||
|
||||
// Verify the masked snippet points to the existing file
|
||||
expect(maskedOutput).toContain(
|
||||
`Full output available at: ${existingOutputFile}`,
|
||||
);
|
||||
|
||||
// Verify the path in maskedOutput is exactly the one we provided
|
||||
expect(maskedOutput).toContain(existingOutputFile);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,18 +48,6 @@ export interface MaskingResult {
|
||||
tokensSaved: number;
|
||||
}
|
||||
|
||||
interface HasOutputFile {
|
||||
outputFile: string;
|
||||
}
|
||||
|
||||
function hasOutputFile(obj: unknown): obj is HasOutputFile {
|
||||
if (typeof obj !== 'object' || obj === null || !('outputFile' in obj)) {
|
||||
return false;
|
||||
}
|
||||
const val = (obj as Record<string, unknown>)['outputFile'];
|
||||
return typeof val === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to manage context window efficiency by masking bulky tool outputs (Tool Output Masking).
|
||||
*
|
||||
@@ -194,44 +182,25 @@ export class ToolOutputMaskingService {
|
||||
|
||||
const toolName = part.functionResponse.name || 'unknown_tool';
|
||||
const callId = part.functionResponse.id || Date.now().toString();
|
||||
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
|
||||
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
|
||||
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
|
||||
.toString(36)
|
||||
.substring(7)}.txt`;
|
||||
const filePath = path.join(toolOutputsDir, fileName);
|
||||
|
||||
await fsPromises.writeFile(filePath, content, 'utf-8');
|
||||
|
||||
const originalResponse =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(part.functionResponse.response as Record<string, unknown>) || {};
|
||||
|
||||
let filePath = '';
|
||||
let fileSizeMB = '0.00';
|
||||
let totalLines = 0;
|
||||
|
||||
if (hasOutputFile(originalResponse) && originalResponse.outputFile) {
|
||||
filePath = originalResponse.outputFile;
|
||||
try {
|
||||
const stats = await fsPromises.stat(filePath);
|
||||
fileSizeMB = (stats.size / 1024 / 1024).toFixed(2);
|
||||
// For truly full files, we don't count lines as it's too slow.
|
||||
// We just indicate it's the full file.
|
||||
totalLines = -1;
|
||||
} catch {
|
||||
// Fallback if file is gone
|
||||
filePath = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
|
||||
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
|
||||
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
|
||||
.toString(36)
|
||||
.substring(7)}.txt`;
|
||||
filePath = path.join(toolOutputsDir, fileName);
|
||||
|
||||
await fsPromises.writeFile(filePath, content, 'utf-8');
|
||||
|
||||
totalLines = content.split('\n').length;
|
||||
fileSizeMB = (Buffer.byteLength(content, 'utf8') / 1024 / 1024).toFixed(
|
||||
2,
|
||||
);
|
||||
}
|
||||
const totalLines = content.split('\n').length;
|
||||
const fileSizeMB = (
|
||||
Buffer.byteLength(content, 'utf8') /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(2);
|
||||
|
||||
let preview = '';
|
||||
if (toolName === SHELL_TOOL_NAME) {
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: false,
|
||||
vertexai: undefined,
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.stringMatching(
|
||||
@@ -365,7 +365,7 @@ describe('createContentGenerator', () => {
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: false,
|
||||
vertexai: undefined,
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
@@ -409,7 +409,7 @@ describe('createContentGenerator', () => {
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: false,
|
||||
vertexai: undefined,
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
@@ -443,7 +443,7 @@ describe('createContentGenerator', () => {
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: false,
|
||||
vertexai: undefined,
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
@@ -481,7 +481,7 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: false,
|
||||
vertexai: undefined,
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: {
|
||||
'User-Agent': expect.any(String),
|
||||
@@ -517,7 +517,7 @@ describe('createContentGenerator', () => {
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: false,
|
||||
vertexai: undefined,
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
@@ -550,7 +550,7 @@ describe('createContentGenerator', () => {
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: false,
|
||||
vertexai: undefined,
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
@@ -589,7 +589,7 @@ describe('createContentGenerator', () => {
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: false,
|
||||
vertexai: undefined,
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
@@ -638,193 +638,6 @@ describe('createContentGenerator', () => {
|
||||
apiVersion: 'v1alpha',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass baseUrl to GoogleGenAI when GOOGLE_GEMINI_BASE_URL is set', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://gemini.test.local');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
|
||||
const config = await createContentGeneratorConfig(
|
||||
mockConfig,
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
await createContentGenerator(config, mockConfig);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: false,
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://gemini.test.local',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass baseUrl to GoogleGenAI when GOOGLE_VERTEX_BASE_URL is set', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://vertex.test.local');
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-project');
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1');
|
||||
|
||||
const config = await createContentGeneratorConfig(
|
||||
mockConfig,
|
||||
AuthType.USE_VERTEX_AI,
|
||||
);
|
||||
await createContentGenerator(config, mockConfig);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: undefined,
|
||||
vertexai: true,
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://vertex.test.local',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer GOOGLE_VERTEX_BASE_URL when authType is USE_VERTEX_AI without inferred vertex credentials', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://gemini.test.local');
|
||||
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://vertex.test.local');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: undefined,
|
||||
vertexai: true,
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://vertex.test.local',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer an explicit baseUrl over GOOGLE_GEMINI_BASE_URL', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://env.test.local');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
|
||||
const config = await createContentGeneratorConfig(
|
||||
mockConfig,
|
||||
AuthType.USE_GEMINI,
|
||||
undefined,
|
||||
'https://explicit.test.local',
|
||||
);
|
||||
await createContentGenerator(config, mockConfig);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://explicit.test.local',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow localhost baseUrl overrides over http', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid custom baseUrl values', async () => {
|
||||
await expect(
|
||||
createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
baseUrl: 'not-a-url',
|
||||
},
|
||||
mockConfig,
|
||||
),
|
||||
).rejects.toThrow('Invalid custom base URL: not-a-url');
|
||||
});
|
||||
|
||||
it('should reject non-https remote custom baseUrl values', async () => {
|
||||
await expect(
|
||||
createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
baseUrl: 'http://example.com',
|
||||
},
|
||||
mockConfig,
|
||||
),
|
||||
).rejects.toThrow('Custom base URL must use HTTPS unless it is localhost.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContentGeneratorConfig', () => {
|
||||
|
||||
@@ -101,21 +101,6 @@ export type ContentGeneratorConfig = {
|
||||
customHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
|
||||
|
||||
function validateBaseUrl(baseUrl: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid custom base URL: ${baseUrl}`);
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) {
|
||||
throw new Error('Custom base URL must use HTTPS unless it is localhost.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createContentGeneratorConfig(
|
||||
config: Config,
|
||||
authType: AuthType | undefined,
|
||||
@@ -288,32 +273,18 @@ export async function createContentGenerator(
|
||||
'x-gemini-api-privileged-user-id': `${installationId}`,
|
||||
};
|
||||
}
|
||||
let baseUrl = config.baseUrl;
|
||||
if (!baseUrl) {
|
||||
const envBaseUrl =
|
||||
config.authType === AuthType.USE_VERTEX_AI
|
||||
? process.env['GOOGLE_VERTEX_BASE_URL']
|
||||
: process.env['GOOGLE_GEMINI_BASE_URL'];
|
||||
if (envBaseUrl) {
|
||||
validateBaseUrl(envBaseUrl);
|
||||
baseUrl = envBaseUrl;
|
||||
}
|
||||
} else {
|
||||
validateBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
const httpOptions: {
|
||||
baseUrl?: string;
|
||||
headers: Record<string, string>;
|
||||
} = { headers };
|
||||
|
||||
if (baseUrl) {
|
||||
httpOptions.baseUrl = baseUrl;
|
||||
if (config.baseUrl) {
|
||||
httpOptions.baseUrl = config.baseUrl;
|
||||
}
|
||||
|
||||
const googleGenAI = new GoogleGenAI({
|
||||
apiKey: config.apiKey === '' ? undefined : config.apiKey,
|
||||
vertexai: config.vertexai ?? config.authType === AuthType.USE_VERTEX_AI,
|
||||
vertexai: config.vertexai,
|
||||
httpOptions,
|
||||
...(apiVersionEnv && { apiVersion: apiVersionEnv }),
|
||||
});
|
||||
|
||||
@@ -519,70 +519,4 @@ describe('GeminiChat Network Retries', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should retry on OpenSSL 3.x SSL error during stream iteration (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => {
|
||||
// OpenSSL 3.x produces a different error code format than OpenSSL 1.x
|
||||
const sslError = new Error(
|
||||
'request to https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent failed',
|
||||
) as NodeJS.ErrnoException & { type?: string };
|
||||
sslError.type = 'system';
|
||||
sslError.errno =
|
||||
'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC' as unknown as number;
|
||||
sslError.code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC';
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
.mockImplementationOnce(async () =>
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: 'Partial response...' }] } },
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
throw sslError;
|
||||
})(),
|
||||
)
|
||||
.mockImplementationOnce(async () =>
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Complete response after retry' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'test-model' },
|
||||
'test message',
|
||||
'prompt-id-ssl3-mid-stream',
|
||||
new AbortController().signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const events: StreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
|
||||
expect(retryEvent).toBeDefined();
|
||||
|
||||
const successChunk = events.find(
|
||||
(e) =>
|
||||
e.type === StreamEventType.CHUNK &&
|
||||
e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
|
||||
'Complete response after retry',
|
||||
);
|
||||
expect(successChunk).toBeDefined();
|
||||
|
||||
expect(mockLogNetworkRetryAttempt).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
error_type: 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
mkdir: vi.fn(actual.mkdir),
|
||||
open: vi.fn(actual.open),
|
||||
rename: vi.fn(actual.rename),
|
||||
realpath: vi.fn(actual.realpath),
|
||||
};
|
||||
return {
|
||||
...mockFs,
|
||||
@@ -92,13 +93,14 @@ describe('createPolicyEngineConfig', () => {
|
||||
});
|
||||
|
||||
vi.mocked(fs.stat).mockImplementation(async (p) => {
|
||||
if (nodePath.resolve(p.toString()) === nodePath.dirname(resolvedPath)) {
|
||||
const resolvedP = nodePath.resolve(p.toString());
|
||||
if (resolvedP === nodePath.dirname(resolvedPath)) {
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as unknown as Stats;
|
||||
}
|
||||
if (nodePath.resolve(p.toString()) === resolvedPath) {
|
||||
if (resolvedP === resolvedPath) {
|
||||
return {
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
@@ -111,6 +113,21 @@ describe('createPolicyEngineConfig', () => {
|
||||
).stat(p);
|
||||
});
|
||||
|
||||
vi.mocked(fs.realpath).mockImplementation(async (p) => {
|
||||
const resolvedP = nodePath.resolve(p.toString());
|
||||
if (
|
||||
resolvedP === resolvedPath ||
|
||||
resolvedP === nodePath.dirname(resolvedPath)
|
||||
) {
|
||||
return resolvedP;
|
||||
}
|
||||
return (
|
||||
await vi.importActual<typeof import('node:fs/promises')>(
|
||||
'node:fs/promises',
|
||||
)
|
||||
).realpath(p);
|
||||
});
|
||||
|
||||
vi.mocked(fs.readFile).mockImplementation(async (p) => {
|
||||
if (nodePath.resolve(p.toString()) === resolvedPath) {
|
||||
return content;
|
||||
|
||||
@@ -1762,39 +1762,6 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
|
||||
describe('shell command parsing failure', () => {
|
||||
it('should return ALLOW in YOLO mode for dangerous commands due to heuristics override', async () => {
|
||||
// Create an engine with YOLO mode and a sandbox manager that flags a command as dangerous
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 999,
|
||||
modes: [ApprovalMode.YOLO],
|
||||
},
|
||||
];
|
||||
|
||||
const mockSandboxManager = new NoopSandboxManager();
|
||||
mockSandboxManager.isDangerousCommand = vi.fn().mockReturnValue(true);
|
||||
mockSandboxManager.isKnownSafeCommand = vi.fn().mockReturnValue(false);
|
||||
|
||||
engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
sandboxManager: mockSandboxManager,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'powershell echo "dangerous"' },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Even though the command is flagged as dangerous, YOLO mode should preserve the ALLOW decision
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
|
||||
const { splitCommands } = await import('../utils/shell-utils.js');
|
||||
const rules: PolicyRule[] = [
|
||||
|
||||
@@ -312,13 +312,6 @@ export class PolicyEngine {
|
||||
const parsedArgs = parsedObjArgs.map(extractStringFromParseEntry);
|
||||
|
||||
if (this.sandboxManager.isDangerousCommand(parsedArgs)) {
|
||||
if (this.approvalMode === ApprovalMode.YOLO) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command evaluated as dangerous, but YOLO mode is active. Preserving decision: ${command}`,
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command evaluated as dangerous, forcing ASK_USER: ${command}`,
|
||||
);
|
||||
|
||||
@@ -686,6 +686,107 @@ priority = 100
|
||||
});
|
||||
});
|
||||
|
||||
describe('Symlink support', () => {
|
||||
it('should load a symlinked policy file', async () => {
|
||||
const realFile = path.join(tempDir, 'real.txt'); // Not .toml
|
||||
await fs.writeFile(
|
||||
realFile,
|
||||
'[[rule]]\ntoolName = "symlink-test"\ndecision = "allow"\npriority = 100\n',
|
||||
);
|
||||
|
||||
const symlinkFile = path.join(tempDir, 'link.toml');
|
||||
await fs.symlink(realFile, symlinkFile);
|
||||
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
const result = await loadPoliciesFromToml([tempDir], getPolicyTier);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].toolName).toBe('symlink-test');
|
||||
});
|
||||
|
||||
it('should load from a symlinked directory', async () => {
|
||||
const realSubDir = path.join(tempDir, 'real-dir');
|
||||
await fs.mkdir(realSubDir);
|
||||
await fs.writeFile(
|
||||
path.join(realSubDir, 'policy.toml'),
|
||||
'[[rule]]\ntoolName = "dir-link-test"\ndecision = "allow"\npriority = 100\n',
|
||||
);
|
||||
|
||||
const symlinkDir = path.join(tempDir, 'link-dir');
|
||||
await fs.symlink(realSubDir, symlinkDir);
|
||||
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
const result = await loadPoliciesFromToml([symlinkDir], getPolicyTier);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].toolName).toBe('dir-link-test');
|
||||
});
|
||||
|
||||
it('should load from a symlinked subdirectory (recursive)', async () => {
|
||||
const realSubDir = path.join(tempDir, 'real-subdir');
|
||||
await fs.mkdir(realSubDir);
|
||||
await fs.writeFile(
|
||||
path.join(realSubDir, 'policy.toml'),
|
||||
'[[rule]]\ntoolName = "subdir-link-test"\ndecision = "allow"\npriority = 100\n',
|
||||
);
|
||||
|
||||
const symlinkSubDir = path.join(tempDir, 'link-subdir');
|
||||
await fs.symlink(realSubDir, symlinkSubDir);
|
||||
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
// Load from tempDir, which contains link-subdir
|
||||
const result = await loadPoliciesFromToml([tempDir], getPolicyTier);
|
||||
|
||||
// Current implementation is NOT recursive, so this is expected to FAIL
|
||||
// but once we add recursion and symlink following, it should PASS.
|
||||
expect(result.rules.some((r) => r.toolName === 'subdir-link-test')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent circular symlink traversal', async () => {
|
||||
const subDir = path.join(tempDir, 'circular-dir');
|
||||
await fs.mkdir(subDir);
|
||||
await fs.writeFile(
|
||||
path.join(subDir, 'policy.toml'),
|
||||
'[[rule]]\ntoolName = "circular-test"\ndecision = "allow"\npriority = 100\n',
|
||||
);
|
||||
|
||||
// Create a symlink back to its parent
|
||||
await fs.symlink(subDir, path.join(subDir, 'link-back'));
|
||||
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
const result = await loadPoliciesFromToml([subDir], getPolicyTier);
|
||||
|
||||
// Should load policy.toml once and stop
|
||||
expect(
|
||||
result.rules.filter((r) => r.toolName === 'circular-test'),
|
||||
).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should ignore broken symlinks and continue loading other policies', async () => {
|
||||
const realFile = path.join(tempDir, 'real.toml');
|
||||
await fs.writeFile(
|
||||
realFile,
|
||||
'[[rule]]\ntoolName = "valid-test"\ndecision = "allow"\npriority = 100\n',
|
||||
);
|
||||
|
||||
// Create a broken symlink (points to a non-existent file)
|
||||
const brokenLink = path.join(tempDir, 'broken.toml');
|
||||
await fs.symlink(path.join(tempDir, 'does-not-exist.toml'), brokenLink);
|
||||
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
const result = await loadPoliciesFromToml([tempDir], getPolicyTier);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].toolName).toBe('valid-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool name validation', () => {
|
||||
it('should warn for unrecognized tool names with suggestions', async () => {
|
||||
const result = await runLoadPoliciesFromToml(`
|
||||
|
||||
@@ -151,28 +151,51 @@ export interface PolicyFile {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads policy files from a directory or a single file.
|
||||
* Reads policy files from a directory or a single file, following symlinks.
|
||||
* Supports recursion and prevents circular symlink traversal.
|
||||
*
|
||||
* @param policyPath Path to a directory or a .toml file.
|
||||
* @param visitedPaths Set of real paths already visited to prevent circularity.
|
||||
* @returns Array of PolicyFile objects.
|
||||
*/
|
||||
export async function readPolicyFiles(
|
||||
policyPath: string,
|
||||
visitedPaths: Set<string> = new Set(),
|
||||
): Promise<PolicyFile[]> {
|
||||
let filesToLoad: string[] = [];
|
||||
let baseDir = '';
|
||||
const results: PolicyFile[] = [];
|
||||
|
||||
try {
|
||||
const realPath = await fs.realpath(policyPath);
|
||||
if (visitedPaths.has(realPath)) {
|
||||
return [];
|
||||
}
|
||||
visitedPaths.add(realPath);
|
||||
|
||||
const stats = await fs.stat(policyPath);
|
||||
if (stats.isDirectory()) {
|
||||
baseDir = policyPath;
|
||||
const dirEntries = await fs.readdir(policyPath, { withFileTypes: true });
|
||||
filesToLoad = dirEntries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
|
||||
.map((entry) => entry.name);
|
||||
for (const entry of dirEntries) {
|
||||
const entryPath = path.join(policyPath, entry.name);
|
||||
try {
|
||||
const entryStats = await fs.stat(entryPath);
|
||||
|
||||
if (entryStats.isDirectory()) {
|
||||
// Recursive call
|
||||
results.push(...(await readPolicyFiles(entryPath, visitedPaths)));
|
||||
} else if (entryStats.isFile() && entry.name.endsWith('.toml')) {
|
||||
const content = await fs.readFile(entryPath, 'utf-8');
|
||||
results.push({ path: entryPath, content });
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNodeError(e) && e.code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else if (stats.isFile() && policyPath.endsWith('.toml')) {
|
||||
baseDir = path.dirname(policyPath);
|
||||
filesToLoad = [path.basename(policyPath)];
|
||||
const content = await fs.readFile(policyPath, 'utf-8');
|
||||
results.push({ path: policyPath, content });
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNodeError(e) && e.code === 'ENOENT') {
|
||||
@@ -181,12 +204,6 @@ export async function readPolicyFiles(
|
||||
throw e;
|
||||
}
|
||||
|
||||
const results: PolicyFile[] = [];
|
||||
for (const file of filesToLoad) {
|
||||
const filePath = path.join(baseDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
results.push({ path: filePath, content });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,14 +47,22 @@ describe('Workspace-Level Policies', () => {
|
||||
const mockRoot = nodePath.resolve('/mock/');
|
||||
const mockStat = vi.fn(async (path: string) => {
|
||||
if (typeof path === 'string' && path.startsWith(mockRoot)) {
|
||||
const isFile = path.endsWith('.toml');
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isDirectory: () => !isFile,
|
||||
isFile: () => isFile,
|
||||
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
|
||||
}
|
||||
return actualFs.stat(path);
|
||||
});
|
||||
|
||||
const mockRealpath = vi.fn(async (path: string) => {
|
||||
if (typeof path === 'string' && path.startsWith(mockRoot)) {
|
||||
return path;
|
||||
}
|
||||
return actualFs.realpath(path);
|
||||
});
|
||||
|
||||
// Mock readdir to return a policy file for each tier
|
||||
const mockReaddir = vi.fn(async (path: string) => {
|
||||
const normalizedPath = nodePath.normalize(path);
|
||||
@@ -125,10 +133,12 @@ priority = 10
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
realpath: mockRealpath,
|
||||
},
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
realpath: mockRealpath,
|
||||
}));
|
||||
|
||||
const { createPolicyEngineConfig } = await import('./config.js');
|
||||
@@ -172,14 +182,22 @@ priority = 10
|
||||
const mockRoot = nodePath.resolve('/mock/');
|
||||
const mockStat = vi.fn(async (path: string) => {
|
||||
if (typeof path === 'string' && path.startsWith(mockRoot)) {
|
||||
const isFile = path.endsWith('.toml');
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isDirectory: () => !isFile,
|
||||
isFile: () => isFile,
|
||||
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
|
||||
}
|
||||
return actualFs.stat(path);
|
||||
});
|
||||
|
||||
const mockRealpath = vi.fn(async (path: string) => {
|
||||
if (typeof path === 'string' && path.startsWith(mockRoot)) {
|
||||
return path;
|
||||
}
|
||||
return actualFs.realpath(path);
|
||||
});
|
||||
|
||||
const mockReaddir = vi.fn(async (path: string) => {
|
||||
const normalizedPath = nodePath.normalize(path);
|
||||
if (normalizedPath.endsWith(nodePath.normalize('default/policies')))
|
||||
@@ -206,10 +224,12 @@ priority=10`,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
realpath: mockRealpath,
|
||||
},
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
realpath: mockRealpath,
|
||||
}));
|
||||
|
||||
const { createPolicyEngineConfig } = await import('./config.js');
|
||||
@@ -238,14 +258,22 @@ priority=10`,
|
||||
const mockRoot = nodePath.resolve('/mock/');
|
||||
const mockStat = vi.fn(async (path: string) => {
|
||||
if (typeof path === 'string' && path.startsWith(mockRoot)) {
|
||||
const isFile = path.endsWith('.toml');
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isDirectory: () => !isFile,
|
||||
isFile: () => isFile,
|
||||
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
|
||||
}
|
||||
return actualFs.stat(path);
|
||||
});
|
||||
|
||||
const mockRealpath = vi.fn(async (path: string) => {
|
||||
if (typeof path === 'string' && path.startsWith(mockRoot)) {
|
||||
return path;
|
||||
}
|
||||
return actualFs.realpath(path);
|
||||
});
|
||||
|
||||
const mockReaddir = vi.fn(async (path: string) => {
|
||||
const normalizedPath = nodePath.normalize(path);
|
||||
if (normalizedPath.endsWith(nodePath.normalize('workspace/policies')))
|
||||
@@ -272,10 +300,12 @@ priority=500`,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
realpath: mockRealpath,
|
||||
},
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
realpath: mockRealpath,
|
||||
}));
|
||||
|
||||
const { createPolicyEngineConfig } = await import('./config.js');
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { ToolExecutor } from './tool-executor.js';
|
||||
import {
|
||||
type Config,
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
vi.mock('../utils/fileUtils.js', () => ({
|
||||
saveTruncatedToolOutput: vi.fn(),
|
||||
formatTruncatedToolOutput: vi.fn(),
|
||||
moveToolOutputToFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock executeToolWithHooks
|
||||
@@ -438,10 +436,73 @@ describe('ToolExecutor', () => {
|
||||
const response = result.response.responseParts[0]?.functionResponse
|
||||
?.response as Record<string, unknown>;
|
||||
// The content should be the *truncated* version returned by the mock formatTruncatedToolOutput
|
||||
expect(response).toEqual({
|
||||
output: 'TruncatedContent...',
|
||||
outputFile: '/tmp/truncated_output.txt',
|
||||
});
|
||||
expect(response).toEqual({ output: 'TruncatedContent...' });
|
||||
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
|
||||
}
|
||||
});
|
||||
|
||||
it('should truncate large MCP tool output with single text Part', async () => {
|
||||
// 1. Setup Config for Truncation
|
||||
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
|
||||
|
||||
const mcpToolName = 'get_big_text';
|
||||
const messageBus = createMockMessageBus();
|
||||
const mcpTool = new DiscoveredMCPTool(
|
||||
{} as CallableTool,
|
||||
'my-server',
|
||||
'get_big_text',
|
||||
'A test MCP tool',
|
||||
{},
|
||||
messageBus,
|
||||
);
|
||||
const invocation = mcpTool.build({});
|
||||
const longText = 'This is a very long MCP output that should be truncated.';
|
||||
|
||||
// 2. Mock execution returning Part[] with single text Part
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
|
||||
llmContent: [{ text: longText }],
|
||||
returnDisplay: longText,
|
||||
});
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-mcp-trunc',
|
||||
name: mcpToolName,
|
||||
args: { query: 'test' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-mcp-trunc',
|
||||
},
|
||||
tool: mcpTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
// 3. Execute
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: new AbortController().signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
// 4. Verify Truncation Logic
|
||||
expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith(
|
||||
longText,
|
||||
mcpToolName,
|
||||
'call-mcp-trunc',
|
||||
expect.any(String),
|
||||
'test-session-id',
|
||||
);
|
||||
|
||||
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
|
||||
longText,
|
||||
'/tmp/truncated_output.txt',
|
||||
10,
|
||||
);
|
||||
|
||||
expect(result.status).toBe(CoreToolCallStatus.Success);
|
||||
if (result.status === CoreToolCallStatus.Success) {
|
||||
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
|
||||
}
|
||||
});
|
||||
@@ -536,228 +597,6 @@ describe('ToolExecutor', () => {
|
||||
expect(result.status).toBe(CoreToolCallStatus.Success);
|
||||
});
|
||||
|
||||
it('should truncate large output and move file when fullOutputFilePath is provided', async () => {
|
||||
// 1. Setup Config for Truncation
|
||||
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
|
||||
vi.spyOn(fileUtils, 'moveToolOutputToFile').mockResolvedValue({
|
||||
outputFile: '/tmp/moved_output.txt',
|
||||
});
|
||||
|
||||
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
|
||||
const invocation = mockTool.build({});
|
||||
const longOutput = 'This is a very long output that should be truncated.';
|
||||
|
||||
// 2. Mock execution returning long content AND fullOutputFilePath
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
|
||||
llmContent: longOutput,
|
||||
returnDisplay: longOutput,
|
||||
fullOutputFilePath: '/tmp/temp_full_output.txt',
|
||||
});
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-trunc-full',
|
||||
name: SHELL_TOOL_NAME,
|
||||
args: { command: 'echo long' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-trunc-full',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
// 3. Execute
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: new AbortController().signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
// 4. Verify Truncation Logic
|
||||
expect(fileUtils.moveToolOutputToFile).toHaveBeenCalledWith(
|
||||
'/tmp/temp_full_output.txt',
|
||||
SHELL_TOOL_NAME,
|
||||
'call-trunc-full',
|
||||
expect.any(String), // temp dir
|
||||
'test-session-id', // session id from makeFakeConfig
|
||||
);
|
||||
|
||||
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
|
||||
longOutput,
|
||||
'/tmp/moved_output.txt',
|
||||
10, // threshold (maxChars)
|
||||
);
|
||||
|
||||
expect(result.status).toBe(CoreToolCallStatus.Success);
|
||||
if (result.status === CoreToolCallStatus.Success) {
|
||||
const response = result.response.responseParts[0]?.functionResponse
|
||||
?.response as Record<string, unknown>;
|
||||
// The content should be the *truncated* version returned by the mock formatTruncatedToolOutput
|
||||
expect(response).toEqual({
|
||||
output: 'TruncatedContent...',
|
||||
outputFile: '/tmp/moved_output.txt',
|
||||
});
|
||||
expect(result.response.outputFile).toBe('/tmp/moved_output.txt');
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve temporary file when fullOutputFilePath is provided but output is not truncated', async () => {
|
||||
// 1. Setup Config for Truncation
|
||||
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(100);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
|
||||
vi.spyOn(config, 'getSessionId').mockReturnValue('test-session-id');
|
||||
const unlinkSpy = vi
|
||||
.spyOn(fsPromises, 'unlink')
|
||||
.mockResolvedValue(undefined);
|
||||
vi.spyOn(fileUtils, 'moveToolOutputToFile').mockResolvedValue({
|
||||
outputFile: '/tmp/moved_output_short.txt',
|
||||
});
|
||||
|
||||
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
|
||||
const invocation = mockTool.build({});
|
||||
const shortOutput = 'Short';
|
||||
|
||||
// 2. Mock execution returning short content AND fullOutputFilePath
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
|
||||
llmContent: shortOutput,
|
||||
returnDisplay: shortOutput,
|
||||
fullOutputFilePath: '/tmp/temp_full_output_short.txt',
|
||||
});
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-short-full',
|
||||
name: SHELL_TOOL_NAME,
|
||||
args: { command: 'echo short' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-short-full',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
// 3. Execute
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: new AbortController().signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
// 4. Verify file preservation
|
||||
expect(fileUtils.moveToolOutputToFile).toHaveBeenCalledWith(
|
||||
'/tmp/temp_full_output_short.txt',
|
||||
SHELL_TOOL_NAME,
|
||||
'call-short-full',
|
||||
expect.any(String),
|
||||
'test-session-id',
|
||||
);
|
||||
expect(unlinkSpy).not.toHaveBeenCalled();
|
||||
expect(fileUtils.formatTruncatedToolOutput).not.toHaveBeenCalled();
|
||||
|
||||
// We should save it since fullOutputFilePath was provided
|
||||
expect(result.status).toBe(CoreToolCallStatus.Success);
|
||||
if (result.status === CoreToolCallStatus.Success) {
|
||||
const response = result.response.responseParts[0]?.functionResponse
|
||||
?.response as Record<string, unknown>;
|
||||
expect(response).toEqual({
|
||||
output: 'Short',
|
||||
outputFile: '/tmp/moved_output_short.txt',
|
||||
});
|
||||
expect(result.response.outputFile).toBe('/tmp/moved_output_short.txt');
|
||||
}
|
||||
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should delete temporary file on error if fullOutputFilePath is provided', async () => {
|
||||
const unlinkSpy = vi
|
||||
.spyOn(fsPromises, 'unlink')
|
||||
.mockResolvedValue(undefined);
|
||||
const mockTool = new MockTool({ name: 'failTool' });
|
||||
const invocation = mockTool.build({});
|
||||
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
|
||||
llmContent: 'partial',
|
||||
returnDisplay: 'partial',
|
||||
fullOutputFilePath: '/tmp/temp_error.txt',
|
||||
error: { message: 'Tool Failed' },
|
||||
});
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-err',
|
||||
name: 'failTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-err',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: new AbortController().signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_error.txt');
|
||||
expect(result.status).toBe(CoreToolCallStatus.Error);
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should delete temporary file on abort if fullOutputFilePath is provided', async () => {
|
||||
const unlinkSpy = vi
|
||||
.spyOn(fsPromises, 'unlink')
|
||||
.mockResolvedValue(undefined);
|
||||
const mockTool = new MockTool({ name: 'slowTool' });
|
||||
const invocation = mockTool.build({});
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
|
||||
async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
llmContent: 'partial',
|
||||
returnDisplay: 'partial',
|
||||
fullOutputFilePath: '/tmp/temp_abort.txt',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-abort',
|
||||
name: 'slowTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-abort',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: controller.signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_abort.txt');
|
||||
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should report execution ID updates for backgroundable tools', async () => {
|
||||
// 1. Setup ShellToolInvocation
|
||||
const messageBus = createMockMessageBus();
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
ToolErrorType,
|
||||
ToolOutputTruncatedEvent,
|
||||
@@ -26,7 +24,6 @@ import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
|
||||
import {
|
||||
saveTruncatedToolOutput,
|
||||
formatTruncatedToolOutput,
|
||||
moveToolOutputToFile,
|
||||
} from '../utils/fileUtils.js';
|
||||
import { convertToFunctionResponse } from '../utils/generateContentResponseUtilities.js';
|
||||
import {
|
||||
@@ -141,16 +138,6 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
if (toolResult.fullOutputFilePath) {
|
||||
await fsPromises
|
||||
.unlink(toolResult.fullOutputFilePath)
|
||||
.catch((error) => {
|
||||
debugLogger.warn(
|
||||
`Failed to delete temporary tool output file on abort: ${toolResult.fullOutputFilePath}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
completedToolCall = await this.createCancelledResult(
|
||||
call,
|
||||
'User cancelled tool execution.',
|
||||
@@ -162,16 +149,6 @@ export class ToolExecutor {
|
||||
toolResult,
|
||||
);
|
||||
} else {
|
||||
if (toolResult.fullOutputFilePath) {
|
||||
await fsPromises
|
||||
.unlink(toolResult.fullOutputFilePath)
|
||||
.catch((error) => {
|
||||
debugLogger.warn(
|
||||
`Failed to delete temporary tool output file on error: ${toolResult.fullOutputFilePath}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
const displayText =
|
||||
typeof toolResult.returnDisplay === 'string'
|
||||
? toolResult.returnDisplay
|
||||
@@ -220,80 +197,33 @@ export class ToolExecutor {
|
||||
private async truncateOutputIfNeeded(
|
||||
call: ToolCall,
|
||||
content: PartListUnion,
|
||||
fullOutputFilePath?: string,
|
||||
): Promise<{ truncatedContent: PartListUnion; outputFile?: string }> {
|
||||
const toolName = call.request.name;
|
||||
const callId = call.request.callId;
|
||||
|
||||
if (this.config.isContextManagementEnabled()) {
|
||||
const distiller = new ToolOutputDistillationService(
|
||||
this.config,
|
||||
this.context.geminiClient,
|
||||
this.context.promptId,
|
||||
);
|
||||
const result = await distiller.distill(
|
||||
call.request.name,
|
||||
call.request.callId,
|
||||
content,
|
||||
);
|
||||
|
||||
let finalOutputFile = result.outputFile;
|
||||
if (fullOutputFilePath) {
|
||||
const { outputFile: movedPath } = await moveToolOutputToFile(
|
||||
fullOutputFilePath,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.config.getSessionId(),
|
||||
);
|
||||
finalOutputFile = movedPath;
|
||||
if (result.outputFile) {
|
||||
try {
|
||||
await fsPromises.unlink(result.outputFile);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`Failed to delete distiller's temporary tool output file: ${result.outputFile}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
truncatedContent: result.truncatedContent,
|
||||
outputFile: finalOutputFile,
|
||||
};
|
||||
return distiller.distill(call.request.name, call.request.callId, content);
|
||||
}
|
||||
|
||||
const toolName = call.request.name;
|
||||
const callId = call.request.callId;
|
||||
let outputFile: string | undefined;
|
||||
|
||||
if (fullOutputFilePath) {
|
||||
const { outputFile: movedPath } = await moveToolOutputToFile(
|
||||
fullOutputFilePath,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.config.getSessionId(),
|
||||
);
|
||||
outputFile = movedPath;
|
||||
}
|
||||
|
||||
if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) {
|
||||
const threshold = this.config.getTruncateToolOutputThreshold();
|
||||
|
||||
if (threshold > 0 && content.length > threshold) {
|
||||
const originalContentLength = content.length;
|
||||
|
||||
if (!outputFile) {
|
||||
const { outputFile: writtenPath } = await saveTruncatedToolOutput(
|
||||
content,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.context.promptId,
|
||||
);
|
||||
outputFile = writtenPath;
|
||||
}
|
||||
|
||||
const { outputFile: savedPath } = await saveTruncatedToolOutput(
|
||||
content,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.context.promptId,
|
||||
);
|
||||
outputFile = savedPath;
|
||||
const truncatedContent = formatTruncatedToolOutput(
|
||||
content,
|
||||
outputFile,
|
||||
@@ -325,16 +255,14 @@ export class ToolExecutor {
|
||||
|
||||
if (threshold > 0 && textContent.length > threshold) {
|
||||
const originalContentLength = textContent.length;
|
||||
if (!outputFile) {
|
||||
const { outputFile: savedPath } = await saveTruncatedToolOutput(
|
||||
textContent,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.context.promptId,
|
||||
);
|
||||
outputFile = savedPath;
|
||||
}
|
||||
const { outputFile: savedPath } = await saveTruncatedToolOutput(
|
||||
textContent,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.context.promptId,
|
||||
);
|
||||
outputFile = savedPath;
|
||||
const truncatedText = formatTruncatedToolOutput(
|
||||
textContent,
|
||||
outputFile,
|
||||
@@ -386,11 +314,7 @@ export class ToolExecutor {
|
||||
// Attempt to truncate and save output if we have content, even in cancellation case
|
||||
// This is to handle cases where the tool may have produced output before cancellation
|
||||
const { truncatedContent: output, outputFile: truncatedOutputFile } =
|
||||
await this.truncateOutputIfNeeded(
|
||||
call,
|
||||
toolResult.llmContent,
|
||||
toolResult.fullOutputFilePath,
|
||||
);
|
||||
await this.truncateOutputIfNeeded(call, toolResult?.llmContent);
|
||||
|
||||
outputFile = truncatedOutputFile;
|
||||
responseParts = convertToFunctionResponse(
|
||||
@@ -399,7 +323,6 @@ export class ToolExecutor {
|
||||
output,
|
||||
this.config.getActiveModel(),
|
||||
this.config,
|
||||
outputFile,
|
||||
);
|
||||
|
||||
// Inject the cancellation error into the response object
|
||||
@@ -409,16 +332,6 @@ export class ToolExecutor {
|
||||
respObj['error'] = errorMessage;
|
||||
}
|
||||
} else {
|
||||
if (toolResult?.fullOutputFilePath) {
|
||||
try {
|
||||
await fsPromises.unlink(toolResult.fullOutputFilePath);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`Failed to delete temporary tool output file: ${toolResult.fullOutputFilePath}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
responseParts = [
|
||||
{
|
||||
functionResponse: {
|
||||
@@ -456,11 +369,8 @@ export class ToolExecutor {
|
||||
toolResult: ToolResult,
|
||||
): Promise<SuccessfulToolCall> {
|
||||
const { truncatedContent: content, outputFile } =
|
||||
await this.truncateOutputIfNeeded(
|
||||
call,
|
||||
toolResult.llmContent,
|
||||
toolResult.fullOutputFilePath,
|
||||
);
|
||||
await this.truncateOutputIfNeeded(call, toolResult.llmContent);
|
||||
|
||||
const toolName = call.request.originalRequestName || call.request.name;
|
||||
const callId = call.request.callId;
|
||||
|
||||
@@ -470,7 +380,6 @@ export class ToolExecutor {
|
||||
content,
|
||||
this.config.getActiveModel(),
|
||||
this.config,
|
||||
outputFile,
|
||||
);
|
||||
|
||||
const successResponse: ToolCallResponseInfo = {
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface ExecutionResult {
|
||||
pid: number | undefined;
|
||||
executionMethod: ExecutionMethod;
|
||||
backgrounded?: boolean;
|
||||
fullOutputFilePath?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionHandle {
|
||||
@@ -36,14 +35,6 @@ export interface ExecutionHandle {
|
||||
}
|
||||
|
||||
export type ExecutionOutputEvent =
|
||||
| {
|
||||
type: 'raw_data';
|
||||
chunk: string;
|
||||
}
|
||||
| {
|
||||
type: 'file_data';
|
||||
chunk: string;
|
||||
}
|
||||
| {
|
||||
type: 'data';
|
||||
chunk: string | AnsiOutput;
|
||||
|
||||
@@ -53,7 +53,7 @@ vi.mock('../utils/events.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: { debug: vi.fn() },
|
||||
debugLogger: { log: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
@@ -153,14 +153,14 @@ describe('KeychainService', () => {
|
||||
|
||||
// Because it falls back to FileKeychain, it is always available.
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('encountered an error'),
|
||||
'locked',
|
||||
);
|
||||
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ available: false }),
|
||||
);
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Using FileKeychain fallback'),
|
||||
);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
@@ -173,7 +173,7 @@ describe('KeychainService', () => {
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed structural validation'),
|
||||
expect.objectContaining({ getPassword: expect.any(Array) }),
|
||||
);
|
||||
@@ -191,7 +191,7 @@ describe('KeychainService', () => {
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('functional verification failed'),
|
||||
);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
@@ -243,7 +243,7 @@ describe('KeychainService', () => {
|
||||
);
|
||||
expect(mockKeytar.setPassword).not.toHaveBeenCalled();
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('MacOS default keychain not found'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ export class KeychainService {
|
||||
}
|
||||
|
||||
// If native failed or was skipped, return the secure file fallback.
|
||||
debugLogger.debug('Using FileKeychain fallback for secure storage.');
|
||||
debugLogger.log('Using FileKeychain fallback for secure storage.');
|
||||
return new FileKeychain();
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export class KeychainService {
|
||||
|
||||
// Probing macOS prevents process-blocking popups when no keychain exists.
|
||||
if (os.platform() === 'darwin' && !this.isMacOSKeychainAvailable()) {
|
||||
debugLogger.debug(
|
||||
debugLogger.log(
|
||||
'MacOS default keychain not found; skipping functional verification.',
|
||||
);
|
||||
return null;
|
||||
@@ -140,15 +140,12 @@ export class KeychainService {
|
||||
return keychainModule;
|
||||
}
|
||||
|
||||
debugLogger.debug('Keychain functional verification failed');
|
||||
debugLogger.log('Keychain functional verification failed');
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Avoid logging full error objects to prevent PII exposure.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.debug(
|
||||
'Keychain initialization encountered an error:',
|
||||
message,
|
||||
);
|
||||
debugLogger.log('Keychain initialization encountered an error:', message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +162,7 @@ export class KeychainService {
|
||||
return potential as Keychain;
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
debugLogger.log(
|
||||
'Keychain module failed structural validation:',
|
||||
result.error.flatten().fieldErrors,
|
||||
);
|
||||
|
||||
@@ -33,16 +33,7 @@ const mockIsBinary = vi.hoisted(() => vi.fn());
|
||||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
const mockHomedir = vi.hoisted(() => vi.fn());
|
||||
const mockMkdirSync = vi.hoisted(() => vi.fn());
|
||||
const mockCreateWriteStream = vi.hoisted(() =>
|
||||
vi.fn().mockReturnValue({
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb?: () => void) => {
|
||||
if (cb) cb();
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
closed: false,
|
||||
}),
|
||||
);
|
||||
const mockCreateWriteStream = vi.hoisted(() => vi.fn());
|
||||
const mockGetPty = vi.hoisted(() => vi.fn());
|
||||
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
|
||||
const mockResolveExecutable = vi.hoisted(() => vi.fn());
|
||||
@@ -101,7 +92,6 @@ vi.mock('node:os', () => ({
|
||||
default: {
|
||||
platform: mockPlatform,
|
||||
homedir: mockHomedir,
|
||||
tmpdir: () => '/tmp',
|
||||
constants: {
|
||||
signals: {
|
||||
SIGTERM: 15,
|
||||
@@ -218,15 +208,6 @@ describe('ShellExecutionService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateWriteStream.mockReturnValue({
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb?: () => void) => {
|
||||
if (cb) cb();
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
closed: false,
|
||||
on: vi.fn(),
|
||||
});
|
||||
ExecutionLifecycleService.resetForTest();
|
||||
ShellExecutionService.resetForTest();
|
||||
mockSerializeTerminalToObject.mockReturnValue([]);
|
||||
@@ -508,7 +489,6 @@ describe('ShellExecutionService', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
headlessTerminal: mockHeadlessTerminal as any,
|
||||
command: 'some-command',
|
||||
lastCommittedLine: -1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -640,7 +620,7 @@ describe('ShellExecutionService', () => {
|
||||
});
|
||||
|
||||
it('should handle a synchronous spawn error', async () => {
|
||||
mockGetPty.mockResolvedValue(null);
|
||||
mockGetPty.mockImplementation(() => null);
|
||||
|
||||
mockCpSpawn.mockImplementation(() => {
|
||||
throw new Error('Simulated PTY spawn error');
|
||||
@@ -744,13 +724,7 @@ describe('ShellExecutionService', () => {
|
||||
});
|
||||
|
||||
describe('Backgrounding', () => {
|
||||
let mockWriteStream: {
|
||||
write: Mock;
|
||||
end: Mock;
|
||||
on: Mock;
|
||||
destroy: Mock;
|
||||
closed: boolean;
|
||||
};
|
||||
let mockWriteStream: { write: Mock; end: Mock; on: Mock };
|
||||
let mockBgChildProcess: EventEmitter & Partial<ChildProcess>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -758,8 +732,6 @@ describe('ShellExecutionService', () => {
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb) => cb?.()),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
closed: false,
|
||||
};
|
||||
|
||||
mockMkdirSync.mockReturnValue(undefined);
|
||||
@@ -1017,8 +989,6 @@ describe('ShellExecutionService', () => {
|
||||
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
});
|
||||
|
||||
// We don't check result here because result is not available in the test body as written
|
||||
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
|
||||
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
||||
type: 'binary_detected',
|
||||
@@ -1331,7 +1301,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
const { result, handle } = await simulateExecution('ls -l', (cp) => {
|
||||
cp.stdout?.emit('data', Buffer.from('file1.txt\n'));
|
||||
cp.stderr?.emit('data', Buffer.from('a warning'));
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
@@ -1368,7 +1338,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
it('should strip ANSI color codes from output', async () => {
|
||||
const { result } = await simulateExecution('ls --color=auto', (cp) => {
|
||||
cp.stdout?.emit('data', Buffer.from('a\u001b[31mred\u001b[0mword'));
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
@@ -1389,7 +1359,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
const multiByteChar = Buffer.from('你好', 'utf-8');
|
||||
cp.stdout?.emit('data', multiByteChar.slice(0, 2));
|
||||
cp.stdout?.emit('data', multiByteChar.slice(2));
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
expect(result.output.trim()).toBe('你好');
|
||||
@@ -1397,7 +1367,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
|
||||
it('should handle commands with no output', async () => {
|
||||
const { result } = await simulateExecution('touch file', (cp) => {
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
@@ -1419,7 +1389,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
cp.stdout?.emit('data', Buffer.from(chunk1));
|
||||
cp.stdout?.emit('data', Buffer.from(chunk2));
|
||||
cp.stdout?.emit('data', Buffer.from(chunk3));
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
const truncationMessage =
|
||||
@@ -1444,7 +1414,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
it('should capture a non-zero exit code and format output correctly', async () => {
|
||||
const { result } = await simulateExecution('a-bad-command', (cp) => {
|
||||
cp.stderr?.emit('data', Buffer.from('command not found'));
|
||||
cp.emit('close', 127, null);
|
||||
cp.emit('exit', 127, null);
|
||||
cp.emit('close', 127, null);
|
||||
});
|
||||
|
||||
@@ -1455,7 +1425,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
|
||||
it('should capture a termination signal', async () => {
|
||||
const { result } = await simulateExecution('long-process', (cp) => {
|
||||
cp.emit('close', null, 'SIGTERM');
|
||||
cp.emit('exit', null, 'SIGTERM');
|
||||
cp.emit('close', null, 'SIGTERM');
|
||||
});
|
||||
|
||||
@@ -1467,7 +1437,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
const spawnError = new Error('spawn EACCES');
|
||||
const { result } = await simulateExecution('protected-cmd', (cp) => {
|
||||
cp.emit('error', spawnError);
|
||||
cp.emit('close', 1, null);
|
||||
cp.emit('exit', 1, null);
|
||||
cp.emit('close', 1, null);
|
||||
});
|
||||
|
||||
@@ -1513,11 +1483,11 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
if (expectedExit.signal) {
|
||||
cp.emit('close', null, expectedExit.signal);
|
||||
cp.emit('exit', null, expectedExit.signal);
|
||||
cp.emit('close', null, expectedExit.signal);
|
||||
}
|
||||
if (typeof expectedExit.code === 'number') {
|
||||
cp.emit('close', expectedExit.code, null);
|
||||
cp.emit('exit', expectedExit.code, null);
|
||||
cp.emit('close', expectedExit.code, null);
|
||||
}
|
||||
},
|
||||
@@ -1586,7 +1556,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
);
|
||||
|
||||
// Finally, simulate the process exiting and await the result
|
||||
mockChildProcess.emit('close', null, 'SIGKILL');
|
||||
mockChildProcess.emit('exit', null, 'SIGKILL');
|
||||
mockChildProcess.emit('close', null, 'SIGKILL');
|
||||
const result = await handle.result;
|
||||
|
||||
@@ -1606,11 +1576,9 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
await simulateExecution('cat image.png', (cp) => {
|
||||
cp.stdout?.emit('data', binaryChunk1);
|
||||
cp.stdout?.emit('data', binaryChunk2);
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
// We don't check result here because result is not available in the test body as written
|
||||
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
|
||||
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
||||
type: 'binary_detected',
|
||||
@@ -1636,6 +1604,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
await simulateExecution('cat mixed_file', (cp) => {
|
||||
cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02]));
|
||||
cp.stdout?.emit('data', Buffer.from('more text'));
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
@@ -1671,7 +1640,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
it('should use powershell.exe on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
await simulateExecution('dir "foo bar"', (cp) => {
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
@@ -1688,7 +1657,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
it('should use bash and detached process group on Linux', async () => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
await simulateExecution('ls "foo bar"', (cp) => {
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
@@ -1802,7 +1771,7 @@ describe('ShellExecutionService execution method selection', () => {
|
||||
);
|
||||
|
||||
// Simulate exit to allow promise to resolve
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
const result = await handle.result;
|
||||
|
||||
expect(mockGetPty).not.toHaveBeenCalled();
|
||||
@@ -1825,7 +1794,7 @@ describe('ShellExecutionService execution method selection', () => {
|
||||
);
|
||||
|
||||
// Simulate exit to allow promise to resolve
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
const result = await handle.result;
|
||||
|
||||
expect(mockGetPty).toHaveBeenCalled();
|
||||
@@ -1891,6 +1860,10 @@ describe('ShellExecutionService environment variables', () => {
|
||||
// Small delay to allow async ops to complete
|
||||
setTimeout(() => mockPtyProcess.emit('exit', { exitCode, signal }), 0);
|
||||
});
|
||||
mockChildProcess.on('exit', (code, signal) => {
|
||||
// Small delay to allow async ops to complete
|
||||
setTimeout(() => mockChildProcess.emit('close', code, signal), 0);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1953,7 +1926,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
});
|
||||
@@ -2013,7 +1986,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
});
|
||||
@@ -2061,7 +2034,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).toHaveProperty('GEMINI_CLI', '1');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
});
|
||||
@@ -2118,7 +2091,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
);
|
||||
|
||||
// Clean up
|
||||
mockChild.emit('close', 0, null);
|
||||
mockChild.emit('exit', 0, null);
|
||||
mockChild.emit('close', 0, null);
|
||||
await handle.result;
|
||||
});
|
||||
@@ -2167,7 +2140,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_2', '');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
@@ -2207,7 +2180,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).not.toHaveProperty('GIT_CONFIG_COUNT');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { Writable } from 'node:stream';
|
||||
import os from 'node:os';
|
||||
import fs, { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import type { IPty } from '@lydell/node-pty';
|
||||
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
|
||||
import {
|
||||
@@ -121,8 +120,6 @@ interface ActivePty {
|
||||
maxSerializedLines?: number;
|
||||
command: string;
|
||||
sessionId?: string;
|
||||
lastSerializedOutput?: AnsiOutput;
|
||||
lastCommittedLine: number;
|
||||
}
|
||||
|
||||
interface ActiveChildProcess {
|
||||
@@ -151,48 +148,6 @@ const findLastContentLine = (
|
||||
return -1;
|
||||
};
|
||||
|
||||
const emitPendingLines = (
|
||||
activePty: ActivePty,
|
||||
pid: number,
|
||||
onOutputEvent: (event: ShellOutputEvent) => void,
|
||||
forceAll = false,
|
||||
) => {
|
||||
const buffer = activePty.headlessTerminal.buffer.active;
|
||||
const limit = forceAll ? buffer.length : buffer.baseY;
|
||||
|
||||
let chunks = '';
|
||||
for (let i = activePty.lastCommittedLine + 1; i < limit; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (!line) continue;
|
||||
|
||||
let trimRight = true;
|
||||
let isNextLineWrapped = false;
|
||||
if (i + 1 < buffer.length) {
|
||||
const nextLine = buffer.getLine(i + 1);
|
||||
if (nextLine?.isWrapped) {
|
||||
isNextLineWrapped = true;
|
||||
trimRight = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lineContent = line.translateToString(trimRight);
|
||||
chunks += lineContent;
|
||||
if (!isNextLineWrapped) {
|
||||
chunks += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'file_data',
|
||||
chunk: chunks,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ExecutionLifecycleService.emitEvent(pid, event);
|
||||
activePty.lastCommittedLine = limit - 1;
|
||||
}
|
||||
};
|
||||
|
||||
const getFullBufferText = (terminal: pkg.Terminal, startLine = 0): string => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
@@ -371,110 +326,32 @@ export class ShellExecutionService {
|
||||
shouldUseNodePty: boolean,
|
||||
shellExecutionConfig: ShellExecutionConfig,
|
||||
): Promise<ShellExecutionHandle> {
|
||||
const outputFileName = `gemini_shell_output_${crypto.randomBytes(6).toString('hex')}.log`;
|
||||
const outputFilePath = path.join(os.tmpdir(), outputFileName);
|
||||
const outputStream = fs.createWriteStream(outputFilePath);
|
||||
|
||||
let totalBytesWritten = 0;
|
||||
|
||||
const interceptedOnOutputEvent = (event: ShellOutputEvent) => {
|
||||
switch (event.type) {
|
||||
case 'raw_data':
|
||||
break;
|
||||
case 'file_data':
|
||||
outputStream.write(event.chunk);
|
||||
totalBytesWritten += Buffer.byteLength(event.chunk);
|
||||
break;
|
||||
case 'binary_detected':
|
||||
case 'binary_progress':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
onOutputEvent(event);
|
||||
};
|
||||
|
||||
let handlePromise: Promise<ShellExecutionHandle>;
|
||||
|
||||
if (shouldUseNodePty) {
|
||||
handlePromise = getPty().then((ptyInfo) => {
|
||||
if (ptyInfo) {
|
||||
return this.executeWithPty(
|
||||
const ptyInfo = await getPty();
|
||||
if (ptyInfo) {
|
||||
try {
|
||||
return await this.executeWithPty(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
interceptedOnOutputEvent,
|
||||
onOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
ptyInfo,
|
||||
).catch(() =>
|
||||
this.childProcessFallback(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
interceptedOnOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
shouldUseNodePty,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Fallback to child_process
|
||||
}
|
||||
return this.childProcessFallback(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
interceptedOnOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
shouldUseNodePty,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
handlePromise = this.childProcessFallback(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
interceptedOnOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
shouldUseNodePty,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const handle = await handlePromise;
|
||||
|
||||
const wrappedResultPromise = handle.result
|
||||
.then(async (result) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
outputStream.end(resolve);
|
||||
});
|
||||
// The threshold logic is handled later by ToolExecutor/caller, so we just return the full file path if anything was written
|
||||
if (
|
||||
totalBytesWritten > 0 &&
|
||||
!result.backgrounded &&
|
||||
!abortSignal.aborted &&
|
||||
!result.error
|
||||
) {
|
||||
return {
|
||||
...result,
|
||||
fullOutputFilePath: outputFilePath,
|
||||
};
|
||||
} else {
|
||||
if (!outputStream.closed) {
|
||||
outputStream.destroy();
|
||||
}
|
||||
await fs.promises.unlink(outputFilePath).catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (!outputStream.closed) {
|
||||
outputStream.destroy();
|
||||
}
|
||||
await fs.promises.unlink(outputFilePath).catch(() => undefined);
|
||||
throw err;
|
||||
});
|
||||
|
||||
return {
|
||||
pid: handle.pid,
|
||||
result: wrappedResultPromise,
|
||||
};
|
||||
return this.childProcessFallback(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
onOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
shouldUseNodePty,
|
||||
);
|
||||
}
|
||||
|
||||
private static appendAndTruncate(
|
||||
@@ -784,24 +661,6 @@ export class ShellExecutionService {
|
||||
}
|
||||
|
||||
if (decodedChunk) {
|
||||
const rawEvent: ShellOutputEvent = {
|
||||
type: 'raw_data',
|
||||
chunk: decodedChunk,
|
||||
};
|
||||
onOutputEvent(rawEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
|
||||
}
|
||||
|
||||
const fileEvent: ShellOutputEvent = {
|
||||
type: 'file_data',
|
||||
chunk: stripAnsi(decodedChunk),
|
||||
};
|
||||
onOutputEvent(fileEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
|
||||
}
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: decodedChunk,
|
||||
@@ -913,7 +772,7 @@ export class ShellExecutionService {
|
||||
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
child.on('exit', (code, signal) => {
|
||||
handleExit(code, signal);
|
||||
});
|
||||
|
||||
@@ -925,15 +784,6 @@ export class ShellExecutionService {
|
||||
if (remaining) {
|
||||
state.output += remaining;
|
||||
if (isStreamingRawContent) {
|
||||
const rawEvent: ShellOutputEvent = {
|
||||
type: 'raw_data',
|
||||
chunk: remaining,
|
||||
};
|
||||
onOutputEvent(rawEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
|
||||
}
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: remaining,
|
||||
@@ -942,15 +792,6 @@ export class ShellExecutionService {
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, event);
|
||||
}
|
||||
|
||||
const fileEvent: ShellOutputEvent = {
|
||||
type: 'file_data',
|
||||
chunk: stripAnsi(remaining),
|
||||
};
|
||||
onOutputEvent(fileEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -959,15 +800,6 @@ export class ShellExecutionService {
|
||||
if (remaining) {
|
||||
state.output += remaining;
|
||||
if (isStreamingRawContent) {
|
||||
const rawEvent: ShellOutputEvent = {
|
||||
type: 'raw_data',
|
||||
chunk: remaining,
|
||||
};
|
||||
onOutputEvent(rawEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
|
||||
}
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: remaining,
|
||||
@@ -976,15 +808,6 @@ export class ShellExecutionService {
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, event);
|
||||
}
|
||||
|
||||
const fileEvent: ShellOutputEvent = {
|
||||
type: 'file_data',
|
||||
chunk: stripAnsi(remaining),
|
||||
};
|
||||
onOutputEvent(fileEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1111,7 +934,6 @@ export class ShellExecutionService {
|
||||
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
|
||||
command: shellExecutionConfig.originalCommand ?? commandToExecute,
|
||||
sessionId: shellExecutionConfig.sessionId,
|
||||
lastCommittedLine: -1,
|
||||
});
|
||||
|
||||
const result = ExecutionLifecycleService.attachExecution(ptyPid, {
|
||||
@@ -1277,38 +1099,10 @@ export class ShellExecutionService {
|
||||
}, 68);
|
||||
};
|
||||
|
||||
let lastYdisp = 0;
|
||||
let hasReachedMax = false;
|
||||
const scrollbackLimit =
|
||||
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
|
||||
|
||||
headlessTerminal.onScroll((ydisp) => {
|
||||
headlessTerminal.onScroll(() => {
|
||||
if (!isWriting) {
|
||||
render();
|
||||
}
|
||||
|
||||
if (
|
||||
ydisp === scrollbackLimit &&
|
||||
lastYdisp === scrollbackLimit &&
|
||||
hasReachedMax
|
||||
) {
|
||||
const activePty = this.activePtys.get(ptyPid);
|
||||
if (activePty) {
|
||||
activePty.lastCommittedLine--;
|
||||
}
|
||||
}
|
||||
if (
|
||||
ydisp === scrollbackLimit &&
|
||||
headlessTerminal.buffer.active.length === scrollbackLimit + rows
|
||||
) {
|
||||
hasReachedMax = true;
|
||||
}
|
||||
lastYdisp = ydisp;
|
||||
|
||||
const activePtyForEmit = this.activePtys.get(ptyPid);
|
||||
if (activePtyForEmit) {
|
||||
emitPendingLines(activePtyForEmit, ptyPid, onOutputEvent);
|
||||
}
|
||||
});
|
||||
|
||||
const handleOutput = (data: Buffer) => {
|
||||
@@ -1393,11 +1187,6 @@ export class ShellExecutionService {
|
||||
render(true);
|
||||
cmdCleanup?.();
|
||||
|
||||
const activePty = ShellExecutionService.activePtys.get(ptyPid);
|
||||
if (activePty && isStreamingRawContent) {
|
||||
emitPendingLines(activePty, ptyPid, onOutputEvent, true);
|
||||
}
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
@@ -1701,7 +1490,6 @@ export class ShellExecutionService {
|
||||
startLine,
|
||||
endLine,
|
||||
);
|
||||
activePty.lastSerializedOutput = bufferData;
|
||||
const event: ShellOutputEvent = { type: 'data', chunk: bufferData };
|
||||
ExecutionLifecycleService.emitEvent(pid, event);
|
||||
}
|
||||
|
||||
@@ -17,18 +17,13 @@ import {
|
||||
_resetGlobalMemoryMonitorForTests,
|
||||
} from './memory-monitor.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
recordMemoryUsage,
|
||||
recordCpuUsage,
|
||||
isPerformanceMonitoringActive,
|
||||
} from './metrics.js';
|
||||
import { recordMemoryUsage, isPerformanceMonitoringActive } from './metrics.js';
|
||||
import { HighWaterMarkTracker } from './high-water-mark-tracker.js';
|
||||
import { RateLimiter } from './rate-limiter.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./metrics.js', () => ({
|
||||
recordMemoryUsage: vi.fn(),
|
||||
recordCpuUsage: vi.fn(),
|
||||
isPerformanceMonitoringActive: vi.fn(),
|
||||
MemoryMetricType: {
|
||||
HEAP_USED: 'heap_used',
|
||||
@@ -55,7 +50,6 @@ vi.mock('node:process', () => ({
|
||||
}));
|
||||
|
||||
const mockRecordMemoryUsage = vi.mocked(recordMemoryUsage);
|
||||
const mockRecordCpuUsage = vi.mocked(recordCpuUsage);
|
||||
const mockIsPerformanceMonitoringActive = vi.mocked(
|
||||
isPerformanceMonitoringActive,
|
||||
);
|
||||
@@ -198,13 +192,6 @@ describe('MemoryMonitor', () => {
|
||||
component: 'test_context',
|
||||
},
|
||||
);
|
||||
expect(mockRecordCpuUsage).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.any(Number),
|
||||
{
|
||||
component: 'test_context',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not record metrics when performance monitoring is inactive', () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { isUserActive } from './activity-detector.js';
|
||||
import { HighWaterMarkTracker } from './high-water-mark-tracker.js';
|
||||
import {
|
||||
recordMemoryUsage,
|
||||
recordCpuUsage,
|
||||
MemoryMetricType,
|
||||
isPerformanceMonitoringActive,
|
||||
} from './metrics.js';
|
||||
@@ -38,7 +37,6 @@ export class MemoryMonitor {
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private isRunning = false;
|
||||
private lastSnapshot: MemorySnapshot | null = null;
|
||||
private lastCpuUsage: NodeJS.CpuUsage | null = null;
|
||||
private monitoringInterval: number = 10000;
|
||||
private highWaterMarkTracker: HighWaterMarkTracker;
|
||||
private rateLimiter: RateLimiter;
|
||||
@@ -193,13 +191,6 @@ export class MemoryMonitor {
|
||||
memory_type: MemoryMetricType.RSS,
|
||||
component: context,
|
||||
});
|
||||
|
||||
// Record delta CPU usage (in microseconds)
|
||||
const cpuUsage = process.cpuUsage(this.lastCpuUsage ?? undefined);
|
||||
this.lastCpuUsage = process.cpuUsage();
|
||||
recordCpuUsage(config, cpuUsage.user + cpuUsage.system, {
|
||||
component: context,
|
||||
});
|
||||
}
|
||||
|
||||
this.lastSnapshot = snapshot;
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
canUseRipgrep,
|
||||
RipGrepTool,
|
||||
ensureRgPath,
|
||||
type RipGrepToolParams,
|
||||
getRipgrepPath,
|
||||
} from './ripGrep.js';
|
||||
import type { GrepResult } from './tools.js';
|
||||
import path from 'node:path';
|
||||
@@ -18,21 +25,18 @@ import { isSubpath } from '../utils/paths.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
|
||||
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import EventEmitter from 'node:events';
|
||||
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { fileExists } from '../utils/fileUtils.js';
|
||||
|
||||
vi.mock('../utils/fileUtils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils/fileUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
fileExists: vi.fn(),
|
||||
};
|
||||
});
|
||||
// Mock dependencies for canUseRipgrep
|
||||
vi.mock('@joshua.litt/get-ripgrep', () => ({
|
||||
downloadRipGrep: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock child_process for ripgrep calls
|
||||
vi.mock('child_process', () => ({
|
||||
@@ -40,42 +44,161 @@ vi.mock('child_process', () => ({
|
||||
}));
|
||||
|
||||
const mockSpawn = vi.mocked(spawn);
|
||||
const downloadRipGrepMock = vi.mocked(downloadRipGrep);
|
||||
const originalGetGlobalBinDir = Storage.getGlobalBinDir.bind(Storage);
|
||||
const storageSpy = vi.spyOn(Storage, 'getGlobalBinDir');
|
||||
|
||||
function getRipgrepBinaryName() {
|
||||
return process.platform === 'win32' ? 'rg.exe' : 'rg';
|
||||
}
|
||||
|
||||
describe('canUseRipgrep', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fileExists).mockReset();
|
||||
let tempRootDir: string;
|
||||
let binDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
|
||||
binDir = path.join(tempRootDir, 'bin');
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
|
||||
await fs.rm(tempRootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return true if ripgrep already exists', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
const existingPath = path.join(binDir, getRipgrepBinaryName());
|
||||
await fs.writeFile(existingPath, '');
|
||||
|
||||
const result = await canUseRipgrep();
|
||||
expect(result).toBe(true);
|
||||
expect(downloadRipGrepMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false if file does not exist', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
it('should download ripgrep and return true if it does not exist initially', async () => {
|
||||
const expectedPath = path.join(binDir, getRipgrepBinaryName());
|
||||
|
||||
downloadRipGrepMock.mockImplementation(async () => {
|
||||
await fs.writeFile(expectedPath, '');
|
||||
});
|
||||
|
||||
const result = await canUseRipgrep();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false if download fails and file does not exist', async () => {
|
||||
const result = await canUseRipgrep();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
});
|
||||
|
||||
it('should propagate errors from downloadRipGrep', async () => {
|
||||
const error = new Error('Download failed');
|
||||
downloadRipGrepMock.mockRejectedValue(error);
|
||||
|
||||
await expect(canUseRipgrep()).rejects.toThrow(error);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
});
|
||||
|
||||
it('should only download once when called concurrently', async () => {
|
||||
const expectedPath = path.join(binDir, getRipgrepBinaryName());
|
||||
|
||||
downloadRipGrepMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
fs.writeFile(expectedPath, '')
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
}, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
const firstCall = ensureRgPath();
|
||||
const secondCall = ensureRgPath();
|
||||
|
||||
const [pathOne, pathTwo] = await Promise.all([firstCall, secondCall]);
|
||||
|
||||
expect(pathOne).toBe(expectedPath);
|
||||
expect(pathTwo).toBe(expectedPath);
|
||||
expect(downloadRipGrepMock).toHaveBeenCalledTimes(1);
|
||||
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureRgPath', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fileExists).mockReset();
|
||||
let tempRootDir: string;
|
||||
let binDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
|
||||
binDir = path.join(tempRootDir, 'bin');
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
|
||||
await fs.rm(tempRootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return rg path if ripgrep already exists', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
const existingPath = path.join(binDir, getRipgrepBinaryName());
|
||||
await fs.writeFile(existingPath, '');
|
||||
|
||||
const rgPath = await ensureRgPath();
|
||||
expect(rgPath).toBe(await getRipgrepPath());
|
||||
expect(rgPath).toBe(existingPath);
|
||||
expect(downloadRipGrep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if ripgrep cannot be used', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
await expect(ensureRgPath()).rejects.toThrow(
|
||||
/Cannot find bundled ripgrep binary/,
|
||||
);
|
||||
it('should return rg path if ripgrep is downloaded successfully', async () => {
|
||||
const expectedPath = path.join(binDir, getRipgrepBinaryName());
|
||||
|
||||
downloadRipGrepMock.mockImplementation(async () => {
|
||||
await fs.writeFile(expectedPath, '');
|
||||
});
|
||||
|
||||
const rgPath = await ensureRgPath();
|
||||
expect(rgPath).toBe(expectedPath);
|
||||
expect(downloadRipGrep).toHaveBeenCalledTimes(1);
|
||||
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error if ripgrep cannot be used after download attempt', async () => {
|
||||
await expect(ensureRgPath()).rejects.toThrow('Cannot use ripgrep.');
|
||||
expect(downloadRipGrep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should propagate errors from downloadRipGrep', async () => {
|
||||
const error = new Error('Download failed');
|
||||
downloadRipGrepMock.mockRejectedValue(error);
|
||||
|
||||
await expect(ensureRgPath()).rejects.toThrow(error);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'win32')(
|
||||
'should detect ripgrep when only rg.exe exists on Windows',
|
||||
async () => {
|
||||
const expectedRgExePath = path.join(binDir, 'rg.exe');
|
||||
await fs.writeFile(expectedRgExePath, '');
|
||||
|
||||
const rgPath = await ensureRgPath();
|
||||
expect(rgPath).toBe(expectedRgExePath);
|
||||
expect(downloadRipGrep).not.toHaveBeenCalled();
|
||||
await expect(fs.access(expectedRgExePath)).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Helper function to create mock spawn implementations
|
||||
@@ -124,6 +247,9 @@ function createMockSpawn(
|
||||
|
||||
describe('RipGrepTool', () => {
|
||||
let tempRootDir: string;
|
||||
let tempBinRoot: string;
|
||||
let binDir: string;
|
||||
let ripgrepBinaryPath: string;
|
||||
let grepTool: RipGrepTool;
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
@@ -140,12 +266,19 @@ describe('RipGrepTool', () => {
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
mockSpawn.mockReset();
|
||||
mockSpawn.mockImplementation(createMockSpawn());
|
||||
tempBinRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
|
||||
binDir = path.join(tempBinRoot, 'bin');
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
const binaryName = process.platform === 'win32' ? 'rg.exe' : 'rg';
|
||||
ripgrepBinaryPath = path.join(binDir, binaryName);
|
||||
await fs.writeFile(ripgrepBinaryPath, '');
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
@@ -202,7 +335,9 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
|
||||
await fs.rm(tempRootDir, { recursive: true, force: true });
|
||||
await fs.rm(tempBinRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
@@ -699,16 +834,16 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
it('should throw an error if ripgrep is not available', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
await fs.rm(ripgrepBinaryPath, { force: true });
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
|
||||
const params: RipGrepToolParams = { pattern: 'world' };
|
||||
const invocation = grepTool.build(params);
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
expect(result.llmContent).toContain('Cannot find bundled ripgrep binary');
|
||||
|
||||
// restore the mock for subsequent tests
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
expect(await invocation.execute({ abortSignal })).toStrictEqual({
|
||||
llmContent: 'Error during grep search operation: Cannot use ripgrep.',
|
||||
returnDisplay: 'Error: Cannot use ripgrep.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1945,68 +2080,6 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRipgrepPath', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OS/Architecture Resolution', () => {
|
||||
it.each([
|
||||
{ platform: 'darwin', arch: 'arm64', expectedBin: 'rg-darwin-arm64' },
|
||||
{ platform: 'darwin', arch: 'x64', expectedBin: 'rg-darwin-x64' },
|
||||
{ platform: 'linux', arch: 'arm64', expectedBin: 'rg-linux-arm64' },
|
||||
{ platform: 'linux', arch: 'x64', expectedBin: 'rg-linux-x64' },
|
||||
{ platform: 'win32', arch: 'x64', expectedBin: 'rg-win32-x64.exe' },
|
||||
])(
|
||||
'should map $platform $arch to $expectedBin',
|
||||
async ({ platform, arch, expectedBin }) => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue(platform as NodeJS.Platform);
|
||||
vi.spyOn(os, 'arch').mockReturnValue(arch);
|
||||
vi.mocked(fileExists).mockImplementation(async (checkPath) =>
|
||||
checkPath.endsWith(expectedBin),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath?.endsWith(expectedBin)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Path Fallback Logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
vi.spyOn(os, 'arch').mockReturnValue('x64');
|
||||
});
|
||||
|
||||
it('should resolve the SEA (flattened) path first', async () => {
|
||||
vi.mocked(fileExists).mockImplementation(async (checkPath) =>
|
||||
checkPath.includes(path.normalize('tools/vendor/ripgrep')),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath).toContain(path.normalize('tools/vendor/ripgrep'));
|
||||
});
|
||||
|
||||
it('should fall back to the Dev path if SEA path is missing', async () => {
|
||||
vi.mocked(fileExists).mockImplementation(
|
||||
async (checkPath) =>
|
||||
checkPath.includes(path.normalize('core/vendor/ripgrep')) &&
|
||||
!checkPath.includes('tools'),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath).toContain(path.normalize('core/vendor/ripgrep'));
|
||||
expect(resolvedPath).not.toContain('tools');
|
||||
});
|
||||
|
||||
it('should return null if binary is missing from both paths', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).toBeNull();
|
||||
});
|
||||
});
|
||||
afterAll(() => {
|
||||
storageSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -8,8 +8,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
@@ -23,6 +22,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { fileExists } from '../utils/fileUtils.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { GREP_TOOL_NAME } from './tool-names.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
@@ -39,48 +39,73 @@ import { RIP_GREP_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { type GrepMatch, formatGrepResults } from './grep-utils.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
function getRgCandidateFilenames(): readonly string[] {
|
||||
return process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg'];
|
||||
}
|
||||
|
||||
export async function getRipgrepPath(): Promise<string | null> {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
// Map to the correct bundled binary
|
||||
const binName = `rg-${platform}-${arch}${platform === 'win32' ? '.exe' : ''}`;
|
||||
|
||||
const candidatePaths = [
|
||||
// 1. SEA runtime layout: everything is flattened into the root dir
|
||||
path.resolve(__dirname, 'vendor/ripgrep', binName),
|
||||
// 2. Dev/Dist layout: packages/core/dist/tools/ripGrep.js -> packages/core/vendor/ripgrep
|
||||
path.resolve(__dirname, '../../vendor/ripgrep', binName),
|
||||
];
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
if (await fileExists(candidate)) {
|
||||
return candidate;
|
||||
async function resolveExistingRgPath(): Promise<string | null> {
|
||||
const binDir = Storage.getGlobalBinDir();
|
||||
for (const fileName of getRgCandidateFilenames()) {
|
||||
const candidatePath = path.join(binDir, fileName);
|
||||
if (await fileExists(candidatePath)) {
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
let ripgrepAcquisitionPromise: Promise<string | null> | null = null;
|
||||
/**
|
||||
* Checks if `rg` exists in the bundled vendor directory.
|
||||
* Ensures a ripgrep binary is available.
|
||||
*
|
||||
* NOTE:
|
||||
* - The Gemini CLI currently prefers a managed ripgrep binary downloaded
|
||||
* into its global bin directory.
|
||||
* - Even if ripgrep is available on the system PATH, it is intentionally
|
||||
* not used at this time.
|
||||
*
|
||||
* Preference for system-installed ripgrep is blocked on:
|
||||
* - checksum verification of external binaries
|
||||
* - internalization of the get-ripgrep dependency
|
||||
*
|
||||
* See:
|
||||
* - feat(core): Prefer rg in system path (#11847)
|
||||
* - Move get-ripgrep to third_party (#12099)
|
||||
*/
|
||||
export async function canUseRipgrep(): Promise<boolean> {
|
||||
const binPath = await getRipgrepPath();
|
||||
return binPath !== null;
|
||||
async function ensureRipgrepAvailable(): Promise<string | null> {
|
||||
const existingPath = await resolveExistingRgPath();
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
if (!ripgrepAcquisitionPromise) {
|
||||
ripgrepAcquisitionPromise = (async () => {
|
||||
try {
|
||||
await downloadRipGrep(Storage.getGlobalBinDir());
|
||||
return await resolveExistingRgPath();
|
||||
} finally {
|
||||
ripgrepAcquisitionPromise = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return ripgrepAcquisitionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures `rg` is available, or throws.
|
||||
* Checks if `rg` exists, if not then attempt to download it.
|
||||
*/
|
||||
export async function canUseRipgrep(): Promise<boolean> {
|
||||
return (await ensureRipgrepAvailable()) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures `rg` is downloaded, or throws.
|
||||
*/
|
||||
export async function ensureRgPath(): Promise<string> {
|
||||
const binPath = await getRipgrepPath();
|
||||
if (binPath !== null) {
|
||||
return binPath;
|
||||
const downloadedPath = await ensureRipgrepAvailable();
|
||||
if (downloadedPath) {
|
||||
return downloadedPath;
|
||||
}
|
||||
throw new Error(`Cannot find bundled ripgrep binary.`);
|
||||
throw new Error('Cannot use ripgrep.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,7 +119,6 @@ describe('ShellTool', () => {
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
|
||||
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
|
||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000),
|
||||
getWorkspaceContext: vi
|
||||
.fn()
|
||||
.mockReturnValue(new WorkspaceContext(tempRootDir)),
|
||||
|
||||
@@ -9,7 +9,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { debugLogger } from '../index.js';
|
||||
import { type SandboxPermissions } from '../services/sandboxManager.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import {
|
||||
@@ -34,10 +34,7 @@ import {
|
||||
type ShellOutputEvent,
|
||||
} from '../services/shellExecutionService.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
import {
|
||||
serializeAnsiOutputToText,
|
||||
type AnsiOutput,
|
||||
} from '../utils/terminalSerializer.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import {
|
||||
getCommandRoots,
|
||||
initializeShellParsers,
|
||||
@@ -48,7 +45,6 @@ import {
|
||||
} from '../utils/shell-utils.js';
|
||||
import { SHELL_TOOL_NAME } from './tool-names.js';
|
||||
import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { getShellDefinition } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
@@ -253,10 +249,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
abortSignal: AbortSignal,
|
||||
forcedDecision?: ForcedToolDecision,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.context.config.getApprovalMode() === ApprovalMode.YOLO) {
|
||||
return super.shouldConfirmExecute(abortSignal, forcedDecision);
|
||||
}
|
||||
|
||||
if (this.params[PARAM_ADDITIONAL_PERMISSIONS]) {
|
||||
return this.getConfirmationDetails(abortSignal);
|
||||
}
|
||||
@@ -467,14 +459,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
|
||||
const onAbort = () => combinedController.abort();
|
||||
|
||||
const outputFileName = `gemini_shell_output_${crypto.randomBytes(6).toString('hex')}.log`;
|
||||
const projectTempDir = this.context.config.storage.getProjectTempDir();
|
||||
fs.mkdirSync(projectTempDir, { recursive: true });
|
||||
const outputFilePath = path.join(projectTempDir, outputFileName);
|
||||
const outputStream = fs.createWriteStream(outputFilePath);
|
||||
|
||||
let fullOutputReturned = false;
|
||||
|
||||
try {
|
||||
// pgrep is not available on Windows, so we can't get background PIDs
|
||||
const commandToExecute = this.wrapCommandForPgrep(
|
||||
@@ -501,7 +485,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
let cumulativeOutput: string | AnsiOutput = '';
|
||||
let lastUpdateTime = Date.now();
|
||||
let isBinaryStream = false;
|
||||
let totalBytesWritten = 0;
|
||||
|
||||
const resetTimeout = () => {
|
||||
if (timeoutMs <= 0) {
|
||||
@@ -527,65 +510,31 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
cwd,
|
||||
(event: ShellOutputEvent) => {
|
||||
resetTimeout(); // Reset timeout on any event
|
||||
if (!updateOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldUpdate = false;
|
||||
|
||||
switch (event.type) {
|
||||
case 'raw_data':
|
||||
// We do not write raw data to the file to avoid spurious escape codes.
|
||||
// We rely on 'file_data' for the clean output stream.
|
||||
break;
|
||||
case 'file_data':
|
||||
totalBytesWritten += Buffer.byteLength(event.chunk);
|
||||
outputStream.write(event.chunk);
|
||||
break;
|
||||
case 'data':
|
||||
if (isBinaryStream) break;
|
||||
if (typeof event.chunk === 'string') {
|
||||
if (typeof cumulativeOutput === 'string') {
|
||||
// Accumulate string chunks, capped at 16MB (same as ShellExecutionService)
|
||||
const MAX_BUFFER = 16 * 1024 * 1024;
|
||||
if (
|
||||
cumulativeOutput.length + event.chunk.length >
|
||||
MAX_BUFFER
|
||||
) {
|
||||
cumulativeOutput = (cumulativeOutput + event.chunk).slice(
|
||||
-MAX_BUFFER,
|
||||
);
|
||||
} else {
|
||||
cumulativeOutput += event.chunk;
|
||||
}
|
||||
} else {
|
||||
// In case of mode switch (unlikely)
|
||||
cumulativeOutput = event.chunk;
|
||||
}
|
||||
} else {
|
||||
// Snapshots (AnsiOutput) always replace
|
||||
cumulativeOutput = event.chunk;
|
||||
}
|
||||
if (updateOutput && !this.params.is_background) {
|
||||
updateOutput(cumulativeOutput);
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
cumulativeOutput = event.chunk;
|
||||
shouldUpdate = true;
|
||||
break;
|
||||
case 'binary_detected':
|
||||
isBinaryStream = true;
|
||||
cumulativeOutput =
|
||||
'[Binary output detected. Halting stream...]';
|
||||
if (updateOutput && !this.params.is_background) {
|
||||
updateOutput(cumulativeOutput);
|
||||
}
|
||||
shouldUpdate = true;
|
||||
break;
|
||||
case 'binary_progress':
|
||||
isBinaryStream = true;
|
||||
cumulativeOutput = `[Receiving binary output... ${formatBytes(
|
||||
event.bytesReceived,
|
||||
)} received]`;
|
||||
if (
|
||||
updateOutput &&
|
||||
!this.params.is_background &&
|
||||
Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS
|
||||
) {
|
||||
updateOutput(cumulativeOutput);
|
||||
lastUpdateTime = Date.now();
|
||||
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
|
||||
shouldUpdate = true;
|
||||
}
|
||||
break;
|
||||
case 'exit':
|
||||
@@ -594,6 +543,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
throw new Error('An unhandled ShellOutputEvent was found.');
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdate && !this.params.is_background) {
|
||||
updateOutput(cumulativeOutput);
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
},
|
||||
combinedController.signal,
|
||||
this.context.config.getEnableInteractiveShell(),
|
||||
@@ -656,14 +610,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
if (!completed) {
|
||||
const initialOutputText =
|
||||
typeof cumulativeOutput === 'string'
|
||||
? cumulativeOutput
|
||||
: serializeAnsiOutputToText(cumulativeOutput);
|
||||
|
||||
// Return early with initial output if still running
|
||||
return {
|
||||
llmContent: `Command is running in background. PID: ${pid}. Initial output:\n${initialOutputText}`,
|
||||
llmContent: `Command is running in background. PID: ${pid}. Initial output:\n${cumulativeOutput}`,
|
||||
returnDisplay: `Background process started with PID ${pid}.`,
|
||||
};
|
||||
}
|
||||
@@ -671,15 +620,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
const result = await resultPromise;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
outputStream.on('error', reject);
|
||||
outputStream.end(resolve);
|
||||
});
|
||||
|
||||
// Ensure the stream is fully closed before we proceed
|
||||
if (!outputStream.closed) {
|
||||
await new Promise<void>((resolve) => outputStream.on('close', resolve));
|
||||
}
|
||||
|
||||
const backgroundPIDs: number[] = [];
|
||||
if (os.platform() !== 'win32') {
|
||||
@@ -973,46 +913,21 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
this.context.geminiClient,
|
||||
signal,
|
||||
);
|
||||
const threshold = this.context.config.getTruncateToolOutputThreshold();
|
||||
const fullOutputFilePath =
|
||||
threshold > 0 && totalBytesWritten >= threshold
|
||||
? outputFilePath
|
||||
: undefined;
|
||||
|
||||
const toolResult: ToolResult = {
|
||||
return {
|
||||
llmContent: summary,
|
||||
returnDisplay,
|
||||
fullOutputFilePath,
|
||||
...executionError,
|
||||
};
|
||||
if (toolResult.fullOutputFilePath) {
|
||||
fullOutputReturned = true;
|
||||
}
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
const threshold = this.context.config.getTruncateToolOutputThreshold();
|
||||
const fullOutputFilePath =
|
||||
threshold > 0 && totalBytesWritten >= threshold
|
||||
? outputFilePath
|
||||
: undefined;
|
||||
|
||||
const toolResult: ToolResult = {
|
||||
return {
|
||||
llmContent,
|
||||
returnDisplay,
|
||||
data,
|
||||
fullOutputFilePath,
|
||||
...executionError,
|
||||
};
|
||||
if (toolResult.fullOutputFilePath) {
|
||||
fullOutputReturned = true;
|
||||
}
|
||||
return toolResult;
|
||||
} finally {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
if (!outputStream.closed) {
|
||||
outputStream.destroy();
|
||||
}
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
timeoutController.signal.removeEventListener('abort', onAbort);
|
||||
try {
|
||||
@@ -1020,13 +935,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
} catch {
|
||||
// Ignore errors during unlink
|
||||
}
|
||||
if (!fullOutputReturned) {
|
||||
try {
|
||||
await fsPromises.unlink(outputFilePath);
|
||||
} catch {
|
||||
// Ignore errors during unlink
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,13 +776,6 @@ export interface ToolResult {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional path to a file containing the full, non-truncated output of the tool.
|
||||
* If provided, the scheduler may use this file for long-term storage and
|
||||
* reference it in the conversation history if the output is truncated.
|
||||
*/
|
||||
fullOutputFilePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -195,7 +195,6 @@ describe('compatibility', () => {
|
||||
desc: '256 colors are not supported',
|
||||
},
|
||||
])('should return $expected when $desc', ({ depth, term, expected }) => {
|
||||
vi.stubEnv('COLORTERM', '');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(depth);
|
||||
if (term !== undefined) {
|
||||
vi.stubEnv('TERM', term);
|
||||
@@ -204,13 +203,6 @@ describe('compatibility', () => {
|
||||
}
|
||||
expect(supports256Colors()).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return true when COLORTERM is kmscon', () => {
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
vi.stubEnv('TERM', 'linux');
|
||||
vi.stubEnv('COLORTERM', 'kmscon');
|
||||
expect(supports256Colors()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsTrueColor', () => {
|
||||
@@ -238,12 +230,6 @@ describe('compatibility', () => {
|
||||
expected: true,
|
||||
desc: 'getColorDepth returns >= 24',
|
||||
},
|
||||
{
|
||||
colorterm: 'kmscon',
|
||||
depth: 4,
|
||||
expected: true,
|
||||
desc: 'COLORTERM is kmscon',
|
||||
},
|
||||
{
|
||||
colorterm: '',
|
||||
depth: 8,
|
||||
@@ -423,18 +409,6 @@ describe('compatibility', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return no color warnings for kmscon terminal', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
vi.stubEnv('TERM', 'linux');
|
||||
vi.stubEnv('COLORTERM', 'kmscon');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings.find((w) => w.id === '256-color')).toBeUndefined();
|
||||
expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return no warnings in a standard environment with true color', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
|
||||
@@ -85,11 +85,6 @@ export function supports256Colors(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Terminals supporting true color (like kmscon) also support 256 colors
|
||||
if (supportsTrueColor()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -100,8 +95,7 @@ export function supportsTrueColor(): boolean {
|
||||
// Check COLORTERM environment variable
|
||||
if (
|
||||
process.env['COLORTERM'] === 'truecolor' ||
|
||||
process.env['COLORTERM'] === '24bit' ||
|
||||
process.env['COLORTERM'] === 'kmscon'
|
||||
process.env['COLORTERM'] === '24bit'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -677,48 +677,6 @@ ${head}
|
||||
${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves tool output from a source path to a temporary file for later retrieval.
|
||||
*/
|
||||
export async function moveToolOutputToFile(
|
||||
sourcePath: string,
|
||||
toolName: string,
|
||||
id: string | number, // Accept string (callId) or number (truncationId)
|
||||
projectTempDir: string,
|
||||
sessionId?: string,
|
||||
): Promise<{ outputFile: string }> {
|
||||
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
|
||||
const safeId = sanitizeFilenamePart(id.toString()).toLowerCase();
|
||||
const fileName = safeId.startsWith(safeToolName)
|
||||
? `${safeId}.txt`
|
||||
: `${safeToolName}_${safeId}.txt`;
|
||||
|
||||
let toolOutputDir = path.join(projectTempDir, TOOL_OUTPUTS_DIR);
|
||||
if (sessionId) {
|
||||
const safeSessionId = sanitizeFilenamePart(sessionId);
|
||||
toolOutputDir = path.join(toolOutputDir, `session-${safeSessionId}`);
|
||||
}
|
||||
const outputFile = path.join(toolOutputDir, fileName);
|
||||
|
||||
await fsPromises.mkdir(toolOutputDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Attempt rename (efficient if on the same filesystem)
|
||||
await fsPromises.rename(sourcePath, outputFile);
|
||||
} catch (error: unknown) {
|
||||
// If rename fails (e.g. cross-filesystem), copy and then delete
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((error as { code?: string }).code === 'EXDEV') {
|
||||
await fsPromises.copyFile(sourcePath, outputFile);
|
||||
await fsPromises.unlink(sourcePath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { outputFile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves tool output to a temporary file for later retrieval.
|
||||
*/
|
||||
|
||||
@@ -22,13 +22,12 @@ function createFunctionResponsePart(
|
||||
callId: string,
|
||||
toolName: string,
|
||||
output: string,
|
||||
outputFile?: string,
|
||||
): Part {
|
||||
return {
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: { output, outputFile },
|
||||
response: { output },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -51,12 +50,9 @@ export function convertToFunctionResponse(
|
||||
llmContent: PartListUnion,
|
||||
model: string,
|
||||
config?: Config,
|
||||
outputFile?: string,
|
||||
): Part[] {
|
||||
if (typeof llmContent === 'string') {
|
||||
return [
|
||||
createFunctionResponsePart(callId, toolName, llmContent, outputFile),
|
||||
];
|
||||
return [createFunctionResponsePart(callId, toolName, llmContent)];
|
||||
}
|
||||
|
||||
const parts = toParts(llmContent);
|
||||
@@ -98,10 +94,7 @@ export function convertToFunctionResponse(
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: {
|
||||
...(textParts.length > 0 ? { output: textParts.join('\n') } : {}),
|
||||
outputFile,
|
||||
},
|
||||
response: textParts.length > 0 ? { output: textParts.join('\n') } : {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -511,40 +511,6 @@ describe('retryWithBackoff', () => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on OpenSSL 3.x SSL error code (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => {
|
||||
const error = new Error('SSL error');
|
||||
(error as any).code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC';
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on unknown SSL BAD_RECORD_MAC variant via substring fallback', async () => {
|
||||
const error = new Error('SSL error');
|
||||
(error as any).code = 'ERR_SSL_SOME_FUTURE_BAD_RECORD_MAC';
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on gaxios-style SSL error with code property', async () => {
|
||||
// This matches the exact structure from issue #17318
|
||||
const error = new Error(
|
||||
|
||||
@@ -53,30 +53,14 @@ const RETRYABLE_NETWORK_CODES = [
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
// SSL/TLS transient errors
|
||||
'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC',
|
||||
'ERR_SSL_WRONG_VERSION_NUMBER',
|
||||
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
|
||||
'ERR_SSL_BAD_RECORD_MAC',
|
||||
'EPROTO', // Generic protocol error (often SSL-related)
|
||||
];
|
||||
|
||||
// Node.js builds SSL error codes by prepending ERR_SSL_ to the uppercased
|
||||
// OpenSSL reason string with spaces replaced by underscores (see
|
||||
// TLSWrap::ClearOut in node/src/crypto/crypto_tls.cc). The reason string
|
||||
// format varies by OpenSSL version (e.g. ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC
|
||||
// on OpenSSL 1.x, ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC on OpenSSL 3.x), so
|
||||
// match the stable suffix instead of enumerating every variant.
|
||||
const RETRYABLE_SSL_ERROR_PATTERN = /^ERR_SSL_.*BAD_RECORD_MAC/i;
|
||||
|
||||
/**
|
||||
* Returns true if the error code should be retried: either an exact match
|
||||
* against RETRYABLE_NETWORK_CODES, or an SSL BAD_RECORD_MAC variant (the
|
||||
* OpenSSL reason-string portion of the code varies across OpenSSL versions).
|
||||
*/
|
||||
function isRetryableSslErrorCode(code: string): boolean {
|
||||
return (
|
||||
RETRYABLE_NETWORK_CODES.includes(code) ||
|
||||
RETRYABLE_SSL_ERROR_PATTERN.test(code)
|
||||
);
|
||||
}
|
||||
|
||||
function getNetworkErrorCode(error: unknown): string | undefined {
|
||||
const getCode = (obj: unknown): string | undefined => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
@@ -128,7 +112,7 @@ export function getRetryErrorType(error: unknown): string {
|
||||
}
|
||||
|
||||
const errorCode = getNetworkErrorCode(error);
|
||||
if (errorCode && isRetryableSslErrorCode(errorCode)) {
|
||||
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
@@ -169,7 +153,7 @@ export function isRetryableError(
|
||||
): boolean {
|
||||
// Check for common network error codes
|
||||
const errorCode = getNetworkErrorCode(error);
|
||||
if (errorCode && isRetryableSslErrorCode(errorCode)) {
|
||||
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -250,12 +250,6 @@ export function serializeTerminalToObject(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function serializeAnsiOutputToText(output: AnsiOutput): string {
|
||||
return output
|
||||
.map((line) => line.map((segment) => segment.text).join(''))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ANSI color palette from https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit
|
||||
const ANSI_COLORS = [
|
||||
'#000000',
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -147,9 +147,7 @@ export class PerfTestHarness {
|
||||
throw new Error(`No active timer found for label "${label}"`);
|
||||
}
|
||||
|
||||
// Round wall-clock time to nearest 0.1 ms
|
||||
const wallClockMs =
|
||||
Math.round((performance.now() - timer.startTime) * 10) / 10;
|
||||
const wallClockMs = performance.now() - timer.startTime;
|
||||
const cpuDelta = process.cpuUsage(timer.startCpuUsage);
|
||||
this.activeTimers.delete(label);
|
||||
|
||||
|
||||
@@ -193,28 +193,6 @@ export function checkModelOutputContent(
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export interface MetricDataPoint {
|
||||
attributes?: Record<string, unknown>;
|
||||
value?: {
|
||||
sum?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
count?: number;
|
||||
};
|
||||
startTime?: [number, number];
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export interface TelemetryMetric {
|
||||
descriptor: {
|
||||
name: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
unit?: string;
|
||||
};
|
||||
dataPoints: MetricDataPoint[];
|
||||
}
|
||||
|
||||
export interface ParsedLog {
|
||||
attributes?: {
|
||||
'event.name'?: string;
|
||||
@@ -235,7 +213,11 @@ export interface ParsedLog {
|
||||
prompt_id?: string;
|
||||
};
|
||||
scopeMetrics?: {
|
||||
metrics: TelemetryMetric[];
|
||||
metrics: {
|
||||
descriptor: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -1315,10 +1297,6 @@ export class TestRig {
|
||||
return logs;
|
||||
}
|
||||
|
||||
readTelemetryLogs(): ParsedLog[] {
|
||||
return this._readAndParseTelemetryLog();
|
||||
}
|
||||
|
||||
private _readAndParseTelemetryLog(): ParsedLog[] {
|
||||
// Telemetry is always written to the test directory
|
||||
const logFilePath = join(this.homeDir!, 'telemetry.log');
|
||||
@@ -1472,7 +1450,7 @@ export class TestRig {
|
||||
);
|
||||
}
|
||||
|
||||
readMetric(metricName: string): TelemetryMetric | null {
|
||||
readMetric(metricName: string): Record<string, unknown> | null {
|
||||
const logs = this._readAndParseTelemetryLog();
|
||||
for (const logData of logs) {
|
||||
if (logData.scopeMetrics) {
|
||||
|
||||
@@ -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.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-04-14T14:04:02.662Z",
|
||||
"updatedAt": "2026-04-09T02:30:22.000Z",
|
||||
"scenarios": {
|
||||
"cold-startup-time": {
|
||||
"wallClockMs": 927.6,
|
||||
"wallClockMs": 927.553249999999,
|
||||
"cpuTotalUs": 1470,
|
||||
"timestamp": "2026-04-08T22:27:54.871Z"
|
||||
},
|
||||
"idle-cpu-usage": {
|
||||
"wallClockMs": 5000.5,
|
||||
"wallClockMs": 5000.460750000002,
|
||||
"cpuTotalUs": 12157,
|
||||
"timestamp": "2026-04-08T22:28:19.098Z"
|
||||
},
|
||||
"asian-language-conv": {
|
||||
"wallClockMs": 2315.1,
|
||||
"cpuTotalUs": 6283,
|
||||
"timestamp": "2026-04-14T15:22:56.133Z"
|
||||
},
|
||||
"skill-loading-time": {
|
||||
"wallClockMs": 930.1,
|
||||
"wallClockMs": 930.0920409999962,
|
||||
"cpuTotalUs": 1323,
|
||||
"timestamp": "2026-04-08T22:28:23.290Z"
|
||||
},
|
||||
@@ -26,31 +21,6 @@
|
||||
"wallClockMs": 1119.9,
|
||||
"cpuTotalUs": 2100,
|
||||
"timestamp": "2026-04-09T02:30:22.000Z"
|
||||
},
|
||||
"long-conversation-resume": {
|
||||
"wallClockMs": 4212.5,
|
||||
"cpuTotalUs": 351393,
|
||||
"timestamp": "2026-04-14T14:02:53.268Z"
|
||||
},
|
||||
"long-conversation-typing": {
|
||||
"wallClockMs": 113.7,
|
||||
"cpuTotalUs": 3304,
|
||||
"timestamp": "2026-04-14T14:03:12.525Z"
|
||||
},
|
||||
"long-conversation-execution": {
|
||||
"wallClockMs": 248.7,
|
||||
"cpuTotalUs": 3825,
|
||||
"timestamp": "2026-04-14T14:03:28.575Z"
|
||||
},
|
||||
"long-conversation-terminal-scrolling": {
|
||||
"wallClockMs": 362.4,
|
||||
"cpuTotalUs": 12755860,
|
||||
"timestamp": "2026-04-14T14:03:45.687Z"
|
||||
},
|
||||
"long-conversation-alternate-scrolling": {
|
||||
"wallClockMs": 362.4,
|
||||
"cpuTotalUs": 12755860,
|
||||
"timestamp": "2026-04-14T14:04:02.662Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
TestRig,
|
||||
PerfTestHarness,
|
||||
type PerfSnapshot,
|
||||
} from '@google/gemini-cli-test-utils';
|
||||
import { TestRig, PerfTestHarness } from '@google/gemini-cli-test-utils';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINES_PATH = join(__dirname, 'baselines.json');
|
||||
@@ -108,36 +98,6 @@ describe('CPU Performance Tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('asian-language-conv: verify perf is acceptable ', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'asian-language-conv',
|
||||
async () => {
|
||||
const rig = new TestRig();
|
||||
try {
|
||||
rig.setup('perf-asian-language', {
|
||||
fakeResponsesPath: join(__dirname, 'perf.asian-language.responses'),
|
||||
});
|
||||
|
||||
return await harness.measure('asian-language', async () => {
|
||||
await rig.run({
|
||||
args: ['嗨'],
|
||||
timeout: 120000,
|
||||
env: { GEMINI_API_KEY: 'fake-perf-test-key' },
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('skill-loading-time: startup with many skills within baseline', async () => {
|
||||
const SKILL_COUNT = 20;
|
||||
|
||||
@@ -205,7 +165,7 @@ describe('CPU Performance Tests', () => {
|
||||
const snapshot = await harness.measureWithEventLoop(
|
||||
'high-volume-output',
|
||||
async () => {
|
||||
await rig.run({
|
||||
const runResult = await rig.run({
|
||||
args: ['Generate 1M lines of output'],
|
||||
timeout: 120000,
|
||||
env: {
|
||||
@@ -216,6 +176,7 @@ describe('CPU Performance Tests', () => {
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
console.log(` Child Process Output:`, runResult);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -255,7 +216,8 @@ describe('CPU Performance Tests', () => {
|
||||
JSON.stringify(toolLatencyMetric),
|
||||
);
|
||||
}
|
||||
const logs = rig.readTelemetryLogs();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const logs = (rig as any)._readAndParseTelemetryLog();
|
||||
console.log(` Total telemetry log entries: ${logs.length}`);
|
||||
for (const logData of logs) {
|
||||
if (logData.scopeMetrics) {
|
||||
@@ -280,9 +242,10 @@ describe('CPU Performance Tests', () => {
|
||||
|
||||
const findValue = (percentile: string) => {
|
||||
const dp = eventLoopMetric.dataPoints.find(
|
||||
(p) => p.attributes?.['percentile'] === percentile,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(p: any) => p.attributes.percentile === percentile,
|
||||
);
|
||||
return dp?.value?.min;
|
||||
return dp ? dp.value.min : undefined;
|
||||
};
|
||||
|
||||
snapshot.childEventLoopDelayP50Ms = findValue('p50');
|
||||
@@ -303,358 +266,4 @@ describe('CPU Performance Tests', () => {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
describe('long-conversation', () => {
|
||||
let rig: TestRig;
|
||||
const identifier = 'perf-long-conversation';
|
||||
const SESSION_ID =
|
||||
'anonymous_unique_id_577296e0eee5afecdcec05d11838e0cd1a851cd97a28119a4a876b11';
|
||||
const LARGE_CHAT_SOURCE = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'memory-tests',
|
||||
'large-chat-session.json',
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!existsSync(LARGE_CHAT_SOURCE)) {
|
||||
throw new Error(
|
||||
`Performance test fixture missing: ${LARGE_CHAT_SOURCE}.`,
|
||||
);
|
||||
}
|
||||
|
||||
rig = new TestRig();
|
||||
rig.setup(identifier, {
|
||||
fakeResponsesPath: join(__dirname, 'perf.long-chat.responses'),
|
||||
});
|
||||
|
||||
const geminiDir = join(rig.homeDir!, '.gemini');
|
||||
const projectTempDir = join(geminiDir, 'tmp', identifier);
|
||||
const targetChatsDir = join(projectTempDir, 'chats');
|
||||
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(geminiDir, 'projects.json'),
|
||||
JSON.stringify({
|
||||
projects: { [rig.testDir!]: identifier },
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(projectTempDir, '.project_root'), rig.testDir!);
|
||||
copyFileSync(
|
||||
LARGE_CHAT_SOURCE,
|
||||
join(targetChatsDir, `session-${SESSION_ID}.json`),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it('session-load: resume a 60MB chat history', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-resume',
|
||||
async () => {
|
||||
const snapshot = await harness.measureWithEventLoop(
|
||||
'resume',
|
||||
async () => {
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
await run.kill();
|
||||
},
|
||||
);
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('typing: latency when typing into a large session', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-typing',
|
||||
async () => {
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await harness.measureWithEventLoop(
|
||||
'typing',
|
||||
async () => {
|
||||
// On average, the expected latency per key is under 30ms.
|
||||
for (const char of 'Hello') {
|
||||
await run.type(char);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await run.kill();
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('execution: response latency for a simple shell command', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-execution',
|
||||
async () => {
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
await run.expectText('Type your message');
|
||||
|
||||
const snapshot = await harness.measureWithEventLoop(
|
||||
'execution',
|
||||
async () => {
|
||||
await run.sendKeys('!echo hi\r');
|
||||
await run.expectText('hi');
|
||||
},
|
||||
);
|
||||
|
||||
await run.kill();
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('terminal-scrolling: latency when scrolling a large terminal buffer', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-terminal-scrolling',
|
||||
async () => {
|
||||
// Enable terminalBuffer to intentionally test CLI scrolling logic
|
||||
const settingsPath = join(rig.homeDir!, '.gemini', 'settings.json');
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
security: { folderTrust: { enabled: false } },
|
||||
ui: { terminalBuffer: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
await run.expectText('Type your message');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await run.sendKeys('\u001b[5~'); // PageUp
|
||||
}
|
||||
|
||||
// Scroll to the very top
|
||||
await run.sendKeys('\u001b[H'); // Home
|
||||
// Verify top line of chat is visible.
|
||||
await run.expectText('Authenticated with');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await run.sendKeys('\u001b[6~'); // PageDown
|
||||
}
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
await run.kill();
|
||||
|
||||
const eventLoopMetric = rig.readMetric('event_loop.delay');
|
||||
const cpuMetric = rig.readMetric('cpu.usage');
|
||||
|
||||
let p50Ms = 0;
|
||||
let p95Ms = 0;
|
||||
let maxMs = 0;
|
||||
if (eventLoopMetric) {
|
||||
const dataPoints = eventLoopMetric.dataPoints;
|
||||
const p50Data = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'p50',
|
||||
);
|
||||
const p95Data = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'p95',
|
||||
);
|
||||
const maxData = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'max',
|
||||
);
|
||||
|
||||
if (p50Data?.value?.sum) p50Ms = p50Data.value.sum;
|
||||
if (p95Data?.value?.sum) p95Ms = p95Data.value.sum;
|
||||
if (maxData?.value?.sum) maxMs = maxData.value.sum;
|
||||
}
|
||||
|
||||
let cpuTotalUs = 0;
|
||||
if (cpuMetric) {
|
||||
const dataPoints = cpuMetric.dataPoints;
|
||||
for (const dp of dataPoints) {
|
||||
if (dp.value?.sum && dp.value.sum > 0) {
|
||||
cpuTotalUs += dp.value.sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
const cpuUserUs = cpuTotalUs;
|
||||
const cpuSystemUs = 0;
|
||||
|
||||
const snapshot: PerfSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
label: 'scrolling',
|
||||
wallClockMs: Math.round(p50Ms * 10) / 10,
|
||||
cpuTotalUs,
|
||||
cpuUserUs,
|
||||
cpuSystemUs,
|
||||
eventLoopDelayP50Ms: p50Ms,
|
||||
eventLoopDelayP95Ms: p95Ms,
|
||||
eventLoopDelayMaxMs: maxMs,
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('alternate-scrolling: latency when scrolling a large alternate buffer', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-alternate-scrolling',
|
||||
async () => {
|
||||
// Enable useAlternateBuffer to intentionally test CLI scrolling logic
|
||||
const settingsPath = join(rig.homeDir!, '.gemini', 'settings.json');
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
security: { folderTrust: { enabled: false } },
|
||||
ui: { useAlternateBuffer: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
await run.expectText('Type your message');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await run.sendKeys('\u001b[5~'); // PageUp
|
||||
}
|
||||
|
||||
// Scroll to the very top
|
||||
await run.sendKeys('\u001b[H'); // Home
|
||||
// Verify top line of chat is visible.
|
||||
await run.expectText('Authenticated with');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await run.sendKeys('\u001b[6~'); // PageDown
|
||||
}
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
await run.kill();
|
||||
|
||||
const eventLoopMetric = rig.readMetric('event_loop.delay');
|
||||
const cpuMetric = rig.readMetric('cpu.usage');
|
||||
|
||||
let p50Ms = 0;
|
||||
let p95Ms = 0;
|
||||
let maxMs = 0;
|
||||
if (eventLoopMetric) {
|
||||
const dataPoints = eventLoopMetric.dataPoints;
|
||||
const p50Data = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'p50',
|
||||
);
|
||||
const p95Data = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'p95',
|
||||
);
|
||||
const maxData = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'max',
|
||||
);
|
||||
|
||||
if (p50Data?.value?.sum) p50Ms = p50Data.value.sum;
|
||||
if (p95Data?.value?.sum) p95Ms = p95Data.value.sum;
|
||||
if (maxData?.value?.sum) maxMs = maxData.value.sum;
|
||||
}
|
||||
|
||||
let cpuTotalUs = 0;
|
||||
if (cpuMetric) {
|
||||
const dataPoints = cpuMetric.dataPoints;
|
||||
for (const dp of dataPoints) {
|
||||
if (dp.value?.sum && dp.value.sum > 0) {
|
||||
cpuTotalUs += dp.value.sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
const cpuUserUs = cpuTotalUs;
|
||||
const cpuSystemUs = 0;
|
||||
|
||||
const snapshot: PerfSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
label: 'scrolling',
|
||||
wallClockMs: Math.round(p50Ms * 10) / 10,
|
||||
cpuTotalUs,
|
||||
cpuUserUs,
|
||||
cpuSystemUs,
|
||||
eventLoopDelayP50Ms: p50Ms,
|
||||
eventLoopDelayP95Ms: p95Ms,
|
||||
eventLoopDelayMaxMs: maxMs,
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"你好!我是 Gemini CLI,你的 AI 编程助手"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":20648,"candidatesTokenCount":12,"totalTokenCount":20769,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\"complexity_reasoning\":\"simple\",\"complexity_score\":1}"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am a large conversation model response."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"candidatesTokenCount":10,"promptTokenCount":20,"totalTokenCount":30}}]}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\"originalSummary\":\"large chat summary\",\"events\":[]}"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"countTokens","response":{"totalTokens":100}}
|
||||
@@ -108,16 +108,4 @@ if (!existsSync(bundleMcpSrc)) {
|
||||
cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true });
|
||||
console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/');
|
||||
|
||||
// 7. Copy pre-built ripgrep vendor binaries
|
||||
const ripgrepVendorSrc = join(root, 'packages/core/vendor/ripgrep');
|
||||
const ripgrepVendorDest = join(bundleDir, 'vendor', 'ripgrep');
|
||||
if (existsSync(ripgrepVendorSrc)) {
|
||||
mkdirSync(ripgrepVendorDest, { recursive: true });
|
||||
cpSync(ripgrepVendorSrc, ripgrepVendorDest, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
console.log('Copied ripgrep vendor binaries to bundle/vendor/ripgrep/');
|
||||
}
|
||||
|
||||
console.log('Assets copied to bundle/');
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview This script downloads pre-built ripgrep binaries for all supported
|
||||
* architectures and platforms. These binaries are checked into the repository
|
||||
* under packages/core/vendor/ripgrep.
|
||||
*
|
||||
* Maintainers should periodically run this script to upgrade the version
|
||||
* of ripgrep being distributed.
|
||||
*
|
||||
* Usage: npx tsx scripts/download-ripgrep-binaries.ts
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream } from 'node:stream/web';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CORE_VENDOR_DIR = path.join(__dirname, '../packages/core/vendor/ripgrep');
|
||||
const VERSION = 'v13.0.0-10';
|
||||
|
||||
interface Target {
|
||||
platform: string;
|
||||
arch: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
const targets: Target[] = [
|
||||
{ platform: 'darwin', arch: 'arm64', file: 'aarch64-apple-darwin.tar.gz' },
|
||||
{ platform: 'darwin', arch: 'x64', file: 'x86_64-apple-darwin.tar.gz' },
|
||||
{
|
||||
platform: 'linux',
|
||||
arch: 'arm64',
|
||||
file: 'aarch64-unknown-linux-gnu.tar.gz',
|
||||
},
|
||||
{ platform: 'linux', arch: 'x64', file: 'x86_64-unknown-linux-musl.tar.gz' },
|
||||
{ platform: 'win32', arch: 'x64', file: 'x86_64-pc-windows-msvc.zip' },
|
||||
];
|
||||
|
||||
async function downloadBinary() {
|
||||
await fsPromises.mkdir(CORE_VENDOR_DIR, { recursive: true });
|
||||
|
||||
for (const target of targets) {
|
||||
const url = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/${VERSION}/ripgrep-${VERSION}-${target.file}`;
|
||||
const archivePath = path.join(CORE_VENDOR_DIR, target.file);
|
||||
const binName = `rg-${target.platform}-${target.arch}${target.platform === 'win32' ? '.exe' : ''}`;
|
||||
const finalBinPath = path.join(CORE_VENDOR_DIR, binName);
|
||||
|
||||
if (fs.existsSync(finalBinPath)) {
|
||||
console.log(`[Cache] ${binName} already exists.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[Download] ${url} -> ${archivePath}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(`Response body is null for ${url}`);
|
||||
}
|
||||
|
||||
const fileStream = createWriteStream(archivePath);
|
||||
|
||||
// Node 18+ global fetch response.body is a ReadableStream (web stream)
|
||||
// pipeline(Readable.fromWeb(response.body), fileStream) works in Node 18+
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as ReadableStream),
|
||||
fileStream,
|
||||
);
|
||||
|
||||
console.log(`[Extract] Extracting ${archivePath}...`);
|
||||
// Extract using shell commands for simplicity
|
||||
if (target.file.endsWith('.tar.gz')) {
|
||||
execFileSync('tar', ['-xzf', archivePath, '-C', CORE_VENDOR_DIR]);
|
||||
// Microsoft's ripgrep release extracts directly to `rg` inside the current directory sometimes
|
||||
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg');
|
||||
if (fs.existsSync(sourceBin)) {
|
||||
await fsPromises.rename(sourceBin, finalBinPath);
|
||||
} else {
|
||||
// Fallback for sub-directory if it happens
|
||||
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.tar.gz', '')}`;
|
||||
const fallbackSourceBin = path.join(
|
||||
CORE_VENDOR_DIR,
|
||||
extractedDirName,
|
||||
'rg',
|
||||
);
|
||||
if (fs.existsSync(fallbackSourceBin)) {
|
||||
await fsPromises.rename(fallbackSourceBin, finalBinPath);
|
||||
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
`Could not find extracted 'rg' binary for ${target.platform} ${target.arch}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (target.file.endsWith('.zip')) {
|
||||
execFileSync('unzip', ['-o', '-q', archivePath, '-d', CORE_VENDOR_DIR]);
|
||||
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg.exe');
|
||||
if (fs.existsSync(sourceBin)) {
|
||||
await fsPromises.rename(sourceBin, finalBinPath);
|
||||
} else {
|
||||
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.zip', '')}`;
|
||||
const fallbackSourceBin = path.join(
|
||||
CORE_VENDOR_DIR,
|
||||
extractedDirName,
|
||||
'rg.exe',
|
||||
);
|
||||
if (fs.existsSync(fallbackSourceBin)) {
|
||||
await fsPromises.rename(fallbackSourceBin, finalBinPath);
|
||||
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
`Could not find extracted 'rg.exe' binary for ${target.platform} ${target.arch}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up archive
|
||||
await fsPromises.unlink(archivePath);
|
||||
console.log(`[Success] Saved to ${finalBinPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
downloadBinary().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user