mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a5c470faf | |||
| ae509f1ba5 | |||
| 65c3d7cabb | |||
| c72ac00a53 | |||
| 2bc481a3b5 | |||
| 169f9e457d | |||
| 8481d977bb | |||
| dd4d7301ba | |||
| 5357770a3d | |||
| dc3ab31af0 |
@@ -2,11 +2,7 @@
|
||||
"experimental": {
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true,
|
||||
"gemma": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"voiceMode": true
|
||||
"autoMemory": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -337,7 +337,6 @@ jobs:
|
||||
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
GEMINI_MODEL: 'gemini-3-pro-preview'
|
||||
# Only run always passes behavioral tests.
|
||||
EVAL_SUITE_TYPE: 'behavioral'
|
||||
|
||||
@@ -66,7 +66,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: 'true'
|
||||
EVAL_SUITE_TYPE: "${{ github.event.inputs.suite_type || 'behavioral' }}"
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
name: '🧠 Gemini CLI Bot: Brain'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Every 24 hours
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
brain:
|
||||
name: 'Brain (Reasoning Layer)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build Gemini CLI'
|
||||
run: 'npm run bundle'
|
||||
|
||||
- name: 'Download Previous Metrics'
|
||||
uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # ratchet:actions/download-artifact@v4
|
||||
with:
|
||||
name: 'metrics-before'
|
||||
path: 'tools/gemini-cli-bot/history/'
|
||||
continue-on-error: true
|
||||
@@ -1,59 +0,0 @@
|
||||
name: '🔄 Gemini CLI Bot: Pulse'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/30 * * * *' # Every 30 minutes
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
pulse:
|
||||
name: 'Pulse (Reflex Layer)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Collect Metrics'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: 'npm run metrics'
|
||||
|
||||
- name: 'Archive Metrics'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'metrics-before'
|
||||
path: 'metrics-before.csv'
|
||||
|
||||
- name: 'Run Reflex Processes'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
if [ -d "tools/gemini-cli-bot/processes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/processes/scripts)" ]; then
|
||||
for script in tools/gemini-cli-bot/processes/scripts/*.ts; do
|
||||
echo "Running reflex script: $script"
|
||||
npx tsx "$script"
|
||||
done
|
||||
else
|
||||
echo "No reflex scripts found."
|
||||
fi
|
||||
+2
-2
@@ -40,8 +40,8 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
USER node
|
||||
|
||||
# install gemini-cli and clean up
|
||||
COPY --chown=node:node packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY --chown=node:node packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
RUN npm install -g /tmp/gemini-core.tgz \
|
||||
&& npm install -g /tmp/gemini-cli.tgz \
|
||||
&& node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
|
||||
|
||||
@@ -371,8 +371,6 @@ for planned features and priorities.
|
||||
|
||||
## 📖 Resources
|
||||
|
||||
- **[Free Course](https://learn.deeplearning.ai/courses/gemini-cli-code-and-create-with-an-open-source-agent/information)** -
|
||||
Learn the basics.
|
||||
- **[Official Roadmap](./ROADMAP.md)** - See what's coming next.
|
||||
- **[Changelog](https://www.geminicli.com/docs/changelogs)** - See recent
|
||||
notable updates.
|
||||
|
||||
@@ -18,24 +18,6 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.39.0 - 2026-04-23
|
||||
|
||||
- **Skill Management:** Added a new `/memory` inbox command for reviewing and
|
||||
patching skills extracted during sessions
|
||||
([#24544](https://github.com/google-gemini/gemini-cli/pull/24544) by
|
||||
@SandyTao520, [#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
by @SandyTao520).
|
||||
- **Improved Transparency:** Plan Mode now requires confirmation for skill
|
||||
activation and allows plan inspection
|
||||
([#24946](https://github.com/google-gemini/gemini-cli/pull/24946),
|
||||
[#25058](https://github.com/google-gemini/gemini-cli/pull/25058) by
|
||||
@ruomengz).
|
||||
- **Architecture & Reliability:** Introduced a decoupled `ContextManager`
|
||||
architecture and resolved several critical memory leaks and PTY exhaustion
|
||||
issues ([#24752](https://github.com/google-gemini/gemini-cli/pull/24752) by
|
||||
@joshualitt, [#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
by @spencer426).
|
||||
|
||||
## Announcements: v0.38.0 - 2026-04-14
|
||||
|
||||
- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on
|
||||
|
||||
+255
-243
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.39.0
|
||||
# Latest stable release: v0.38.2
|
||||
|
||||
Released: April 23, 2026
|
||||
Released: April 17, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,252 +11,264 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Skill Extractor & Memory Inbox:** Introduced the `/memory` command to review
|
||||
and patch skills extracted during agent sessions, streamlining the continuous
|
||||
learning workflow.
|
||||
- **Enhanced Plan Mode Security:** Increased transparency in Plan Mode by
|
||||
requiring user confirmation for skill activation and allowing users to view
|
||||
the full content of generated plans.
|
||||
- **Advanced Display Protocol:** Implemented a tool-controlled display protocol,
|
||||
enabling agents to provide richer, more structured visual feedback during
|
||||
execution.
|
||||
- **Core Architecture Refactor:** Introduced a decoupled `ContextManager` and
|
||||
`Sidecar` architecture to improve state management and session resilience.
|
||||
- **Streamlined Agent Feedback:** Restored the display of model thoughts and raw
|
||||
text in responses, ensuring full visibility into the agent's reasoning
|
||||
process.
|
||||
- **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.
|
||||
|
||||
## 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(patch): cherry-pick 14b2f35 to release/v0.38.1-pr-24974 to patch version
|
||||
v0.38.1 and create version 0.38.2 by @gemini-cli-robot in
|
||||
[#25585](https://github.com/google-gemini/gemini-cli/pull/25585)
|
||||
- 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
|
||||
[#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)
|
||||
- fix(patch): cherry-pick a4e98c0 to release/v0.39.0-preview.0-pr-25138 to patch
|
||||
version v0.39.0-preview.0 and create version 0.39.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#25766](https://github.com/google-gemini/gemini-cli/pull/25766)
|
||||
- fix(patch): cherry-pick d6f88f8 to release/v0.39.0-preview.1-pr-25670 to patch
|
||||
version v0.39.0-preview.1 and create version 0.39.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#25776](https://github.com/google-gemini/gemini-cli/pull/25776)
|
||||
[#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.2...v0.39.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.2
|
||||
|
||||
+13
-19
@@ -161,25 +161,19 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
|
||||
| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` |
|
||||
| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
|
||||
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. | `"gemini-live"` |
|
||||
| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
|
||||
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `1000` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -117,10 +117,6 @@ the following methods:
|
||||
These methods will trust the current workspace for the duration of the session
|
||||
without prompting.
|
||||
|
||||
For detailed instructions on managing folder trust within CI/CD workflows,
|
||||
review the
|
||||
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
|
||||
|
||||
## Overriding the trust file location
|
||||
|
||||
By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you
|
||||
|
||||
@@ -563,18 +563,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-2.5-flash-lite"
|
||||
}
|
||||
},
|
||||
"gemma-4-31b-it": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemma-4-31b-it"
|
||||
}
|
||||
},
|
||||
"gemma-4-26b-a4b-it": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemma-4-26b-a4b-it"
|
||||
}
|
||||
},
|
||||
"gemini-2.5-flash-base": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
@@ -846,28 +834,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemma-4-31b-it": {
|
||||
"displayName": "gemma-4-31b-it",
|
||||
"tier": "custom",
|
||||
"family": "gemma-4",
|
||||
"isPreview": false,
|
||||
"isVisible": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemma-4-26b-a4b-it": {
|
||||
"displayName": "gemma-4-26b-a4b-it",
|
||||
"tier": "custom",
|
||||
"family": "gemma-4",
|
||||
"isPreview": false,
|
||||
"isVisible": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
"tier": "auto",
|
||||
"isPreview": true,
|
||||
@@ -938,12 +904,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
```json
|
||||
{
|
||||
"gemma-4-31b-it": {
|
||||
"default": "gemma-4-31b-it"
|
||||
},
|
||||
"gemma-4-26b-a4b-it": {
|
||||
"default": "gemma-4-26b-a4b-it"
|
||||
},
|
||||
"gemini-3.1-pro-preview": {
|
||||
"default": "gemini-3.1-pro-preview",
|
||||
"contexts": [
|
||||
@@ -1507,12 +1467,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.confirmationRequired`** (array):
|
||||
- **Description:** Tool names that always require user confirmation. Takes
|
||||
precedence over allowed tools and core tool allowlists.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.exclude`** (array):
|
||||
- **Description:** Tool names to exclude from discovery.
|
||||
- **Default:** `undefined`
|
||||
@@ -1686,37 +1640,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.gemma`** (boolean):
|
||||
- **Description:** Enable access to Gemma 4 models (experimental).
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.voiceMode`** (boolean):
|
||||
- **Description:** Enable experimental voice dictation and commands (/voice,
|
||||
/voice model).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.voice.activationMode`** (enum):
|
||||
- **Description:** How to trigger voice recording with the Space key.
|
||||
- **Default:** `"push-to-talk"`
|
||||
- **Values:** `"push-to-talk"`, `"toggle"`
|
||||
|
||||
- **`experimental.voice.backend`** (enum):
|
||||
- **Description:** The backend to use for voice transcription.
|
||||
- **Default:** `"gemini-live"`
|
||||
- **Values:** `"gemini-live"`, `"whisper"`
|
||||
|
||||
- **`experimental.voice.whisperModel`** (enum):
|
||||
- **Description:** The Whisper model to use for local transcription.
|
||||
- **Default:** `"ggml-base.en.bin"`
|
||||
- **Values:** `"ggml-tiny.en.bin"`, `"ggml-base.en.bin"`,
|
||||
`"ggml-large-v3-turbo-q5_0.bin"`, `"ggml-large-v3-turbo-q8_0.bin"`
|
||||
|
||||
- **`experimental.voice.stopGracePeriodMs`** (number):
|
||||
- **Description:** How long to wait for final transcription after stopping
|
||||
recording.
|
||||
- **Default:** `1000`
|
||||
|
||||
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
|
||||
- **Description:** Enable non-interactive agent sessions.
|
||||
- **Default:** `false`
|
||||
|
||||
@@ -115,7 +115,6 @@ available combinations.
|
||||
| `app.restart` | Restart the application. | `R`<br />`Shift+R` |
|
||||
| `app.suspend` | Suspend the CLI and move it to the background. | `Ctrl+Z` |
|
||||
| `app.showShellUnfocusWarning` | Show warning when trying to move focus away from shell input. | `Tab` |
|
||||
| `app.voiceModePTT` | Hold to speak in Voice Mode. | `Space` |
|
||||
|
||||
#### Background Shell Controls
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('Hierarchical Memory', () => {
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: false },
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -55,7 +55,7 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: false },
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -96,7 +96,7 @@ Provide the answer as an XML block like this:
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: false },
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,78 +5,12 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
loadConversationRecord,
|
||||
SESSION_FILE_PREFIX,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
evalTest,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
function findDir(base: string, name: string): string | null {
|
||||
if (!fs.existsSync(base)) return null;
|
||||
const files = fs.readdirSync(base);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(base, file);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
if (file === name) return fullPath;
|
||||
const found = findDir(fullPath, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadLatestSessionRecord(homeDir: string, sessionId: string) {
|
||||
const chatsDir = findDir(path.join(homeDir, '.gemini'), 'chats');
|
||||
if (!chatsDir) {
|
||||
throw new Error('Could not find chats directory for eval session logs');
|
||||
}
|
||||
|
||||
const candidates = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter(
|
||||
(file) =>
|
||||
file.startsWith(SESSION_FILE_PREFIX) &&
|
||||
(file.endsWith('.json') || file.endsWith('.jsonl')),
|
||||
);
|
||||
|
||||
const matchingRecords = [];
|
||||
for (const file of candidates) {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
const record = await loadConversationRecord(filePath);
|
||||
if (record?.sessionId === sessionId) {
|
||||
matchingRecords.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
matchingRecords.sort(
|
||||
(a, b) => Date.parse(b.lastUpdated) - Date.parse(a.lastUpdated),
|
||||
);
|
||||
return matchingRecords[0] ?? null;
|
||||
}
|
||||
|
||||
async function waitForSessionScratchpad(
|
||||
homeDir: string,
|
||||
sessionId: string,
|
||||
timeoutMs = 30000,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const record = await loadLatestSessionRecord(homeDir, sessionId);
|
||||
if (record?.memoryScratchpad) {
|
||||
return record;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return loadLatestSessionRecord(homeDir, sessionId);
|
||||
}
|
||||
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
@@ -84,11 +18,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `remember that my favorite color is blue.
|
||||
|
||||
@@ -111,11 +40,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I don't want you to ever run npm commands.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -137,11 +61,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I want you to always lint after building.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -164,11 +83,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I'm going to get a coffee.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -194,11 +108,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `Please remember that my dog's name is Buddy.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -220,11 +129,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -247,11 +151,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingDbSchemaLocationAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -281,11 +180,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -308,11 +202,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingBuildArtifactLocationAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -342,11 +231,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingMainEntryPointAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -375,11 +259,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `My birthday is on June 15th.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -635,103 +514,6 @@ describe('save_memory', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2SessionScratchpad =
|
||||
'Session summary persists memory scratchpad for memory-saving sessions';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2SessionScratchpad,
|
||||
sessionId: 'memory-scratchpad-eval',
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'Across all my projects, I prefer Vitest over Jest for testing.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Noted. What else should I keep in mind?' }],
|
||||
timestamp: '2026-01-01T00:00:05Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-3',
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'For this repo I was debugging a flaky API test earlier, but that was just transient context.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:01:00Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-4',
|
||||
type: 'gemini',
|
||||
content: [
|
||||
{ text: 'Understood. I will only save the durable preference.' },
|
||||
],
|
||||
timestamp: '2026-01-01T00:01:05Z',
|
||||
},
|
||||
],
|
||||
prompt:
|
||||
'Please save any persistent preferences or facts about me from our conversation to memory.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
expect(
|
||||
writeCalls.length,
|
||||
'Expected memoryV2 save flow to edit a markdown memory file',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await rig.run({
|
||||
args: ['--list-sessions'],
|
||||
approvalMode: 'yolo',
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
const record = await waitForSessionScratchpad(
|
||||
rig.homeDir!,
|
||||
'memory-scratchpad-eval',
|
||||
);
|
||||
expect(
|
||||
record?.memoryScratchpad,
|
||||
'Expected the resumed session log to contain a memoryScratchpad after session summary generation',
|
||||
).toBeDefined();
|
||||
expect(record?.memoryScratchpad?.version).toBe(1);
|
||||
expect(
|
||||
record?.memoryScratchpad?.toolSequence?.some((toolName) =>
|
||||
['write_file', 'replace'].includes(toolName),
|
||||
),
|
||||
'Expected memoryScratchpad.toolSequence to include the markdown editing tool used for memory persistence',
|
||||
).toBe(true);
|
||||
expect(
|
||||
record?.memoryScratchpad?.touchedPaths?.length,
|
||||
'Expected memoryScratchpad to capture at least one touched path',
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
record?.memoryScratchpad?.workflowSummary,
|
||||
'Expected memoryScratchpad.workflowSummary to be populated',
|
||||
).toMatch(/write_file|replace/i);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2RoutesUserProject =
|
||||
'Agent routes personal-to-user project notes to user-project memory';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
|
||||
+17
-630
@@ -6,30 +6,21 @@
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect } from 'vitest';
|
||||
import {
|
||||
type Config,
|
||||
ApprovalMode,
|
||||
type MemoryScratchpad,
|
||||
SESSION_FILE_PREFIX,
|
||||
getProjectHash,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ComponentRig, componentEvalTest } from './component-test-helper.js';
|
||||
import {
|
||||
average,
|
||||
averageNullable,
|
||||
countMatchingIds,
|
||||
roundStat,
|
||||
} from './statistics-helper.js';
|
||||
import { prepareWorkspace } from './test-helper.js';
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
|
||||
interface SeedSession {
|
||||
sessionId: string;
|
||||
summary: string;
|
||||
userTurns: string[];
|
||||
timestampOffsetMinutes: number;
|
||||
memoryScratchpad?: MemoryScratchpad;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
@@ -39,81 +30,6 @@ interface MessageRecord {
|
||||
content: Array<{ text: string }>;
|
||||
}
|
||||
|
||||
interface SessionVersion {
|
||||
sessionId: string;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface ExtractionRunSnapshot {
|
||||
sessionIds: string[];
|
||||
skillsCreated: string[];
|
||||
candidateSessions: SessionVersion[];
|
||||
processedSessions: SessionVersion[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
terminateReason?: string;
|
||||
}
|
||||
|
||||
interface ExtractionOutcome {
|
||||
state: { runs: ExtractionRunSnapshot[] };
|
||||
skillsDir: string;
|
||||
skillBodies: string[];
|
||||
}
|
||||
|
||||
interface SkillQualitySignal {
|
||||
label: string;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
interface ScratchpadRunMetrics {
|
||||
turnCount: number | null;
|
||||
durationMs: number | null;
|
||||
terminateReason: string | null;
|
||||
skillsCreated: number;
|
||||
candidateSessions: number;
|
||||
processedSessions: number;
|
||||
relevantReads: number;
|
||||
distractorReads: number;
|
||||
totalReads: number;
|
||||
recall: number;
|
||||
precision: number;
|
||||
signalScore: number;
|
||||
skillQualityScore: number;
|
||||
skillQualityMax: number;
|
||||
skillQualityRatio: number;
|
||||
missingQualitySignals: string[];
|
||||
}
|
||||
|
||||
interface ScratchpadStatsTrial {
|
||||
trial: number;
|
||||
baseline: ScratchpadRunMetrics;
|
||||
enhanced: ScratchpadRunMetrics;
|
||||
}
|
||||
|
||||
interface ScratchpadStatsAggregate {
|
||||
turnCountAvg: number | null;
|
||||
durationMsAvg: number | null;
|
||||
recallAvg: number;
|
||||
precisionAvg: number;
|
||||
signalScoreAvg: number;
|
||||
relevantReadsAvg: number;
|
||||
distractorReadsAvg: number;
|
||||
skillsCreatedAvg: number;
|
||||
skillQualityScoreAvg: number;
|
||||
skillQualityRatioAvg: number;
|
||||
}
|
||||
|
||||
interface ScratchpadStatsReport {
|
||||
generatedAt: string;
|
||||
trials: number;
|
||||
aggregate: {
|
||||
baseline: ScratchpadStatsAggregate;
|
||||
enhanced: ScratchpadStatsAggregate;
|
||||
};
|
||||
deltas: ScratchpadStatsAggregate;
|
||||
results: ScratchpadStatsTrial[];
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES = {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
@@ -152,143 +68,6 @@ function buildMessages(userTurns: string[]): MessageRecord[] {
|
||||
]);
|
||||
}
|
||||
|
||||
function padTurns(turns: string[]): string[] {
|
||||
if (turns.length >= 10) {
|
||||
return turns;
|
||||
}
|
||||
|
||||
const padded = [...turns];
|
||||
for (let i = turns.length; i < 10; i++) {
|
||||
padded.push(`${turns[i % turns.length]} (repeat ${i + 1})`);
|
||||
}
|
||||
return padded;
|
||||
}
|
||||
|
||||
function createScratchpad(
|
||||
workflowSummary: string,
|
||||
touchedPaths: string[],
|
||||
validationStatus: MemoryScratchpad['validationStatus'] = 'passed',
|
||||
): MemoryScratchpad {
|
||||
return {
|
||||
version: 1,
|
||||
workflowSummary,
|
||||
toolSequence: ['run_shell_command'],
|
||||
touchedPaths,
|
||||
validationStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkflowComparisonSessions(withScratchpad: boolean): {
|
||||
sessions: SeedSession[];
|
||||
relevantSessionIds: string[];
|
||||
distractorSessionIds: string[];
|
||||
} {
|
||||
const relevantWorkflowSummary =
|
||||
'run_shell_command -> run_shell_command | paths packages/cli/src/config/settings.ts, docs/settings.md | validated';
|
||||
|
||||
const relevantScratchpad = withScratchpad
|
||||
? createScratchpad(relevantWorkflowSummary, [
|
||||
'packages/cli/src/config/settings.ts',
|
||||
'docs/settings.md',
|
||||
])
|
||||
: undefined;
|
||||
|
||||
const sessions: SeedSession[] = [
|
||||
{
|
||||
sessionId: 'hidden-settings-workflow-a',
|
||||
summary: 'Prepare release notes for settings launch',
|
||||
timestampOffsetMinutes: 420,
|
||||
memoryScratchpad: relevantScratchpad,
|
||||
userTurns: padTurns([
|
||||
'When we add a new setting, the durable workflow is to regenerate the settings docs instead of editing them by hand.',
|
||||
'The sequence that worked was npm run predocs:settings, npm run schema:settings, then npm run docs:settings.',
|
||||
'Skipping predocs leaves stale defaults in the generated docs.',
|
||||
'We verify the workflow by checking that both the schema output and docs update together.',
|
||||
'This exact command order is the recurring workflow we use for settings changes.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'hidden-settings-workflow-b',
|
||||
summary: 'Investigate CI drift in generated config reference',
|
||||
timestampOffsetMinutes: 390,
|
||||
memoryScratchpad: relevantScratchpad,
|
||||
userTurns: padTurns([
|
||||
'The config reference drift was fixed by rerunning the standard settings regeneration workflow.',
|
||||
'We again used npm run predocs:settings before npm run schema:settings and npm run docs:settings.',
|
||||
'The recurring rule is never to hand-edit generated settings docs.',
|
||||
'The validation step is to confirm the schema artifact and docs changed together after regeneration.',
|
||||
'This is the same recurring workflow we use every time a setting changes.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-release-notes',
|
||||
summary: 'Prepare release notes for auth launch',
|
||||
timestampOffsetMinutes: 360,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This release-notes task was one-off and just needed manual wording updates.',
|
||||
'I edited CHANGELOG.md and docs/release-notes.md directly.',
|
||||
'There was no reusable command sequence here beyond proofreading the copy.',
|
||||
'This task should not become a standing workflow.',
|
||||
'Once the wording landed, we were done.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-ci-snapshots',
|
||||
summary: 'Investigate CI drift in auth snapshots',
|
||||
timestampOffsetMinutes: 330,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This auth snapshot issue was specific to a flaky test in CI.',
|
||||
'The only commands we ran were npm test -- auth and an isolated snapshot update.',
|
||||
'It was not the recurring settings-doc workflow.',
|
||||
'Once the flaky snapshot passed, there was no broader reusable procedure.',
|
||||
'Treat this as a one-off CI cleanup.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-onboarding-docs',
|
||||
summary: 'Refresh onboarding documentation copy',
|
||||
timestampOffsetMinutes: 300,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This was just a docs wording cleanup in docs/onboarding.md.',
|
||||
'No command sequence was involved.',
|
||||
'We manually edited the copy and reviewed it.',
|
||||
'There is no recurring operational workflow to capture here.',
|
||||
'This should stay a one-off docs edit.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-deploy-copy',
|
||||
summary: 'Adjust deployment checklist wording',
|
||||
timestampOffsetMinutes: 270,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This was a wording-only change to docs/deploy.md.',
|
||||
'We did not run a reusable command sequence.',
|
||||
'It should not become a skill.',
|
||||
'The edit was only for this deploy checklist cleanup.',
|
||||
'After the copy change, the task was complete.',
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
sessions,
|
||||
relevantSessionIds: [
|
||||
'hidden-settings-workflow-a',
|
||||
'hidden-settings-workflow-b',
|
||||
],
|
||||
distractorSessionIds: [
|
||||
'distractor-release-notes',
|
||||
'distractor-ci-snapshots',
|
||||
'distractor-onboarding-docs',
|
||||
'distractor-deploy-copy',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSessions(
|
||||
config: Config,
|
||||
sessions: SeedSession[],
|
||||
@@ -299,10 +78,9 @@ async function seedSessions(
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionTimestamp = new Date(
|
||||
const timestamp = new Date(
|
||||
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
|
||||
);
|
||||
const timestamp = sessionTimestamp
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace(/:/g, '-');
|
||||
@@ -311,9 +89,8 @@ async function seedSessions(
|
||||
sessionId: session.sessionId,
|
||||
projectHash: getProjectHash(projectRoot),
|
||||
summary: session.summary,
|
||||
memoryScratchpad: session.memoryScratchpad,
|
||||
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: sessionTimestamp.toISOString(),
|
||||
lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||
messages: buildMessages(session.userTurns),
|
||||
};
|
||||
|
||||
@@ -324,9 +101,10 @@ async function seedSessions(
|
||||
}
|
||||
}
|
||||
|
||||
async function runExtractionAndReadState(
|
||||
config: Config,
|
||||
): Promise<ExtractionOutcome> {
|
||||
async function runExtractionAndReadState(config: Config): Promise<{
|
||||
state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
|
||||
skillsDir: string;
|
||||
}> {
|
||||
await startMemoryService(config);
|
||||
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
@@ -335,15 +113,7 @@ async function runExtractionAndReadState(
|
||||
|
||||
const raw = await fsp.readFile(statePath, 'utf-8');
|
||||
const state = JSON.parse(raw) as {
|
||||
runs?: Array<{
|
||||
sessionIds?: string[];
|
||||
skillsCreated?: string[];
|
||||
candidateSessions?: SessionVersion[];
|
||||
processedSessions?: SessionVersion[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
terminateReason?: string;
|
||||
}>;
|
||||
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');
|
||||
@@ -356,292 +126,27 @@ async function runExtractionAndReadState(
|
||||
skillsCreated: Array.isArray(run.skillsCreated)
|
||||
? run.skillsCreated
|
||||
: [],
|
||||
candidateSessions: Array.isArray(run.candidateSessions)
|
||||
? run.candidateSessions
|
||||
: [],
|
||||
processedSessions: Array.isArray(run.processedSessions)
|
||||
? run.processedSessions
|
||||
: [],
|
||||
turnCount:
|
||||
typeof run.turnCount === 'number' ? run.turnCount : undefined,
|
||||
durationMs:
|
||||
typeof run.durationMs === 'number' ? run.durationMs : undefined,
|
||||
terminateReason:
|
||||
typeof run.terminateReason === 'string'
|
||||
? run.terminateReason
|
||||
: undefined,
|
||||
})),
|
||||
},
|
||||
skillsDir,
|
||||
skillBodies: await readSkillBodies(skillsDir),
|
||||
};
|
||||
}
|
||||
|
||||
async function summarizeScratchpadRun(
|
||||
outcome: ExtractionOutcome,
|
||||
run: ExtractionRunSnapshot,
|
||||
scenario: ReturnType<typeof createWorkflowComparisonSessions>,
|
||||
): Promise<ScratchpadRunMetrics> {
|
||||
const relevantReads = countMatchingIds(
|
||||
run.processedSessions,
|
||||
scenario.relevantSessionIds,
|
||||
);
|
||||
const distractorReads = countMatchingIds(
|
||||
run.processedSessions,
|
||||
scenario.distractorSessionIds,
|
||||
);
|
||||
const totalReads = run.processedSessions.length;
|
||||
const quality = scoreSkillQuality(
|
||||
outcome.skillBodies,
|
||||
SETTINGS_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
return {
|
||||
turnCount: run.turnCount ?? null,
|
||||
durationMs: run.durationMs ?? null,
|
||||
terminateReason: run.terminateReason ?? null,
|
||||
skillsCreated: run.skillsCreated.length,
|
||||
candidateSessions: run.candidateSessions.length,
|
||||
processedSessions: totalReads,
|
||||
relevantReads,
|
||||
distractorReads,
|
||||
totalReads,
|
||||
recall: relevantReads / scenario.relevantSessionIds.length,
|
||||
precision: totalReads === 0 ? 0 : relevantReads / totalReads,
|
||||
signalScore: relevantReads - distractorReads,
|
||||
skillQualityScore: quality.score,
|
||||
skillQualityMax: quality.maxScore,
|
||||
skillQualityRatio:
|
||||
quality.maxScore === 0 ? 0 : quality.score / quality.maxScore,
|
||||
missingQualitySignals: quality.missing,
|
||||
};
|
||||
}
|
||||
|
||||
function averageScratchpadRuns(
|
||||
runs: ScratchpadRunMetrics[],
|
||||
): ScratchpadStatsAggregate {
|
||||
return {
|
||||
turnCountAvg: roundStat(averageNullable(runs.map((run) => run.turnCount))),
|
||||
durationMsAvg: roundStat(
|
||||
averageNullable(runs.map((run) => run.durationMs)),
|
||||
),
|
||||
recallAvg: roundStat(average(runs.map((run) => run.recall))) ?? 0,
|
||||
precisionAvg: roundStat(average(runs.map((run) => run.precision))) ?? 0,
|
||||
signalScoreAvg: roundStat(average(runs.map((run) => run.signalScore))) ?? 0,
|
||||
relevantReadsAvg:
|
||||
roundStat(average(runs.map((run) => run.relevantReads))) ?? 0,
|
||||
distractorReadsAvg:
|
||||
roundStat(average(runs.map((run) => run.distractorReads))) ?? 0,
|
||||
skillsCreatedAvg:
|
||||
roundStat(average(runs.map((run) => run.skillsCreated))) ?? 0,
|
||||
skillQualityScoreAvg:
|
||||
roundStat(average(runs.map((run) => run.skillQualityScore))) ?? 0,
|
||||
skillQualityRatioAvg:
|
||||
roundStat(average(runs.map((run) => run.skillQualityRatio))) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function diffScratchpadAggregates(
|
||||
baseline: ScratchpadStatsAggregate,
|
||||
enhanced: ScratchpadStatsAggregate,
|
||||
): ScratchpadStatsAggregate {
|
||||
return {
|
||||
turnCountAvg:
|
||||
baseline.turnCountAvg === null || enhanced.turnCountAvg === null
|
||||
? null
|
||||
: roundStat(enhanced.turnCountAvg - baseline.turnCountAvg),
|
||||
durationMsAvg:
|
||||
baseline.durationMsAvg === null || enhanced.durationMsAvg === null
|
||||
? null
|
||||
: roundStat(enhanced.durationMsAvg - baseline.durationMsAvg),
|
||||
recallAvg: roundStat(enhanced.recallAvg - baseline.recallAvg) ?? 0,
|
||||
precisionAvg: roundStat(enhanced.precisionAvg - baseline.precisionAvg) ?? 0,
|
||||
signalScoreAvg:
|
||||
roundStat(enhanced.signalScoreAvg - baseline.signalScoreAvg) ?? 0,
|
||||
relevantReadsAvg:
|
||||
roundStat(enhanced.relevantReadsAvg - baseline.relevantReadsAvg) ?? 0,
|
||||
distractorReadsAvg:
|
||||
roundStat(enhanced.distractorReadsAvg - baseline.distractorReadsAvg) ?? 0,
|
||||
skillsCreatedAvg:
|
||||
roundStat(enhanced.skillsCreatedAvg - baseline.skillsCreatedAvg) ?? 0,
|
||||
skillQualityScoreAvg:
|
||||
roundStat(
|
||||
enhanced.skillQualityScoreAvg - baseline.skillQualityScoreAvg,
|
||||
) ?? 0,
|
||||
skillQualityRatioAvg:
|
||||
roundStat(
|
||||
enhanced.skillQualityRatioAvg - baseline.skillQualityRatioAvg,
|
||||
) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function runScenarioWithFreshRig(
|
||||
sessions: SeedSession[],
|
||||
): Promise<ExtractionOutcome> {
|
||||
const rig = new ComponentRig({
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
});
|
||||
try {
|
||||
await rig.initialize();
|
||||
await prepareWorkspace(rig.testDir, rig.testDir, WORKSPACE_FILES);
|
||||
await seedSessions(rig.config!, sessions);
|
||||
return await runExtractionAndReadState(rig.config!);
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function runScratchpadStatsTrial(
|
||||
trial: number,
|
||||
): Promise<ScratchpadStatsTrial> {
|
||||
const baselineScenario = createWorkflowComparisonSessions(false);
|
||||
const enhancedScenario = createWorkflowComparisonSessions(true);
|
||||
|
||||
const baselineOutcome = await runScenarioWithFreshRig(
|
||||
baselineScenario.sessions,
|
||||
);
|
||||
const enhancedOutcome = await runScenarioWithFreshRig(
|
||||
enhancedScenario.sessions,
|
||||
);
|
||||
|
||||
const baselineRun = baselineOutcome.state.runs.at(-1);
|
||||
const enhancedRun = enhancedOutcome.state.runs.at(-1);
|
||||
if (!baselineRun || !enhancedRun) {
|
||||
throw new Error('Expected both baseline and scratchpad runs to exist');
|
||||
}
|
||||
|
||||
expectSuccessfulExtractionRun(baselineRun);
|
||||
expectSuccessfulExtractionRun(enhancedRun);
|
||||
|
||||
return {
|
||||
trial,
|
||||
baseline: await summarizeScratchpadRun(
|
||||
baselineOutcome,
|
||||
baselineRun,
|
||||
baselineScenario,
|
||||
),
|
||||
enhanced: await summarizeScratchpadRun(
|
||||
enhancedOutcome,
|
||||
enhancedRun,
|
||||
enhancedScenario,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function runScratchpadStatsReport(
|
||||
trials: number,
|
||||
): Promise<ScratchpadStatsReport> {
|
||||
const results: ScratchpadStatsTrial[] = [];
|
||||
|
||||
for (let trial = 1; trial <= trials; trial++) {
|
||||
results.push(await runScratchpadStatsTrial(trial));
|
||||
}
|
||||
|
||||
const baseline = averageScratchpadRuns(
|
||||
results.map((result) => result.baseline),
|
||||
);
|
||||
const enhanced = averageScratchpadRuns(
|
||||
results.map((result) => result.enhanced),
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
trials,
|
||||
aggregate: {
|
||||
baseline,
|
||||
enhanced,
|
||||
},
|
||||
deltas: diffScratchpadAggregates(baseline, enhanced),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeScratchpadStatsReport(
|
||||
report: ScratchpadStatsReport,
|
||||
): Promise<string> {
|
||||
const outputPath = path.resolve(
|
||||
process.cwd(),
|
||||
'evals/logs/skill_extraction_scratchpad_stats.json',
|
||||
);
|
||||
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fsp.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async function readSkillBodies(skillsDir: string): Promise<string[]> {
|
||||
const bodies: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
bodies.push(
|
||||
await fsp.readFile(
|
||||
path.join(skillsDir, entry.name, 'SKILL.md'),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Ignore incomplete skill directories so one bad artifact does not hide
|
||||
// valid skills created in the same eval run.
|
||||
}
|
||||
}
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
function expectSuccessfulExtractionRun(run: ExtractionRunSnapshot): void {
|
||||
expect(run.turnCount).toBeGreaterThan(0);
|
||||
expect(run.turnCount).toBeLessThanOrEqual(30);
|
||||
expect(run.durationMs).toBeGreaterThan(0);
|
||||
expect(run.terminateReason).toBe('GOAL');
|
||||
}
|
||||
|
||||
function scoreSkillQuality(
|
||||
skillBodies: string[],
|
||||
signals: SkillQualitySignal[],
|
||||
): { score: number; maxScore: number; missing: string[] } {
|
||||
const combined = skillBodies.join('\n\n');
|
||||
const matched = signals.filter((signal) => signal.pattern.test(combined));
|
||||
|
||||
return {
|
||||
score: matched.length,
|
||||
maxScore: signals.length,
|
||||
missing: signals
|
||||
.filter((signal) => !signal.pattern.test(combined))
|
||||
.map((signal) => signal.label),
|
||||
};
|
||||
}
|
||||
|
||||
const SETTINGS_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
|
||||
{ label: 'predocs command', pattern: /npm run predocs:settings/i },
|
||||
{ label: 'schema command', pattern: /npm run schema:settings/i },
|
||||
{ label: 'docs command', pattern: /npm run docs:settings/i },
|
||||
{ label: 'verification guidance', pattern: /verif(?:y|ication)/i },
|
||||
{
|
||||
label: 'generated docs warning or ordering constraint',
|
||||
pattern:
|
||||
/do not hand-edit|manual edits|exact command order|preserve.*order/i,
|
||||
},
|
||||
];
|
||||
|
||||
const DB_MIGRATION_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
|
||||
{ label: 'db check command', pattern: /npm run db:check/i },
|
||||
{ label: 'db migrate command', pattern: /npm run db:migrate/i },
|
||||
{ label: 'db validate command', pattern: /npm run db:validate/i },
|
||||
{ label: 'rollback guidance', pattern: /npm run db:rollback|rollback/i },
|
||||
{
|
||||
label: 'ordering constraint',
|
||||
pattern: /check.*migrate.*validate|ordering is critical|mandatory/i,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared configOverrides for all skill extraction component evals.
|
||||
* - experimentalAutoMemory: enables the Auto Memory skill extraction pipeline.
|
||||
@@ -653,16 +158,6 @@ const EXTRACTION_CONFIG_OVERRIDES = {
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
function parseScratchpadStatsTrials(): number {
|
||||
const configured = Number.parseInt(
|
||||
process.env['SCRATCHPAD_STATS_TRIALS'] ?? '8',
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(configured) && configured > 0 ? configured : 8;
|
||||
}
|
||||
|
||||
const SCRATCHPAD_STATS_TRIALS = parseScratchpadStatsTrials();
|
||||
|
||||
describe('Skill Extraction', () => {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
@@ -769,24 +264,15 @@ describe('Skill Extraction', () => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
const quality = scoreSkillQuality(
|
||||
skillBodies,
|
||||
SETTINGS_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expectSuccessfulExtractionRun(state.runs[0]);
|
||||
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(/verif(?:y|ication)/i);
|
||||
expect(
|
||||
quality.score,
|
||||
`missing quality signals: ${quality.missing.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
expect(combinedSkills).toMatch(/Verification/i);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
@@ -795,96 +281,6 @@ describe('Skill Extraction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'memory scratchpad improves repeated-workflow recall versus summary-only index',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 360000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
assert: async () => {
|
||||
const baselineScenario = createWorkflowComparisonSessions(false);
|
||||
const enhancedScenario = createWorkflowComparisonSessions(true);
|
||||
|
||||
const baselineOutcome = await runScenarioWithFreshRig(
|
||||
baselineScenario.sessions,
|
||||
);
|
||||
const enhancedOutcome = await runScenarioWithFreshRig(
|
||||
enhancedScenario.sessions,
|
||||
);
|
||||
|
||||
const baselineRun = baselineOutcome.state.runs.at(-1);
|
||||
const enhancedRun = enhancedOutcome.state.runs.at(-1);
|
||||
if (!baselineRun || !enhancedRun) {
|
||||
throw new Error('Expected both baseline and scratchpad runs to exist');
|
||||
}
|
||||
|
||||
expectSuccessfulExtractionRun(baselineRun);
|
||||
expectSuccessfulExtractionRun(enhancedRun);
|
||||
|
||||
const baselineRelevantReads = countMatchingIds(
|
||||
baselineRun.processedSessions,
|
||||
baselineScenario.relevantSessionIds,
|
||||
);
|
||||
const enhancedRelevantReads = countMatchingIds(
|
||||
enhancedRun.processedSessions,
|
||||
enhancedScenario.relevantSessionIds,
|
||||
);
|
||||
const baselineDistractorReads = countMatchingIds(
|
||||
baselineRun.processedSessions,
|
||||
baselineScenario.distractorSessionIds,
|
||||
);
|
||||
const enhancedDistractorReads = countMatchingIds(
|
||||
enhancedRun.processedSessions,
|
||||
enhancedScenario.distractorSessionIds,
|
||||
);
|
||||
const baselineSignalScore =
|
||||
baselineRelevantReads - baselineDistractorReads;
|
||||
const enhancedSignalScore =
|
||||
enhancedRelevantReads - enhancedDistractorReads;
|
||||
|
||||
expect(enhancedRun.candidateSessions).toHaveLength(
|
||||
enhancedScenario.sessions.length,
|
||||
);
|
||||
expect(enhancedRelevantReads).toBeGreaterThanOrEqual(2);
|
||||
expect(enhancedRelevantReads).toBeGreaterThanOrEqual(
|
||||
baselineRelevantReads,
|
||||
);
|
||||
expect(enhancedDistractorReads).toBeLessThanOrEqual(
|
||||
baselineDistractorReads,
|
||||
);
|
||||
expect(enhancedSignalScore).toBeGreaterThan(baselineSignalScore);
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env['RUN_SCRATCHPAD_STATS'] === '1') {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'reports memory scratchpad retrieval statistics',
|
||||
timeout: Math.max(360000, SCRATCHPAD_STATS_TRIALS * 150000),
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
assert: async () => {
|
||||
const report = await runScratchpadStatsReport(SCRATCHPAD_STATS_TRIALS);
|
||||
const outputPath = await writeScratchpadStatsReport(report);
|
||||
|
||||
console.info(
|
||||
`Wrote scratchpad stats report to ${outputPath}\n${JSON.stringify(
|
||||
report.aggregate,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(report.results).toHaveLength(SCRATCHPAD_STATS_TRIALS);
|
||||
expect(report.aggregate.baseline.recallAvg).toBeGreaterThan(0);
|
||||
expect(report.aggregate.enhanced.recallAvg).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
it.skip('reports memory scratchpad retrieval statistics', () => {});
|
||||
}
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
@@ -934,24 +330,15 @@ describe('Skill Extraction', () => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
const quality = scoreSkillQuality(
|
||||
skillBodies,
|
||||
DB_MIGRATION_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expectSuccessfulExtractionRun(state.runs[0]);
|
||||
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);
|
||||
expect(
|
||||
quality.score,
|
||||
`missing quality signals: ${quality.missing.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export function countMatchingIds<T extends { sessionId: string }>(
|
||||
items: T[],
|
||||
expectedIds: string[],
|
||||
): number {
|
||||
const expected = new Set(expectedIds);
|
||||
return items.filter((item) => expected.has(item.sessionId)).length;
|
||||
}
|
||||
|
||||
export function roundStat(value: number | null): number | null {
|
||||
return value === null ? null : Number(value.toFixed(4));
|
||||
}
|
||||
|
||||
export function average(values: number[]): number {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
export function averageNullable(values: Array<number | null>): number | null {
|
||||
const numericValues = values.filter((value) => value !== null);
|
||||
return numericValues.length === 0 ? null : average(numericValues);
|
||||
}
|
||||
@@ -172,7 +172,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
timeout: evalCase.timeout,
|
||||
env: {
|
||||
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
|
||||
);
|
||||
|
||||
await run.expectText('Chat history compressed', 5000);
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
// TODO: Context compression is broken and doesn't include the system
|
||||
// instructions or tool counts, so it thinks compression is beneficial when
|
||||
@@ -102,6 +102,9 @@ describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
|
||||
});
|
||||
|
||||
const run = await rig.runInteractive();
|
||||
await run.expectText('tips for getting started:', 5000);
|
||||
// Wait for the async command loaders to finish so /compress is recognized
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
await run.type('/compress');
|
||||
await run.type('\r');
|
||||
|
||||
@@ -116,5 +119,5 @@ describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
|
||||
foundEvent,
|
||||
'chat_compression telemetry event should not be found for NOOP',
|
||||
).toBe(false);
|
||||
});
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ describe('Interactive file system', () => {
|
||||
rig.createFile(fileName, '1.0.0');
|
||||
|
||||
const run = await rig.runInteractive();
|
||||
await run.expectText('tips for getting started:', 5000);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
// Step 1: Read the file
|
||||
const readPrompt = `Read the version from ${fileName}`;
|
||||
@@ -56,5 +58,5 @@ describe('Interactive file system', () => {
|
||||
|
||||
// Wait for telemetry to flush and file system to sync, especially in sandboxed environments
|
||||
await rig.waitForTelemetryReady();
|
||||
});
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -1,76 +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 {
|
||||
WhisperModelManager,
|
||||
WhisperTranscriptionProvider,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import commandExists from 'command-exists';
|
||||
|
||||
describe('Voice Mode Integration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should be able to download tiny whisper model', async () => {
|
||||
// This test doesn't require the binary, only network access.
|
||||
// However, it's slow and downloads 75MB. We'll keep it for now but
|
||||
// wrap it in a try-catch to avoid failing on network flakiness in CI.
|
||||
const manager = new WhisperModelManager();
|
||||
const modelName = 'ggml-tiny.en.bin';
|
||||
|
||||
try {
|
||||
// Cleanup if already exists to ensure we actually test download
|
||||
const modelPath = manager.getModelPath(modelName);
|
||||
if (fs.existsSync(modelPath)) {
|
||||
fs.unlinkSync(modelPath);
|
||||
}
|
||||
|
||||
await manager.downloadModel(modelName);
|
||||
expect(fs.existsSync(modelPath)).toBe(true);
|
||||
expect(fs.statSync(modelPath).size).toBeGreaterThan(70 * 1024 * 1024); // ~75MB
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'Skipping whisper model download test due to error (possibly network):',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}, 300000); // 5 min timeout for download
|
||||
|
||||
it('should initialize WhisperTranscriptionProvider and handle process', async () => {
|
||||
// Skip this test if whisper-stream is not installed (typical for CI)
|
||||
try {
|
||||
await commandExists('whisper-stream');
|
||||
} catch {
|
||||
console.log(
|
||||
'Skipping Whisper transcription test: whisper-stream not found',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = new WhisperModelManager();
|
||||
const modelName = 'ggml-tiny.en.bin';
|
||||
if (!manager.isModelInstalled(modelName)) {
|
||||
await manager.downloadModel(modelName);
|
||||
}
|
||||
|
||||
const provider = new WhisperTranscriptionProvider({
|
||||
modelPath: manager.getModelPath(modelName),
|
||||
});
|
||||
|
||||
// Since we can't easily provide real mic input in CI,
|
||||
// we just verify it can start and be disconnected.
|
||||
await provider.connect();
|
||||
provider.disconnect();
|
||||
});
|
||||
});
|
||||
Generated
+4
-390
@@ -991,37 +991,6 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
|
||||
@@ -1069,24 +1038,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
@@ -1100,19 +1051,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
|
||||
@@ -3795,37 +3733,6 @@
|
||||
"path-browserify": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -4986,13 +4893,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
@@ -5032,30 +4932,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
@@ -6553,13 +6429,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
@@ -7075,23 +6944,6 @@
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depcheck/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/camelcase": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||
@@ -7151,22 +7003,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/minimatch": {
|
||||
"version": "7.4.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.9.tgz",
|
||||
"integrity": "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/resolve-from": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
|
||||
@@ -8057,24 +7893,6 @@
|
||||
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
@@ -8085,19 +7903,6 @@
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -8154,37 +7959,6 @@
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/resolve": {
|
||||
"version": "2.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||
@@ -8243,37 +8017,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
@@ -11914,12 +11657,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -12100,37 +11843,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multimatch/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multimatch/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/multimatch/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
|
||||
@@ -12371,24 +12083,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
@@ -12438,19 +12132,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/normalize-package-data": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
|
||||
@@ -15817,39 +15498,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/text-decoder": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||
@@ -16608,23 +16256,6 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
@@ -16635,22 +16266,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
@@ -18390,7 +18005,6 @@
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"command-exists": "^1.2.9",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
|
||||
+2
-2
@@ -63,7 +63,6 @@
|
||||
"lint:all": "node scripts/lint.js",
|
||||
"format": "prettier --experimental-cli --write .",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present && tsc -b evals/tsconfig.json integration-tests/tsconfig.json memory-tests/tsconfig.json",
|
||||
"metrics": "tsx tools/gemini-cli-bot/metrics/index.ts",
|
||||
"preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci",
|
||||
"prepare": "husky && npm run bundle",
|
||||
"prepare:package": "node scripts/prepare-package.js",
|
||||
@@ -82,7 +81,8 @@
|
||||
"glob": "^12.0.0",
|
||||
"node-domexception": "npm:empty@^0.10.1",
|
||||
"prebuild-install": "npm:nop@1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
"cross-spawn": "^7.0.6",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
|
||||
@@ -112,7 +112,6 @@ export function createMockConfig(
|
||||
}),
|
||||
isContextManagementEnabled: vi.fn().mockReturnValue(false),
|
||||
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import {
|
||||
getCheckpointInfoList,
|
||||
getToolCallDataSchema,
|
||||
isNodeError,
|
||||
performRestore,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getCheckpointInfoList: vi.fn(),
|
||||
getToolCallDataSchema: vi.fn(),
|
||||
isNodeError: vi.fn(),
|
||||
performRestore: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('RestoreCommand', () => {
|
||||
let context: CommandContext;
|
||||
let restoreCommand: RestoreCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
restoreCommand = new RestoreCommand();
|
||||
context = {
|
||||
agentContext: {
|
||||
config: {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempCheckpointsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/checkpoints'),
|
||||
},
|
||||
},
|
||||
},
|
||||
git: {},
|
||||
sendMessage: vi.fn(),
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('delegates to list behavior when invoked without args', async () => {
|
||||
const listExecuteSpy = vi
|
||||
.spyOn(ListCheckpointsCommand.prototype, 'execute')
|
||||
.mockResolvedValue({
|
||||
name: 'restore list',
|
||||
data: 'list data',
|
||||
});
|
||||
|
||||
const response = await restoreCommand.execute(context, []);
|
||||
|
||||
expect(listExecuteSpy).toHaveBeenCalledWith(context);
|
||||
expect(response).toEqual({
|
||||
name: 'restore list',
|
||||
data: 'list data',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns checkpointing-disabled message when disabled', async () => {
|
||||
(
|
||||
context.agentContext.config.getCheckpointingEnabled as Mock
|
||||
).mockReturnValue(false);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['checkpoint1']);
|
||||
|
||||
expect(response.data).toContain('Checkpointing is not enabled');
|
||||
});
|
||||
|
||||
it('returns file-not-found message for missing checkpoint', async () => {
|
||||
const error = new Error('ENOENT');
|
||||
(error as Error & { code: string }).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(isNodeError).mockReturnValue(true);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['missing']);
|
||||
|
||||
expect(response.data).toBe('File not found: missing.json');
|
||||
});
|
||||
|
||||
it('handles checkpoint filename already ending in .json', async () => {
|
||||
const error = new Error('ENOENT');
|
||||
(error as Error & { code: string }).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(isNodeError).mockReturnValue(true);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['existing.json']);
|
||||
|
||||
expect(response.data).toBe('File not found: existing.json');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('existing.json'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns invalid/corrupt checkpoint message when schema parse fails', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"invalid": "data"}');
|
||||
vi.mocked(getToolCallDataSchema).mockReturnValue({
|
||||
safeParse: vi.fn().mockReturnValue({ success: false }),
|
||||
} as unknown as ReturnType<typeof getToolCallDataSchema>);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['invalid']);
|
||||
|
||||
expect(response.data).toBe('Checkpoint file is invalid or corrupted.');
|
||||
});
|
||||
|
||||
it('formats streamed restore results correctly', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"valid": "data"}');
|
||||
vi.mocked(getToolCallDataSchema).mockReturnValue({
|
||||
safeParse: vi
|
||||
.fn()
|
||||
.mockReturnValue({ success: true, data: { some: 'data' } }),
|
||||
} as unknown as ReturnType<typeof getToolCallDataSchema>);
|
||||
|
||||
async function* mockRestoreGenerator() {
|
||||
yield { type: 'message', messageType: 'info', content: 'Restoring...' };
|
||||
yield { type: 'load_history', clientHistory: [{}, {}] };
|
||||
yield { type: 'other', some: 'other' };
|
||||
}
|
||||
vi.mocked(performRestore).mockReturnValue(
|
||||
mockRestoreGenerator() as unknown as ReturnType<typeof performRestore>,
|
||||
);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['valid']);
|
||||
|
||||
expect(response.data).toContain('[INFO] Restoring...');
|
||||
expect(response.data).toContain('Loaded history with 2 messages.');
|
||||
expect(response.data).toContain(
|
||||
'Restored: {"type":"other","some":"other"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns generic unexpected error message for non-ENOENT failures', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Random error'));
|
||||
vi.mocked(isNodeError).mockReturnValue(false);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['error']);
|
||||
|
||||
expect(response.data).toContain(
|
||||
'An unexpected error occurred during restore: Error: Random error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListCheckpointsCommand', () => {
|
||||
let context: CommandContext;
|
||||
let listCommand: ListCheckpointsCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
listCommand = new ListCheckpointsCommand();
|
||||
context = {
|
||||
agentContext: {
|
||||
config: {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempCheckpointsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/checkpoints'),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('returns checkpointing-disabled message when disabled', async () => {
|
||||
(
|
||||
context.agentContext.config.getCheckpointingEnabled as Mock
|
||||
).mockReturnValue(false);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toContain('Checkpointing is not enabled');
|
||||
});
|
||||
|
||||
it('returns "No checkpoints found." when no .json checkpoints exist', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
'not-a-checkpoint.txt',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('No checkpoints found.');
|
||||
});
|
||||
|
||||
it('ignores error when mkdir fails', async () => {
|
||||
vi.mocked(fs.mkdir).mockRejectedValue(new Error('mkdir fail'));
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('No checkpoints found.');
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('formats checkpoint summary output from checkpoint metadata', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
'cp1.json',
|
||||
'cp2.json',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
vi.mocked(getCheckpointInfoList).mockReturnValue([
|
||||
{ messageId: 'id1', checkpoint: 'cp1' },
|
||||
{ messageId: 'id2', checkpoint: 'cp2' },
|
||||
]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toContain('Available Checkpoints:');
|
||||
// Note: The current implementation of ListCheckpointsCommand incorrectly accesses
|
||||
// fileName, toolName, etc. which don't exist on CheckpointInfo, resulting in 'Unknown'.
|
||||
expect(response.data).toContain('- **Unknown**: Unknown (Status: Unknown)');
|
||||
});
|
||||
|
||||
it('handles empty checkpoint info list', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.readdir).mockResolvedValue(['some.json'] as any);
|
||||
vi.mocked(getCheckpointInfoList).mockReturnValue([]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('Available Checkpoints:\n');
|
||||
});
|
||||
|
||||
it('returns generic unexpected error message on failures', async () => {
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('Readdir fail'));
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe(
|
||||
'An unexpected error occurred while listing checkpoints.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3055,18 +3055,6 @@ describe('loadCliConfig gemmaModelRouter', () => {
|
||||
expect(gemmaSettings.classifier?.model).toBe('custom-gemma');
|
||||
});
|
||||
|
||||
it('should load experimental.gemma setting from merged settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
gemma: true,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExperimentalGemma()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle partial gemmaModelRouter settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
|
||||
@@ -1000,7 +1000,6 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.general?.plan?.enabled ?? true,
|
||||
voiceMode: settings.experimental?.voiceMode,
|
||||
tracker: settings.experimental?.taskTracker,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
@@ -1012,7 +1011,6 @@ export async function loadCliConfig(
|
||||
experimentalJitContext,
|
||||
experimentalMemoryV2: settings.experimental?.memoryV2,
|
||||
experimentalAutoMemory: settings.experimental?.autoMemory,
|
||||
experimentalGemma: settings.experimental?.gemma,
|
||||
contextManagement,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration:
|
||||
|
||||
@@ -298,33 +298,6 @@ describe('settings-validation', () => {
|
||||
expect(issue).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept customThemes with text.response color override', () => {
|
||||
// Regression test for #25610: `response` is a documented and
|
||||
// implemented color override for model responses (see
|
||||
// packages/cli/src/ui/themes/theme.ts and semantic-tokens.ts),
|
||||
// but was missing from the CustomTheme validation schema.
|
||||
const validSettings = {
|
||||
ui: {
|
||||
theme: 'LimeWhite',
|
||||
customThemes: {
|
||||
LimeWhite: {
|
||||
type: 'custom',
|
||||
name: 'LimeWhite',
|
||||
text: {
|
||||
primary: '#00FF00',
|
||||
response: '#FFFFFF',
|
||||
secondary: '#a0a0a0',
|
||||
accent: '#00FF00',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(validSettings);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatValidationError', () => {
|
||||
|
||||
@@ -1667,19 +1667,6 @@ const SETTINGS_SCHEMA = {
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
confirmationRequired: {
|
||||
type: 'array',
|
||||
label: 'Confirmation Required',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: oneLine`
|
||||
Tool names that always require user confirmation.
|
||||
Takes precedence over allowed tools and core tool allowlists.
|
||||
`,
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
exclude: {
|
||||
type: 'array',
|
||||
label: 'Exclude Tools',
|
||||
@@ -2052,96 +2039,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
gemma: {
|
||||
type: 'boolean',
|
||||
label: 'Gemma Models',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable access to Gemma 4 models (experimental).',
|
||||
showInDialog: true,
|
||||
},
|
||||
voiceMode: {
|
||||
type: 'boolean',
|
||||
label: 'Voice Mode',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable experimental voice dictation and commands (/voice, /voice model).',
|
||||
showInDialog: true,
|
||||
},
|
||||
voice: {
|
||||
type: 'object',
|
||||
label: 'Voice',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for voice mode and transcription.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
activationMode: {
|
||||
type: 'enum',
|
||||
label: 'Voice Activation Mode',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'push-to-talk',
|
||||
description: 'How to trigger voice recording with the Space key.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'push-to-talk', label: 'Push-To-Talk (Hold Space)' },
|
||||
{ value: 'toggle', label: 'Toggle (Press Space to start/stop)' },
|
||||
],
|
||||
},
|
||||
backend: {
|
||||
type: 'enum',
|
||||
label: 'Voice Transcription Backend',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'gemini-live',
|
||||
description: 'The backend to use for voice transcription.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'gemini-live', label: 'Gemini Live API (Cloud)' },
|
||||
{ value: 'whisper', label: 'Whisper (Local)' },
|
||||
],
|
||||
},
|
||||
whisperModel: {
|
||||
type: 'enum',
|
||||
label: 'Whisper Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'ggml-base.en.bin',
|
||||
description: 'The Whisper model to use for local transcription.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'ggml-tiny.en.bin', label: 'Tiny (EN) - Fast (~75MB)' },
|
||||
{
|
||||
value: 'ggml-base.en.bin',
|
||||
label: 'Base (EN) - Balanced (~142MB)',
|
||||
},
|
||||
{
|
||||
value: 'ggml-large-v3-turbo-q5_0.bin',
|
||||
label: 'Large v3 Turbo (Q5_0) - High Accuracy (~547MB)',
|
||||
},
|
||||
{
|
||||
value: 'ggml-large-v3-turbo-q8_0.bin',
|
||||
label: 'Large v3 Turbo (Q8_0) - Max Accuracy (~834MB)',
|
||||
},
|
||||
],
|
||||
},
|
||||
stopGracePeriodMs: {
|
||||
type: 'number',
|
||||
label: 'Voice Stop Grace Period (ms)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 1000,
|
||||
description:
|
||||
'How long to wait for final transcription after stopping recording.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
adk: {
|
||||
type: 'object',
|
||||
label: 'ADK',
|
||||
@@ -3278,7 +3175,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
secondary: { type: 'string' },
|
||||
link: { type: 'string' },
|
||||
accent: { type: 'string' },
|
||||
response: { type: 'string' },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
|
||||
@@ -358,8 +358,8 @@ export async function main() {
|
||||
|
||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: argv.isCommand ? false : true,
|
||||
interactive: isHeadlessMode() && !argv.isCommand ? false : true,
|
||||
stderr: true,
|
||||
interactive: isHeadlessMode() ? false : true,
|
||||
debugMode: isDebugMode,
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
@@ -786,16 +786,20 @@ export function initializeOutputListenersAndFlush() {
|
||||
if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
|
||||
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
|
||||
if (payload.type === 'error' || payload.type === 'warn') {
|
||||
writeToStderr(payload.content + '\n');
|
||||
writeToStderr(payload.content);
|
||||
} else {
|
||||
writeToStderr(payload.content + '\n');
|
||||
writeToStdout(payload.content);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
|
||||
coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
|
||||
writeToStderr(payload.message + '\n');
|
||||
if (payload.severity === 'error' || payload.severity === 'warning') {
|
||||
writeToStderr(payload.message);
|
||||
} else {
|
||||
writeToStdout(payload.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +306,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: vi.fn(() => true),
|
||||
getHookSystem: vi.fn(() => undefined),
|
||||
getExperimentalGemma: vi.fn(() => false),
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
|
||||
@@ -170,7 +170,6 @@ describe('BuiltinCommandLoader', () => {
|
||||
getAllSkills: vi.fn().mockReturnValue([]),
|
||||
isAdminEnabled: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'other',
|
||||
}),
|
||||
@@ -397,7 +396,6 @@ describe('BuiltinCommandLoader profile', () => {
|
||||
getAllSkills: vi.fn().mockReturnValue([]),
|
||||
isAdminEnabled: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'other',
|
||||
}),
|
||||
|
||||
@@ -62,7 +62,6 @@ import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
|
||||
import { gemmaStatusCommand } from '../ui/commands/gemmaStatusCommand.js';
|
||||
import { voiceCommand } from '../ui/commands/voiceCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
@@ -228,7 +227,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
...(this.config?.isVoiceModeEnabled() ? [voiceCommand] : []),
|
||||
...(this.config?.getContentGeneratorConfig()?.authType ===
|
||||
AuthType.LOGIN_WITH_GOOGLE
|
||||
? [upgradeCommand]
|
||||
|
||||
@@ -89,7 +89,6 @@ describe('ShellProcessor', () => {
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
check: mockPolicyEngineCheck,
|
||||
}),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
get config() {
|
||||
return this as unknown as Config;
|
||||
},
|
||||
|
||||
@@ -168,7 +168,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
|
||||
@@ -552,8 +552,6 @@ const mockUIActions: UIActions = {
|
||||
exitPrivacyNotice: vi.fn(),
|
||||
closeSettingsDialog: vi.fn(),
|
||||
closeModelDialog: vi.fn(),
|
||||
openVoiceModelDialog: vi.fn(),
|
||||
closeVoiceModelDialog: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
openPermissionsDialog: vi.fn(),
|
||||
@@ -600,7 +598,6 @@ const mockUIActions: UIActions = {
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
getPreferredEditor: vi.fn(),
|
||||
clearAccountSuspension: vi.fn(),
|
||||
setVoiceModeEnabled: vi.fn(),
|
||||
};
|
||||
|
||||
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
|
||||
@@ -103,7 +103,6 @@ import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useVoiceModelCommand } from './hooks/useVoiceModelCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import {
|
||||
@@ -313,7 +312,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
|
||||
const [shellModeActive, setShellModeActive] = useState(false);
|
||||
const [isVoiceModeEnabled, setVoiceModeEnabled] = useState(false);
|
||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||
useState<boolean>(false);
|
||||
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
||||
@@ -948,12 +946,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||
useModelCommand();
|
||||
|
||||
const {
|
||||
isVoiceModelDialogOpen,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
} = useVoiceModelCommand();
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
|
||||
@@ -977,7 +969,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
quit: (messages: HistoryItem[]) => {
|
||||
@@ -990,7 +981,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
},
|
||||
setDebugMessage,
|
||||
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
|
||||
toggleVoiceMode: () => setVoiceModeEnabled((prev) => !prev),
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
@@ -1016,7 +1006,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
setQuittingMessages,
|
||||
setDebugMessage,
|
||||
@@ -2202,7 +2191,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
isVoiceModelDialogOpen ||
|
||||
isAgentConfigDialogOpen ||
|
||||
isPermissionsDialogOpen ||
|
||||
isAuthenticating ||
|
||||
@@ -2460,7 +2448,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isVoiceModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
@@ -2481,7 +2468,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingGeminiHistoryItems,
|
||||
thought,
|
||||
isInputActive,
|
||||
isVoiceModeEnabled,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
@@ -2573,7 +2559,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isVoiceModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
@@ -2594,7 +2579,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingGeminiHistoryItems,
|
||||
thought,
|
||||
isInputActive,
|
||||
isVoiceModeEnabled,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
@@ -2687,8 +2671,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
@@ -2769,9 +2751,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setAccountSuspensionInfo(null);
|
||||
setAuthState(AuthState.Updating);
|
||||
},
|
||||
setVoiceModeEnabled: (value: boolean) => {
|
||||
setVoiceModeEnabled(value);
|
||||
},
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -2785,8 +2764,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
@@ -2830,7 +2807,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
historyManager,
|
||||
getPreferredEditor,
|
||||
setVoiceModeEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ export interface CommandContext {
|
||||
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVoiceMode: () => void;
|
||||
toggleDebugProfiler: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
reloadCommands: () => void;
|
||||
@@ -126,7 +125,6 @@ export interface OpenDialogActionReturn {
|
||||
| 'settings'
|
||||
| 'sessionBrowser'
|
||||
| 'model'
|
||||
| 'voice-model'
|
||||
| 'agentConfig'
|
||||
| 'permissions';
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const voiceCommand: SlashCommand = {
|
||||
name: 'voice',
|
||||
altNames: [],
|
||||
description: 'Toggle voice dictation mode',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => {
|
||||
context.ui.toggleVoiceMode();
|
||||
},
|
||||
subCommands: [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Manage voice transcription models',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'voice-model',
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -25,7 +25,6 @@ import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { VoiceModelDialog } from './VoiceModelDialog.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useQuotaState } from '../contexts/QuotaContext.js';
|
||||
@@ -239,9 +238,6 @@ export const DialogManager = ({
|
||||
if (uiState.isModelDialogOpen) {
|
||||
return <ModelDialog onClose={uiActions.closeModelDialog} />;
|
||||
}
|
||||
if (uiState.isVoiceModelDialogOpen) {
|
||||
return <VoiceModelDialog onClose={uiActions.closeVoiceModelDialog} />;
|
||||
}
|
||||
if (
|
||||
uiState.isAgentConfigDialogOpen &&
|
||||
uiState.selectedAgentName &&
|
||||
|
||||
@@ -9,41 +9,12 @@ import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act, useState, useMemo } from 'react';
|
||||
import type { EventEmitter } from 'node:events';
|
||||
|
||||
const { fakeTranscriptionProvider } = vi.hoisted(() => {
|
||||
// Use require within hoisted block for immediate synchronous access
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-syntax
|
||||
const { EventEmitter } = require('node:events');
|
||||
class FakeTranscriptionProvider extends EventEmitter {
|
||||
connect = vi.fn().mockResolvedValue(undefined);
|
||||
disconnect = vi.fn();
|
||||
sendAudioChunk = vi.fn();
|
||||
getTranscription = vi.fn().mockReturnValue('');
|
||||
}
|
||||
return {
|
||||
fakeTranscriptionProvider: new FakeTranscriptionProvider(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = (await importOriginal()) as any;
|
||||
return {
|
||||
...actual,
|
||||
TranscriptionFactory: {
|
||||
createProvider: vi.fn(() => fakeTranscriptionProvider),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
InputPrompt,
|
||||
tryTogglePasteExpansion,
|
||||
type InputPromptProps,
|
||||
} from './InputPrompt.js';
|
||||
import { InputContext } from '../contexts/InputContext.js';
|
||||
import { type UIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
calculateTransformationsForLine,
|
||||
calculateTransformedLine,
|
||||
@@ -446,7 +417,6 @@ describe('InputPrompt', () => {
|
||||
getWorkspaceContext: () => ({
|
||||
getDirectories: () => ['/test/project/src'],
|
||||
}),
|
||||
getContentGeneratorConfig: () => ({ apiKey: 'test-api-key' }),
|
||||
} as unknown as Config,
|
||||
slashCommands: mockSlashCommands,
|
||||
commandContext: mockCommandContext,
|
||||
@@ -4955,383 +4925,6 @@ describe('InputPrompt', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Voice Mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
fakeTranscriptionProvider as unknown as EventEmitter
|
||||
).removeAllListeners();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should start recording when space is pressed and voice mode is enabled (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Initially not recording
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
expect(lastFrame()).toContain(
|
||||
'Voice mode: Space to start/stop recording',
|
||||
);
|
||||
|
||||
// Press space to start
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Now should show listening
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should toggle recording off when space is pressed again (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Start recording
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
// Stop recording
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
expect(lastFrame()).toContain(
|
||||
'Voice mode: Space to start/stop recording',
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should resume recording when space is pressed even if buffer is not empty (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('some existing text');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Should show voice mode hint even if buffer is not empty (new behavior)
|
||||
expect(lastFrame()).toContain(
|
||||
'Voice mode: Space to start/stop recording',
|
||||
);
|
||||
expect(lastFrame()).toContain('some existing text');
|
||||
|
||||
// Press space to start recording again
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not start recording if voice mode is disabled (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: false } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Press space
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Should NOT show listening, instead should call handleInput which handles space
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should append transcription correctly across multiple turn updates (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('initial');
|
||||
});
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Start recording
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Emit first transcription
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'transcription',
|
||||
'hello',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith('initial hello', 'end');
|
||||
});
|
||||
|
||||
// Emit turnComplete (Gemini Live starts over after this)
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'turnComplete',
|
||||
);
|
||||
});
|
||||
|
||||
// Emit second part (Gemini Live sends new turn text starting from empty)
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'transcription',
|
||||
'world',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Should have appended 'world' to the baseline 'initial hello'
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith(
|
||||
'initial hello world',
|
||||
'end',
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should append transcription correctly when resuming voice mode (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('First turn.');
|
||||
});
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Start recording (resumed)
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Emit transcription
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'transcription',
|
||||
'Second turn.',
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith(
|
||||
'First turn. Second turn.',
|
||||
'end',
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('push-to-talk', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should insert a space on a single tap', async () => {
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'push-to-talk' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Voice mode: Hold Space to record');
|
||||
|
||||
// Press space once
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Should insert space optimistically
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
|
||||
// Advance timer past HOLD_DELAY_MS
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
});
|
||||
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should start recording on hold (simulated by repeat spaces)', async () => {
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'push-to-talk' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// First space
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
|
||||
|
||||
// Second space (repeat)
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should have backspaced the optimistic space
|
||||
expect(mockBuffer.backspace).toHaveBeenCalled();
|
||||
// Should show listening
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should stop recording when space heartbeat stops (release)', async () => {
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'push-to-talk' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Start hold
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Use a short interval in waitFor to prevent advancing fake timers past the 300ms RELEASE_DELAY_MS
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
},
|
||||
{ interval: 10 },
|
||||
);
|
||||
|
||||
// Simulate heartbeat (held key) - send space first to reset timer, then advance
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
|
||||
// Stop heartbeat (release)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400); // Past RELEASE_DELAY_MS
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should cancel hold state if non-space key is pressed after first space', async () => {
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'push-to-talk' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// First space
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Type 'a'
|
||||
await act(async () => {
|
||||
stdin.write('a');
|
||||
});
|
||||
|
||||
// Should NOT start recording on next space even if fast
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
expect(mockBuffer.insert).toHaveBeenCalledTimes(2); // Two spaces inserted
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'a' }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clean(str: string | undefined): string {
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
debugLogger,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useVoiceMode } from '../hooks/useVoiceMode.js';
|
||||
import {
|
||||
parseInputForHighlighting,
|
||||
parseSegmentsFromTokens,
|
||||
@@ -160,6 +159,7 @@ export function isLargePaste(text: string): boolean {
|
||||
}
|
||||
|
||||
const DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS = 350;
|
||||
|
||||
/**
|
||||
* Attempt to toggle expansion of a paste placeholder in the buffer.
|
||||
* Returns true if a toggle action was performed or hint was shown, false otherwise.
|
||||
@@ -238,7 +238,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setEmbeddedShellFocused,
|
||||
setShortcutsHelpVisible,
|
||||
toggleCleanUiDetailsVisible,
|
||||
setVoiceModeEnabled,
|
||||
} = useUIActions();
|
||||
const {
|
||||
terminalWidth,
|
||||
@@ -247,7 +246,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundTasks,
|
||||
backgroundTaskHeight,
|
||||
shortcutsHelpVisible,
|
||||
isVoiceModeEnabled,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
|
||||
@@ -265,7 +263,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
resetEscapeState();
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetTurnBaseline();
|
||||
resetCompletionState();
|
||||
} else if (history.length > 0) {
|
||||
onSubmit('/rewind');
|
||||
@@ -284,16 +281,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const hasUserNavigatedSuggestions = useRef(false);
|
||||
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
|
||||
|
||||
const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
|
||||
buffer,
|
||||
config,
|
||||
settings,
|
||||
setQueueErrorMessage,
|
||||
isVoiceModeEnabled,
|
||||
setVoiceModeEnabled,
|
||||
keyMatchers,
|
||||
});
|
||||
|
||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
||||
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
|
||||
@@ -400,7 +387,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
|
||||
// if onSubmit triggers a re-render while the buffer still holds the old value.
|
||||
buffer.setText('');
|
||||
resetTurnBaseline();
|
||||
onSubmit(processedValue);
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
@@ -412,7 +398,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
shellModeActive,
|
||||
shellHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
resetTurnBaseline,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -662,8 +647,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
if (handleVoiceInput(key)) return true;
|
||||
|
||||
// Determine if this keypress is a history navigation command
|
||||
const isHistoryUp =
|
||||
!shellModeActive &&
|
||||
@@ -890,9 +873,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) {
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
resetTurnBaseline();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
const cancelSearch = (
|
||||
setActive: (active: boolean) => void,
|
||||
@@ -1377,7 +1360,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundTaskHeight,
|
||||
streamingState,
|
||||
handleEscPress,
|
||||
resetTurnBaseline,
|
||||
registerPlainTabPress,
|
||||
resetPlainTabPress,
|
||||
toggleCleanUiDetailsVisible,
|
||||
@@ -1387,9 +1369,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
keyMatchers,
|
||||
isHelpDismissKey,
|
||||
settings,
|
||||
handleVoiceInput,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, {
|
||||
isActive: !isEmbeddedShellFocused && !copyModeEnabled,
|
||||
priority: true,
|
||||
@@ -1810,39 +1792,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
)}{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{isRecording && (
|
||||
<Box flexDirection="row" marginBottom={0}>
|
||||
<Text color={theme.status.success}>🎙️ Listening...</Text>
|
||||
</Box>
|
||||
)}
|
||||
{isVoiceModeEnabled && !isRecording && (
|
||||
<Box flexDirection="row" marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>
|
||||
> Voice mode:{' '}
|
||||
{(settings.experimental.voice?.activationMode ??
|
||||
'push-to-talk') === 'push-to-talk'
|
||||
? 'Hold Space to record'
|
||||
: 'Space to start/stop recording'}{' '}
|
||||
(Esc to exit)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{buffer.text.length === 0 && !isRecording ? (
|
||||
!isVoiceModeEnabled && placeholder ? (
|
||||
showCursor ? (
|
||||
<Text
|
||||
terminalCursorFocus={showCursor}
|
||||
terminalCursorPosition={0}
|
||||
>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>
|
||||
{placeholder.slice(1)}
|
||||
</Text>
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
showCursor ? (
|
||||
<Text
|
||||
terminalCursorFocus={showCursor}
|
||||
terminalCursorPosition={0}
|
||||
>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>
|
||||
{placeholder.slice(1)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : null
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
|
||||
@@ -65,7 +65,6 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: () => boolean;
|
||||
getProModelNoAccess: () => Promise<boolean>;
|
||||
getProModelNoAccessSync: () => boolean;
|
||||
getExperimentalGemma: () => boolean;
|
||||
getLastRetrievedQuota: () =>
|
||||
| {
|
||||
buckets: Array<{
|
||||
@@ -86,7 +85,6 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
|
||||
getProModelNoAccess: mockGetProModelNoAccess,
|
||||
getProModelNoAccessSync: mockGetProModelNoAccessSync,
|
||||
getExperimentalGemma: () => false,
|
||||
getLastRetrievedQuota: () => ({ buckets: [] }),
|
||||
getSessionId: () => 'test-session-id',
|
||||
};
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMMA_4_31B_IT_MODEL,
|
||||
GEMMA_4_26B_A4B_IT_MODEL,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
getDisplayString,
|
||||
@@ -224,9 +222,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
const showGemmaModels = config?.getExperimentalGemma() ?? false;
|
||||
|
||||
const options = [
|
||||
const list = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
@@ -244,21 +240,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
if (showGemmaModels) {
|
||||
options.push(
|
||||
{
|
||||
value: GEMMA_4_31B_IT_MODEL,
|
||||
title: getDisplayString(GEMMA_4_31B_IT_MODEL),
|
||||
key: GEMMA_4_31B_IT_MODEL,
|
||||
},
|
||||
{
|
||||
value: GEMMA_4_26B_A4B_IT_MODEL,
|
||||
title: getDisplayString(GEMMA_4_26B_A4B_IT_MODEL),
|
||||
key: GEMMA_4_26B_A4B_IT_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
@@ -289,15 +270,15 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
});
|
||||
}
|
||||
|
||||
options.unshift(...previewOptions);
|
||||
list.unshift(...previewOptions);
|
||||
}
|
||||
|
||||
if (!hasAccessToProModel) {
|
||||
// Filter out all Pro models for free tier
|
||||
return options.filter((option) => !isProModel(option.value));
|
||||
return list.filter((option) => !isProModel(option.value));
|
||||
}
|
||||
|
||||
return options;
|
||||
return list;
|
||||
}, [
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
|
||||
@@ -86,7 +86,6 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getProjectTempDir: () => '/tmp/test',
|
||||
},
|
||||
getSessionId: () => 'default-session-id',
|
||||
getExperimentalGemma: () => false,
|
||||
...overrides,
|
||||
}) as Config;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ enum TerminalKeys {
|
||||
LEFT_ARROW = '\u001B[D',
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
BACKSPACE = '\u0008',
|
||||
BACKSPACE = '\x7f',
|
||||
CTRL_P = '\u0010',
|
||||
CTRL_N = '\u000E',
|
||||
}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { useSettingsStore } from '../contexts/SettingsContext.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
import { isBinaryAvailable } from '@google/gemini-cli-core';
|
||||
import {
|
||||
WhisperModelManager,
|
||||
type WhisperModelProgress,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
|
||||
interface VoiceModelDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DialogView = 'backend' | 'whisper-models';
|
||||
|
||||
const WHISPER_MODELS = [
|
||||
{
|
||||
value: 'ggml-tiny.en.bin',
|
||||
label: 'Tiny (EN)',
|
||||
description: 'Fastest, lower accuracy (~75MB)',
|
||||
},
|
||||
{
|
||||
value: 'ggml-base.en.bin',
|
||||
label: 'Base (EN)',
|
||||
description: 'Balanced speed and accuracy (~142MB)',
|
||||
},
|
||||
{
|
||||
value: 'ggml-large-v3-turbo-q5_0.bin',
|
||||
label: 'Large v3 Turbo (Q5_0)',
|
||||
description: 'High accuracy, quantized (~547MB)',
|
||||
},
|
||||
{
|
||||
value: 'ggml-large-v3-turbo-q8_0.bin',
|
||||
label: 'Large v3 Turbo (Q8_0)',
|
||||
description: 'Maximum accuracy, high memory (~834MB)',
|
||||
},
|
||||
];
|
||||
|
||||
export function VoiceModelDialog({
|
||||
onClose,
|
||||
}: VoiceModelDialogProps): React.JSX.Element {
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
const [view, setView] = useState<DialogView>('backend');
|
||||
const [downloadProgress, setDownloadProgress] =
|
||||
useState<WhisperModelProgress | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const whisperInstalled = useMemo(
|
||||
() => isBinaryAvailable('whisper-stream'),
|
||||
[],
|
||||
);
|
||||
const modelManager = useMemo(() => new WhisperModelManager(), []);
|
||||
|
||||
const currentBackend =
|
||||
settings.merged.experimental.voice?.backend ?? 'gemini-live';
|
||||
const currentWhisperModel =
|
||||
settings.merged.experimental.voice?.whisperModel ?? 'ggml-base.en.bin';
|
||||
|
||||
const handleKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
if (key.name === 'escape') {
|
||||
if (view === 'whisper-models') {
|
||||
setView('backend');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[view, onClose],
|
||||
);
|
||||
|
||||
useKeypress(handleKeypress, { isActive: true });
|
||||
|
||||
const handleBackendSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === 'whisper') {
|
||||
setView('whisper-models');
|
||||
} else {
|
||||
setSetting(
|
||||
SettingScope.User,
|
||||
'experimental.voice.backend',
|
||||
'gemini-live',
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[setSetting, onClose],
|
||||
);
|
||||
|
||||
const handleWhisperModelSelect = useCallback(
|
||||
async (modelName: string) => {
|
||||
if (modelManager.isModelInstalled(modelName)) {
|
||||
setSetting(SettingScope.User, 'experimental.voice.backend', 'whisper');
|
||||
setSetting(
|
||||
SettingScope.User,
|
||||
'experimental.voice.whisperModel',
|
||||
modelName,
|
||||
);
|
||||
onClose();
|
||||
} else {
|
||||
setError(null);
|
||||
const onProgress = (p: WhisperModelProgress) => setDownloadProgress(p);
|
||||
modelManager.on('progress', onProgress);
|
||||
|
||||
try {
|
||||
await modelManager.downloadModel(modelName);
|
||||
|
||||
setSetting(
|
||||
SettingScope.User,
|
||||
'experimental.voice.backend',
|
||||
'whisper',
|
||||
);
|
||||
setSetting(
|
||||
SettingScope.User,
|
||||
'experimental.voice.whisperModel',
|
||||
modelName,
|
||||
);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
`Failed to download: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
modelManager.off('progress', onProgress);
|
||||
setDownloadProgress(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[modelManager, setSetting, onClose],
|
||||
);
|
||||
|
||||
const backendOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 'gemini-live',
|
||||
title: 'Gemini Live API (Cloud)',
|
||||
description: 'Real-time cloud transcription via Gemini Live API.',
|
||||
key: 'gemini-live',
|
||||
},
|
||||
{
|
||||
value: 'whisper',
|
||||
title: 'Whisper (Local)',
|
||||
description: whisperInstalled
|
||||
? 'Local transcription using whisper.cpp.'
|
||||
: 'Local transcription (Requires: brew install whisper-cpp)',
|
||||
key: 'whisper',
|
||||
},
|
||||
],
|
||||
[whisperInstalled],
|
||||
);
|
||||
|
||||
const whisperOptions = useMemo(
|
||||
() =>
|
||||
WHISPER_MODELS.map((m) => ({
|
||||
value: m.value,
|
||||
title: `${m.label}${modelManager.isModelInstalled(m.value) ? ' (Installed)' : ' (Download)'}`,
|
||||
description: m.description,
|
||||
key: m.value,
|
||||
})),
|
||||
[modelManager],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>
|
||||
{view === 'backend'
|
||||
? 'Select Voice Transcription Backend'
|
||||
: 'Select Whisper Model'}
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{downloadProgress ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box>
|
||||
<Text>Downloading {downloadProgress.modelName}... </Text>
|
||||
<CliSpinner />
|
||||
<Text> {Math.round(downloadProgress.percentage * 100)}%</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginTop={1}>
|
||||
{view === 'backend' ? (
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={backendOptions}
|
||||
onSelect={handleBackendSelect}
|
||||
initialIndex={currentBackend === 'whisper' ? 1 : 0}
|
||||
showNumbers={true}
|
||||
/>
|
||||
) : (
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={whisperOptions}
|
||||
onSelect={handleWhisperModelSelect}
|
||||
initialIndex={whisperOptions.findIndex(
|
||||
(o) => o.value === currentWhisperModel,
|
||||
)}
|
||||
showNumbers={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{view === 'whisper-models'
|
||||
? '(Press Esc to go back)'
|
||||
: '(Press Esc to close)'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { HintMessage } from './HintMessage.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
|
||||
describe('HintMessage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders normal hint message with correct prefix', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<HintMessage text="Try this instead" />,
|
||||
{ width: 80 },
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('💡');
|
||||
expect(output).toContain('Steering Hint: Try this instead');
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('with NO_COLOR set', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NO_COLOR', '1');
|
||||
});
|
||||
|
||||
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<HintMessage text="Try this instead" />,
|
||||
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
|
||||
expect(output).not.toContain('▄');
|
||||
expect(output).not.toContain('▀');
|
||||
|
||||
const lines = output.split('\n').filter((l) => l.trim() !== '');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain('💡');
|
||||
expect(lines[0]).toContain('Steering Hint: Try this instead');
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,7 @@ export const HintMessage: React.FC<HintMessageProps> = ({ text }) => {
|
||||
const prefix = '💡 ';
|
||||
const prefixWidth = prefix.length;
|
||||
const config = useConfig();
|
||||
const useBackgroundColorSetting = config.getUseBackgroundColor();
|
||||
const useBackgroundColor =
|
||||
useBackgroundColorSetting && !!theme.background.message;
|
||||
const useBackgroundColor = config.getUseBackgroundColor();
|
||||
|
||||
return (
|
||||
<HalfLinePaddedBox
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { UserMessage } from './UserMessage.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock the commandUtils to control isSlashCommand behavior
|
||||
vi.mock('../../utils/commandUtils.js', () => ({
|
||||
@@ -15,11 +14,6 @@ vi.mock('../../utils/commandUtils.js', () => ({
|
||||
}));
|
||||
|
||||
describe('UserMessage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders normal user message with correct prefix', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<UserMessage text="Hello Gemini" width={80} />,
|
||||
@@ -66,32 +60,4 @@ describe('UserMessage', () => {
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('with NO_COLOR set', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NO_COLOR', '1');
|
||||
});
|
||||
|
||||
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<UserMessage text="Hello Gemini" width={80} />,
|
||||
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
|
||||
expect(output).not.toContain('▄');
|
||||
expect(output).not.toContain('▀');
|
||||
|
||||
// There should be empty lines above and below the message due to marginY={1}.
|
||||
// lastFrame() returns the full buffer, so we can check for leading/trailing newlines or empty lines.
|
||||
const lines = output.split('\n').filter((l) => l.trim() !== '');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain('> Hello Gemini');
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
|
||||
const prefixWidth = prefix.length;
|
||||
const isSlashCommand = checkIsSlashCommand(text);
|
||||
const config = useConfig();
|
||||
const useBackgroundColorSetting = config.getUseBackgroundColor();
|
||||
const useBackgroundColor =
|
||||
useBackgroundColorSetting && !!theme.background.message;
|
||||
const useBackgroundColor = config.getUseBackgroundColor();
|
||||
|
||||
const textColor = isSlashCommand ? theme.text.accent : theme.text.primary;
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { UserShellMessage } from './UserShellMessage.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
|
||||
describe('UserShellMessage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders normal shell message with correct prefix', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<UserShellMessage text="ls -la" width={80} />,
|
||||
{ width: 80 },
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('$ ls -la');
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('with NO_COLOR set', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NO_COLOR', '1');
|
||||
});
|
||||
|
||||
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<UserShellMessage text="ls -la" width={80} />,
|
||||
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
|
||||
expect(output).not.toContain('▄');
|
||||
expect(output).not.toContain('▀');
|
||||
|
||||
const lines = output.split('\n').filter((l) => l.trim() !== '');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain('$ ls -la');
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,7 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
|
||||
width,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const useBackgroundColorSetting = config.getUseBackgroundColor();
|
||||
const useBackgroundColor =
|
||||
useBackgroundColorSetting && !!theme.background.message;
|
||||
const useBackgroundColor = config.getUseBackgroundColor();
|
||||
|
||||
// Remove leading '!' if present, as App.tsx adds it for the processor.
|
||||
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`HintMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
|
||||
"
|
||||
💡 Steering Hint: Try this instead
|
||||
"
|
||||
`;
|
||||
@@ -28,9 +28,3 @@ exports[`UserMessage > transforms image paths in user message 1`] = `
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`UserMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
|
||||
"
|
||||
> Hello Gemini
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`UserShellMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
|
||||
"
|
||||
$ ls -la
|
||||
"
|
||||
`;
|
||||
@@ -24,7 +24,7 @@ enum TerminalKeys {
|
||||
LEFT_ARROW = '\u001B[D',
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
BACKSPACE = '\u0008',
|
||||
BACKSPACE = '\x7f',
|
||||
CTRL_L = '\u000C',
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,17 @@ import { act } from 'react';
|
||||
import { renderHookWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { vi, afterAll, beforeAll, type Mock } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import {
|
||||
vi,
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from 'vitest';
|
||||
import {
|
||||
useKeypressContext,
|
||||
ESC_TIMEOUT,
|
||||
@@ -431,6 +441,80 @@ describe('KeypressContext', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Windows Terminal Backspace handling', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should NOT treat \\b as ctrl when WT_SESSION is NOT present and OS is not Windows_NT', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('OS', 'Linux');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\b');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat \\b as ctrl when WT_SESSION IS present (even if not Windows_NT)', async () => {
|
||||
vi.stubEnv('WT_SESSION', 'some-id');
|
||||
vi.stubEnv('OS', 'Linux');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\b');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat \\b as ctrl when OS is Windows_NT', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('OS', 'Windows_NT');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\b');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat \\x7f as regular backspace regardless of WT_SESSION or OS', async () => {
|
||||
vi.stubEnv('WT_SESSION', 'some-id');
|
||||
vi.stubEnv('OS', 'Windows_NT');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x7f');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paste mode', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -651,8 +651,20 @@ function* emitKeys(
|
||||
// tab
|
||||
name = 'tab';
|
||||
alt = escaped;
|
||||
} else if (ch === '\b' || ch === '\x7f') {
|
||||
// backspace or ctrl+h
|
||||
} else if (ch === '\b') {
|
||||
// ctrl+h / ctrl+backspace (windows terminals send \x08 for ctrl+backspace)
|
||||
name = 'backspace';
|
||||
// In Windows environments, \b is sent for Ctrl+Backspace (standard backspace is translated to \x7f).
|
||||
// We scope this to Windows/WT_SESSION to avoid breaking other unixes where \b is a plain backspace.
|
||||
if (
|
||||
typeof process !== 'undefined' &&
|
||||
(process.env?.['OS'] === 'Windows_NT' || !!process.env?.['WT_SESSION'])
|
||||
) {
|
||||
ctrl = true;
|
||||
}
|
||||
alt = escaped;
|
||||
} else if (ch === '\x7f') {
|
||||
// backspace
|
||||
name = 'backspace';
|
||||
alt = escaped;
|
||||
} else if (ch === ESC) {
|
||||
|
||||
@@ -41,8 +41,6 @@ export interface UIActions {
|
||||
exitPrivacyNotice: () => void;
|
||||
closeSettingsDialog: () => void;
|
||||
closeModelDialog: () => void;
|
||||
openVoiceModelDialog: () => void;
|
||||
closeVoiceModelDialog: () => void;
|
||||
openAgentConfigDialog: (
|
||||
name: string,
|
||||
displayName: string,
|
||||
@@ -95,7 +93,6 @@ export interface UIActions {
|
||||
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
||||
getPreferredEditor: () => EditorType | undefined;
|
||||
clearAccountSuspension: () => void;
|
||||
setVoiceModeEnabled: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -112,7 +112,6 @@ export interface UIState {
|
||||
isSettingsDialogOpen: boolean;
|
||||
isSessionBrowserOpen: boolean;
|
||||
isModelDialogOpen: boolean;
|
||||
isVoiceModelDialogOpen: boolean;
|
||||
isAgentConfigDialogOpen: boolean;
|
||||
selectedAgentName?: string;
|
||||
selectedAgentDisplayName?: string;
|
||||
@@ -133,7 +132,6 @@ export interface UIState {
|
||||
pendingGeminiHistoryItems: HistoryItemWithoutId[];
|
||||
thought: ThoughtSummary | null;
|
||||
isInputActive: boolean;
|
||||
isVoiceModeEnabled: boolean;
|
||||
isResuming: boolean;
|
||||
shouldShowIdePrompt: boolean;
|
||||
isFolderTrustDialogOpen: boolean;
|
||||
|
||||
@@ -205,13 +205,11 @@ describe('useSlashCommandProcessor', () => {
|
||||
openSettingsDialog: vi.fn(),
|
||||
openSessionBrowser: vi.fn(),
|
||||
openModelDialog: mockOpenModelDialog,
|
||||
openVoiceModelDialog: vi.fn(),
|
||||
openAgentConfigDialog,
|
||||
openPermissionsDialog: vi.fn(),
|
||||
quit: mockSetQuittingMessages,
|
||||
setDebugMessage: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleVoiceMode: vi.fn(),
|
||||
toggleDebugProfiler: vi.fn(),
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||
|
||||
@@ -72,7 +72,6 @@ interface SlashCommandProcessorActions {
|
||||
openSettingsDialog: () => void;
|
||||
openSessionBrowser: () => void;
|
||||
openModelDialog: () => void;
|
||||
openVoiceModelDialog: () => void;
|
||||
openAgentConfigDialog: (
|
||||
name: string,
|
||||
displayName: string,
|
||||
@@ -82,7 +81,6 @@ interface SlashCommandProcessorActions {
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVoiceMode: () => void;
|
||||
toggleDebugProfiler: () => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
@@ -234,7 +232,6 @@ export const useSlashCommandProcessor = (
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
toggleCorgiMode: actions.toggleCorgiMode,
|
||||
toggleVoiceMode: actions.toggleVoiceMode,
|
||||
toggleDebugProfiler: actions.toggleDebugProfiler,
|
||||
toggleVimEnabled,
|
||||
reloadCommands,
|
||||
@@ -506,9 +503,6 @@ export const useSlashCommandProcessor = (
|
||||
case 'model':
|
||||
actions.openModelDialog();
|
||||
return { type: 'handled' };
|
||||
case 'voice-model':
|
||||
actions.openVoiceModelDialog();
|
||||
return { type: 'handled' };
|
||||
case 'agentConfig': {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const props = result.props as Record<string, unknown>;
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
AudioRecorder,
|
||||
TranscriptionFactory,
|
||||
debugLogger,
|
||||
type Config,
|
||||
type TranscriptionProvider,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type { MergedSettings } from '../../config/settingsSchema.js';
|
||||
import type { Key } from './useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
|
||||
interface UseVoiceModeProps {
|
||||
buffer: TextBuffer;
|
||||
config: Config;
|
||||
settings: MergedSettings;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
isVoiceModeEnabled: boolean;
|
||||
setVoiceModeEnabled: (enabled: boolean) => void;
|
||||
keyMatchers: Record<Command, (key: Key) => boolean>;
|
||||
}
|
||||
|
||||
const HOLD_DELAY_MS = 600;
|
||||
const RELEASE_DELAY_MS = 300;
|
||||
|
||||
export function useVoiceMode({
|
||||
buffer,
|
||||
config,
|
||||
settings,
|
||||
setQueueErrorMessage,
|
||||
isVoiceModeEnabled,
|
||||
setVoiceModeEnabled,
|
||||
keyMatchers,
|
||||
}: UseVoiceModeProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
const liveTranscriptionRef = useRef('');
|
||||
const stopRequestedRef = useRef(false);
|
||||
const isRecordingRef = useRef(false);
|
||||
const lastFailureTimeRef = useRef(0);
|
||||
const recordingInProgressRef = useRef(false);
|
||||
const voiceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const recorderRef = useRef<AudioRecorder | null>(null);
|
||||
const transcriptionServiceRef = useRef<TranscriptionProvider | null>(null);
|
||||
const turnBaselineRef = useRef<string | null>(null);
|
||||
|
||||
const pttStateRef = useRef<'idle' | 'possible-hold' | 'recording'>('idle');
|
||||
const pttTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const disconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const bufferRef = useRef(buffer);
|
||||
bufferRef.current = buffer;
|
||||
|
||||
const stopVoiceRecording = useCallback(() => {
|
||||
if (stopRequestedRef.current) return;
|
||||
debugLogger.debug('[Voice] Stop requested');
|
||||
stopRequestedRef.current = true;
|
||||
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stop();
|
||||
recorderRef.current = null;
|
||||
}
|
||||
|
||||
const serviceToDisconnect = transcriptionServiceRef.current;
|
||||
transcriptionServiceRef.current = null;
|
||||
|
||||
if (serviceToDisconnect) {
|
||||
const isLive = settings.experimental.voice?.backend === 'gemini-live';
|
||||
const gracePeriodMs =
|
||||
settings.experimental.voice?.stopGracePeriodMs ??
|
||||
(isLive ? 2000 : 1000);
|
||||
debugLogger.debug(
|
||||
`[Voice] Draining transcription for ${gracePeriodMs}ms`,
|
||||
);
|
||||
|
||||
if (disconnectTimerRef.current) clearTimeout(disconnectTimerRef.current);
|
||||
disconnectTimerRef.current = setTimeout(() => {
|
||||
debugLogger.debug('[Voice] Grace period ended, disconnecting service');
|
||||
serviceToDisconnect.disconnect();
|
||||
disconnectTimerRef.current = null;
|
||||
}, gracePeriodMs);
|
||||
}
|
||||
|
||||
liveTranscriptionRef.current = '';
|
||||
pttStateRef.current = 'idle';
|
||||
}, [settings.experimental.voice]);
|
||||
|
||||
const startVoiceRecording = useCallback(() => {
|
||||
if (
|
||||
isRecordingRef.current ||
|
||||
Date.now() - lastFailureTimeRef.current < 2000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (disconnectTimerRef.current) {
|
||||
clearTimeout(disconnectTimerRef.current);
|
||||
disconnectTimerRef.current = null;
|
||||
}
|
||||
|
||||
recordingInProgressRef.current = true;
|
||||
turnBaselineRef.current = bufferRef.current.text;
|
||||
|
||||
setIsConnecting(true);
|
||||
setIsRecording(true);
|
||||
isRecordingRef.current = true;
|
||||
|
||||
liveTranscriptionRef.current = '';
|
||||
stopRequestedRef.current = false;
|
||||
|
||||
const apiKey =
|
||||
config.getContentGeneratorConfig()?.apiKey ||
|
||||
process.env['GEMINI_API_KEY'] ||
|
||||
'';
|
||||
|
||||
const startAsync = async () => {
|
||||
// If there's an active draining service, disconnect it immediately
|
||||
// before starting a new one to prevent orphaned event collisions.
|
||||
if (disconnectTimerRef.current) {
|
||||
clearTimeout(disconnectTimerRef.current);
|
||||
disconnectTimerRef.current = null;
|
||||
}
|
||||
if (transcriptionServiceRef.current) {
|
||||
transcriptionServiceRef.current.disconnect();
|
||||
transcriptionServiceRef.current = null;
|
||||
}
|
||||
|
||||
const cleanupIfStopped = () => {
|
||||
if (stopRequestedRef.current) {
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stop();
|
||||
recorderRef.current = null;
|
||||
}
|
||||
if (transcriptionServiceRef.current) {
|
||||
transcriptionServiceRef.current.disconnect();
|
||||
transcriptionServiceRef.current = null;
|
||||
}
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
recordingInProgressRef.current = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (cleanupIfStopped()) return;
|
||||
|
||||
const voiceBackend =
|
||||
settings.experimental.voice?.backend ?? 'gemini-live';
|
||||
|
||||
if (!apiKey && voiceBackend === 'gemini-live') {
|
||||
setQueueErrorMessage(
|
||||
'Cloud voice mode requires a GEMINI_API_KEY. Please set it in your environment or ~/.gemini/.env.',
|
||||
);
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
recordingInProgressRef.current = false;
|
||||
lastFailureTimeRef.current = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
if (voiceBackend === 'gemini-live') {
|
||||
recorderRef.current = new AudioRecorder();
|
||||
}
|
||||
|
||||
const currentService = TranscriptionFactory.createProvider(
|
||||
settings.experimental.voice,
|
||||
apiKey,
|
||||
);
|
||||
transcriptionServiceRef.current = currentService;
|
||||
|
||||
currentService.on('transcription', (text) => {
|
||||
if (
|
||||
transcriptionServiceRef.current !== currentService &&
|
||||
stopRequestedRef.current
|
||||
) {
|
||||
// If this is an orphaned service that was replaced by a new session, ignore its events
|
||||
return;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const currentBufferText = bufferRef.current.text;
|
||||
const previousTranscription = liveTranscriptionRef.current;
|
||||
|
||||
let newTotalText = currentBufferText;
|
||||
|
||||
if (
|
||||
previousTranscription &&
|
||||
currentBufferText.endsWith(previousTranscription)
|
||||
) {
|
||||
newTotalText = currentBufferText.slice(
|
||||
0,
|
||||
-previousTranscription.length,
|
||||
);
|
||||
} else if (
|
||||
currentBufferText &&
|
||||
!currentBufferText.endsWith(' ') &&
|
||||
!currentBufferText.endsWith('\n')
|
||||
) {
|
||||
newTotalText += ' ';
|
||||
}
|
||||
|
||||
newTotalText += text;
|
||||
bufferRef.current.setText(newTotalText, 'end');
|
||||
}
|
||||
liveTranscriptionRef.current = text;
|
||||
});
|
||||
|
||||
currentService.on('turnComplete', () => {
|
||||
if (
|
||||
transcriptionServiceRef.current !== currentService &&
|
||||
stopRequestedRef.current
|
||||
)
|
||||
return;
|
||||
liveTranscriptionRef.current = '';
|
||||
});
|
||||
|
||||
currentService.on('error', (err) => {
|
||||
if (transcriptionServiceRef.current !== currentService) return;
|
||||
debugLogger.error('[Voice] Transcription error:', err);
|
||||
lastFailureTimeRef.current = Date.now();
|
||||
recordingInProgressRef.current = false;
|
||||
});
|
||||
|
||||
currentService.on('close', () => {
|
||||
if (transcriptionServiceRef.current !== currentService) return;
|
||||
if (!stopRequestedRef.current) {
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
recordingInProgressRef.current = false;
|
||||
lastFailureTimeRef.current = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await currentService.connect();
|
||||
if (cleanupIfStopped()) return;
|
||||
|
||||
await recorderRef.current?.start();
|
||||
if (cleanupIfStopped()) return;
|
||||
|
||||
setIsConnecting(false);
|
||||
|
||||
const currentVoiceBackend =
|
||||
settings.experimental.voice?.backend ?? 'gemini-live';
|
||||
|
||||
recorderRef.current?.on('data', (chunk) => {
|
||||
if (currentVoiceBackend === 'gemini-live') {
|
||||
currentService.sendAudioChunk(chunk);
|
||||
}
|
||||
});
|
||||
recorderRef.current?.on('error', (err) => {
|
||||
debugLogger.error('[Voice] Recorder error:', err);
|
||||
stopVoiceRecording();
|
||||
lastFailureTimeRef.current = Date.now();
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (transcriptionServiceRef.current !== currentService) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setQueueErrorMessage(`Voice mode failure: ${message}`);
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
recordingInProgressRef.current = false;
|
||||
lastFailureTimeRef.current = Date.now();
|
||||
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stop();
|
||||
recorderRef.current = null;
|
||||
}
|
||||
if (transcriptionServiceRef.current) {
|
||||
transcriptionServiceRef.current.disconnect();
|
||||
transcriptionServiceRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void startAsync();
|
||||
}, [
|
||||
config,
|
||||
settings.experimental.voice,
|
||||
setQueueErrorMessage,
|
||||
stopVoiceRecording,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (voiceTimeoutRef.current) clearTimeout(voiceTimeoutRef.current);
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stop();
|
||||
recorderRef.current = null;
|
||||
}
|
||||
if (transcriptionServiceRef.current) {
|
||||
transcriptionServiceRef.current.disconnect();
|
||||
transcriptionServiceRef.current = null;
|
||||
}
|
||||
if (pttTimerRef.current) clearTimeout(pttTimerRef.current);
|
||||
if (disconnectTimerRef.current) clearTimeout(disconnectTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleVoiceInput = useCallback(
|
||||
(key: Key): boolean => {
|
||||
const activeRecording = isRecording || isRecordingRef.current;
|
||||
|
||||
if (activeRecording) {
|
||||
const activationMode =
|
||||
settings.experimental.voice?.activationMode ?? 'push-to-talk';
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
stopVoiceRecording();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.VOICE_MODE_PTT](key)) {
|
||||
if (activationMode === 'push-to-talk') {
|
||||
if (pttTimerRef.current) {
|
||||
clearTimeout(pttTimerRef.current);
|
||||
}
|
||||
pttTimerRef.current = setTimeout(() => {
|
||||
stopVoiceRecording();
|
||||
pttTimerRef.current = null;
|
||||
}, RELEASE_DELAY_MS);
|
||||
return true;
|
||||
} else {
|
||||
stopVoiceRecording();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isVoiceModeEnabled) {
|
||||
const activationMode =
|
||||
settings.experimental.voice?.activationMode ?? 'push-to-talk';
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key) && buffer.text === '') {
|
||||
setVoiceModeEnabled(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.VOICE_MODE_PTT](key)) {
|
||||
if (
|
||||
key.name === 'space' &&
|
||||
!key.ctrl &&
|
||||
!key.alt &&
|
||||
!key.shift &&
|
||||
!key.cmd
|
||||
) {
|
||||
if (activationMode === 'toggle') {
|
||||
startVoiceRecording();
|
||||
return true;
|
||||
} else {
|
||||
if (pttStateRef.current === 'idle') {
|
||||
buffer.insert(' ');
|
||||
pttStateRef.current = 'possible-hold';
|
||||
|
||||
if (pttTimerRef.current) clearTimeout(pttTimerRef.current);
|
||||
pttTimerRef.current = setTimeout(() => {
|
||||
pttStateRef.current = 'idle';
|
||||
pttTimerRef.current = null;
|
||||
}, HOLD_DELAY_MS);
|
||||
return true;
|
||||
} else if (pttStateRef.current === 'possible-hold') {
|
||||
if (pttTimerRef.current) clearTimeout(pttTimerRef.current);
|
||||
buffer.backspace();
|
||||
pttStateRef.current = 'recording';
|
||||
startVoiceRecording();
|
||||
|
||||
pttTimerRef.current = setTimeout(() => {
|
||||
stopVoiceRecording();
|
||||
pttTimerRef.current = null;
|
||||
}, RELEASE_DELAY_MS);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pttStateRef.current === 'possible-hold') {
|
||||
pttStateRef.current = 'idle';
|
||||
if (pttTimerRef.current) {
|
||||
clearTimeout(pttTimerRef.current);
|
||||
pttTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[
|
||||
isRecording,
|
||||
isVoiceModeEnabled,
|
||||
settings.experimental.voice,
|
||||
keyMatchers,
|
||||
stopVoiceRecording,
|
||||
startVoiceRecording,
|
||||
buffer,
|
||||
setVoiceModeEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isConnecting,
|
||||
startVoiceRecording,
|
||||
stopVoiceRecording,
|
||||
handleVoiceInput,
|
||||
resetTurnBaseline: () => {
|
||||
turnBaselineRef.current = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseVoiceModelCommandReturn {
|
||||
isVoiceModelDialogOpen: boolean;
|
||||
openVoiceModelDialog: () => void;
|
||||
closeVoiceModelDialog: () => void;
|
||||
}
|
||||
|
||||
export const useVoiceModelCommand = (): UseVoiceModelCommandReturn => {
|
||||
const [isVoiceModelDialogOpen, setIsVoiceModelDialogOpen] = useState(false);
|
||||
|
||||
const openVoiceModelDialog = useCallback(() => {
|
||||
setIsVoiceModelDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeVoiceModelDialog = useCallback(() => {
|
||||
setIsVoiceModelDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isVoiceModelDialogOpen,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
};
|
||||
};
|
||||
@@ -97,7 +97,6 @@ export enum Command {
|
||||
RESTART_APP = 'app.restart',
|
||||
SUSPEND_APP = 'app.suspend',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
|
||||
VOICE_MODE_PTT = 'app.voiceModePTT',
|
||||
|
||||
// Background Shell Controls
|
||||
BACKGROUND_SHELL_ESCAPE = 'background.escape',
|
||||
@@ -408,7 +407,9 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
|
||||
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||
[Command.VOICE_MODE_PTT, [new KeyBinding('space')]],
|
||||
[Command.DUMP_FRAME, [new KeyBinding('f8')]],
|
||||
[Command.START_RECORDING, [new KeyBinding('f6')]],
|
||||
[Command.STOP_RECORDING, [new KeyBinding('f7')]],
|
||||
|
||||
// Background Shell Controls
|
||||
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
|
||||
@@ -423,10 +424,6 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
// Extension Controls
|
||||
[Command.UPDATE_EXTENSION, [new KeyBinding('i')]],
|
||||
[Command.LINK_EXTENSION, [new KeyBinding('l')]],
|
||||
|
||||
[Command.DUMP_FRAME, [new KeyBinding('f8')]],
|
||||
[Command.START_RECORDING, [new KeyBinding('f6')]],
|
||||
[Command.STOP_RECORDING, [new KeyBinding('f7')]],
|
||||
]);
|
||||
|
||||
interface CommandCategory {
|
||||
@@ -541,7 +538,6 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.RESTART_APP,
|
||||
Command.SUSPEND_APP,
|
||||
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
|
||||
Command.VOICE_MODE_PTT,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -662,7 +658,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to move focus away from shell input.',
|
||||
[Command.VOICE_MODE_PTT]: 'Hold to speak in Voice Mode.',
|
||||
|
||||
// Background Shell Controls
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
|
||||
|
||||
@@ -43,6 +43,5 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundTasks: () => {},
|
||||
toggleShortcutsHelp: () => {},
|
||||
toggleVoiceMode: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
|
||||
message:
|
||||
'Automatic update failed. Please try updating manually:\n\nnpm i -g @google/gemini-cli@2.0.0',
|
||||
'Automatic update failed. Please try updating manually. (command: npm i -g @google/gemini-cli@2.0.0)',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,7 +325,7 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
|
||||
message:
|
||||
'Automatic update failed. Please try updating manually. (error: Spawn error)\n\nnpm i -g @google/gemini-cli@2.0.0',
|
||||
'Automatic update failed. Please try updating manually. (error: Spawn error)',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -435,15 +435,13 @@ describe('setUpdateHandler', () => {
|
||||
});
|
||||
|
||||
it('should handle update-failed event', () => {
|
||||
updateEventEmitter.emit('update-failed', {
|
||||
message: 'Failed message with command',
|
||||
});
|
||||
updateEventEmitter.emit('update-failed', { message: 'Failed' });
|
||||
|
||||
expect(setUpdateInfo).toHaveBeenCalledWith(null);
|
||||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed message with command',
|
||||
text: 'Automatic update failed. Please try updating manually',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -148,7 +148,7 @@ export function handleAutoUpdate(
|
||||
});
|
||||
} else {
|
||||
updateEventEmitter.emit('update-failed', {
|
||||
message: `Automatic update failed. Please try updating manually:\n\n${updateCommand}`,
|
||||
message: `Automatic update failed. Please try updating manually. (command: ${updateCommand})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -156,7 +156,7 @@ export function handleAutoUpdate(
|
||||
updateProcess.on('error', (err) => {
|
||||
_updateInProgress = false;
|
||||
updateEventEmitter.emit('update-failed', {
|
||||
message: `Automatic update failed. Please try updating manually. (error: ${err.message})\n\n${updateCommand}`,
|
||||
message: `Automatic update failed. Please try updating manually. (error: ${err.message})`,
|
||||
});
|
||||
});
|
||||
return updateProcess;
|
||||
@@ -184,14 +184,12 @@ export function setUpdateHandler(
|
||||
}, 60000);
|
||||
};
|
||||
|
||||
const handleUpdateFailed = (data?: { message: string }) => {
|
||||
const handleUpdateFailed = () => {
|
||||
setUpdateInfo(null);
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text:
|
||||
data?.message ||
|
||||
`Automatic update failed. Please try updating manually`,
|
||||
text: `Automatic update failed. Please try updating manually`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -69,7 +69,6 @@ describe('Session Cleanup (Refactored)', () => {
|
||||
},
|
||||
getSessionId: () => 'current123',
|
||||
getDebugMode: () => false,
|
||||
getExperimentalGemma: () => false,
|
||||
initialize: async () => {},
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -96,9 +96,7 @@ const folderTrustCheck: WarningCheck = {
|
||||
|
||||
if (isHeadlessMode()) {
|
||||
throw new FatalUntrustedWorkspaceError(
|
||||
'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, ' +
|
||||
'set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode. ' +
|
||||
'For more details, see https://geminicli.com/docs/cli/trusted-folders/#headless-and-automated-environments',
|
||||
'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ export {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
GEMMA_4_31B_IT_MODEL,
|
||||
GEMMA_4_26B_A4B_IT_MODEL,
|
||||
} from './src/config/models.js';
|
||||
export {
|
||||
serializeTerminalToObject,
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"command-exists": "^1.2.9",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
|
||||
@@ -779,8 +779,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
return {
|
||||
result: finalResult || 'Task completed.',
|
||||
terminate_reason: terminateReason,
|
||||
turn_count: turnCounter,
|
||||
duration_ms: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -788,8 +786,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
result:
|
||||
finalResult || 'Agent execution was terminated before completion.',
|
||||
terminate_reason: terminateReason,
|
||||
turn_count: turnCounter,
|
||||
duration_ms: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
// Check if the error is an AbortError caused by our internal timeout.
|
||||
@@ -830,8 +826,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
return {
|
||||
result: finalResult,
|
||||
terminate_reason: terminateReason,
|
||||
turn_count: turnCounter,
|
||||
duration_ms: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -846,8 +840,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
return {
|
||||
result: finalResult,
|
||||
terminate_reason: terminateReason,
|
||||
turn_count: turnCounter,
|
||||
duration_ms: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -74,14 +74,12 @@ describe('SkillExtractionAgent', () => {
|
||||
|
||||
expect(query).toContain(existingSkillsSummary);
|
||||
expect(query).toContain(sessionIndex);
|
||||
expect(query).toContain('optional workflow hint');
|
||||
expect(query).toContain(
|
||||
'workflow hints alone is never enough evidence for a reusable skill.',
|
||||
'The summary is a user-intent summary, not a workflow summary.',
|
||||
);
|
||||
expect(query).toContain(
|
||||
'Session summaries describe user intent; optional workflow hints describe likely procedural traces.',
|
||||
'The session summaries describe user intent, not workflow details.',
|
||||
);
|
||||
expect(query).toContain('Use workflow hints for routing');
|
||||
expect(query).toContain(
|
||||
'Only write a skill if the evidence shows a durable, recurring workflow',
|
||||
);
|
||||
|
||||
@@ -303,11 +303,10 @@ export const SkillExtractionAgent = (
|
||||
'# Session Index',
|
||||
'',
|
||||
'Below is an index of past conversation sessions. Each line shows:',
|
||||
'[NEW] or [old] status, a 1-line user-intent summary, optional workflow hint, message count, and the file path.',
|
||||
'[NEW] or [old] status, a 1-line summary, message count, and the file path.',
|
||||
'',
|
||||
'Some lines may include "| workflow: ..."; this is a compact workflow hint from session metadata.',
|
||||
'Use workflow hints to prioritize which sessions to read and to group likely recurring workflows.',
|
||||
'Matching summary text or workflow hints alone is never enough evidence for a reusable skill.',
|
||||
'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)',
|
||||
@@ -327,7 +326,7 @@ export const SkillExtractionAgent = (
|
||||
|
||||
return {
|
||||
systemPrompt: buildSystemPrompt(skillsDir),
|
||||
query: `${initialContext}\n\nAnalyze the session index above. Session summaries describe user intent; optional workflow hints describe likely procedural traces. Use workflow hints for routing, then read sessions that suggest repeated workflows using read_file to verify recurrence from transcript evidence. 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. 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.`,
|
||||
};
|
||||
},
|
||||
runConfig: {
|
||||
|
||||
@@ -36,8 +36,6 @@ export enum AgentTerminateMode {
|
||||
export interface OutputObject {
|
||||
result: string;
|
||||
terminate_reason: AgentTerminateMode;
|
||||
turn_count?: number;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -960,11 +960,8 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
await config.getExperimentsAsync();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should NOT switch to flash model if user has Pro access and model is auto', async () => {
|
||||
@@ -3645,47 +3642,6 @@ describe('Config JIT Initialization', () => {
|
||||
expect(config.isAutoMemoryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when experimentalGemma is true', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
experimentalGemma: true,
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
expect(config.getExperimentalGemma()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when experimentalGemma is false', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
experimentalGemma: false,
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
expect(config.getExperimentalGemma()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when experimentalGemma is not provided', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
expect(config.getExperimentalGemma()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be independent of experimentalMemoryV2', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
|
||||
@@ -691,7 +691,6 @@ export interface ConfigParameters {
|
||||
ptyInfo?: string;
|
||||
disableYoloMode?: boolean;
|
||||
disableAlwaysAllow?: boolean;
|
||||
voiceMode?: boolean;
|
||||
rawOutput?: boolean;
|
||||
acceptRawOutputRisk?: boolean;
|
||||
dynamicModelConfiguration?: boolean;
|
||||
@@ -712,7 +711,6 @@ export interface ConfigParameters {
|
||||
autoDistillation?: boolean;
|
||||
experimentalMemoryV2?: boolean;
|
||||
experimentalAutoMemory?: boolean;
|
||||
experimentalGemma?: boolean;
|
||||
experimentalContextManagementConfig?: string;
|
||||
experimentalAgentHistoryTruncation?: boolean;
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
@@ -958,13 +956,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly experimentalMemoryV2: boolean;
|
||||
private readonly experimentalAutoMemory: boolean;
|
||||
private readonly experimentalGemma: boolean;
|
||||
private readonly experimentalContextManagementConfig?: string;
|
||||
private readonly memoryBoundaryMarkers: readonly string[];
|
||||
private readonly topicUpdateNarration: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
private readonly planEnabled: boolean;
|
||||
private readonly voiceMode: boolean;
|
||||
private readonly trackerEnabled: boolean;
|
||||
private readonly planModeRoutingEnabled: boolean;
|
||||
private readonly modelSteering: boolean;
|
||||
@@ -1119,7 +1115,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? true;
|
||||
this.voiceMode = params.voiceMode ?? false;
|
||||
this.trackerEnabled = params.tracker ?? false;
|
||||
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
@@ -1179,7 +1174,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? true;
|
||||
this.experimentalMemoryV2 = params.experimentalMemoryV2 ?? true;
|
||||
this.experimentalAutoMemory = params.experimentalAutoMemory ?? false;
|
||||
this.experimentalGemma = params.experimentalGemma ?? false;
|
||||
this.experimentalContextManagementConfig =
|
||||
params.experimentalContextManagementConfig;
|
||||
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
|
||||
@@ -1596,12 +1590,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const [experiments] = await Promise.all([
|
||||
this.experimentsPromise,
|
||||
quotaPromise.catch((e) => {
|
||||
debugLogger.error('Failed to fetch user quota', e);
|
||||
}),
|
||||
]);
|
||||
// Fetch experiments and update timeouts before continuing initialization
|
||||
const experiments = await this.experimentsPromise;
|
||||
|
||||
const requestTimeoutMs = this.getRequestTimeoutMs();
|
||||
if (requestTimeoutMs !== undefined) {
|
||||
@@ -1611,6 +1601,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
// Initialize BaseLlmClient now that the ContentGenerator and experiments are available
|
||||
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
|
||||
|
||||
await quotaPromise;
|
||||
|
||||
const authType = this.contentGeneratorConfig.authType;
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI ||
|
||||
@@ -1631,21 +1623,16 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
const adminControlsEnabled =
|
||||
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
|
||||
false;
|
||||
|
||||
try {
|
||||
const adminControls = await fetchAdminControls(
|
||||
codeAssistServer,
|
||||
this.getRemoteAdminSettings(),
|
||||
adminControlsEnabled,
|
||||
(newSettings: AdminControlsSettings) => {
|
||||
this.setRemoteAdminSettings(newSettings);
|
||||
coreEvents.emitAdminSettingsChanged();
|
||||
},
|
||||
);
|
||||
this.setRemoteAdminSettings(adminControls);
|
||||
} catch (e) {
|
||||
debugLogger.error('Failed to fetch admin controls', e);
|
||||
}
|
||||
const adminControls = await fetchAdminControls(
|
||||
codeAssistServer,
|
||||
this.getRemoteAdminSettings(),
|
||||
adminControlsEnabled,
|
||||
(newSettings: AdminControlsSettings) => {
|
||||
this.setRemoteAdminSettings(newSettings);
|
||||
coreEvents.emitAdminSettingsChanged();
|
||||
},
|
||||
);
|
||||
this.setRemoteAdminSettings(adminControls);
|
||||
|
||||
if ((await this.getProModelNoAccess()) && isAutoModel(this.model)) {
|
||||
this.setModel(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
@@ -2527,10 +2514,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalAutoMemory;
|
||||
}
|
||||
|
||||
getExperimentalGemma(): boolean {
|
||||
return this.experimentalGemma;
|
||||
}
|
||||
|
||||
getExperimentalContextManagementConfig(): string | undefined {
|
||||
return this.experimentalContextManagementConfig;
|
||||
}
|
||||
@@ -2972,10 +2955,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.planEnabled;
|
||||
}
|
||||
|
||||
isVoiceModeEnabled(): boolean {
|
||||
return this.voiceMode;
|
||||
}
|
||||
|
||||
isTrackerEnabled(): boolean {
|
||||
return this.trackerEnabled;
|
||||
}
|
||||
|
||||
@@ -89,19 +89,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
},
|
||||
},
|
||||
'gemma-4-31b-it': {
|
||||
extends: 'chat-base-3',
|
||||
modelConfig: {
|
||||
model: 'gemma-4-31b-it',
|
||||
},
|
||||
},
|
||||
'gemma-4-26b-a4b-it': {
|
||||
extends: 'chat-base-3',
|
||||
modelConfig: {
|
||||
model: 'gemma-4-26b-a4b-it',
|
||||
},
|
||||
},
|
||||
|
||||
// Bases for the internal model configs.
|
||||
'gemini-2.5-flash-base': {
|
||||
extends: 'base',
|
||||
@@ -330,23 +317,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
isVisible: true,
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'gemma-4-31b-it': {
|
||||
displayName: 'gemma-4-31b-it',
|
||||
tier: 'custom',
|
||||
family: 'gemma-4',
|
||||
isPreview: false,
|
||||
isVisible: true,
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
'gemma-4-26b-a4b-it': {
|
||||
displayName: 'gemma-4-26b-a4b-it',
|
||||
tier: 'custom',
|
||||
family: 'gemma-4',
|
||||
isPreview: false,
|
||||
isVisible: true,
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
|
||||
// Aliases
|
||||
auto: {
|
||||
tier: 'auto',
|
||||
@@ -392,13 +362,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
modelIdResolutions: {
|
||||
'gemma-4-31b-it': {
|
||||
default: 'gemma-4-31b-it',
|
||||
},
|
||||
'gemma-4-26b-a4b-it': {
|
||||
default: 'gemma-4-26b-a4b-it',
|
||||
},
|
||||
|
||||
'gemini-3.1-pro-preview': {
|
||||
default: 'gemini-3.1-pro-preview',
|
||||
contexts: [
|
||||
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
isPreviewModel,
|
||||
isProModel,
|
||||
GEMMA_4_31B_IT_MODEL,
|
||||
GEMMA_4_26B_A4B_IT_MODEL,
|
||||
} from './models.js';
|
||||
import type { Config } from './config.js';
|
||||
import { ModelConfigService } from '../services/modelConfigService.js';
|
||||
@@ -358,10 +356,6 @@ describe('getDisplayString', () => {
|
||||
|
||||
it('should return the model name as is for other models', () => {
|
||||
expect(getDisplayString('custom-model')).toBe('custom-model');
|
||||
expect(getDisplayString(GEMMA_4_31B_IT_MODEL)).toBe(GEMMA_4_31B_IT_MODEL);
|
||||
expect(getDisplayString(GEMMA_4_26B_A4B_IT_MODEL)).toBe(
|
||||
GEMMA_4_26B_A4B_IT_MODEL,
|
||||
);
|
||||
expect(getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe(
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
);
|
||||
@@ -579,17 +573,6 @@ describe('isActiveModel', () => {
|
||||
expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for Gemma 4 models only when experimentalGemma is true', () => {
|
||||
expect(isActiveModel(GEMMA_4_31B_IT_MODEL)).toBe(false);
|
||||
expect(isActiveModel(GEMMA_4_26B_A4B_IT_MODEL)).toBe(false);
|
||||
expect(isActiveModel(GEMMA_4_31B_IT_MODEL, false, false, false, true)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isActiveModel(GEMMA_4_26B_A4B_IT_MODEL, false, false, false, true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for Gemini 3.1 models when Gemini 3.1 is not launched', () => {
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(false);
|
||||
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(false);
|
||||
|
||||
@@ -61,9 +61,6 @@ export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
|
||||
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
|
||||
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
|
||||
|
||||
export const GEMMA_4_31B_IT_MODEL = 'gemma-4-31b-it';
|
||||
export const GEMMA_4_26B_A4B_IT_MODEL = 'gemma-4-26b-a4b-it';
|
||||
|
||||
export const VALID_GEMINI_MODELS = new Set([
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
@@ -73,9 +70,6 @@ export const VALID_GEMINI_MODELS = new Set([
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
|
||||
GEMMA_4_31B_IT_MODEL,
|
||||
GEMMA_4_26B_A4B_IT_MODEL,
|
||||
]);
|
||||
|
||||
export const PREVIEW_GEMINI_MODEL_AUTO = 'auto-gemini-3';
|
||||
@@ -263,10 +257,6 @@ export function getDisplayString(
|
||||
return 'Auto (Gemini 3)';
|
||||
case DEFAULT_GEMINI_MODEL_AUTO:
|
||||
return 'Auto (Gemini 2.5)';
|
||||
case GEMMA_4_31B_IT_MODEL:
|
||||
return GEMMA_4_31B_IT_MODEL;
|
||||
case GEMMA_4_26B_A4B_IT_MODEL:
|
||||
return GEMMA_4_26B_A4B_IT_MODEL;
|
||||
case GEMINI_MODEL_ALIAS_PRO:
|
||||
return PREVIEW_GEMINI_MODEL;
|
||||
case GEMINI_MODEL_ALIAS_FLASH:
|
||||
@@ -448,14 +438,10 @@ export function isActiveModel(
|
||||
useGemini3_1: boolean = false,
|
||||
useGemini3_1FlashLite: boolean = false,
|
||||
useCustomToolModel: boolean = false,
|
||||
experimentalGemma: boolean = false,
|
||||
): boolean {
|
||||
if (!VALID_GEMINI_MODELS.has(model)) {
|
||||
return false;
|
||||
}
|
||||
if (model === GEMMA_4_31B_IT_MODEL || model === GEMMA_4_26B_A4B_IT_MODEL) {
|
||||
return experimentalGemma;
|
||||
}
|
||||
if (model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL) {
|
||||
return useGemini3_1FlashLite;
|
||||
}
|
||||
|
||||
@@ -10,23 +10,17 @@ import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
GEMMA_4_31B_IT_MODEL,
|
||||
GEMMA_4_26B_A4B_IT_MODEL,
|
||||
} from '../config/models.js';
|
||||
|
||||
type Model = string;
|
||||
type TokenCount = number;
|
||||
|
||||
export const DEFAULT_TOKEN_LIMIT = 1_048_576;
|
||||
export const GEMMA_4_TOKEN_LIMIT = 256_000;
|
||||
|
||||
export function tokenLimit(model: Model): TokenCount {
|
||||
// Add other models as they become relevant or if specified by config
|
||||
// Pulled from https://ai.google.dev/gemini-api/docs/models
|
||||
switch (model) {
|
||||
case GEMMA_4_31B_IT_MODEL:
|
||||
case GEMMA_4_26B_A4B_IT_MODEL:
|
||||
return GEMMA_4_TOKEN_LIMIT;
|
||||
case PREVIEW_GEMINI_MODEL:
|
||||
case PREVIEW_GEMINI_FLASH_MODEL:
|
||||
case DEFAULT_GEMINI_MODEL:
|
||||
|
||||
@@ -297,12 +297,3 @@ export * from './context/profiles.js';
|
||||
|
||||
// Export trust utility
|
||||
export * from './utils/trust.js';
|
||||
|
||||
// Export voice utilities
|
||||
export * from './voice/audioRecorder.js';
|
||||
export * from './voice/transcriptionProvider.js';
|
||||
export * from './voice/geminiLiveTranscriptionProvider.js';
|
||||
export * from './voice/whisperTranscriptionProvider.js';
|
||||
export * from './voice/transcriptionFactory.js';
|
||||
export * from './voice/whisperModelManager.js';
|
||||
export { isBinaryAvailable } from './utils/binaryCheck.js';
|
||||
|
||||
@@ -74,9 +74,7 @@ export const ADMIN_POLICY_TIER = 5;
|
||||
|
||||
export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9;
|
||||
export const EXCLUDE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.4;
|
||||
export const CONFIRMATION_REQUIRED_PRIORITY = USER_POLICY_TIER + 0.35;
|
||||
export const ALLOWED_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.3;
|
||||
export const CORE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.25;
|
||||
export const TRUSTED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.2;
|
||||
export const ALLOWED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.1;
|
||||
|
||||
@@ -436,21 +434,10 @@ export async function createPolicyEngineConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const nonPlanModes = [
|
||||
ApprovalMode.DEFAULT,
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
ApprovalMode.YOLO,
|
||||
];
|
||||
|
||||
const mapToolsToRules = (
|
||||
tools: string[],
|
||||
priority: number,
|
||||
source: string,
|
||||
modes?: ApprovalMode[],
|
||||
addDefaultDenyForTools = false,
|
||||
) => {
|
||||
const toolsWithNarrowing = new Set<string>();
|
||||
for (const tool of tools) {
|
||||
// Tools that are explicitly allowed in the settings.
|
||||
// Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows)
|
||||
if (settings.tools?.allowed) {
|
||||
for (const tool of settings.tools.allowed) {
|
||||
// Check for legacy format: toolName(args)
|
||||
const match = tool.match(/^([a-zA-Z0-9_-]+)\((.*)\)$/);
|
||||
if (match) {
|
||||
@@ -462,17 +449,15 @@ export async function createPolicyEngineConfig(
|
||||
|
||||
// Treat args as a command prefix for shell tool
|
||||
if (toolName === SHELL_TOOL_NAME) {
|
||||
toolsWithNarrowing.add(toolName);
|
||||
const patterns = buildArgsPatterns(undefined, args);
|
||||
for (const pattern of patterns) {
|
||||
if (pattern) {
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority,
|
||||
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
|
||||
argsPattern: new RegExp(pattern),
|
||||
source,
|
||||
modes,
|
||||
source: 'Settings (Tools Allowed)',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -482,9 +467,8 @@ export async function createPolicyEngineConfig(
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority,
|
||||
source,
|
||||
modes,
|
||||
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
|
||||
source: 'Settings (Tools Allowed)',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -495,70 +479,11 @@ export async function createPolicyEngineConfig(
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority,
|
||||
source,
|
||||
modes,
|
||||
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
|
||||
source: 'Settings (Tools Allowed)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (addDefaultDenyForTools) {
|
||||
for (const toolName of toolsWithNarrowing) {
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: priority - 0.01,
|
||||
source: `${source} (Narrowing Enforcement)`,
|
||||
modes,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Tools that are explicitly allowed in the settings.
|
||||
// Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows)
|
||||
if (settings.tools?.allowed) {
|
||||
mapToolsToRules(
|
||||
settings.tools.allowed,
|
||||
ALLOWED_TOOLS_FLAG_PRIORITY,
|
||||
'Settings (Tools Allowed)',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Tools that explicitly require confirmation in the settings.
|
||||
// Priority: CONFIRMATION_REQUIRED_PRIORITY (overrides allowed and core)
|
||||
if (settings.tools?.confirmationRequired) {
|
||||
for (const tool of settings.tools.confirmationRequired) {
|
||||
rules.push({
|
||||
toolName: SHELL_TOOL_NAMES.includes(tool) ? SHELL_TOOL_NAME : tool,
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: CONFIRMATION_REQUIRED_PRIORITY,
|
||||
source: 'Settings (Confirmation Required)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Core tools that are restricted in the settings.
|
||||
// Priority: CORE_TOOLS_FLAG_PRIORITY (user tier - core tool allowlist)
|
||||
if (settings.tools?.core) {
|
||||
mapToolsToRules(
|
||||
settings.tools.core,
|
||||
CORE_TOOLS_FLAG_PRIORITY,
|
||||
'Settings (Core Tools)',
|
||||
nonPlanModes,
|
||||
);
|
||||
|
||||
// If core tools are restricted, we should add a default DENY rule for everything else
|
||||
// at a slightly lower priority than the explicit allows.
|
||||
rules.push({
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: CORE_TOOLS_FLAG_PRIORITY - 0.01,
|
||||
source: 'Settings (Core Tools Allowlist Enforcement)',
|
||||
modes: nonPlanModes,
|
||||
});
|
||||
}
|
||||
|
||||
// MCP servers that are trusted in the settings.
|
||||
@@ -576,7 +501,6 @@ export async function createPolicyEngineConfig(
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: TRUSTED_MCP_SERVER_PRIORITY,
|
||||
source: 'Settings (MCP Trusted)',
|
||||
modes: nonPlanModes,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -595,7 +519,6 @@ export async function createPolicyEngineConfig(
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: ALLOWED_MCP_SERVER_PRIORITY,
|
||||
source: 'Settings (MCP Allowed)',
|
||||
modes: nonPlanModes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createPolicyEngineConfig } from './config.js';
|
||||
import { PolicyEngine } from './policy-engine.js';
|
||||
import { PolicyDecision, ApprovalMode } from './types.js';
|
||||
|
||||
describe('PolicyEngine - Core Tools Mapping', () => {
|
||||
it('should allow tools explicitly listed in settings.tools.core', async () => {
|
||||
const settings = {
|
||||
tools: {
|
||||
core: ['run_shell_command(ls)', 'run_shell_command(git status)'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true, // interactive
|
||||
);
|
||||
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Test simple tool name
|
||||
const result1 = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result1.decision).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Test tool name with args
|
||||
const result2 = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'git status' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result2.decision).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Test tool not in core list
|
||||
const result3 = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'npm test' } },
|
||||
undefined,
|
||||
);
|
||||
// Should be DENIED because of strict allowlist
|
||||
expect(result3.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow tools in tools.core even if they are restricted by default policies', async () => {
|
||||
// By default run_shell_command is ASK_USER.
|
||||
// Putting it in tools.core should make it ALLOW.
|
||||
const settings = {
|
||||
tools: {
|
||||
core: ['run_shell_command'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'any command' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,7 @@ import {
|
||||
import type { FunctionCall } from '@google/genai';
|
||||
import { SafetyCheckDecision } from '../safety/protocol.js';
|
||||
import type { CheckerRunner } from '../safety/checker-runner.js';
|
||||
import {
|
||||
initializeShellParsers,
|
||||
parseCommandDetails,
|
||||
} from '../utils/shell-utils.js';
|
||||
import { initializeShellParsers } from '../utils/shell-utils.js';
|
||||
import { buildArgsPatterns } from './utils.js';
|
||||
import {
|
||||
NoopSandboxManager,
|
||||
@@ -46,35 +43,6 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
|
||||
}
|
||||
return [command];
|
||||
}),
|
||||
parseCommandDetails: vi.fn().mockImplementation((command: string) => {
|
||||
// Basic mock implementation for PolicyEngine test needs
|
||||
const commands = command.includes('&&')
|
||||
? command.split('&&').map((c) => c.trim())
|
||||
: [command.trim()];
|
||||
|
||||
// Detect $(...) or `...` and add as sub-commands for recursion tests
|
||||
const subCommands = [...commands];
|
||||
for (const cmd of commands) {
|
||||
const subMatch = cmd.match(/\$\((.*)\)/) || cmd.match(/`(.*)`/);
|
||||
if (subMatch?.[1]) {
|
||||
subCommands.push(subMatch[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
details: subCommands.map((c, i) => ({
|
||||
name: c.split(' ')[0],
|
||||
text: c,
|
||||
startIndex: i === 0 ? 0 : -1, // Simple root indication
|
||||
})),
|
||||
hasError: false,
|
||||
};
|
||||
}),
|
||||
stripShellWrapper: vi.fn().mockImplementation((command: string) => {
|
||||
// Simple mock for stripping wrappers
|
||||
const match = command.match(/^(?:bash|sh|zsh)\s+-c\s+["'](.*)["']$/i);
|
||||
return match ? match[1] : command;
|
||||
}),
|
||||
hasRedirection: vi.fn().mockImplementation(
|
||||
(command: string) =>
|
||||
// Simple mock: true if '>' is present, unless it looks like "-> arrow"
|
||||
@@ -480,77 +448,6 @@ describe('PolicyEngine', () => {
|
||||
const { decision } = await engine.check({ name: 'test-tool' }, undefined);
|
||||
expect(decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should fail closed in YOLO mode when shell parsing fails for restricted rule', async () => {
|
||||
const originalMock = vi
|
||||
.mocked(parseCommandDetails)
|
||||
.getMockImplementation();
|
||||
vi.mocked(parseCommandDetails).mockImplementationOnce(
|
||||
(command: string) => {
|
||||
if (command === 'echo bypass') {
|
||||
return { details: [], hasError: true };
|
||||
}
|
||||
return originalMock!(command);
|
||||
},
|
||||
);
|
||||
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
argsPattern: /"command":"echo/,
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
});
|
||||
|
||||
const { decision } = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo bypass' } },
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should fail closed in YOLO mode when shell parsing has errors for restricted rule', async () => {
|
||||
const originalMock = vi
|
||||
.mocked(parseCommandDetails)
|
||||
.getMockImplementation();
|
||||
vi.mocked(parseCommandDetails).mockImplementationOnce(
|
||||
(command: string) => {
|
||||
if (command === 'echo bypass') {
|
||||
return {
|
||||
details: [{ name: 'echo', text: 'echo bypass', startIndex: 0 }],
|
||||
hasError: true,
|
||||
};
|
||||
}
|
||||
return originalMock!(command);
|
||||
},
|
||||
);
|
||||
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
argsPattern: /"command":"echo/,
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
});
|
||||
|
||||
const { decision } = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo bypass' } },
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRule', () => {
|
||||
@@ -1965,6 +1862,7 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
|
||||
it('should return ASK_USER in non-YOLO mode if shell command parsing fails', async () => {
|
||||
const { splitCommands } = await import('../utils/shell-utils.js');
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
@@ -1979,11 +1877,7 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
|
||||
// Simulate parsing failure
|
||||
const { parseCommandDetails } = await import('../utils/shell-utils.js');
|
||||
vi.mocked(parseCommandDetails).mockReturnValueOnce({
|
||||
details: [],
|
||||
hasError: true,
|
||||
});
|
||||
vi.mocked(splitCommands).mockReturnValueOnce([]);
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'complex command' } },
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
import { type FunctionCall } from '@google/genai';
|
||||
import {
|
||||
SHELL_TOOL_NAMES,
|
||||
REDIRECTION_NAMES,
|
||||
initializeShellParsers,
|
||||
parseCommandDetails,
|
||||
stripShellWrapper,
|
||||
splitCommands,
|
||||
hasRedirection,
|
||||
extractStringFromParseEntry,
|
||||
} from '../utils/shell-utils.js';
|
||||
@@ -361,25 +359,16 @@ export class PolicyEngine {
|
||||
}
|
||||
|
||||
await initializeShellParsers();
|
||||
const parsed = parseCommandDetails(command);
|
||||
const subCommands = parsed?.details ?? [];
|
||||
const subCommands = splitCommands(command);
|
||||
|
||||
// Handle parser failures or syntax errors
|
||||
if (subCommands.length === 0 || parsed?.hasError) {
|
||||
if (subCommands.length === 0) {
|
||||
// If the matched rule says DENY, we should respect it immediately even if parsing fails.
|
||||
if (ruleDecision === PolicyDecision.DENY) {
|
||||
return { decision: PolicyDecision.DENY, rule };
|
||||
}
|
||||
|
||||
// In YOLO mode, we should proceed anyway even if we can't parse the command.
|
||||
if (this.approvalMode === ApprovalMode.YOLO) {
|
||||
// Block execution if arguments cannot be validated
|
||||
if (rule?.argsPattern) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Parsing failed for restricted rule, forcing DENY: ${command}`,
|
||||
);
|
||||
return { decision: PolicyDecision.DENY, rule };
|
||||
}
|
||||
// Allow if no argument restrictions apply
|
||||
return {
|
||||
decision: PolicyDecision.ALLOW,
|
||||
rule,
|
||||
@@ -391,109 +380,115 @@ export class PolicyEngine {
|
||||
);
|
||||
|
||||
// Parsing logic failed, we can't trust it. Use default decision ASK_USER (or DENY in non-interactive).
|
||||
// We return the rule that matched so the evaluation loop terminates.
|
||||
return {
|
||||
decision: this.defaultDecision,
|
||||
rule,
|
||||
};
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
|
||||
);
|
||||
// If there are multiple parts, or if we just want to validate the single part against DENY rules
|
||||
if (subCommands.length > 0) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
|
||||
);
|
||||
|
||||
if (ruleDecision === PolicyDecision.DENY) {
|
||||
return { decision: PolicyDecision.DENY, rule };
|
||||
}
|
||||
if (ruleDecision === PolicyDecision.DENY) {
|
||||
return { decision: PolicyDecision.DENY, rule };
|
||||
}
|
||||
|
||||
// Start with the decision from the rule or heuristics.
|
||||
// If the tool call was already downgraded (e.g. by heuristics), we start there.
|
||||
let aggregateDecision = ruleDecision;
|
||||
// Start optimistically. If all parts are ALLOW, the whole is ALLOW.
|
||||
// We will downgrade if any part is ASK_USER or DENY.
|
||||
let aggregateDecision = PolicyDecision.ALLOW;
|
||||
let responsibleRule: PolicyRule | undefined;
|
||||
|
||||
// If heuristics downgraded the decision, we don't blame the rule.
|
||||
let responsibleRule: PolicyRule | undefined =
|
||||
rule && ruleDecision === rule.decision ? rule : undefined;
|
||||
|
||||
// Check for redirection on the full command string.
|
||||
// Redirection always downgrades ALLOW to ASK_USER (it never upgrades).
|
||||
if (this.shouldDowngradeForRedirection(command, allowRedirection)) {
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
// Check for redirection on the full command string
|
||||
if (this.shouldDowngradeForRedirection(command, allowRedirection)) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${command}`,
|
||||
);
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
responsibleRule = undefined; // Inherent policy
|
||||
}
|
||||
}
|
||||
|
||||
for (const detail of subCommands) {
|
||||
if (REDIRECTION_NAMES.has(detail.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subCmd = detail.text.trim();
|
||||
const isAtomic =
|
||||
subCmd === command ||
|
||||
(detail.startIndex === 0 && detail.text.length === command.length);
|
||||
|
||||
// Recursive check for shell wrappers (bash -c, etc.)
|
||||
const stripped = stripShellWrapper(subCmd);
|
||||
if (stripped !== subCmd) {
|
||||
const wrapperResult = await this.check(
|
||||
{ name: toolName, args: { command: stripped, dir_path } },
|
||||
serverName,
|
||||
toolAnnotations,
|
||||
subagent,
|
||||
true,
|
||||
);
|
||||
|
||||
if (wrapperResult.decision === PolicyDecision.DENY)
|
||||
return wrapperResult;
|
||||
if (wrapperResult.decision === PolicyDecision.ASK_USER) {
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
responsibleRule = wrapperResult.rule;
|
||||
for (const rawSubCmd of subCommands) {
|
||||
const subCmd = rawSubCmd.trim();
|
||||
// Prevent infinite recursion for the root command
|
||||
if (subCmd === command) {
|
||||
if (this.shouldDowngradeForRedirection(subCmd, allowRedirection)) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
|
||||
);
|
||||
// Redirection always downgrades ALLOW to ASK_USER
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
responsibleRule = undefined; // Inherent policy
|
||||
}
|
||||
} else {
|
||||
responsibleRule ??= wrapperResult.rule;
|
||||
// Atomic command matching the rule.
|
||||
if (
|
||||
ruleDecision === PolicyDecision.ASK_USER &&
|
||||
aggregateDecision === PolicyDecision.ALLOW
|
||||
) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
responsibleRule = rule;
|
||||
}
|
||||
}
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAtomic) {
|
||||
const subResult = await this.check(
|
||||
{ name: toolName, args: { command: subCmd, dir_path } },
|
||||
serverName,
|
||||
toolAnnotations,
|
||||
subagent,
|
||||
true,
|
||||
);
|
||||
|
||||
if (subResult.decision === PolicyDecision.DENY) return subResult;
|
||||
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
|
||||
const subDecision = subResult.decision;
|
||||
|
||||
if (subResult.decision === PolicyDecision.ASK_USER) {
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
responsibleRule = subResult.rule;
|
||||
} else {
|
||||
responsibleRule ??= subResult.rule;
|
||||
}
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
// If any part is DENIED, the whole command is DENY
|
||||
if (subDecision === PolicyDecision.DENY) {
|
||||
return {
|
||||
decision: PolicyDecision.DENY,
|
||||
rule: subResult.rule,
|
||||
};
|
||||
}
|
||||
|
||||
// Downgrade if sub-command has redirection
|
||||
// If any part requires ASK_USER, the whole command requires ASK_USER
|
||||
if (subDecision === PolicyDecision.ASK_USER) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
if (!responsibleRule) {
|
||||
responsibleRule = subResult.rule;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for redirection in allowed sub-commands
|
||||
if (
|
||||
subResult.decision === PolicyDecision.ALLOW &&
|
||||
subDecision === PolicyDecision.ALLOW &&
|
||||
this.shouldDowngradeForRedirection(subCmd, allowRedirection)
|
||||
) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
|
||||
);
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
responsibleRule = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decision: aggregateDecision,
|
||||
// If we stayed at ALLOW, we return the original rule (if any).
|
||||
// If we downgraded, we return the responsible rule (or undefined if implicit).
|
||||
rule: aggregateDecision === ruleDecision ? rule : responsibleRule,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
decision: aggregateDecision,
|
||||
rule: aggregateDecision === ruleDecision ? rule : responsibleRule,
|
||||
decision: ruleDecision,
|
||||
rule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -506,7 +501,6 @@ export class PolicyEngine {
|
||||
serverName: string | undefined,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
skipHeuristics = false,
|
||||
): Promise<CheckResult> {
|
||||
// Case 1: Metadata injection is the primary and safest way to identify an MCP server.
|
||||
// If we have explicit `_serverName` metadata (usually injected by tool-registry for active tools), use it.
|
||||
@@ -600,7 +594,6 @@ export class PolicyEngine {
|
||||
|
||||
let ruleDecision = rule.decision;
|
||||
if (
|
||||
!skipHeuristics &&
|
||||
isShellCommand &&
|
||||
command &&
|
||||
!('commandPrefix' in rule) &&
|
||||
@@ -622,10 +615,12 @@ export class PolicyEngine {
|
||||
subagent,
|
||||
);
|
||||
decision = shellResult.decision;
|
||||
matchedRule = shellResult.rule;
|
||||
break;
|
||||
if (shellResult.rule) {
|
||||
matchedRule = shellResult.rule;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
decision = ruleDecision;
|
||||
decision = rule.decision;
|
||||
matchedRule = rule;
|
||||
break;
|
||||
}
|
||||
@@ -648,7 +643,7 @@ export class PolicyEngine {
|
||||
);
|
||||
if (toolName && SHELL_TOOL_NAMES.includes(toolName)) {
|
||||
let heuristicDecision = this.defaultDecision;
|
||||
if (!skipHeuristics && command) {
|
||||
if (command) {
|
||||
heuristicDecision = await this.applyShellHeuristics(
|
||||
command,
|
||||
heuristicDecision,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { PolicyEngine } from './policy-engine.js';
|
||||
import { PolicyDecision, ApprovalMode } from './types.js';
|
||||
import { initializeShellParsers } from '../utils/shell-utils.js';
|
||||
import { buildArgsPatterns } from './utils.js';
|
||||
|
||||
describe('PolicyEngine - Shell Safety Regression Suite', () => {
|
||||
let engine: PolicyEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeShellParsers();
|
||||
});
|
||||
|
||||
const setupEngine = (allowedCommands: string[]) => {
|
||||
const rules = allowedCommands.map((cmd) => ({
|
||||
toolName: 'run_shell_command',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
argsPattern: new RegExp(buildArgsPatterns(undefined, cmd)[0]!),
|
||||
priority: 10,
|
||||
}));
|
||||
|
||||
return new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
defaultDecision: PolicyDecision.ASK_USER,
|
||||
});
|
||||
};
|
||||
|
||||
it('should block unauthorized chained command with &&', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi && ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should allow authorized chained command with &&', async () => {
|
||||
engine = setupEngine(['echo', 'ls']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi && ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should block unauthorized chained command with ||', async () => {
|
||||
engine = setupEngine(['false']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'false || ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should block unauthorized chained command with ;', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi; ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should block unauthorized command in pipe |', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi | grep "hi"' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should allow authorized command in pipe |', async () => {
|
||||
engine = setupEngine(['echo', 'grep']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi | grep "hi"' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should block unauthorized chained command with &', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi & ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should allow authorized chained command with &', async () => {
|
||||
engine = setupEngine(['echo', 'ls']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi & ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should block unauthorized command in nested substitution', async () => {
|
||||
engine = setupEngine(['echo', 'cat']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo $(cat $(ls))' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should allow authorized command in nested substitution', async () => {
|
||||
engine = setupEngine(['echo', 'cat', 'ls']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo $(cat $(ls))' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should block command redirection if not explicitly allowed', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi > /tmp/test' } },
|
||||
undefined,
|
||||
);
|
||||
// Inherent policy: redirection downgrades to ASK_USER
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
});
|
||||
@@ -59,30 +59,6 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
initializeShellParsers: vi.fn(),
|
||||
parseCommandDetails: (command: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(commandMap, command)) {
|
||||
const subcommands = commandMap[command];
|
||||
return {
|
||||
details: subcommands.map((text) => ({
|
||||
name: text.split(' ')[0],
|
||||
text,
|
||||
startIndex: command.indexOf(text),
|
||||
})),
|
||||
hasError: subcommands.length === 0 && command.includes('&&&'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
details: [
|
||||
{
|
||||
name: command.split(' ')[0],
|
||||
text: command,
|
||||
startIndex: 0,
|
||||
},
|
||||
],
|
||||
hasError: false,
|
||||
};
|
||||
},
|
||||
stripShellWrapper: (command: string) => command,
|
||||
splitCommands: (command: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(commandMap, command)) {
|
||||
return commandMap[command];
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeAll, vi } from 'vitest';
|
||||
import { PolicyEngine } from './policy-engine.js';
|
||||
import { PolicyDecision } from './types.js';
|
||||
import { initializeShellParsers } from '../utils/shell-utils.js';
|
||||
|
||||
// Mock node:os to ensure shell-utils logic always thinks it's on a POSIX-like system.
|
||||
// This ensures that internal calls to getShellConfiguration() and isWindows()
|
||||
// within the shell-utils module return 'bash' configuration, even on Windows CI.
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
platform: () => 'linux',
|
||||
},
|
||||
platform: () => 'linux',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock shell-utils to ensure consistent behavior across platforms (especially Windows CI)
|
||||
// We want to test PolicyEngine logic with Bash syntax rules.
|
||||
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/shell-utils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getShellConfiguration: () => ({
|
||||
executable: 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
shell: 'bash',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PolicyEngine Command Substitution Validation', () => {
|
||||
beforeAll(async () => {
|
||||
await initializeShellParsers();
|
||||
});
|
||||
|
||||
const setupEngine = (blockedCmd: string) =>
|
||||
new PolicyEngine({
|
||||
defaultDecision: PolicyDecision.ALLOW,
|
||||
rules: [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
argsPattern: new RegExp(`"command":"${blockedCmd}"`),
|
||||
decision: PolicyDecision.DENY,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('should block echo $(dangerous_cmd) when dangerous_cmd is explicitly blocked', async () => {
|
||||
const engine = setupEngine('dangerous_cmd');
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo $(dangerous_cmd)' } },
|
||||
'test-server',
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should block backtick substitution `dangerous_cmd`', async () => {
|
||||
const engine = setupEngine('dangerous_cmd');
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo `dangerous_cmd`' } },
|
||||
'test-server',
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should block commands inside subshells (dangerous_cmd)', async () => {
|
||||
const engine = setupEngine('dangerous_cmd');
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: '(dangerous_cmd)' } },
|
||||
'test-server',
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should handle nested substitutions deeply', async () => {
|
||||
const engine = setupEngine('deep_danger');
|
||||
const result = await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'echo $(ls $(deep_danger))' },
|
||||
},
|
||||
'test-server',
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
});
|
||||
@@ -335,10 +335,8 @@ export interface PolicySettings {
|
||||
allowed?: string[];
|
||||
};
|
||||
tools?: {
|
||||
core?: string[];
|
||||
exclude?: string[];
|
||||
allowed?: string[];
|
||||
confirmationRequired?: string[];
|
||||
};
|
||||
mcpServers?: Record<string, { trust?: boolean }>;
|
||||
// User provided policies that will replace the USER level policies in ~/.gemini/policies
|
||||
|
||||
@@ -112,7 +112,6 @@ export async function loadConversationRecord(
|
||||
userMessageCount?: number;
|
||||
firstUserMessage?: string;
|
||||
hasUserOrAssistantMessage?: boolean;
|
||||
memoryScratchpadIsStale?: boolean;
|
||||
})
|
||||
| null
|
||||
> {
|
||||
@@ -134,8 +133,6 @@ export async function loadConversationRecord(
|
||||
string,
|
||||
{ isUser: boolean; isUserOrAssistant: boolean }
|
||||
>();
|
||||
let isTrackingMemoryScratchpadFreshness = false;
|
||||
let memoryScratchpadIsStale = false;
|
||||
let firstUserMessageStr: string | undefined;
|
||||
|
||||
for await (const line of rl) {
|
||||
@@ -143,9 +140,6 @@ export async function loadConversationRecord(
|
||||
try {
|
||||
const record = JSON.parse(line) as unknown;
|
||||
if (isRewindRecord(record)) {
|
||||
if (isTrackingMemoryScratchpadFreshness) {
|
||||
memoryScratchpadIsStale = true;
|
||||
}
|
||||
const rewindId = record.$rewindTo;
|
||||
if (options?.metadataOnly) {
|
||||
const idx = messageIds.indexOf(rewindId);
|
||||
@@ -174,9 +168,6 @@ export async function loadConversationRecord(
|
||||
}
|
||||
}
|
||||
} else if (isMessageRecord(record)) {
|
||||
if (isTrackingMemoryScratchpadFreshness) {
|
||||
memoryScratchpadIsStale = true;
|
||||
}
|
||||
const id = record.id;
|
||||
const isUser = hasProperty(record, 'type') && record.type === 'user';
|
||||
const isUserOrAssistant =
|
||||
@@ -215,12 +206,6 @@ export async function loadConversationRecord(
|
||||
}
|
||||
}
|
||||
} else if (isMetadataUpdateRecord(record)) {
|
||||
if (hasProperty(record.$set, 'memoryScratchpad')) {
|
||||
isTrackingMemoryScratchpadFreshness = Boolean(
|
||||
record.$set.memoryScratchpad,
|
||||
);
|
||||
memoryScratchpadIsStale = false;
|
||||
}
|
||||
// Metadata update
|
||||
metadata = {
|
||||
...metadata,
|
||||
@@ -272,7 +257,6 @@ export async function loadConversationRecord(
|
||||
startTime: metadata.startTime || new Date().toISOString(),
|
||||
lastUpdated: metadata.lastUpdated || new Date().toISOString(),
|
||||
summary: metadata.summary,
|
||||
memoryScratchpad: metadata.memoryScratchpad,
|
||||
directories: metadata.directories,
|
||||
kind: metadata.kind,
|
||||
messages: options?.metadataOnly ? [] : loadedMessages,
|
||||
@@ -283,9 +267,6 @@ export async function loadConversationRecord(
|
||||
options?.metadataOnly && metadataMessages.length > 0
|
||||
? metadataMessages.filter((m) => m.type === 'user').length
|
||||
: userMessageCount,
|
||||
memoryScratchpadIsStale: isTrackingMemoryScratchpadFreshness
|
||||
? memoryScratchpadIsStale
|
||||
: undefined,
|
||||
firstUserMessage: fallbackFirstUserMessage,
|
||||
hasUserOrAssistantMessage:
|
||||
options?.metadataOnly && metadataMessages.length > 0
|
||||
@@ -351,13 +332,6 @@ export class ChatRecordingService {
|
||||
for (const msg of this.cachedConversation.messages) {
|
||||
this.appendRecord(msg);
|
||||
}
|
||||
if (this.cachedConversation.memoryScratchpad) {
|
||||
this.appendRecord({
|
||||
$set: {
|
||||
memoryScratchpad: this.cachedConversation.memoryScratchpad,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update the session ID in the existing file
|
||||
|
||||
@@ -25,19 +25,6 @@ export interface TokensSummary {
|
||||
total: number; // totalTokenCount
|
||||
}
|
||||
|
||||
export type MemoryValidationStatus = 'passed' | 'failed' | 'unknown';
|
||||
|
||||
/**
|
||||
* Lightweight workflow metadata attached to a session for memory extraction.
|
||||
*/
|
||||
export interface MemoryScratchpad {
|
||||
version: 1;
|
||||
workflowSummary?: string;
|
||||
toolSequence?: string[];
|
||||
touchedPaths?: string[];
|
||||
validationStatus?: MemoryValidationStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base fields common to all messages.
|
||||
*/
|
||||
@@ -96,7 +83,6 @@ export interface ConversationRecord {
|
||||
lastUpdated: string;
|
||||
messages: MessageRecord[];
|
||||
summary?: string;
|
||||
memoryScratchpad?: MemoryScratchpad;
|
||||
/** Workspace directories added during the session via /dir add */
|
||||
directories?: string[];
|
||||
/** The kind of conversation (main agent or subagent) */
|
||||
@@ -134,7 +120,6 @@ export interface PartialMetadataRecord {
|
||||
startTime?: string;
|
||||
lastUpdated?: string;
|
||||
summary?: string;
|
||||
memoryScratchpad?: MemoryScratchpad;
|
||||
directories?: string[];
|
||||
kind?: 'main' | 'subagent';
|
||||
}
|
||||
|
||||
@@ -127,7 +127,6 @@ async function writeConversationJsonl(
|
||||
startTime: conversation.startTime,
|
||||
lastUpdated: conversation.lastUpdated,
|
||||
summary: conversation.summary,
|
||||
memoryScratchpad: conversation.memoryScratchpad,
|
||||
directories: conversation.directories,
|
||||
kind: conversation.kind,
|
||||
};
|
||||
@@ -566,7 +565,7 @@ describe('memoryService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('records only sessions whose read_file completed successfully as processed', async () => {
|
||||
it('records only sessions whose read_file calls succeed as processed', async () => {
|
||||
const { startMemoryService, readExtractionState } = await import(
|
||||
'./memoryService.js'
|
||||
);
|
||||
@@ -596,69 +595,17 @@ describe('memoryService', () => {
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
});
|
||||
const failedConversation = createConversation({
|
||||
sessionId: 'failed-session',
|
||||
summary: 'read_file errors on this one',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-03T01:00:00Z',
|
||||
});
|
||||
const rejectedConversation = createConversation({
|
||||
sessionId: 'rejected-session',
|
||||
summary: 'read_file was rejected for this one',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-02T02:00:00Z',
|
||||
});
|
||||
const mismatchedEndConversation = createConversation({
|
||||
sessionId: 'mismatched-end-session',
|
||||
summary: 'read_file start with a mismatched tool end',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-02T03:00:00Z',
|
||||
});
|
||||
const mismatchedErrorConversation = createConversation({
|
||||
sessionId: 'mismatched-error-session',
|
||||
summary: 'read_file recovers after a mismatched tool error',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-02T04:00:00Z',
|
||||
});
|
||||
|
||||
const openedPath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-02T00-00-opened.jsonl`,
|
||||
);
|
||||
const failedPath = path.join(
|
||||
const skippedPath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-03T00-00-failed.jsonl`,
|
||||
);
|
||||
const rejectedPath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-02T00-00-rejected.jsonl`,
|
||||
);
|
||||
const mismatchedEndPath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-02T00-00-mismatched-end.jsonl`,
|
||||
);
|
||||
const mismatchedErrorPath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-02T00-00-mismatched-error.jsonl`,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-skipped.jsonl`,
|
||||
);
|
||||
await writeConversationJsonl(openedPath, openedConversation);
|
||||
await writeConversationJsonl(failedPath, failedConversation);
|
||||
await writeConversationJsonl(rejectedPath, rejectedConversation);
|
||||
await writeConversationJsonl(
|
||||
mismatchedEndPath,
|
||||
mismatchedEndConversation,
|
||||
);
|
||||
await writeConversationJsonl(
|
||||
mismatchedErrorPath,
|
||||
mismatchedErrorConversation,
|
||||
);
|
||||
await writeConversationJsonl(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-skipped.jsonl`,
|
||||
),
|
||||
skippedConversation,
|
||||
);
|
||||
await writeConversationJsonl(skippedPath, skippedConversation);
|
||||
|
||||
vi.mocked(LocalAgentExecutor.create).mockImplementationOnce(
|
||||
async (_definition, _context, onActivity) =>
|
||||
@@ -674,44 +621,14 @@ describe('memoryService', () => {
|
||||
callId: 'call-opened',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
id: 'call-opened',
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
args: { file_path: failedPath },
|
||||
callId: 'call-failed',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
id: 'call-failed',
|
||||
data: { isError: true },
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
args: { file_path: rejectedPath },
|
||||
callId: 'call-rejected',
|
||||
args: { file_path: skippedPath },
|
||||
callId: 'call-skipped',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
@@ -720,8 +637,18 @@ describe('memoryService', () => {
|
||||
type: 'ERROR',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
callId: 'call-rejected',
|
||||
error: 'User rejected this operation.',
|
||||
callId: 'call-skipped',
|
||||
error: 'access denied',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
id: 'call-opened',
|
||||
data: { content: 'Read this one' },
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
@@ -734,56 +661,6 @@ describe('memoryService', () => {
|
||||
callId: 'call-unrelated',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
args: { file_path: mismatchedEndPath },
|
||||
callId: 'call-mismatched-end',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: {
|
||||
name: 'write_file',
|
||||
id: 'call-mismatched-end',
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
args: { file_path: mismatchedErrorPath },
|
||||
callId: 'call-mismatched-error',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'ERROR',
|
||||
data: {
|
||||
name: 'write_file',
|
||||
callId: 'call-mismatched-error',
|
||||
error: 'Different tool failed.',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
id: 'call-mismatched-error',
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
}),
|
||||
}) as never,
|
||||
@@ -814,22 +691,6 @@ describe('memoryService', () => {
|
||||
);
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].candidateSessions).toEqual([
|
||||
{
|
||||
sessionId: 'failed-session',
|
||||
lastUpdated: '2025-01-03T01:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'mismatched-error-session',
|
||||
lastUpdated: '2025-01-02T04:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'mismatched-end-session',
|
||||
lastUpdated: '2025-01-02T03:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'rejected-session',
|
||||
lastUpdated: '2025-01-02T02:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'opened-session',
|
||||
lastUpdated: '2025-01-02T01:00:00Z',
|
||||
@@ -840,19 +701,12 @@ describe('memoryService', () => {
|
||||
},
|
||||
]);
|
||||
expect(state.runs[0].processedSessions).toEqual([
|
||||
{
|
||||
sessionId: 'mismatched-error-session',
|
||||
lastUpdated: '2025-01-02T04:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'opened-session',
|
||||
lastUpdated: '2025-01-02T01:00:00Z',
|
||||
},
|
||||
]);
|
||||
expect(state.runs[0].sessionIds).toEqual([
|
||||
'mismatched-error-session',
|
||||
'opened-session',
|
||||
]);
|
||||
expect(state.runs[0].sessionIds).toEqual(['opened-session']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1048,178 +902,6 @@ describe('memoryService', () => {
|
||||
expect(result.sessionIndex).toContain(path.join(chatsDir, fileName));
|
||||
});
|
||||
|
||||
it('falls back to scratchpad workflow summary when summary is missing', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
const conversation = createConversation({
|
||||
sessionId: 'scratchpad-only',
|
||||
summary: undefined,
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
workflowSummary:
|
||||
'read_file -> edit | paths packages/core/src/services/memoryService.ts | validated',
|
||||
},
|
||||
messageCount: 20,
|
||||
});
|
||||
await writeConversationJsonl(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-scratch01.jsonl`,
|
||||
),
|
||||
conversation,
|
||||
);
|
||||
|
||||
const result = await buildSessionIndex(chatsDir, { runs: [] });
|
||||
|
||||
expect(result.sessionIndex).toContain('read_file -> edit');
|
||||
expect(result.sessionIndex).not.toContain('(no summary)');
|
||||
});
|
||||
|
||||
it('ignores malformed scratchpad workflow summaries while indexing sessions', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
const malformedConversation = createConversation({
|
||||
sessionId: 'malformed-scratchpad',
|
||||
summary: undefined,
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
workflowSummary: 123,
|
||||
} as unknown as ConversationRecord['memoryScratchpad'],
|
||||
messageCount: 20,
|
||||
});
|
||||
await writeConversationJsonl(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-badpad.jsonl`,
|
||||
),
|
||||
malformedConversation,
|
||||
);
|
||||
|
||||
const validConversation = createConversation({
|
||||
sessionId: 'valid-session',
|
||||
summary: 'Still indexes other sessions',
|
||||
messageCount: 20,
|
||||
});
|
||||
await writeConversationJsonl(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-valid.jsonl`,
|
||||
),
|
||||
validConversation,
|
||||
);
|
||||
|
||||
const result = await buildSessionIndex(chatsDir, { runs: [] });
|
||||
|
||||
expect(result.sessionIndex).toContain('(no summary)');
|
||||
expect(result.sessionIndex).toContain('Still indexes other sessions');
|
||||
expect(result.sessionIndex).not.toContain('123');
|
||||
});
|
||||
|
||||
it('appends workflow summary when both summary and scratchpad are present', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
const conversation = createConversation({
|
||||
sessionId: 'summary-and-scratchpad',
|
||||
summary: 'Fix session scanning',
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
workflowSummary:
|
||||
'read_file -> edit | paths packages/core/src/services/sessionSummaryUtils.ts',
|
||||
},
|
||||
messageCount: 20,
|
||||
});
|
||||
await writeConversationJsonl(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-scratch02.jsonl`,
|
||||
),
|
||||
conversation,
|
||||
);
|
||||
|
||||
const result = await buildSessionIndex(chatsDir, { runs: [] });
|
||||
|
||||
expect(result.sessionIndex).toContain('Fix session scanning | workflow:');
|
||||
expect(result.sessionIndex).toContain('sessionSummaryUtils.ts');
|
||||
});
|
||||
|
||||
it('omits stale scratchpad workflow summaries from resumed JSONL sessions', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
const conversation = createConversation({
|
||||
sessionId: 'stale-scratchpad',
|
||||
summary: 'Resume memory work',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
});
|
||||
const filePath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-stale001.jsonl`,
|
||||
);
|
||||
await writeConversationJsonl(filePath, conversation);
|
||||
await fs.appendFile(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
$set: {
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
workflowSummary: 'stale_workflow | paths stale.ts',
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
await fs.appendFile(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({
|
||||
id: 'resumed-user-message',
|
||||
timestamp: '2025-01-02T01:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Continue after the scratchpad was written' }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
$set: { lastUpdated: '2025-01-02T01:00:01Z' },
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
|
||||
const result = await buildSessionIndex(chatsDir, { runs: [] });
|
||||
|
||||
expect(result.sessionIndex).toContain('Resume memory work');
|
||||
expect(result.sessionIndex).not.toContain('stale_workflow');
|
||||
expect(result.sessionIndex).not.toContain('stale.ts');
|
||||
});
|
||||
|
||||
it('sanitizes shell command workflow summaries before indexing sessions', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
const conversation = createConversation({
|
||||
sessionId: 'raw-shell-scratchpad',
|
||||
summary: 'Investigate API migration',
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
workflowSummary:
|
||||
'run_shell_command: curl https://api.example.com -H "Authorization: Bearer sk-secret-token" -> read_file | paths package.json',
|
||||
},
|
||||
messageCount: 20,
|
||||
});
|
||||
await writeConversationJsonl(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-shellraw.jsonl`,
|
||||
),
|
||||
conversation,
|
||||
);
|
||||
|
||||
const result = await buildSessionIndex(chatsDir, { runs: [] });
|
||||
|
||||
expect(result.sessionIndex).toContain(
|
||||
'workflow: run_shell_command: curl -> read_file | paths package.json',
|
||||
);
|
||||
expect(result.sessionIndex).not.toContain('Authorization');
|
||||
expect(result.sessionIndex).not.toContain('sk-secret-token');
|
||||
expect(result.sessionIndex).not.toContain('https://api.example.com');
|
||||
});
|
||||
|
||||
it('filters out subagent sessions', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
@@ -1494,9 +1176,6 @@ describe('memoryService', () => {
|
||||
},
|
||||
],
|
||||
skillsCreated: ['debug-helper', 'test-gen'],
|
||||
turnCount: 4,
|
||||
durationMs: 1875,
|
||||
terminateReason: 'GOAL',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1523,9 +1202,6 @@ describe('memoryService', () => {
|
||||
]);
|
||||
expect(result.runs[0].sessionIds).toEqual(['s1']);
|
||||
expect(result.runs[0].runAt).toBe('2025-06-01T00:00:00Z');
|
||||
expect(result.runs[0].turnCount).toBe(4);
|
||||
expect(result.runs[0].durationMs).toBe(1875);
|
||||
expect(result.runs[0].terminateReason).toBe('GOAL');
|
||||
});
|
||||
|
||||
it('writeExtractionState + readExtractionState roundtrips runs correctly', async () => {
|
||||
@@ -1559,17 +1235,11 @@ describe('memoryService', () => {
|
||||
},
|
||||
],
|
||||
skillsCreated: ['skill-x'],
|
||||
turnCount: 3,
|
||||
durationMs: 2400,
|
||||
terminateReason: 'GOAL',
|
||||
},
|
||||
{
|
||||
runAt: '2025-01-02T00:00:00Z',
|
||||
sessionIds: ['c'],
|
||||
skillsCreated: [],
|
||||
turnCount: 1,
|
||||
durationMs: 900,
|
||||
terminateReason: 'GOAL',
|
||||
},
|
||||
];
|
||||
const state: ExtractionState = { runs };
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
SESSION_FILE_PREFIX,
|
||||
loadConversationRecord,
|
||||
type ConversationRecord,
|
||||
type MemoryScratchpad,
|
||||
} from './chatRecordingService.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
@@ -23,10 +22,7 @@ import { FRONTMATTER_REGEX, parseFrontmatter } from '../skills/skillLoader.js';
|
||||
import { LocalAgentExecutor } from '../agents/local-executor.js';
|
||||
import { SkillExtractionAgent } from '../agents/skill-extraction-agent.js';
|
||||
import { getModelConfigAlias } from '../agents/registry.js';
|
||||
import {
|
||||
isToolActivityError,
|
||||
type SubagentActivityEvent,
|
||||
} from '../agents/types.js';
|
||||
import type { SubagentActivityEvent } from '../agents/types.js';
|
||||
import { ExecutionLifecycleService } from './executionLifecycleService.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
@@ -40,7 +36,6 @@ import {
|
||||
applyParsedSkillPatches,
|
||||
hasParsedPatchHunks,
|
||||
} from './memoryPatchUtils.js';
|
||||
import { sanitizeWorkflowSummaryForScratchpad } from './sessionScratchpadUtils.js';
|
||||
|
||||
const LOCK_FILENAME = '.extraction.lock';
|
||||
const STATE_FILENAME = '.extraction-state.json';
|
||||
@@ -58,6 +53,20 @@ interface LockInfo {
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
function hasProperty<T extends string>(
|
||||
obj: unknown,
|
||||
prop: T,
|
||||
): obj is { [key in T]: unknown } {
|
||||
return obj !== null && typeof obj === 'object' && prop in obj;
|
||||
}
|
||||
|
||||
function isStringProperty<T extends string>(
|
||||
obj: unknown,
|
||||
prop: T,
|
||||
): obj is { [key in T]: string } {
|
||||
return hasProperty(obj, prop) && typeof obj[prop] === 'string';
|
||||
}
|
||||
|
||||
interface SessionVersion {
|
||||
sessionId: string;
|
||||
lastUpdated: string;
|
||||
@@ -66,7 +75,6 @@ interface SessionVersion {
|
||||
interface IndexedSession extends SessionVersion {
|
||||
filePath: string;
|
||||
summary?: string;
|
||||
memoryScratchpad?: MemoryScratchpad;
|
||||
userMessageCount: number;
|
||||
}
|
||||
|
||||
@@ -79,9 +87,6 @@ export interface ExtractionRun {
|
||||
candidateSessions?: SessionVersion[];
|
||||
processedSessions?: SessionVersion[];
|
||||
skillsCreated: string[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
terminateReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,25 +153,12 @@ function normalizeStringArray(value: unknown): string[] {
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
function normalizeOptionalNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function isExtractionRunLike(value: unknown): value is {
|
||||
runAt: string;
|
||||
sessionIds?: unknown;
|
||||
candidateSessions?: unknown;
|
||||
processedSessions?: unknown;
|
||||
skillsCreated: unknown;
|
||||
turnCount?: unknown;
|
||||
durationMs?: unknown;
|
||||
terminateReason?: unknown;
|
||||
} {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
@@ -206,9 +198,6 @@ function buildExtractionRun(value: unknown): ExtractionRun | null {
|
||||
processedSessions:
|
||||
processedSessions.length > 0 ? processedSessions : undefined,
|
||||
skillsCreated: normalizeStringArray(value.skillsCreated),
|
||||
turnCount: normalizeOptionalNumber(value.turnCount),
|
||||
durationMs: normalizeOptionalNumber(value.durationMs),
|
||||
terminateReason: normalizeOptionalString(value.terminateReason),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,7 +291,7 @@ function shouldReplaceIndexedSession(
|
||||
return compareIndexedSessions(candidate, existing) < 0;
|
||||
}
|
||||
|
||||
function isReadFileActivity(
|
||||
function isReadFileStartActivity(
|
||||
activity: SubagentActivityEvent,
|
||||
): activity is SubagentActivityEvent & {
|
||||
data: { name: string; args?: { file_path?: unknown }; callId?: unknown };
|
||||
@@ -313,36 +302,11 @@ function isReadFileActivity(
|
||||
);
|
||||
}
|
||||
|
||||
function getReadFileCallId(activity: SubagentActivityEvent): string | null {
|
||||
if (isReadFileActivity(activity)) {
|
||||
const { callId } = activity.data;
|
||||
return typeof callId === 'string' ? callId : null;
|
||||
}
|
||||
|
||||
if (
|
||||
activity.type === 'TOOL_CALL_END' &&
|
||||
activity.data['name'] === READ_FILE_TOOL_NAME
|
||||
) {
|
||||
const id = activity.data['id'];
|
||||
return typeof id === 'string' ? id : null;
|
||||
}
|
||||
|
||||
if (
|
||||
activity.type === 'ERROR' &&
|
||||
activity.data['name'] === READ_FILE_TOOL_NAME
|
||||
) {
|
||||
const callId = activity.data['callId'];
|
||||
return typeof callId === 'string' ? callId : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getResolvedActivityFilePath(
|
||||
function getResolvedReadFilePath(
|
||||
config: Config,
|
||||
activity: SubagentActivityEvent,
|
||||
): string | null {
|
||||
if (!isReadFileActivity(activity)) {
|
||||
if (!isReadFileStartActivity(activity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -356,11 +320,48 @@ function getResolvedActivityFilePath(
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetDir =
|
||||
'getTargetDir' in config && typeof config.getTargetDir === 'function'
|
||||
? config.getTargetDir()
|
||||
: process.cwd();
|
||||
return path.resolve(targetDir, args.file_path);
|
||||
return path.resolve(config.getTargetDir(), args.file_path);
|
||||
}
|
||||
|
||||
function getReadFileStartCallId(
|
||||
activity: SubagentActivityEvent,
|
||||
): string | null {
|
||||
if (
|
||||
!isReadFileStartActivity(activity) ||
|
||||
!isStringProperty(activity.data, 'callId')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activity.data.callId;
|
||||
}
|
||||
|
||||
function getCompletedReadFileCallId(
|
||||
activity: SubagentActivityEvent,
|
||||
): string | null {
|
||||
if (
|
||||
activity.type !== 'TOOL_CALL_END' ||
|
||||
activity.data['name'] !== READ_FILE_TOOL_NAME ||
|
||||
!isStringProperty(activity.data, 'id')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activity.data['id'];
|
||||
}
|
||||
|
||||
function getFailedReadFileCallId(
|
||||
activity: SubagentActivityEvent,
|
||||
): string | null {
|
||||
if (
|
||||
activity.type !== 'ERROR' ||
|
||||
activity.data['name'] !== READ_FILE_TOOL_NAME ||
|
||||
!isStringProperty(activity.data, 'callId')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activity.data['callId'];
|
||||
}
|
||||
|
||||
function getUserMessageCount(
|
||||
@@ -579,10 +580,6 @@ async function scanEligibleSessions(
|
||||
lastUpdated: conversation.lastUpdated,
|
||||
filePath,
|
||||
summary: conversation.summary,
|
||||
memoryScratchpad:
|
||||
conversation.memoryScratchpadIsStale === true
|
||||
? undefined
|
||||
: conversation.memoryScratchpad,
|
||||
userMessageCount: getUserMessageCount(conversation),
|
||||
};
|
||||
|
||||
@@ -598,28 +595,6 @@ async function scanEligibleSessions(
|
||||
return Array.from(latestBySessionId.values()).sort(compareIndexedSessions);
|
||||
}
|
||||
|
||||
function formatSessionHeadline(session: IndexedSession): string {
|
||||
const rawWorkflowSummary = session.memoryScratchpad?.workflowSummary;
|
||||
const sanitizedWorkflowSummary =
|
||||
typeof rawWorkflowSummary === 'string'
|
||||
? sanitizeWorkflowSummaryForScratchpad(rawWorkflowSummary)
|
||||
: undefined;
|
||||
const workflowSummary = sanitizedWorkflowSummary?.trim()
|
||||
? sanitizedWorkflowSummary
|
||||
: undefined;
|
||||
const summary = session.summary ?? workflowSummary ?? '(no summary)';
|
||||
|
||||
if (
|
||||
session.summary &&
|
||||
workflowSummary &&
|
||||
workflowSummary !== session.summary
|
||||
) {
|
||||
return `${summary} | workflow: ${workflowSummary}`;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a session index for the extraction agent: a compact listing of all
|
||||
* eligible sessions with their summary, file path, and new/previously-processed status.
|
||||
@@ -676,7 +651,8 @@ export async function buildSessionIndex(
|
||||
const status = candidateSessionIds.has(getSessionVersionKey(session))
|
||||
? '[NEW]'
|
||||
: '[old]';
|
||||
return `${status} ${formatSessionHeadline(session)} (${session.userMessageCount} user msgs) — ${session.filePath}`;
|
||||
const summary = session.summary ?? '(no summary)';
|
||||
return `${status} ${summary} (${session.userMessageCount} user msgs) — ${session.filePath}`;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1023,19 +999,18 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
session,
|
||||
]),
|
||||
);
|
||||
const pendingReadFileSessions = new Map<string, SessionVersion>();
|
||||
const processedSessionKeys = new Set<string>();
|
||||
const pendingReadFileSessions = new Map<string, string>();
|
||||
|
||||
// Create and run the extraction agent
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
agentDefinition,
|
||||
context,
|
||||
(activity) => {
|
||||
const readFileCallId = getReadFileCallId(activity);
|
||||
|
||||
if (activity.type === 'TOOL_CALL_START') {
|
||||
const resolvedPath = getResolvedActivityFilePath(config, activity);
|
||||
if (!resolvedPath || !readFileCallId) {
|
||||
const readFileCallId = getReadFileStartCallId(activity);
|
||||
if (readFileCallId) {
|
||||
const resolvedPath = getResolvedReadFilePath(config, activity);
|
||||
if (!resolvedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1044,31 +1019,35 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingReadFileSessions.set(readFileCallId, session);
|
||||
pendingReadFileSessions.set(
|
||||
readFileCallId,
|
||||
getSessionVersionKey(session),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!readFileCallId) {
|
||||
const completedReadFileCallId = getCompletedReadFileCallId(activity);
|
||||
if (completedReadFileCallId) {
|
||||
const sessionKey = pendingReadFileSessions.get(
|
||||
completedReadFileCallId,
|
||||
);
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
processedSessionKeys.add(sessionKey);
|
||||
pendingReadFileSessions.delete(completedReadFileCallId);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = pendingReadFileSessions.get(readFileCallId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingReadFileSessions.delete(readFileCallId);
|
||||
|
||||
if (
|
||||
activity.type === 'TOOL_CALL_END' &&
|
||||
!isToolActivityError(activity.data['data'])
|
||||
) {
|
||||
processedSessionKeys.add(getSessionVersionKey(session));
|
||||
const failedReadFileCallId = getFailedReadFileCallId(activity);
|
||||
if (failedReadFileCallId) {
|
||||
pendingReadFileSessions.delete(failedReadFileCallId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const executorResult = await executor.run(
|
||||
await executor.run(
|
||||
{ request: 'Extract skills from the provided sessions.' },
|
||||
abortController.signal,
|
||||
);
|
||||
@@ -1128,11 +1107,6 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
})),
|
||||
processedSessions,
|
||||
skillsCreated,
|
||||
turnCount: normalizeOptionalNumber(executorResult?.turn_count),
|
||||
durationMs: normalizeOptionalNumber(executorResult?.duration_ms),
|
||||
terminateReason: normalizeOptionalString(
|
||||
executorResult?.terminate_reason,
|
||||
),
|
||||
};
|
||||
const updatedState: ExtractionState = {
|
||||
runs: [...state.runs, run],
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SHELL_TOOL_NAME } from '../tools/definitions/base-declarations.js';
|
||||
import {
|
||||
sanitizeWorkflowSummaryForScratchpad,
|
||||
summarizeShellCommandForScratchpad,
|
||||
} from './sessionScratchpadUtils.js';
|
||||
|
||||
describe('sessionScratchpadUtils', () => {
|
||||
describe('summarizeShellCommandForScratchpad', () => {
|
||||
it('summarizes quoted and assignment-prefixed shell commands', () => {
|
||||
expect(summarizeShellCommandForScratchpad('"npm" run test')).toBe('npm');
|
||||
expect(
|
||||
summarizeShellCommandForScratchpad(
|
||||
'DATABASE_URL=postgres://user:password@example/db pnpm test',
|
||||
),
|
||||
).toBe('pnpm');
|
||||
});
|
||||
|
||||
it('handles adversarial unterminated quoted input without exposing arguments', () => {
|
||||
const adversarialCommand = `"${'\\"!'.repeat(10_000)}`;
|
||||
|
||||
expect(summarizeShellCommandForScratchpad(adversarialCommand)).toBe(
|
||||
'shell',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeWorkflowSummaryForScratchpad', () => {
|
||||
it('sanitizes adversarial shell commands in workflow summaries', () => {
|
||||
const adversarialCommand = `"${'\\"!'.repeat(10_000)}`;
|
||||
|
||||
expect(
|
||||
sanitizeWorkflowSummaryForScratchpad(
|
||||
`${SHELL_TOOL_NAME}: ${adversarialCommand} -> read_file`,
|
||||
),
|
||||
).toBe(`${SHELL_TOOL_NAME}: shell -> read_file`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SHELL_TOOL_NAME } from '../tools/definitions/base-declarations.js';
|
||||
|
||||
const WORKFLOW_PART_SEPARATOR = ' | ';
|
||||
const TOOL_SEQUENCE_SEPARATOR = ' -> ';
|
||||
const SHELL_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
||||
const SAFE_COMMAND_NAME_REGEX = /^[A-Za-z0-9_.@+-]+$/;
|
||||
const SAFE_TOOL_SEQUENCE_ENTRY_REGEX = /^[A-Za-z_][A-Za-z0-9_:.]*$/;
|
||||
|
||||
function tokenizeShellCommand(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let currentToken = '';
|
||||
let quote: '"' | "'" | '`' | undefined;
|
||||
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const char = command[i];
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === '"' && char === '\\' && i + 1 < command.length) {
|
||||
currentToken += command[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentToken += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ' ' || char === '\t' || char === '\n' || char === '\r') {
|
||||
if (currentToken) {
|
||||
tokens.push(currentToken);
|
||||
currentToken = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'" || char === '`') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentToken += char;
|
||||
}
|
||||
|
||||
if (currentToken) {
|
||||
tokens.push(currentToken);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function getSafeCommandName(token: string): string | undefined {
|
||||
if (!token || SHELL_ASSIGNMENT_REGEX.test(token)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pathParts = token.split(/[/\\]/).filter(Boolean);
|
||||
const basename = pathParts[pathParts.length - 1] ?? token;
|
||||
if (!basename || basename.includes('://')) {
|
||||
return 'shell';
|
||||
}
|
||||
|
||||
return SAFE_COMMAND_NAME_REGEX.test(basename) ? basename : 'shell';
|
||||
}
|
||||
|
||||
export function summarizeShellCommandForScratchpad(
|
||||
command: string,
|
||||
): string | undefined {
|
||||
const normalized = command.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const token of tokenizeShellCommand(normalized)) {
|
||||
const commandName = getSafeCommandName(token);
|
||||
if (commandName) {
|
||||
return commandName;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sanitizeWorkflowToolSequenceEntry(entry: string): string | undefined {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const shellPrefix = `${SHELL_TOOL_NAME}:`;
|
||||
if (trimmed.startsWith(shellPrefix)) {
|
||||
const command = trimmed.slice(shellPrefix.length).trim();
|
||||
const commandSummary = summarizeShellCommandForScratchpad(command);
|
||||
return commandSummary
|
||||
? `${SHELL_TOOL_NAME}: ${commandSummary}`
|
||||
: SHELL_TOOL_NAME;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmed === SHELL_TOOL_NAME ||
|
||||
SAFE_TOOL_SEQUENCE_ENTRY_REGEX.test(trimmed)
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function sanitizeWorkflowSummaryForScratchpad(summary: string): string {
|
||||
const normalized = summary.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized.includes(`${SHELL_TOOL_NAME}:`)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const sanitizedParts: string[] = [];
|
||||
for (const part of normalized.split(WORKFLOW_PART_SEPARATOR)) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.includes(`${SHELL_TOOL_NAME}:`)) {
|
||||
const sanitizedToolSequence = trimmed
|
||||
.split(TOOL_SEQUENCE_SEPARATOR)
|
||||
.map(sanitizeWorkflowToolSequenceEntry)
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
if (sanitizedToolSequence.length > 0) {
|
||||
sanitizedParts.push(
|
||||
sanitizedToolSequence.join(TOOL_SEQUENCE_SEPARATOR),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmed.startsWith('paths ') ||
|
||||
trimmed === 'validated' ||
|
||||
trimmed === 'validation failed'
|
||||
) {
|
||||
sanitizedParts.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizedParts.join(WORKFLOW_PART_SEPARATOR);
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import { generateSummary, getPreviousSession } from './sessionSummaryUtils.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
import * as chatRecordingService from './chatRecordingService.js';
|
||||
import type { ConversationRecord } from './chatRecordingService.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -39,33 +37,25 @@ vi.mock('./chatRecordingService.js', async () => {
|
||||
|
||||
interface SessionFixture {
|
||||
summary?: string;
|
||||
memoryScratchpad?: unknown;
|
||||
sessionId?: string;
|
||||
startTime?: string;
|
||||
lastUpdated?: string;
|
||||
kind?: ConversationRecord['kind'];
|
||||
messages?: ConversationRecord['messages'];
|
||||
userMessageCount: number;
|
||||
}
|
||||
|
||||
function buildLegacySessionJson(fixture: SessionFixture): string {
|
||||
const messages =
|
||||
fixture.messages ??
|
||||
Array.from({ length: fixture.userMessageCount }, (_, i) => ({
|
||||
id: String(i + 1),
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: `Message ${i + 1}` }],
|
||||
}));
|
||||
return JSON.stringify({
|
||||
sessionId: fixture.sessionId ?? 'session-id',
|
||||
projectHash: 'abc123',
|
||||
startTime: fixture.startTime ?? '2024-01-01T00:00:00Z',
|
||||
lastUpdated: fixture.lastUpdated ?? '2024-01-01T00:00:00Z',
|
||||
summary: fixture.summary,
|
||||
memoryScratchpad: fixture.memoryScratchpad,
|
||||
...(fixture.kind ? { kind: fixture.kind } : {}),
|
||||
messages,
|
||||
messages: Array.from({ length: fixture.userMessageCount }, (_, i) => ({
|
||||
id: String(i + 1),
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: `Message ${i + 1}` }],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,22 +66,17 @@ function buildJsonlSession(fixture: SessionFixture): string {
|
||||
startTime: fixture.startTime ?? '2024-01-01T00:00:00Z',
|
||||
lastUpdated: fixture.lastUpdated ?? '2024-01-01T00:00:00Z',
|
||||
...(fixture.summary !== undefined ? { summary: fixture.summary } : {}),
|
||||
...(fixture.memoryScratchpad !== undefined
|
||||
? { memoryScratchpad: fixture.memoryScratchpad }
|
||||
: {}),
|
||||
...(fixture.kind ? { kind: fixture.kind } : {}),
|
||||
};
|
||||
const messages =
|
||||
fixture.messages ??
|
||||
Array.from({ length: fixture.userMessageCount }, (_, i) => ({
|
||||
id: String(i + 1),
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: `Message ${i + 1}` }],
|
||||
}));
|
||||
const lines: string[] = [JSON.stringify(metadata)];
|
||||
for (const message of messages) {
|
||||
lines.push(JSON.stringify(message));
|
||||
for (let i = 0; i < fixture.userMessageCount; i++) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
id: String(i + 1),
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: `Message ${i + 1}` }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -134,7 +119,6 @@ describe('sessionSummaryUtils', () => {
|
||||
|
||||
mockConfig = {
|
||||
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
|
||||
getProjectRoot: vi.fn().mockReturnValue(projectTempDir),
|
||||
getSessionId: vi.fn().mockReturnValue('current-session'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue(projectTempDir),
|
||||
@@ -173,50 +157,13 @@ describe('sessionSummaryUtils', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if most recent session already has summary metadata', async () => {
|
||||
it('should return null if most recent session already has summary', async () => {
|
||||
await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-abc12345.json',
|
||||
buildLegacySessionJson({
|
||||
userMessageCount: 5,
|
||||
summary: 'Existing summary',
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
workflowSummary: 'read_file -> edit',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return path if most recent session has summary but no scratchpad', async () => {
|
||||
const filePath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-abc12345.json',
|
||||
buildLegacySessionJson({
|
||||
userMessageCount: 5,
|
||||
summary: 'Existing summary',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
expect(result).toBe(filePath);
|
||||
});
|
||||
|
||||
it('should return null if most recent session has scratchpad but no summary', async () => {
|
||||
await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-abc12345.json',
|
||||
buildLegacySessionJson({
|
||||
userMessageCount: 5,
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
workflowSummary: 'read_file -> edit',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -355,36 +302,6 @@ describe('sessionSummaryUtils', () => {
|
||||
metadataOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip subagent sessions when backfilling scratchpads', async () => {
|
||||
const mainPath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-main0001.jsonl',
|
||||
buildJsonlSession({
|
||||
sessionId: 'main-session',
|
||||
userMessageCount: 2,
|
||||
lastUpdated: '2024-01-01T10:00:00Z',
|
||||
summary: 'Main session summary',
|
||||
}),
|
||||
);
|
||||
await setSessionMtime(mainPath, '2024-01-01T10:00:00Z');
|
||||
|
||||
await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-02T10-00-sub00001.jsonl',
|
||||
buildJsonlSession({
|
||||
sessionId: 'subagent-session',
|
||||
userMessageCount: 2,
|
||||
lastUpdated: '2024-01-02T10:00:00Z',
|
||||
summary: 'Subagent summary',
|
||||
kind: 'subagent',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
expect(result).toBe(mainPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSummary', () => {
|
||||
@@ -407,7 +324,6 @@ describe('sessionSummaryUtils', () => {
|
||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
||||
const written = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
||||
expect(written.summary).toBe('Add dark mode to the app');
|
||||
expect(written.memoryScratchpad).toEqual({ version: 1 });
|
||||
expect(written.lastUpdated).toBe(lastUpdated);
|
||||
});
|
||||
|
||||
@@ -440,160 +356,10 @@ describe('sessionSummaryUtils', () => {
|
||||
expect(lastRecord).toEqual({
|
||||
$set: {
|
||||
summary: 'Add dark mode to the app',
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should backfill scratchpad without regenerating summary', async () => {
|
||||
const filePath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-backfill.jsonl',
|
||||
buildJsonlSession({
|
||||
userMessageCount: 2,
|
||||
summary: 'Existing summary',
|
||||
}),
|
||||
);
|
||||
|
||||
await generateSummary(mockConfig);
|
||||
|
||||
expect(mockGenerateSummary).not.toHaveBeenCalled();
|
||||
const lines = (await fs.readFile(filePath, 'utf-8'))
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
const lastRecord = JSON.parse(lines[lines.length - 1]);
|
||||
expect(lastRecord).toEqual({
|
||||
$set: {
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not retry summary generation after writing a scratchpad fallback', async () => {
|
||||
const filePath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-summary-fallback.jsonl',
|
||||
buildJsonlSession({
|
||||
sessionId: 'summary-fallback-session',
|
||||
userMessageCount: 2,
|
||||
messages: [
|
||||
{
|
||||
id: 'u1',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Read package metadata' }],
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Reading package.json' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'read_file',
|
||||
args: { file_path: 'package.json' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
timestamp: '2024-01-01T00:00:02Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Done' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
mockGenerateSummary.mockResolvedValue(undefined);
|
||||
|
||||
await generateSummary(mockConfig);
|
||||
await generateSummary(mockConfig);
|
||||
|
||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
||||
const savedConversation =
|
||||
await chatRecordingService.loadConversationRecord(filePath);
|
||||
expect(savedConversation?.summary).toBeUndefined();
|
||||
expect(savedConversation?.memoryScratchpad).toEqual({
|
||||
version: 1,
|
||||
workflowSummary: 'read_file | paths package.json',
|
||||
toolSequence: ['read_file'],
|
||||
touchedPaths: ['package.json'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh stale scratchpads when messages were appended after metadata', async () => {
|
||||
const filePath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-resumed1.jsonl',
|
||||
buildJsonlSession({
|
||||
sessionId: 'resumed-session',
|
||||
userMessageCount: 2,
|
||||
summary: 'Existing summary',
|
||||
lastUpdated: '2024-01-01T10:00:00Z',
|
||||
}),
|
||||
);
|
||||
await fs.appendFile(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
$set: {
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
workflowSummary: 'read_file',
|
||||
toolSequence: ['read_file'],
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
await fs.appendFile(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({
|
||||
id: 'u-resumed',
|
||||
timestamp: '2024-01-02T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Update src/app.ts' }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'g-resumed',
|
||||
timestamp: '2024-01-02T00:00:01Z',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Editing file' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-resumed',
|
||||
name: 'replace',
|
||||
args: { file_path: 'src/app.ts' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-02T00:00:01Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
JSON.stringify({
|
||||
$set: { lastUpdated: '2024-01-02T00:00:02Z' },
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
|
||||
await generateSummary(mockConfig);
|
||||
|
||||
expect(mockGenerateSummary).not.toHaveBeenCalled();
|
||||
const savedConversation =
|
||||
await chatRecordingService.loadConversationRecord(filePath);
|
||||
expect(savedConversation?.memoryScratchpad).toEqual({
|
||||
version: 1,
|
||||
workflowSummary: 'replace | paths src/app.ts',
|
||||
toolSequence: ['replace'],
|
||||
touchedPaths: ['src/app.ts'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve a newer JSONL lastUpdated written concurrently', async () => {
|
||||
const initialLastUpdated = '2024-01-01T10:00:00Z';
|
||||
const newerLastUpdated = '2024-01-02T12:34:56Z';
|
||||
@@ -645,7 +411,6 @@ describe('sessionSummaryUtils', () => {
|
||||
const savedConversation =
|
||||
await chatRecordingService.loadConversationRecord(filePath);
|
||||
expect(savedConversation?.summary).toBe('Add dark mode to the app');
|
||||
expect(savedConversation?.memoryScratchpad).toEqual({ version: 1 });
|
||||
expect(savedConversation?.lastUpdated).toBe(newerLastUpdated);
|
||||
|
||||
const lines = (await fs.readFile(filePath, 'utf-8'))
|
||||
@@ -655,9 +420,6 @@ describe('sessionSummaryUtils', () => {
|
||||
expect(lastRecord).toEqual({
|
||||
$set: {
|
||||
summary: 'Add dark mode to the app',
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -692,9 +454,6 @@ describe('sessionSummaryUtils', () => {
|
||||
expect(JSON.parse(previousLines[previousLines.length - 1])).toEqual({
|
||||
$set: {
|
||||
summary: 'Add dark mode to the app',
|
||||
memoryScratchpad: {
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -703,312 +462,5 @@ describe('sessionSummaryUtils', () => {
|
||||
.filter(Boolean);
|
||||
expect(currentLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should preserve repo-root file names in scratchpad touched paths', async () => {
|
||||
const filePath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-rootpath.jsonl',
|
||||
buildJsonlSession({
|
||||
sessionId: 'root-path-session',
|
||||
userMessageCount: 2,
|
||||
summary: 'Existing summary',
|
||||
messages: [
|
||||
{
|
||||
id: 'u1',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Inspect package.json' }],
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Reading files' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'read_file',
|
||||
args: { file_path: 'package.json' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
timestamp: '2024-01-01T00:00:02Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Done' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await generateSummary(mockConfig);
|
||||
|
||||
const savedConversation =
|
||||
await chatRecordingService.loadConversationRecord(filePath);
|
||||
expect(savedConversation?.memoryScratchpad).toEqual({
|
||||
version: 1,
|
||||
workflowSummary: 'read_file | paths package.json',
|
||||
toolSequence: ['read_file'],
|
||||
touchedPaths: ['package.json'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should summarize shell commands without raw arguments in scratchpad tool sequence', async () => {
|
||||
const filePath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-shellcmd.jsonl',
|
||||
buildJsonlSession({
|
||||
sessionId: 'shell-command-session',
|
||||
userMessageCount: 2,
|
||||
summary: 'Existing summary',
|
||||
messages: [
|
||||
{
|
||||
id: 'u1',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Run the migration and regenerate docs' }],
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Running commands' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'run_shell_command',
|
||||
args: {
|
||||
command:
|
||||
'curl https://api.example.com -H "Authorization: Bearer sk-secret-token"',
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
name: 'run_shell_command',
|
||||
args: {
|
||||
command:
|
||||
'DATABASE_URL=postgresql://user:password@localhost/db npm run migrate -- --name add-users',
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T00:00:02Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
timestamp: '2024-01-01T00:00:03Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Done' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await generateSummary(mockConfig);
|
||||
|
||||
const savedConversation =
|
||||
await chatRecordingService.loadConversationRecord(filePath);
|
||||
expect(savedConversation?.memoryScratchpad).toEqual({
|
||||
version: 1,
|
||||
workflowSummary: 'run_shell_command: curl -> run_shell_command: npm',
|
||||
toolSequence: ['run_shell_command: curl', 'run_shell_command: npm'],
|
||||
});
|
||||
expect(
|
||||
savedConversation?.memoryScratchpad?.workflowSummary,
|
||||
).not.toContain('Authorization');
|
||||
expect(
|
||||
savedConversation?.memoryScratchpad?.workflowSummary,
|
||||
).not.toContain('sk-secret-token');
|
||||
expect(
|
||||
savedConversation?.memoryScratchpad?.workflowSummary,
|
||||
).not.toContain('password');
|
||||
expect(
|
||||
savedConversation?.memoryScratchpad?.workflowSummary,
|
||||
).not.toContain('add-users');
|
||||
});
|
||||
|
||||
it('should not classify validation substrings as validation tools', async () => {
|
||||
const filePath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-validation-substring.jsonl',
|
||||
buildJsonlSession({
|
||||
sessionId: 'validation-substring-session',
|
||||
userMessageCount: 2,
|
||||
summary: 'Existing summary',
|
||||
messages: [
|
||||
{
|
||||
id: 'u1',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Run the contest helper' }],
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Running helper' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'contest_runner',
|
||||
args: {},
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
timestamp: '2024-01-01T00:00:02Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Done' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await generateSummary(mockConfig);
|
||||
|
||||
const savedConversation =
|
||||
await chatRecordingService.loadConversationRecord(filePath);
|
||||
expect(savedConversation?.memoryScratchpad).toEqual({
|
||||
version: 1,
|
||||
workflowSummary: 'contest_runner',
|
||||
toolSequence: ['contest_runner'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should cap nested path extraction depth', async () => {
|
||||
const filePath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-deep-paths.jsonl',
|
||||
buildJsonlSession({
|
||||
sessionId: 'deep-paths-session',
|
||||
userMessageCount: 2,
|
||||
summary: 'Existing summary',
|
||||
messages: [
|
||||
{
|
||||
id: 'u1',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Edit shallow and deeply nested files' }],
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Editing files' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'replace',
|
||||
args: {
|
||||
file_path: 'src/shallow.ts',
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
level4: {
|
||||
level5: {
|
||||
level6: {
|
||||
level7: {
|
||||
file_path: 'src/deep.ts',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
timestamp: '2024-01-01T00:00:02Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Done' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await generateSummary(mockConfig);
|
||||
|
||||
const savedConversation =
|
||||
await chatRecordingService.loadConversationRecord(filePath);
|
||||
expect(savedConversation?.memoryScratchpad).toEqual({
|
||||
version: 1,
|
||||
workflowSummary: 'replace | paths src/shallow.ts',
|
||||
toolSequence: ['replace'],
|
||||
touchedPaths: ['src/shallow.ts'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the latest validation result in scratchpad metadata', async () => {
|
||||
const filePath = await writeSession(
|
||||
chatsDir,
|
||||
'session-2024-01-01T10-00-validation.jsonl',
|
||||
buildJsonlSession({
|
||||
sessionId: 'validation-session',
|
||||
userMessageCount: 2,
|
||||
summary: 'Existing summary',
|
||||
messages: [
|
||||
{
|
||||
id: 'u1',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Fix the tests' }],
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Running tests' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'npm test' },
|
||||
status: CoreToolCallStatus.Error,
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'npm test' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T00:00:02Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
timestamp: '2024-01-01T00:00:03Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'Done' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await generateSummary(mockConfig);
|
||||
|
||||
const savedConversation =
|
||||
await chatRecordingService.loadConversationRecord(filePath);
|
||||
expect(savedConversation?.memoryScratchpad).toEqual({
|
||||
version: 1,
|
||||
workflowSummary: 'run_shell_command: npm | validated',
|
||||
toolSequence: ['run_shell_command: npm'],
|
||||
validationStatus: 'passed',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,29 +12,15 @@ import {
|
||||
SESSION_FILE_PREFIX,
|
||||
loadConversationRecord,
|
||||
type ConversationRecord,
|
||||
type MemoryScratchpad,
|
||||
type ToolCallRecord,
|
||||
} from './chatRecordingService.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
import { SHELL_TOOL_NAME } from '../tools/definitions/base-declarations.js';
|
||||
import { summarizeShellCommandForScratchpad } from './sessionScratchpadUtils.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const MIN_MESSAGES_FOR_SUMMARY = 1;
|
||||
const MAX_SCRATCHPAD_TOOLS = 6;
|
||||
const MAX_SCRATCHPAD_PATHS = 4;
|
||||
const MAX_SCRATCHPAD_PATH_DEPTH = 6;
|
||||
const MAX_WORKFLOW_SUMMARY_LENGTH = 160;
|
||||
const VALIDATION_COMMAND_REGEX =
|
||||
/\b(test|tests|vitest|jest|pytest|cargo test|npm test|pnpm test|yarn test|bun test|lint|build|check|typecheck)\b/i;
|
||||
const PATH_KEY_REGEX = /(path|file|dir|directory|cwd|root)/i;
|
||||
const VALIDATION_TOOL_REGEX = /\b(test|lint|build|check|typecheck)\b/i;
|
||||
|
||||
type LoadedSession = ConversationRecord & {
|
||||
messageCount?: number;
|
||||
userMessageCount?: number;
|
||||
memoryScratchpadIsStale?: boolean;
|
||||
};
|
||||
|
||||
interface SessionFileCandidate {
|
||||
@@ -86,238 +72,6 @@ function getSessionTimestampMs(session: LoadedSession): number {
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function normalizeToolName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
return trimmed.length > 0 ? trimmed : 'unknown_tool';
|
||||
}
|
||||
|
||||
function pushUniqueLimited(
|
||||
target: string[],
|
||||
value: string,
|
||||
limit: number,
|
||||
): void {
|
||||
if (!value || target.includes(value) || target.length >= limit) {
|
||||
return;
|
||||
}
|
||||
target.push(value);
|
||||
}
|
||||
|
||||
function normalizePathCandidate(
|
||||
candidate: string,
|
||||
projectRoot: string,
|
||||
): string | null {
|
||||
const trimmed = candidate.trim();
|
||||
if (
|
||||
trimmed.length === 0 ||
|
||||
trimmed.length > 240 ||
|
||||
trimmed.includes('\n') ||
|
||||
(!trimmed.includes('/') &&
|
||||
!trimmed.includes('\\') &&
|
||||
!trimmed.startsWith('.') &&
|
||||
path.extname(trimmed).length === 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = trimmed.replace(/\\/g, '/');
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
const relative = path.relative(projectRoot, trimmed);
|
||||
normalized =
|
||||
relative && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||
? relative.replace(/\\/g, '/')
|
||||
: path.basename(trimmed);
|
||||
}
|
||||
|
||||
if (normalized.length > 120) {
|
||||
normalized = normalized.split('/').slice(-3).join('/');
|
||||
}
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function collectPathsFromValue(
|
||||
value: unknown,
|
||||
projectRoot: string,
|
||||
paths: string[],
|
||||
keyHint?: string,
|
||||
depth = 0,
|
||||
): void {
|
||||
if (
|
||||
paths.length >= MAX_SCRATCHPAD_PATHS ||
|
||||
depth > MAX_SCRATCHPAD_PATH_DEPTH
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (!keyHint || !PATH_KEY_REGEX.test(keyHint)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizePathCandidate(value, projectRoot);
|
||||
if (normalized) {
|
||||
pushUniqueLimited(paths, normalized, MAX_SCRATCHPAD_PATHS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectPathsFromValue(item, projectRoot, paths, keyHint, depth + 1);
|
||||
if (paths.length >= MAX_SCRATCHPAD_PATHS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
collectPathsFromValue(nestedValue, projectRoot, paths, key, depth + 1);
|
||||
if (paths.length >= MAX_SCRATCHPAD_PATHS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallCommand(toolCall: ToolCallRecord): string | undefined {
|
||||
for (const key of ['command', 'cmd', 'script']) {
|
||||
const value = toolCall.args[key];
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getToolSequenceEntry(toolCall: ToolCallRecord): string {
|
||||
const toolName = normalizeToolName(toolCall.name);
|
||||
if (toolName !== SHELL_TOOL_NAME) {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
const command = getToolCallCommand(toolCall);
|
||||
const commandSummary = command
|
||||
? summarizeShellCommandForScratchpad(command)
|
||||
: undefined;
|
||||
return commandSummary ? `${toolName}: ${commandSummary}` : toolName;
|
||||
}
|
||||
|
||||
function getValidationStatusForToolCall(
|
||||
toolCall: ToolCallRecord,
|
||||
): MemoryScratchpad['validationStatus'] | undefined {
|
||||
const command = getToolCallCommand(toolCall);
|
||||
const isValidationTool =
|
||||
VALIDATION_TOOL_REGEX.test(toolCall.name) ||
|
||||
(command ? VALIDATION_COMMAND_REGEX.test(command) : false);
|
||||
if (!isValidationTool) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (toolCall.status === CoreToolCallStatus.Success) {
|
||||
return 'passed';
|
||||
}
|
||||
if (
|
||||
toolCall.status === CoreToolCallStatus.Error ||
|
||||
toolCall.status === CoreToolCallStatus.Cancelled
|
||||
) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function buildWorkflowSummary(
|
||||
toolSequence: string[],
|
||||
touchedPaths: string[],
|
||||
validationStatus?: MemoryScratchpad['validationStatus'],
|
||||
): string | undefined {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (toolSequence.length > 0) {
|
||||
parts.push(toolSequence.join(' -> '));
|
||||
}
|
||||
if (touchedPaths.length > 0) {
|
||||
parts.push(`paths ${touchedPaths.join(', ')}`);
|
||||
}
|
||||
if (validationStatus === 'passed') {
|
||||
parts.push('validated');
|
||||
} else if (validationStatus === 'failed') {
|
||||
parts.push('validation failed');
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = parts.join(' | ');
|
||||
if (summary.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return summary.length > MAX_WORKFLOW_SUMMARY_LENGTH
|
||||
? `${summary.slice(0, MAX_WORKFLOW_SUMMARY_LENGTH - 3)}...`
|
||||
: summary;
|
||||
}
|
||||
|
||||
function buildMemoryScratchpad(
|
||||
messages: ConversationRecord['messages'],
|
||||
projectRoot: string,
|
||||
): MemoryScratchpad {
|
||||
const toolSequence: string[] = [];
|
||||
const touchedPaths: string[] = [];
|
||||
let validationStatus: MemoryScratchpad['validationStatus'];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type !== 'gemini' || !message.toolCalls) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const toolCall of message.toolCalls) {
|
||||
pushUniqueLimited(
|
||||
toolSequence,
|
||||
getToolSequenceEntry(toolCall),
|
||||
MAX_SCRATCHPAD_TOOLS,
|
||||
);
|
||||
collectPathsFromValue(toolCall.args, projectRoot, touchedPaths);
|
||||
|
||||
const toolValidationStatus = getValidationStatusForToolCall(toolCall);
|
||||
if (toolValidationStatus) {
|
||||
validationStatus = toolValidationStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workflowSummary = buildWorkflowSummary(
|
||||
toolSequence,
|
||||
touchedPaths,
|
||||
validationStatus,
|
||||
);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
...(workflowSummary ? { workflowSummary } : {}),
|
||||
...(toolSequence.length > 0 ? { toolSequence } : {}),
|
||||
...(touchedPaths.length > 0 ? { touchedPaths } : {}),
|
||||
...(validationStatus ? { validationStatus } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function hasCurrentMemoryScratchpad(session: LoadedSession): boolean {
|
||||
return Boolean(
|
||||
session.memoryScratchpad && session.memoryScratchpadIsStale !== true,
|
||||
);
|
||||
}
|
||||
|
||||
function hasSessionSummaryMetadata(session: LoadedSession): boolean {
|
||||
return hasCurrentMemoryScratchpad(session);
|
||||
}
|
||||
|
||||
function getLoadedMessageCount(session: LoadedSession): number {
|
||||
return session.messageCount ?? session.messages.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates and saves a summary for a session file.
|
||||
*/
|
||||
@@ -331,11 +85,10 @@ async function generateAndSaveSummary(
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if workflow metadata already exists; memory extraction can use the
|
||||
// scratchpad even when summary generation was unavailable.
|
||||
if (hasSessionSummaryMetadata(conversation)) {
|
||||
// Skip if summary already exists
|
||||
if (conversation.summary) {
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] Summary metadata already exists for ${sessionPath}, skipping`,
|
||||
`[SessionSummary] Summary already exists for ${sessionPath}, skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -348,30 +101,28 @@ async function generateAndSaveSummary(
|
||||
return;
|
||||
}
|
||||
|
||||
let summary = conversation.summary;
|
||||
if (!summary) {
|
||||
const contentGenerator = config.getContentGenerator();
|
||||
if (!contentGenerator) {
|
||||
debugLogger.debug(
|
||||
'[SessionSummary] Content generator not available, skipping summary generation',
|
||||
);
|
||||
} else {
|
||||
const baseLlmClient = new BaseLlmClient(contentGenerator, config);
|
||||
const summaryService = new SessionSummaryService(baseLlmClient);
|
||||
summary =
|
||||
(await summaryService.generateSummary({
|
||||
messages: conversation.messages,
|
||||
})) ?? undefined;
|
||||
|
||||
if (!summary) {
|
||||
debugLogger.warn(
|
||||
`[SessionSummary] Failed to generate summary for ${sessionPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Create summary service
|
||||
const contentGenerator = config.getContentGenerator();
|
||||
if (!contentGenerator) {
|
||||
debugLogger.debug(
|
||||
'[SessionSummary] Content generator not available, skipping summary generation',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const baseLlmClient = new BaseLlmClient(contentGenerator, config);
|
||||
const summaryService = new SessionSummaryService(baseLlmClient);
|
||||
|
||||
let scratchpadSourceConversation = conversation;
|
||||
// Generate summary
|
||||
const summary = await summaryService.generateSummary({
|
||||
messages: conversation.messages,
|
||||
});
|
||||
|
||||
if (!summary) {
|
||||
debugLogger.warn(
|
||||
`[SessionSummary] Failed to generate summary for ${sessionPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-read the file before writing to handle race conditions. For JSONL we
|
||||
// only need the metadata; for legacy JSON we need the full record so we can
|
||||
@@ -385,53 +136,18 @@ async function generateAndSaveSummary(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if summary metadata was added by another process
|
||||
if (hasSessionSummaryMetadata(freshConversation)) {
|
||||
// Check if summary was added by another process
|
||||
if (freshConversation.summary) {
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] Summary metadata was added by another process for ${sessionPath}`,
|
||||
`[SessionSummary] Summary was added by another process for ${sessionPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!hasCurrentMemoryScratchpad(freshConversation) &&
|
||||
(getLoadedMessageCount(freshConversation) !==
|
||||
getLoadedMessageCount(conversation) ||
|
||||
freshConversation.lastUpdated !== conversation.lastUpdated)
|
||||
) {
|
||||
const latestConversation = await loadConversationRecord(sessionPath);
|
||||
if (!latestConversation) {
|
||||
debugLogger.debug(`[SessionSummary] Could not re-read ${sessionPath}`);
|
||||
return;
|
||||
}
|
||||
if (hasSessionSummaryMetadata(latestConversation)) {
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] Summary metadata was added by another process for ${sessionPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
scratchpadSourceConversation = latestConversation;
|
||||
}
|
||||
|
||||
const metadataUpdate: Partial<ConversationRecord> = {};
|
||||
if (!freshConversation.summary && summary) {
|
||||
metadataUpdate.summary = summary;
|
||||
}
|
||||
if (!hasCurrentMemoryScratchpad(freshConversation)) {
|
||||
metadataUpdate.memoryScratchpad = buildMemoryScratchpad(
|
||||
scratchpadSourceConversation.messages,
|
||||
config.getProjectRoot(),
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(metadataUpdate).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJsonl) {
|
||||
await fs.appendFile(
|
||||
sessionPath,
|
||||
`${JSON.stringify({ $set: metadataUpdate })}\n`,
|
||||
`${JSON.stringify({ $set: { summary } })}\n`,
|
||||
);
|
||||
} else {
|
||||
const lastUpdated = freshConversation.lastUpdated;
|
||||
@@ -440,7 +156,7 @@ async function generateAndSaveSummary(
|
||||
JSON.stringify(
|
||||
{
|
||||
...freshConversation,
|
||||
...metadataUpdate,
|
||||
summary,
|
||||
lastUpdated,
|
||||
},
|
||||
null,
|
||||
@@ -449,13 +165,13 @@ async function generateAndSaveSummary(
|
||||
);
|
||||
}
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] Saved summary metadata for ${sessionPath}${summary ? `: "${summary}"` : ''}`,
|
||||
`[SessionSummary] Saved summary for ${sessionPath}: "${summary}"`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the most recently updated previous session that still needs workflow metadata.
|
||||
* Returns the path if it needs a scratchpad, null otherwise.
|
||||
* Finds the most recently updated previous session that still needs a summary.
|
||||
* Returns the path if it needs a summary, null otherwise.
|
||||
*/
|
||||
export async function getPreviousSession(
|
||||
config: Config,
|
||||
@@ -501,8 +217,7 @@ export async function getPreviousSession(
|
||||
});
|
||||
if (!conversation) continue;
|
||||
if (conversation.sessionId === config.getSessionId()) continue;
|
||||
if (conversation.kind === 'subagent') continue;
|
||||
if (hasSessionSummaryMetadata(conversation)) continue;
|
||||
if (conversation.summary) continue;
|
||||
|
||||
// Only generate summaries for sessions with more than 1 user message.
|
||||
// `loadConversationRecord` populates `userMessageCount` in metadataOnly
|
||||
@@ -549,7 +264,7 @@ export async function getPreviousSession(
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates summary metadata for the previous session if it lacks a scratchpad.
|
||||
* Generates summary for the previous session if it lacks one.
|
||||
* This is designed to be called fire-and-forget on startup.
|
||||
*/
|
||||
export async function generateSummary(config: Config): Promise<void> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user